mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-07 17:33:33 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e8f9b0574 | ||
|
|
19bcdcd000 | ||
|
|
8f5160f827 | ||
|
|
3574a4d2c0 | ||
|
|
b552843fa9 | ||
|
|
0461a133df | ||
|
|
b33857de33 | ||
|
|
433c643f6c | ||
|
|
214119a063 | ||
|
|
989a9f6fc5 | ||
|
|
cf276a5640 | ||
|
|
b5134ffb2f | ||
|
|
5664036712 | ||
|
|
6c2ca64cbe | ||
|
|
08c742287c | ||
|
|
4ee32b8eb2 | ||
|
|
35f07e5653 | ||
|
|
6481aec376 | ||
|
|
98c846ef78 | ||
|
|
6e4a9faaf8 | ||
|
|
05d62db355 | ||
|
|
375c8c9826 | ||
|
|
c6977c8221 | ||
|
|
d407c83cf3 | ||
|
|
214add68d9 | ||
|
|
6d2cb9676d | ||
|
|
c519cb1379 | ||
|
|
356dc4909a | ||
|
|
dc278ad07b | ||
|
|
5cc53eb4bb | ||
|
|
b72576d086 | ||
|
|
30314b1573 | ||
|
|
f721bd5e9a | ||
|
|
d00ea3e3c3 | ||
|
|
67dbd1007a | ||
|
|
cf2a5a135f | ||
|
|
bc23d3a104 | ||
|
|
bb4e1d8b9e |
No files matched your search
@@ -86,13 +86,29 @@ runs:
|
||||
- name: Extract GitHub Context and Create Prompt
|
||||
shell: bash
|
||||
id: prepare_context
|
||||
env:
|
||||
# Pass user-controlled inputs via environment variables to prevent script injection (GHSL-2025-093)
|
||||
EVENT_COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
EVENT_ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
EVENT_ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
EVENT_PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
EVENT_REVIEW_BODY: ${{ github.event.review.body }}
|
||||
EVENT_ASSIGNEE_LOGIN: ${{ github.event.assignee.login }}
|
||||
INPUT_TRIGGER_PHRASE: ${{ inputs.trigger_phrase }}
|
||||
INPUT_ASSIGNEE_TRIGGER: ${{ inputs.assignee_trigger }}
|
||||
INPUT_DIRECT_PROMPT: ${{ inputs.direct_prompt }}
|
||||
EVENT_ACTION: ${{ github.event.action }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
REPO_NAME: ${{ github.repository }}
|
||||
run: |
|
||||
echo "🔍 Extracting GitHub context from event: ${{ github.event_name }}"
|
||||
echo "🔍 Extracting GitHub context from event: $EVENT_NAME"
|
||||
|
||||
# Function to check for trigger phrase
|
||||
check_trigger() {
|
||||
local text="$1"
|
||||
local trigger="${{ inputs.trigger_phrase }}"
|
||||
local trigger="$INPUT_TRIGGER_PHRASE"
|
||||
if [[ "$text" == *"$trigger"* ]]; then
|
||||
return 0
|
||||
fi
|
||||
@@ -104,11 +120,11 @@ runs:
|
||||
USER_REQUEST=""
|
||||
CONTEXT_INFO=""
|
||||
|
||||
case "${{ github.event_name }}" in
|
||||
case "$EVENT_NAME" in
|
||||
"issue_comment")
|
||||
COMMENT_BODY="${{ github.event.comment.body }}"
|
||||
ISSUE_TITLE="${{ github.event.issue.title }}"
|
||||
ISSUE_NUMBER="${{ github.event.issue.number }}"
|
||||
COMMENT_BODY="$EVENT_COMMENT_BODY"
|
||||
ISSUE_TITLE="$EVENT_ISSUE_TITLE"
|
||||
ISSUE_NUMBER="$EVENT_ISSUE_NUMBER"
|
||||
|
||||
if check_trigger "$COMMENT_BODY"; then
|
||||
TRIGGER_FOUND="true"
|
||||
@@ -118,9 +134,9 @@ runs:
|
||||
;;
|
||||
|
||||
"pull_request_review_comment")
|
||||
COMMENT_BODY="${{ github.event.comment.body }}"
|
||||
PR_TITLE="${{ github.event.pull_request.title }}"
|
||||
PR_NUMBER="${{ github.event.pull_request.number }}"
|
||||
COMMENT_BODY="$EVENT_COMMENT_BODY"
|
||||
PR_TITLE="$EVENT_PR_TITLE"
|
||||
PR_NUMBER="$EVENT_PR_NUMBER"
|
||||
|
||||
if check_trigger "$COMMENT_BODY"; then
|
||||
TRIGGER_FOUND="true"
|
||||
@@ -130,9 +146,9 @@ runs:
|
||||
;;
|
||||
|
||||
"pull_request_review")
|
||||
REVIEW_BODY="${{ github.event.review.body }}"
|
||||
PR_TITLE="${{ github.event.pull_request.title }}"
|
||||
PR_NUMBER="${{ github.event.pull_request.number }}"
|
||||
REVIEW_BODY="$EVENT_REVIEW_BODY"
|
||||
PR_TITLE="$EVENT_PR_TITLE"
|
||||
PR_NUMBER="$EVENT_PR_NUMBER"
|
||||
|
||||
if check_trigger "$REVIEW_BODY"; then
|
||||
TRIGGER_FOUND="true"
|
||||
@@ -142,17 +158,17 @@ runs:
|
||||
;;
|
||||
|
||||
"issues")
|
||||
ISSUE_BODY="${{ github.event.issue.body }}"
|
||||
ISSUE_TITLE="${{ github.event.issue.title }}"
|
||||
ISSUE_NUMBER="${{ github.event.issue.number }}"
|
||||
ISSUE_BODY="$EVENT_ISSUE_BODY"
|
||||
ISSUE_TITLE="$EVENT_ISSUE_TITLE"
|
||||
ISSUE_NUMBER="$EVENT_ISSUE_NUMBER"
|
||||
|
||||
if check_trigger "$ISSUE_TITLE" || check_trigger "$ISSUE_BODY"; then
|
||||
TRIGGER_FOUND="true"
|
||||
USER_REQUEST="$ISSUE_BODY"
|
||||
CONTEXT_INFO="Issue #$ISSUE_NUMBER: $ISSUE_TITLE"
|
||||
elif [[ "${{ github.event.action }}" == "assigned" && -n "${{ inputs.assignee_trigger }}" ]]; then
|
||||
ASSIGNEE="${{ github.event.assignee.login }}"
|
||||
if [[ "$ASSIGNEE" == "${{ inputs.assignee_trigger }}" ]]; then
|
||||
elif [[ "$EVENT_ACTION" == "assigned" && -n "$INPUT_ASSIGNEE_TRIGGER" ]]; then
|
||||
ASSIGNEE="$EVENT_ASSIGNEE_LOGIN"
|
||||
if [[ "$ASSIGNEE" == "$INPUT_ASSIGNEE_TRIGGER" ]]; then
|
||||
TRIGGER_FOUND="true"
|
||||
USER_REQUEST="$ISSUE_BODY"
|
||||
CONTEXT_INFO="Issue #$ISSUE_NUMBER assigned to $ASSIGNEE: $ISSUE_TITLE"
|
||||
@@ -162,9 +178,9 @@ runs:
|
||||
esac
|
||||
|
||||
# Check for direct prompt override
|
||||
if [[ -n "${{ inputs.direct_prompt }}" ]]; then
|
||||
if [[ -n "$INPUT_DIRECT_PROMPT" ]]; then
|
||||
TRIGGER_FOUND="true"
|
||||
USER_REQUEST="${{ inputs.direct_prompt }}"
|
||||
USER_REQUEST="$INPUT_DIRECT_PROMPT"
|
||||
CONTEXT_INFO="Automated GitHub workflow"
|
||||
fi
|
||||
|
||||
@@ -181,9 +197,9 @@ runs:
|
||||
cat > /tmp/claude-action/github-context-prompt.txt << EOF
|
||||
You are Claude Code, an AI assistant helping with GitHub workflows and code.
|
||||
|
||||
Repository: ${{ github.repository }}
|
||||
Repository: $REPO_NAME
|
||||
Context: $CONTEXT_INFO
|
||||
Event: ${{ github.event_name }}
|
||||
Event: $EVENT_NAME
|
||||
|
||||
User Request:
|
||||
$USER_REQUEST
|
||||
|
||||
@@ -46,12 +46,16 @@ jobs:
|
||||
id: issue_data
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||
# Pass user-controlled inputs via environment variables to prevent script injection (GHSL-2025-093)
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
EVENT_COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
run: |
|
||||
# Get issue number from event or context
|
||||
if [ "${{ github.event_name }}" = "issue_comment" ]; then
|
||||
ISSUE_NUMBER="${{ github.event.issue.number }}"
|
||||
elif [ "${{ github.event_name }}" = "issues" ]; then
|
||||
ISSUE_NUMBER="${{ github.event.issue.number }}"
|
||||
if [ "$EVENT_NAME" = "issue_comment" ]; then
|
||||
ISSUE_NUMBER="$EVENT_ISSUE_NUMBER"
|
||||
elif [ "$EVENT_NAME" = "issues" ]; then
|
||||
ISSUE_NUMBER="$EVENT_ISSUE_NUMBER"
|
||||
else
|
||||
echo "Unable to determine issue number"
|
||||
exit 1
|
||||
@@ -62,8 +66,8 @@ jobs:
|
||||
|
||||
# Extract additional instructions from comment if triggered by comment
|
||||
ADDITIONAL_INSTRUCTIONS=""
|
||||
if [ "${{ github.event_name }}" = "issue_comment" ]; then
|
||||
COMMENT_BODY="${{ github.event.comment.body }}"
|
||||
if [ "$EVENT_NAME" = "issue_comment" ]; then
|
||||
COMMENT_BODY="$EVENT_COMMENT_BODY"
|
||||
ADDITIONAL_INSTRUCTIONS=$(echo "$COMMENT_BODY" | sed -n 's/.*@gemini[[:space:]]*\(.*\)/\1/p' | head -1)
|
||||
fi
|
||||
|
||||
|
||||
@@ -55,21 +55,27 @@ jobs:
|
||||
id: get_pr
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||
# Pass user-controlled inputs via environment variables to prevent script injection (GHSL-2025-093)
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
EVENT_INPUTS_PR_NUMBER: ${{ github.event.inputs.pr_number }}
|
||||
EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
EVENT_COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
PR_NUMBER=${{ github.event.inputs.pr_number }}
|
||||
elif [ "${{ github.event_name }}" = "issue_comment" ]; then
|
||||
PR_NUMBER=${{ github.event.issue.number }}
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
|
||||
PR_NUMBER=$EVENT_INPUTS_PR_NUMBER
|
||||
elif [ "$EVENT_NAME" = "issue_comment" ]; then
|
||||
PR_NUMBER=$EVENT_ISSUE_NUMBER
|
||||
else
|
||||
PR_NUMBER=${{ github.event.pull_request.number }}
|
||||
PR_NUMBER=$EVENT_PR_NUMBER
|
||||
fi
|
||||
|
||||
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Extract additional instructions from comment (if triggered by comment)
|
||||
ADDITIONAL_INSTRUCTIONS=""
|
||||
if [ "${{ github.event_name }}" = "issue_comment" ]; then
|
||||
COMMENT_BODY="${{ github.event.comment.body }}"
|
||||
if [ "$EVENT_NAME" = "issue_comment" ]; then
|
||||
COMMENT_BODY="$EVENT_COMMENT_BODY"
|
||||
ADDITIONAL_INSTRUCTIONS=$(echo "$COMMENT_BODY" | sed 's/.*@gemini//' | xargs)
|
||||
fi
|
||||
echo "additional_instructions=$ADDITIONAL_INSTRUCTIONS" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -32,38 +32,111 @@ PraisonAI is a production-ready Multi-AI Agents framework with self-reflection,
|
||||
|
||||
## Key Features
|
||||
|
||||
- 🤖 Automated AI Agents Creation
|
||||
- 🔄 Self Reflection AI Agents
|
||||
- 🧠 Reasoning AI Agents
|
||||
- 👁️ Multi Modal AI Agents
|
||||
- 🤝 Multi Agent Collaboration
|
||||
- 🎭 AI Agent Workflow
|
||||
- 📚 Add Custom Knowledge
|
||||
- 🧠 Agents with Short and Long Term Memory
|
||||
- 📄 Chat with PDF Agents
|
||||
- 💻 Code Interpreter Agents
|
||||
- 📚 RAG Agents
|
||||
- 🤔 Async & Parallel Processing
|
||||
- 🔄 Auto Agents
|
||||
- 🔢 Math Agents
|
||||
- 🎯 Structured Output Agents
|
||||
- 🔗 LangChain Integrated Agents
|
||||
- 📞 Callback Agents
|
||||
- 🤏 Mini AI Agents
|
||||
- 🛠️ 100+ Custom Tools
|
||||
- 📄 YAML Configuration
|
||||
- 💯 100+ LLM Support
|
||||
- 🔬 **Deep Research Agents** (OpenAI & Gemini)
|
||||
- 🔄 **Query Rewriter Agent** (HyDE, Step-back, Multi-query)
|
||||
- 🌐 **Native Web Search** (OpenAI, Gemini, Anthropic, xAI, Perplexity)
|
||||
- 📥 **Web Fetch** (Retrieve full content from URLs - Anthropic)
|
||||
- 💾 **Prompt Caching** (Reduce costs & latency - OpenAI, Anthropic, Bedrock, Deepseek)
|
||||
- 🧠 **Claude Memory Tool** (Persistent cross-conversation memory - Anthropic Beta)
|
||||
- 💾 **File-Based Memory** (Zero-dependency persistent memory for all agents)
|
||||
- 🔍 **Built-in Search Tools** (Tavily, You.com, Exa - web search, news, content extraction)
|
||||
- 📋 **Planning Mode** (Plan before execution - Agent & Multi-Agent)
|
||||
- 🔧 **Planning Tools** (Research with tools during planning)
|
||||
- 🧠 **Planning Reasoning** (Chain-of-thought planning)
|
||||
| Feature | Code | Docs |
|
||||
|---------|:----:|:----:|
|
||||
| 🚀 Single Agent | [Example](examples/python/agents/single-agent.py) | [📖](https://docs.praison.ai/agents/single) |
|
||||
| 🤝 Multi Agents | [Example](examples/python/general/mini_agents_example.py) | [📖](https://docs.praison.ai/concepts/agents) |
|
||||
| 🤖 Auto Agents | [Example](examples/python/general/auto_agents_example.py) | [📖](https://docs.praison.ai/features/autoagents) |
|
||||
| 🔄 Self Reflection AI Agents | [Example](examples/python/concepts/self-reflection-details.py) | [📖](https://docs.praison.ai/features/selfreflection) |
|
||||
| 🧠 Reasoning AI Agents | [Example](examples/python/concepts/reasoning-extraction.py) | [📖](https://docs.praison.ai/features/reasoning) |
|
||||
| 👁️ Multi Modal AI Agents | [Example](examples/python/general/multimodal.py) | [📖](https://docs.praison.ai/features/multimodal) |
|
||||
| 🎭 AI Agent Workflow (Context Passing, Per-Step Agents) | [Example](examples/python/stateful/workflow-state-example.py) | [📖](https://docs.praison.ai/features/workflows) |
|
||||
| 📚 Add Custom Knowledge | [Example](examples/python/concepts/knowledge-agents.py) | [📖](https://docs.praison.ai/features/knowledge) |
|
||||
| 🧠 Memory (Short & Long Term) | [Example](examples/python/general/memory_example.py) | [📖](https://docs.praison.ai/concepts/memory) |
|
||||
| 📄 Chat with PDF Agents | [Example](examples/python/concepts/chat-with-pdf.py) | [📖](https://docs.praison.ai/features/chat-with-pdf) |
|
||||
| 💻 Code Interpreter Agents | [Example](examples/python/agents/code-agent.py) | [📖](https://docs.praison.ai/features/codeagent) |
|
||||
| 📚 RAG Agents | [Example](examples/python/concepts/rag-agents.py) | [📖](https://docs.praison.ai/features/rag) |
|
||||
| 🤔 Async & Parallel Processing | [Example](examples/python/general/async_example.py) | [📖](https://docs.praison.ai/features/async) |
|
||||
| 🔢 Math Agents | [Example](examples/python/agents/math-agent.py) | [📖](https://docs.praison.ai/features/mathagent) |
|
||||
| 🎯 Structured Output Agents | [Example](examples/python/general/structured_agents_example.py) | [📖](https://docs.praison.ai/features/structured) |
|
||||
| 🔗 LangChain Integrated Agents | [Example](examples/python/general/langchain_example.py) | [📖](https://docs.praison.ai/features/langchain) |
|
||||
| 📞 Callback Agents | [Example](examples/python/general/advanced-callback-systems.py) | [📖](https://docs.praison.ai/features/callbacks) |
|
||||
| 🛠️ 100+ Custom Tools | [Example](examples/python/general/tools_example.py) | [📖](https://docs.praison.ai/tools/tools) |
|
||||
| 📄 YAML Configuration | [Example](examples/cookbooks/yaml/secondary_market_research_agents.yaml) | [📖](https://docs.praison.ai/developers/agents-playbook) |
|
||||
| 💯 100+ LLM Support | [Example](examples/python/providers/openai/openai_gpt4_example.py) | [📖](https://docs.praison.ai/models) |
|
||||
| 🔬 Deep Research Agents | [Example](examples/python/agents/research-agent.py) | [📖](https://docs.praison.ai/agents/deep-research) |
|
||||
| 🔄 Query Rewriter Agent | [Example](#5-query-rewriter-agent) | [📖](https://docs.praison.ai/agents/query-rewriter) |
|
||||
| 🌐 Native Web Search | [Example](examples/python/agents/websearch-agent.py) | [📖](https://docs.praison.ai/agents/websearch) |
|
||||
| 📥 Web Fetch (Anthropic) | [Example](#web-search-web-fetch--prompt-caching) | [📖](https://docs.praison.ai/features/model-capabilities) |
|
||||
| 💾 Prompt Caching | [Example](#web-search-web-fetch--prompt-caching) | [📖](https://docs.praison.ai/features/model-capabilities) |
|
||||
| 🧠 Claude Memory Tool | [Example](#claude-memory-tool-cli) | [📖](https://docs.praison.ai/features/claude-memory-tool) |
|
||||
| 💾 File-Based Memory | [Example](examples/python/general/memory_example.py) | [📖](https://docs.praison.ai/concepts/memory) |
|
||||
| 🔍 Built-in Search Tools | [Example](examples/python/agents/websearch-agent.py) | [📖](https://docs.praison.ai/tools/tavily) |
|
||||
| 📋 Planning Mode | [Example](examples/python/agents/planning-agent.py) | [📖](https://docs.praison.ai/features/planning-mode) |
|
||||
| 🔧 Planning Tools | [Example](#3-agent-with-planning-mode) | [📖](https://docs.praison.ai/features/planning-mode) |
|
||||
| 🧠 Planning Reasoning | [Example](#3-agent-with-planning-mode) | [📖](https://docs.praison.ai/features/planning-mode) |
|
||||
| 🔌 MCP Transports | [Example](examples/python/mcp/mcp-transports-overview.py) | [📖](https://docs.praison.ai/mcp/transports) |
|
||||
| 🌐 WebSocket MCP | [Example](examples/python/mcp/websocket-mcp.py) | [📖](https://docs.praison.ai/mcp/sse-transport) |
|
||||
| 🔐 MCP Security | [Example](examples/python/mcp/mcp-security.py) | [📖](https://docs.praison.ai/mcp/transports) |
|
||||
| 🔄 MCP Resumability | [Example](examples/python/mcp/mcp-resumability.py) | [📖](https://docs.praison.ai/mcp/sse-transport) |
|
||||
| ⚡ Fast Context | [Example](examples/context/00_agent_fast_context_basic.py) | [📖](https://docs.praison.ai/features/fast-context) |
|
||||
| 🖼️ Image Generation Agent | [Example](examples/python/image/image-agent.py) | [📖](https://docs.praison.ai/features/image-generation) |
|
||||
| 📷 Image to Text Agent | [Example](examples/python/agents/image-to-text-agent.py) | [📖](https://docs.praison.ai/agents/image-to-text) |
|
||||
| 🎬 Video Agent | [Example](examples/python/agents/video-agent.py) | [📖](https://docs.praison.ai/agents/video) |
|
||||
| 📊 Data Analyst Agent | [Example](examples/python/agents/data-analyst-agent.py) | [📖](https://docs.praison.ai/agents/data-analyst) |
|
||||
| 💰 Finance Agent | [Example](examples/python/agents/finance-agent.py) | [📖](https://docs.praison.ai/agents/finance) |
|
||||
| 🛒 Shopping Agent | [Example](examples/python/agents/shopping-agent.py) | [📖](https://docs.praison.ai/agents/shopping) |
|
||||
| ⭐ Recommendation Agent | [Example](examples/python/agents/recommendation-agent.py) | [📖](https://docs.praison.ai/agents/recommendation) |
|
||||
| 📖 Wikipedia Agent | [Example](examples/python/agents/wikipedia-agent.py) | [📖](https://docs.praison.ai/agents/wikipedia) |
|
||||
| 💻 Programming Agent | [Example](examples/python/agents/programming-agent.py) | [📖](https://docs.praison.ai/agents/programming) |
|
||||
| 📝 Markdown Agent | [Example](examples/python/agents/markdown-agent.py) | [📖](https://docs.praison.ai/agents/markdown) |
|
||||
| 📝 Prompt Expander Agent | [Example](#prompt-expansion) | [📖](https://docs.praison.ai/agents/prompt-expander) |
|
||||
| 🔀 Router Agent | [Example](examples/python/agents/router-agent-cost-optimization.py) | [📖](https://docs.praison.ai/features/routing) |
|
||||
| ⛓️ Prompt Chaining | [Example](examples/python/general/prompt_chaining.py) | [📖](https://docs.praison.ai/features/promptchaining) |
|
||||
| 🔍 Evaluator Optimiser | [Example](examples/python/general/evaluator-optimiser.py) | [📖](https://docs.praison.ai/features/evaluator-optimiser) |
|
||||
| 👷 Orchestrator Workers | [Example](examples/python/general/orchestrator-workers.py) | [📖](https://docs.praison.ai/features/orchestrator-worker) |
|
||||
| ⚡ Parallelisation | [Example](examples/python/general/parallelisation.py) | [📖](https://docs.praison.ai/features/parallelisation) |
|
||||
| 🔁 Repetitive Agents | [Example](examples/python/concepts/repetitive-agents.py) | [📖](https://docs.praison.ai/features/repetitive) |
|
||||
| 🤝 Agent Handoffs | [Example](examples/python/handoff/handoff_basic.py) | [📖](https://docs.praison.ai/features/handoffs) |
|
||||
| 🛡️ Guardrails | [Example](examples/python/guardrails/comprehensive-guardrails-example.py) | [📖](https://docs.praison.ai/features/guardrails) |
|
||||
| 💬 Sessions Management | [Example](examples/python/sessions/comprehensive-session-management.py) | [📖](https://docs.praison.ai/features/sessions) |
|
||||
| ✅ Human Approval | [Example](examples/python/general/human_approval_example.py) | [📖](https://docs.praison.ai/features/approval) |
|
||||
| 🔄 Stateful Agents | [Example](examples/python/stateful/workflow-state-example.py) | [📖](https://docs.praison.ai/features/stateful-agents) |
|
||||
| 🤖 Autonomous Workflow | [Example](examples/python/general/autonomous-agent.py) | [📖](https://docs.praison.ai/features/autonomous-workflow) |
|
||||
| 📜 Rules & Instructions | [Example](#6-rules--instructions) | [📖](https://docs.praison.ai/features/rules) |
|
||||
| 🪝 Hooks | [Example](#9-hooks) | [📖](https://docs.praison.ai/features/hooks) |
|
||||
| 📈 Telemetry | [Example](examples/python/telemetry/production-telemetry-example.py) | [📖](https://docs.praison.ai/features/telemetry) |
|
||||
| 📹 Camera Integration | [Example](examples/python/camera/) | [📖](https://docs.praison.ai/features/camera-integration) |
|
||||
| 📄 Project Docs (.praison/docs/) | [Example](#docs-cli) | [📖](https://docs.praison.ai/cli/docs) |
|
||||
| 🔌 MCP Config Management | [Example](#mcp-config-cli) | [📖](https://docs.praison.ai/cli/mcp) |
|
||||
| 💬 AI Commit Messages | [Example](#ai-commit-cli) | [📖](https://docs.praison.ai/cli/commit) |
|
||||
| @ @Mentions in Prompts | [Example](#mentions-in-prompts) | [📖](https://docs.praison.ai/cli/mentions) |
|
||||
| 💾 Auto-Save Sessions | [Example](#session-management-python) | [📖](https://docs.praison.ai/cli/session) |
|
||||
| 📜 History in Context | [Example](#session-management-python) | [📖](https://docs.praison.ai/cli/session) |
|
||||
| ⏸️ Workflow Checkpoints | [Example](examples/python/workflows/workflow_checkpoints.py) | [📖](https://docs.praison.ai/cli/session) |
|
||||
| 🔀 Workflow Branching | [Example](examples/python/workflows/workflow_branching.py) | [📖](https://docs.praison.ai/features/workflows) |
|
||||
| 🔁 Workflow Loops | [Example](examples/python/workflows/workflow_loops.py) | [📖](https://docs.praison.ai/features/workflows) |
|
||||
| 📞 Task Callbacks | [Example](examples/python/workflows/task_callbacks.py) | [📖](https://docs.praison.ai/features/workflows) |
|
||||
| 🛑 Workflow Early Stop | [Example](examples/python/workflows/workflow_early_stop.py) | [📖](https://docs.praison.ai/features/workflows) |
|
||||
|
||||
## Supported Providers
|
||||
|
||||
| Provider | Example |
|
||||
|----------|:-------:|
|
||||
| OpenAI | [Example](examples/python/providers/openai/openai_gpt4_example.py) |
|
||||
| Anthropic | [Example](examples/python/providers/anthropic/anthropic_claude_example.py) |
|
||||
| Google Gemini | [Example](examples/python/providers/google/google_gemini_example.py) |
|
||||
| Ollama | [Example](examples/python/providers/ollama/ollama-agents.py) |
|
||||
| Groq | [Example](examples/python/providers/groq/kimi_with_groq_example.py) |
|
||||
| DeepSeek | [Example](examples/python/providers/deepseek/deepseek_example.py) |
|
||||
| xAI Grok | [Example](examples/python/providers/xai/xai_grok_example.py) |
|
||||
| Mistral | [Example](examples/python/providers/mistral/mistral_example.py) |
|
||||
| Cohere | [Example](examples/python/providers/cohere/cohere_example.py) |
|
||||
| Perplexity | [Example](examples/python/providers/perplexity/perplexity_example.py) |
|
||||
| Fireworks | [Example](examples/python/providers/fireworks/fireworks_example.py) |
|
||||
| Together AI | [Example](examples/python/providers/together/together_ai_example.py) |
|
||||
| OpenRouter | [Example](examples/python/providers/openrouter/openrouter_example.py) |
|
||||
| HuggingFace | [Example](examples/python/providers/huggingface/huggingface_example.py) |
|
||||
| Azure OpenAI | [Example](examples/python/providers/azure/azure_openai_example.py) |
|
||||
| AWS Bedrock | [Example](examples/python/providers/aws/aws_bedrock_example.py) |
|
||||
| Google Vertex | [Example](examples/python/providers/vertex/vertex_example.py) |
|
||||
| Databricks | [Example](examples/python/providers/databricks/databricks_example.py) |
|
||||
| Cloudflare | [Example](examples/python/providers/cloudflare/cloudflare_example.py) |
|
||||
| AI21 | [Example](examples/python/providers/ai21/ai21_example.py) |
|
||||
| Replicate | [Example](examples/python/providers/replicate/replicate_example.py) |
|
||||
| SageMaker | [Example](examples/python/providers/sagemaker/sagemaker_example.py) |
|
||||
| Moonshot | [Example](examples/python/providers/moonshot/moonshot_example.py) |
|
||||
| vLLM | [Example](examples/python/providers/vllm/vllm_example.py) |
|
||||
|
||||
## Using Python Code
|
||||
|
||||
@@ -330,19 +403,104 @@ memories = auto.process_interaction(
|
||||
|
||||
### 8. Workflows
|
||||
|
||||
Create reusable multi-step workflows in `.praison/workflows/`:
|
||||
Create reusable multi-step workflows with context passing and per-step agents:
|
||||
|
||||
```python
|
||||
from praisonaiagents.memory import WorkflowManager
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.memory import WorkflowManager, Workflow, WorkflowStep
|
||||
|
||||
# Simple execution with default agent
|
||||
agent = Agent(name="Assistant", llm="gpt-4o-mini")
|
||||
manager = WorkflowManager()
|
||||
|
||||
# Execute a workflow
|
||||
result = manager.execute(
|
||||
"deploy",
|
||||
executor=lambda prompt: agent.chat(prompt),
|
||||
default_agent=agent,
|
||||
variables={"environment": "production"}
|
||||
)
|
||||
|
||||
# Advanced: Per-step agent configuration
|
||||
workflow = Workflow(
|
||||
name="research_pipeline",
|
||||
default_llm="gpt-4o-mini",
|
||||
steps=[
|
||||
WorkflowStep(
|
||||
name="research",
|
||||
action="Research {{topic}}",
|
||||
agent_config={"role": "Researcher", "goal": "Find information"},
|
||||
tools=["tavily_search"]
|
||||
),
|
||||
WorkflowStep(
|
||||
name="write",
|
||||
action="Write report based on {{previous_output}}",
|
||||
agent_config={"role": "Writer", "goal": "Write content"},
|
||||
context_from=["research"] # Only include research output
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
# Async execution
|
||||
import asyncio
|
||||
result = asyncio.run(manager.aexecute("deploy", default_llm="gpt-4o-mini"))
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
- **Context Passing**: Use `{{previous_output}}` and `{{step_name_output}}` variables
|
||||
- **Per-Step Agents**: Configure different agents with roles, goals, tools for each step
|
||||
- **Async Execution**: Use `aexecute()` for async workflows
|
||||
- **Planning Mode**: Enable at workflow level with `planning=True`
|
||||
- **Branching**: Use `next_steps` and `branch_condition` for conditional routing
|
||||
- **Loops**: Use `loop_over` and `loop_var` to iterate over data
|
||||
|
||||
### Workflow Branching & Loops
|
||||
|
||||
```python
|
||||
from praisonaiagents.memory.workflows import WorkflowStep
|
||||
|
||||
# Branching step
|
||||
decision_step = WorkflowStep(
|
||||
name="decide",
|
||||
action="Evaluate if task is complete",
|
||||
next_steps=["success_step", "retry_step"],
|
||||
branch_condition={"success": ["success_step"], "failure": ["retry_step"]}
|
||||
)
|
||||
|
||||
# Loop step
|
||||
loop_step = WorkflowStep(
|
||||
name="process_items",
|
||||
action="Process {{item}}",
|
||||
loop_over="items", # Variable containing list
|
||||
loop_var="item" # Current item variable name
|
||||
)
|
||||
```
|
||||
|
||||
### Task Callbacks & Variables (process="workflow")
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent, Task, PraisonAIAgents
|
||||
|
||||
# Per-task agent config (no need to create agent separately)
|
||||
task = Task(
|
||||
description="Research {{topic}}",
|
||||
agent_config={"name": "Researcher", "role": "Expert", "llm": "gpt-4o-mini"},
|
||||
variables={"topic": "AI trends"}
|
||||
)
|
||||
|
||||
# Callbacks for workflow execution
|
||||
def on_start(task, task_id):
|
||||
print(f"Starting: {task.name}")
|
||||
|
||||
def on_complete(task, output):
|
||||
print(f"Completed: {task.name}")
|
||||
|
||||
agents = PraisonAIAgents(
|
||||
agents=[agent],
|
||||
tasks=[task],
|
||||
process="workflow",
|
||||
on_task_start=on_start,
|
||||
on_task_complete=on_complete,
|
||||
variables={"global_var": "shared_value"} # Global variables for all tasks
|
||||
)
|
||||
```
|
||||
|
||||
### 9. Hooks
|
||||
@@ -479,17 +637,26 @@ praisonai "Task" --include-rules security,testing
|
||||
# List available workflows
|
||||
praisonai workflow list
|
||||
|
||||
# Execute a workflow
|
||||
praisonai workflow run deploy
|
||||
# Execute a workflow with tools and save output
|
||||
praisonai workflow run "Research Blog" --tools tavily --save
|
||||
|
||||
# Execute with variables
|
||||
praisonai workflow run deploy --workflow-var environment=staging --workflow-var branch=main
|
||||
|
||||
# Execute with planning mode (AI creates sub-steps for each workflow step)
|
||||
praisonai workflow run "Research Blog" --planning --verbose
|
||||
|
||||
# Show workflow details
|
||||
praisonai workflow show deploy
|
||||
|
||||
# Create a new workflow template
|
||||
praisonai workflow create my_workflow
|
||||
|
||||
# Inline workflow (no template file needed)
|
||||
praisonai "What is AI?" --workflow "Research,Summarize" --save
|
||||
|
||||
# Inline workflow with step actions
|
||||
praisonai "GPT-5" --workflow "Research:Search for info,Write:Write blog" --tools tavily
|
||||
```
|
||||
|
||||
### Hooks CLI:
|
||||
@@ -510,6 +677,465 @@ praisonai hooks init
|
||||
praisonai "Research and remember findings" --claude-memory --llm anthropic/claude-sonnet-4-20250514
|
||||
```
|
||||
|
||||
### Guardrail CLI:
|
||||
```bash
|
||||
# Validate output with LLM guardrail
|
||||
praisonai "Write code" --guardrail "Ensure code is secure and follows best practices"
|
||||
|
||||
# Combine with other flags
|
||||
praisonai "Generate SQL query" --guardrail "No DROP or DELETE statements" --save
|
||||
```
|
||||
|
||||
### Metrics CLI:
|
||||
```bash
|
||||
# Display token usage and cost metrics
|
||||
praisonai "Analyze this data" --metrics
|
||||
|
||||
# Combine with other features
|
||||
praisonai "Complex task" --metrics --planning
|
||||
```
|
||||
|
||||
### Image Processing CLI:
|
||||
```bash
|
||||
# Process images with vision-based tasks
|
||||
praisonai "Describe this image" --image path/to/image.png
|
||||
|
||||
# Analyze image content
|
||||
praisonai "What objects are in this photo?" --image photo.jpg --llm openai/gpt-4o
|
||||
```
|
||||
|
||||
### Telemetry CLI:
|
||||
```bash
|
||||
# Enable usage monitoring and analytics
|
||||
praisonai "Task" --telemetry
|
||||
|
||||
# Combine with metrics for full observability
|
||||
praisonai "Complex analysis" --telemetry --metrics
|
||||
```
|
||||
|
||||
### MCP (Model Context Protocol) CLI:
|
||||
```bash
|
||||
# Use MCP server tools
|
||||
praisonai "Search files" --mcp "npx -y @modelcontextprotocol/server-filesystem ."
|
||||
|
||||
# MCP with environment variables
|
||||
praisonai "Search web" --mcp "npx -y @modelcontextprotocol/server-brave-search" --mcp-env "BRAVE_API_KEY=your_key"
|
||||
|
||||
# Multiple MCP options
|
||||
praisonai "Task" --mcp "npx server" --mcp-env "KEY1=value1,KEY2=value2"
|
||||
```
|
||||
|
||||
### Fast Context CLI:
|
||||
```bash
|
||||
# Search codebase for relevant context
|
||||
praisonai "Find authentication code" --fast-context ./src
|
||||
|
||||
# Add code context to any task
|
||||
praisonai "Explain this function" --fast-context /path/to/project
|
||||
```
|
||||
|
||||
### Knowledge CLI:
|
||||
```bash
|
||||
# Add documents to knowledge base
|
||||
praisonai knowledge add document.pdf
|
||||
praisonai knowledge add ./docs/
|
||||
|
||||
# Search knowledge base
|
||||
praisonai knowledge search "API authentication"
|
||||
|
||||
# List indexed documents
|
||||
praisonai knowledge list
|
||||
|
||||
# Clear knowledge base
|
||||
praisonai knowledge clear
|
||||
|
||||
# Show knowledge base info
|
||||
praisonai knowledge info
|
||||
|
||||
# Show all commands
|
||||
praisonai knowledge help
|
||||
```
|
||||
|
||||
### Session CLI:
|
||||
```bash
|
||||
# List all saved sessions
|
||||
praisonai session list
|
||||
|
||||
# Show session details
|
||||
praisonai session show my-project
|
||||
|
||||
# Resume a session (load into memory)
|
||||
praisonai session resume my-project
|
||||
|
||||
# Delete a session
|
||||
praisonai session delete my-project
|
||||
|
||||
# Auto-save session after each run
|
||||
praisonai "Analyze this code" --auto-save my-project
|
||||
|
||||
# Load history from last N sessions into context
|
||||
praisonai "Continue our discussion" --history 5
|
||||
```
|
||||
|
||||
### Session Management (Python):
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Auto-save session after each run
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
memory=True,
|
||||
auto_save="my-project"
|
||||
)
|
||||
|
||||
# Load history from last 5 sessions
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
memory=True,
|
||||
history_in_context=5
|
||||
)
|
||||
```
|
||||
|
||||
### Workflow Checkpoints:
|
||||
```python
|
||||
from praisonaiagents.memory.workflows import WorkflowManager
|
||||
|
||||
manager = WorkflowManager()
|
||||
|
||||
# Save checkpoint after each step
|
||||
result = manager.execute("deploy", checkpoint="deploy-v1")
|
||||
|
||||
# Resume from checkpoint
|
||||
result = manager.execute("deploy", resume="deploy-v1")
|
||||
|
||||
# List/delete checkpoints
|
||||
manager.list_checkpoints()
|
||||
manager.delete_checkpoint("deploy-v1")
|
||||
```
|
||||
|
||||
### Tools CLI:
|
||||
```bash
|
||||
# List all available tools
|
||||
praisonai tools list
|
||||
|
||||
# Get info about a specific tool
|
||||
praisonai tools info internet_search
|
||||
|
||||
# Search for tools
|
||||
praisonai tools search "web"
|
||||
|
||||
# Show all commands
|
||||
praisonai tools help
|
||||
```
|
||||
|
||||
### Handoff CLI:
|
||||
```bash
|
||||
# Enable agent-to-agent task delegation
|
||||
praisonai "Research and write article" --handoff "researcher,writer,editor"
|
||||
|
||||
# Complex multi-agent workflow
|
||||
praisonai "Analyze data and create report" --handoff "analyst,visualizer,writer"
|
||||
```
|
||||
|
||||
### Auto Memory CLI:
|
||||
```bash
|
||||
# Enable automatic memory extraction
|
||||
praisonai "Learn about user preferences" --auto-memory
|
||||
|
||||
# Combine with user isolation
|
||||
praisonai "Remember my settings" --auto-memory --user-id user123
|
||||
```
|
||||
|
||||
### Todo CLI:
|
||||
```bash
|
||||
# Generate todo list from task
|
||||
praisonai "Plan the project" --todo
|
||||
|
||||
# Add a todo item
|
||||
praisonai todo add "Implement feature X"
|
||||
|
||||
# List all todos
|
||||
praisonai todo list
|
||||
|
||||
# Complete a todo
|
||||
praisonai todo complete 1
|
||||
|
||||
# Delete a todo
|
||||
praisonai todo delete 1
|
||||
|
||||
# Clear all todos
|
||||
praisonai todo clear
|
||||
|
||||
# Show all commands
|
||||
praisonai todo help
|
||||
```
|
||||
|
||||
### Router CLI:
|
||||
```bash
|
||||
# Auto-select best model based on task complexity
|
||||
praisonai "Simple question" --router
|
||||
|
||||
# Specify preferred provider
|
||||
praisonai "Complex analysis" --router --router-provider anthropic
|
||||
|
||||
# Router automatically selects:
|
||||
# - Simple tasks → gpt-4o-mini, claude-3-haiku
|
||||
# - Complex tasks → gpt-4-turbo, claude-3-opus
|
||||
```
|
||||
|
||||
### Flow Display CLI:
|
||||
```bash
|
||||
# Enable visual workflow tracking
|
||||
praisonai agents.yaml --flow-display
|
||||
|
||||
# Combine with other features
|
||||
praisonai "Multi-step task" --planning --flow-display
|
||||
```
|
||||
|
||||
### Docs CLI:
|
||||
```bash
|
||||
# List all project docs
|
||||
praisonai docs list
|
||||
|
||||
# Create a new doc
|
||||
praisonai docs create project-overview "This project is a Python web app..."
|
||||
|
||||
# Show a specific doc
|
||||
praisonai docs show project-overview
|
||||
|
||||
# Delete a doc
|
||||
praisonai docs delete old-doc
|
||||
|
||||
# Show all commands
|
||||
praisonai docs help
|
||||
```
|
||||
|
||||
### MCP Config CLI:
|
||||
```bash
|
||||
# List all MCP configurations
|
||||
praisonai mcp list
|
||||
|
||||
# Create a new MCP config
|
||||
praisonai mcp create filesystem npx -y @modelcontextprotocol/server-filesystem .
|
||||
|
||||
# Show a specific config
|
||||
praisonai mcp show filesystem
|
||||
|
||||
# Enable/disable a config
|
||||
praisonai mcp enable filesystem
|
||||
praisonai mcp disable filesystem
|
||||
|
||||
# Delete a config
|
||||
praisonai mcp delete filesystem
|
||||
|
||||
# Show all commands
|
||||
praisonai mcp help
|
||||
```
|
||||
|
||||
### AI Commit CLI:
|
||||
```bash
|
||||
# Generate AI commit message for staged changes
|
||||
praisonai commit
|
||||
|
||||
# Generate, commit, and push
|
||||
praisonai commit --push
|
||||
```
|
||||
|
||||
### @Mentions in Prompts:
|
||||
```bash
|
||||
# Include file content in prompt
|
||||
praisonai "@file:src/main.py explain this code"
|
||||
|
||||
# Include project doc
|
||||
praisonai "@doc:project-overview help me add a feature"
|
||||
|
||||
# Search the web
|
||||
praisonai "@web:python best practices give me tips"
|
||||
|
||||
# Fetch URL content
|
||||
praisonai "@url:https://docs.python.org summarize this"
|
||||
|
||||
# Combine multiple mentions
|
||||
praisonai "@file:main.py @doc:coding-standards review this code"
|
||||
```
|
||||
|
||||
## Prompt Expansion
|
||||
|
||||
Expand short prompts into detailed, actionable prompts:
|
||||
|
||||
### CLI Usage
|
||||
```bash
|
||||
# Expand a short prompt into detailed prompt
|
||||
praisonai "write a movie script in 3 lines" --expand-prompt
|
||||
|
||||
# With verbose output
|
||||
praisonai "blog about AI" --expand-prompt -v
|
||||
|
||||
# With tools for context gathering
|
||||
praisonai "latest AI trends" --expand-prompt --expand-tools tools.py
|
||||
|
||||
# Combine with query rewrite
|
||||
praisonai "AI news" --query-rewrite --expand-prompt
|
||||
```
|
||||
|
||||
### Programmatic Usage
|
||||
```python
|
||||
from praisonaiagents import PromptExpanderAgent, ExpandStrategy
|
||||
|
||||
# Basic usage
|
||||
agent = PromptExpanderAgent()
|
||||
result = agent.expand("write a movie script in 3 lines")
|
||||
print(result.expanded_prompt)
|
||||
|
||||
# With specific strategy
|
||||
result = agent.expand("blog about AI", strategy=ExpandStrategy.DETAILED)
|
||||
|
||||
# Available strategies: BASIC, DETAILED, STRUCTURED, CREATIVE, AUTO
|
||||
```
|
||||
|
||||
**Key Difference:**
|
||||
- `--query-rewrite`: Optimizes queries for search/retrieval (RAG)
|
||||
- `--expand-prompt`: Expands prompts for detailed task execution
|
||||
|
||||
## Web Search, Web Fetch & Prompt Caching
|
||||
|
||||
### CLI Usage
|
||||
```bash
|
||||
# Web Search - Get real-time information
|
||||
praisonai "What are the latest AI news today?" --web-search --llm openai/gpt-4o-search-preview
|
||||
|
||||
# Web Fetch - Retrieve and analyze URL content (Anthropic only)
|
||||
praisonai "Summarize https://docs.praison.ai" --web-fetch --llm anthropic/claude-sonnet-4-20250514
|
||||
|
||||
# Prompt Caching - Reduce costs for repeated prompts
|
||||
praisonai "Analyze this document..." --prompt-caching --llm anthropic/claude-sonnet-4-20250514
|
||||
```
|
||||
|
||||
### Programmatic Usage
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Web Search
|
||||
agent = Agent(
|
||||
instructions="You are a research assistant",
|
||||
llm="openai/gpt-4o-search-preview",
|
||||
web_search=True
|
||||
)
|
||||
|
||||
# Web Fetch (Anthropic only)
|
||||
agent = Agent(
|
||||
instructions="You are a content analyzer",
|
||||
llm="anthropic/claude-sonnet-4-20250514",
|
||||
web_fetch=True
|
||||
)
|
||||
|
||||
# Prompt Caching
|
||||
agent = Agent(
|
||||
instructions="You are an AI assistant..." * 50, # Long system prompt
|
||||
llm="anthropic/claude-sonnet-4-20250514",
|
||||
prompt_caching=True
|
||||
)
|
||||
```
|
||||
|
||||
**Supported Providers:**
|
||||
| Feature | Providers |
|
||||
|---------|----------|
|
||||
| Web Search | OpenAI, Gemini, Anthropic, xAI, Perplexity |
|
||||
| Web Fetch | Anthropic |
|
||||
| Prompt Caching | OpenAI (auto), Anthropic, Bedrock, Deepseek |
|
||||
|
||||
## MCP (Model Context Protocol)
|
||||
|
||||
PraisonAI supports MCP Protocol Revision 2025-11-25 with multiple transports.
|
||||
|
||||
### MCP Client (Consume MCP Servers)
|
||||
```python
|
||||
from praisonaiagents import Agent, MCP
|
||||
|
||||
# stdio - Local NPX/Python servers
|
||||
agent = Agent(tools=MCP("npx @modelcontextprotocol/server-memory"))
|
||||
|
||||
# Streamable HTTP - Production servers
|
||||
agent = Agent(tools=MCP("https://api.example.com/mcp"))
|
||||
|
||||
# WebSocket - Real-time bidirectional
|
||||
agent = Agent(tools=MCP("wss://api.example.com/mcp", auth_token="token"))
|
||||
|
||||
# SSE (Legacy) - Backward compatibility
|
||||
agent = Agent(tools=MCP("http://localhost:8080/sse"))
|
||||
|
||||
# With environment variables
|
||||
agent = Agent(
|
||||
tools=MCP(
|
||||
command="npx",
|
||||
args=["-y", "@modelcontextprotocol/server-brave-search"],
|
||||
env={"BRAVE_API_KEY": "your-key"}
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### MCP Server (Expose Tools as MCP Server)
|
||||
|
||||
Expose your Python functions as MCP tools for Claude Desktop, Cursor, and other MCP clients:
|
||||
|
||||
```python
|
||||
from praisonaiagents.mcp import ToolsMCPServer
|
||||
|
||||
def search_web(query: str, max_results: int = 5) -> dict:
|
||||
"""Search the web for information."""
|
||||
return {"results": [f"Result for {query}"]}
|
||||
|
||||
def calculate(expression: str) -> dict:
|
||||
"""Evaluate a mathematical expression."""
|
||||
return {"result": eval(expression)}
|
||||
|
||||
# Create and run MCP server
|
||||
server = ToolsMCPServer(name="my-tools")
|
||||
server.register_tools([search_web, calculate])
|
||||
server.run() # stdio for Claude Desktop
|
||||
# server.run_sse(host="0.0.0.0", port=8080) # SSE for web clients
|
||||
```
|
||||
|
||||
### MCP Features
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| Session Management | Automatic Mcp-Session-Id handling |
|
||||
| Protocol Versioning | Mcp-Protocol-Version header |
|
||||
| Resumability | SSE stream recovery via Last-Event-ID |
|
||||
| Security | Origin validation, DNS rebinding prevention |
|
||||
| WebSocket | Auto-reconnect with exponential backoff |
|
||||
|
||||
## CLI Features
|
||||
|
||||
| Feature | Docs |
|
||||
|---------|:----:|
|
||||
| 🔄 Query Rewrite - RAG optimization | [📖](https://docs.praison.ai/cli/query-rewrite) |
|
||||
| 🔬 Deep Research - Automated research | [📖](https://docs.praison.ai/cli/deep-research) |
|
||||
| 📋 Planning - Step-by-step execution | [📖](https://docs.praison.ai/cli/planning) |
|
||||
| 💾 Memory - Persistent agent memory | [📖](https://docs.praison.ai/cli/memory) |
|
||||
| 📜 Rules - Auto-discovered instructions | [📖](https://docs.praison.ai/cli/rules) |
|
||||
| 🔄 Workflow - Multi-step workflows | [📖](https://docs.praison.ai/cli/workflow) |
|
||||
| 🪝 Hooks - Event-driven actions | [📖](https://docs.praison.ai/cli/hooks) |
|
||||
| 🧠 Claude Memory - Anthropic memory tool | [📖](https://docs.praison.ai/cli/claude-memory) |
|
||||
| 🛡️ Guardrail - Output validation | [📖](https://docs.praison.ai/cli/guardrail) |
|
||||
| 📊 Metrics - Token usage tracking | [📖](https://docs.praison.ai/cli/metrics) |
|
||||
| 🖼️ Image - Vision processing | [📖](https://docs.praison.ai/cli/image) |
|
||||
| 📡 Telemetry - Usage monitoring | [📖](https://docs.praison.ai/cli/telemetry) |
|
||||
| 🔌 MCP - Model Context Protocol | [📖](https://docs.praison.ai/cli/mcp) |
|
||||
| ⚡ Fast Context - Codebase search | [📖](https://docs.praison.ai/cli/fast-context) |
|
||||
| 📚 Knowledge - RAG management | [📖](https://docs.praison.ai/cli/knowledge) |
|
||||
| 💬 Session - Conversation management | [📖](https://docs.praison.ai/cli/session) |
|
||||
| 🔧 Tools - Tool discovery | [📖](https://docs.praison.ai/cli/tools) |
|
||||
| 🤝 Handoff - Agent delegation | [📖](https://docs.praison.ai/cli/handoff) |
|
||||
| 🧠 Auto Memory - Memory extraction | [📖](https://docs.praison.ai/cli/auto-memory) |
|
||||
| 📋 Todo - Task management | [📖](https://docs.praison.ai/cli/todo) |
|
||||
| 🎯 Router - Smart model selection | [📖](https://docs.praison.ai/cli/router) |
|
||||
| 📈 Flow Display - Visual workflow | [📖](https://docs.praison.ai/cli/flow-display) |
|
||||
| ✨ Prompt Expansion - Detailed prompts | [📖](https://docs.praison.ai/cli/prompt-expansion) |
|
||||
| 🌐 Web Search - Real-time search | [📖](https://docs.praison.ai/cli/web-search) |
|
||||
| 📥 Web Fetch - URL content retrieval | [📖](https://docs.praison.ai/cli/web-fetch) |
|
||||
| 💾 Prompt Caching - Cost reduction | [📖](https://docs.praison.ai/cli/prompt-caching) |
|
||||
|
||||
## Using JavaScript Code
|
||||
|
||||
```bash
|
||||
@@ -928,7 +1554,6 @@ praisonai agents.yaml
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
## Custom Tools
|
||||
|
||||
### Using `@tool` Decorator
|
||||
@@ -1094,6 +1719,73 @@ agent = Agent(
|
||||
| Prompt Caching | OpenAI (auto), Anthropic, Bedrock, Deepseek |
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## MCP (Model Context Protocol)
|
||||
|
||||
PraisonAI supports MCP Protocol Revision 2025-11-25 with multiple transports.
|
||||
|
||||
### MCP Client (Consume MCP Servers)
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent, MCP
|
||||
|
||||
# stdio - Local NPX/Python servers
|
||||
agent = Agent(tools=MCP("npx @modelcontextprotocol/server-memory"))
|
||||
|
||||
# Streamable HTTP - Production servers
|
||||
agent = Agent(tools=MCP("https://api.example.com/mcp"))
|
||||
|
||||
# WebSocket - Real-time bidirectional
|
||||
agent = Agent(tools=MCP("wss://api.example.com/mcp", auth_token="token"))
|
||||
|
||||
# SSE (Legacy) - Backward compatibility
|
||||
agent = Agent(tools=MCP("http://localhost:8080/sse"))
|
||||
|
||||
# With environment variables
|
||||
agent = Agent(
|
||||
tools=MCP(
|
||||
command="npx",
|
||||
args=["-y", "@modelcontextprotocol/server-brave-search"],
|
||||
env={"BRAVE_API_KEY": "your-key"}
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### MCP Server (Expose Tools as MCP Server)
|
||||
|
||||
Expose your Python functions as MCP tools for Claude Desktop, Cursor, and other MCP clients:
|
||||
|
||||
```python
|
||||
from praisonaiagents.mcp import ToolsMCPServer
|
||||
|
||||
def search_web(query: str, max_results: int = 5) -> dict:
|
||||
"""Search the web for information."""
|
||||
return {"results": [f"Result for {query}"]}
|
||||
|
||||
def calculate(expression: str) -> dict:
|
||||
"""Evaluate a mathematical expression."""
|
||||
return {"result": eval(expression)}
|
||||
|
||||
# Create and run MCP server
|
||||
server = ToolsMCPServer(name="my-tools")
|
||||
server.register_tools([search_web, calculate])
|
||||
server.run() # stdio for Claude Desktop
|
||||
# server.run_sse(host="0.0.0.0", port=8080) # SSE for web clients
|
||||
```
|
||||
|
||||
### MCP Features
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Session Management** | Automatic `Mcp-Session-Id` handling |
|
||||
| **Protocol Versioning** | `Mcp-Protocol-Version` header |
|
||||
| **Resumability** | SSE stream recovery via `Last-Event-ID` |
|
||||
| **Security** | Origin validation, DNS rebinding prevention |
|
||||
| **WebSocket** | Auto-reconnect with exponential backoff |
|
||||
|
||||
|
||||
## Development:
|
||||
|
||||
Below is used for development only.
|
||||
@@ -1134,6 +1826,65 @@ cd src/praisonai && uv publish
|
||||
- Submit a pull request via GitHub's web interface.
|
||||
- Await feedback from project maintainers.
|
||||
|
||||
## Advanced Features
|
||||
|
||||
**Research & Intelligence:**
|
||||
- 🔬 **Deep Research Agents** (OpenAI & Gemini)
|
||||
- 🔄 **Query Rewriter Agent** (HyDE, Step-back, Multi-query)
|
||||
- 🌐 **Native Web Search** (OpenAI, Gemini, Anthropic, xAI, Perplexity)
|
||||
- 📥 **Web Fetch** (Retrieve full content from URLs - Anthropic)
|
||||
- 📝 **Prompt Expander Agent** (Expand short prompts into detailed instructions)
|
||||
|
||||
**Memory & Caching:**
|
||||
- 💾 **Prompt Caching** (Reduce costs & latency - OpenAI, Anthropic, Bedrock, Deepseek)
|
||||
- 🧠 **Claude Memory Tool** (Persistent cross-conversation memory - Anthropic Beta)
|
||||
- 💾 **File-Based Memory** (Zero-dependency persistent memory for all agents)
|
||||
- 🔍 **Built-in Search Tools** (Tavily, You.com, Exa - web search, news, content extraction)
|
||||
|
||||
**Planning & Workflows:**
|
||||
- 📋 **Planning Mode** (Plan before execution - Agent & Multi-Agent)
|
||||
- 🔧 **Planning Tools** (Research with tools during planning)
|
||||
- 🧠 **Planning Reasoning** (Chain-of-thought planning)
|
||||
- ⛓️ **Prompt Chaining** (Sequential prompt workflows with gates)
|
||||
- 🔍 **Evaluator Optimiser** (Generate and optimize through iterative feedback)
|
||||
- 👷 **Orchestrator Workers** (Distribute tasks among specialized workers)
|
||||
- ⚡ **Parallelisation** (Execute tasks in parallel for improved performance)
|
||||
- 🔁 **Repetitive Agents** (Handle repetitive tasks through automated loops)
|
||||
- 🤖 **Autonomous Workflow** (Monitor, act, adapt based on environment feedback)
|
||||
|
||||
**Agent Types:**
|
||||
- 🖼️ **Image Generation Agent** (Create images from text descriptions)
|
||||
- 📷 **Image to Text Agent** (Extract text and descriptions from images)
|
||||
- 🎬 **Video Agent** (Analyze and process video content)
|
||||
- 📊 **Data Analyst Agent** (Analyze data and generate insights)
|
||||
- 💰 **Finance Agent** (Financial analysis and recommendations)
|
||||
- 🛒 **Shopping Agent** (Price comparison and shopping assistance)
|
||||
- ⭐ **Recommendation Agent** (Personalized recommendations)
|
||||
- 📖 **Wikipedia Agent** (Search and extract Wikipedia information)
|
||||
- 💻 **Programming Agent** (Code development and analysis)
|
||||
- 📝 **Markdown Agent** (Generate and format Markdown content)
|
||||
- 🔀 **Router Agent** (Dynamic task routing with cost optimization)
|
||||
|
||||
**MCP Protocol:**
|
||||
- 🔌 **MCP Transports** (stdio, Streamable HTTP, WebSocket, SSE - Protocol 2025-11-25)
|
||||
- 🌐 **WebSocket MCP** (Real-time bidirectional connections with auto-reconnect)
|
||||
- 🔐 **MCP Security** (Origin validation, DNS rebinding prevention, secure sessions)
|
||||
- 🔄 **MCP Resumability** (SSE stream recovery via Last-Event-ID)
|
||||
|
||||
**Safety & Control:**
|
||||
- 🤝 **Agent Handoffs** (Transfer context between specialized agents)
|
||||
- 🛡️ **Guardrails** (Input/output validation and safety checks)
|
||||
- ✅ **Human Approval** (Require human confirmation for critical actions)
|
||||
- 💬 **Sessions Management** (Isolated conversation contexts)
|
||||
- 🔄 **Stateful Agents** (Maintain state across interactions)
|
||||
|
||||
**Developer Tools:**
|
||||
- ⚡ **Fast Context** (Rapid parallel code search - 10-20x faster than traditional methods)
|
||||
- 📜 **Rules & Instructions** (Auto-discover CLAUDE.md, AGENTS.md, GEMINI.md)
|
||||
- 🪝 **Hooks** (Pre/post operation hooks for custom logic)
|
||||
- 📈 **Telemetry** (Track agent performance and usage)
|
||||
- 📹 **Camera Integration** (Capture and analyze camera input)
|
||||
|
||||
## Other Features
|
||||
|
||||
- 🔄 Use CrewAI or AG2 (Formerly AutoGen) Framework
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
flask \
|
||||
"praisonai>=2.3.7" \
|
||||
"praisonai>=2.3.18" \
|
||||
"praisonai[api]" \
|
||||
gunicorn \
|
||||
markdown
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=2.3.7" \
|
||||
"praisonai>=2.3.18" \
|
||||
"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>=2.3.7" \
|
||||
"praisonai>=2.3.18" \
|
||||
"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>=2.3.7" \
|
||||
"praisonai>=2.3.18" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Fast Context with Agent - Basic Example
|
||||
|
||||
This is the simplest way to use Fast Context with an Agent.
|
||||
Just set fast_context=True and the agent will automatically
|
||||
have access to rapid code search capabilities.
|
||||
|
||||
Fast Context provides:
|
||||
- 10-20x faster code search than traditional methods
|
||||
- Parallel execution (8 concurrent searches)
|
||||
- Automatic caching for repeated queries
|
||||
- Seamless integration with Agent workflows
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("Agent with Fast Context - Basic Example")
|
||||
print("=" * 60)
|
||||
|
||||
# Create an agent with Fast Context enabled
|
||||
# This is the simplest configuration
|
||||
agent = Agent(
|
||||
name="CodeAssistant",
|
||||
instructions="You are a helpful code assistant.",
|
||||
fast_context=True, # Enable Fast Context
|
||||
fast_context_path=".", # Search in current directory
|
||||
verbose=False
|
||||
)
|
||||
|
||||
print("\n✓ Agent created with Fast Context enabled")
|
||||
print(f" - fast_context_enabled: {agent.fast_context_enabled}")
|
||||
print(f" - fast_context_path: {agent.fast_context_path}")
|
||||
print(f" - fast_context_model: {agent.fast_context_model}")
|
||||
|
||||
# Use delegate_to_fast_context to search code
|
||||
print("\n" + "-" * 60)
|
||||
print("Searching for 'class Agent' in the codebase...")
|
||||
print("-" * 60)
|
||||
|
||||
context = agent.delegate_to_fast_context("class Agent")
|
||||
|
||||
if context:
|
||||
print(f"\n✓ Found relevant code!")
|
||||
print(f" Context length: {len(context)} characters")
|
||||
print("\nPreview of found context:")
|
||||
print("-" * 40)
|
||||
# Show first 500 characters
|
||||
preview = context[:500]
|
||||
print(preview)
|
||||
if len(context) > 500:
|
||||
print("...")
|
||||
else:
|
||||
print("\n✗ No relevant code found")
|
||||
|
||||
# Another search example
|
||||
print("\n" + "-" * 60)
|
||||
print("Searching for 'def chat' in the codebase...")
|
||||
print("-" * 60)
|
||||
|
||||
context = agent.delegate_to_fast_context("def chat")
|
||||
|
||||
if context:
|
||||
print(f"\n✓ Found relevant code!")
|
||||
print(f" Context length: {len(context)} characters")
|
||||
else:
|
||||
print("\n✗ No relevant code found")
|
||||
|
||||
# Access FastContext directly for more control
|
||||
print("\n" + "-" * 60)
|
||||
print("Direct FastContext access for advanced usage...")
|
||||
print("-" * 60)
|
||||
|
||||
fc = agent.fast_context
|
||||
if fc:
|
||||
result = fc.search("import")
|
||||
print(f"\n✓ Direct search for 'import':")
|
||||
print(f" Files found: {result.total_files}")
|
||||
print(f" Search time: {result.search_time_ms}ms")
|
||||
print(f" From cache: {result.from_cache}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Fast Context makes code search instant for your agents!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
Example 1: Basic Fast Context Usage
|
||||
|
||||
This example demonstrates the basic usage of Fast Context for rapid code search.
|
||||
Fast Context is 10-20x faster than traditional sequential search because it:
|
||||
- Executes up to 8 tool calls in parallel
|
||||
- Limits search to 4 turns maximum
|
||||
- Uses a lightweight model optimized for search
|
||||
"""
|
||||
|
||||
from praisonaiagents.context.fast import FastContext
|
||||
import time
|
||||
|
||||
def main():
|
||||
print("=" * 70)
|
||||
print("Fast Context - Basic Usage Example")
|
||||
print("=" * 70)
|
||||
|
||||
# Create FastContext instance pointing to the praisonai-agents codebase
|
||||
fc = FastContext(
|
||||
workspace_path="/Users/praison/praisonai-package/src/praisonai-agents"
|
||||
)
|
||||
|
||||
# Example 1: Simple pattern search
|
||||
print("\n1. Simple Pattern Search")
|
||||
print("-" * 40)
|
||||
|
||||
start = time.perf_counter()
|
||||
result = fc.search("def chat")
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
|
||||
print(f" Query: 'def chat'")
|
||||
print(f" Files found: {result.total_files}")
|
||||
print(f" Search time: {result.search_time_ms}ms (total: {elapsed:.0f}ms)")
|
||||
print(f" Turns used: {result.turns_used}")
|
||||
print(f" Tool calls: {result.total_tool_calls}")
|
||||
|
||||
if result.files:
|
||||
print("\n Top matches:")
|
||||
for f in result.files[:3]:
|
||||
print(f" - {f.path}")
|
||||
for lr in f.line_ranges[:2]:
|
||||
print(f" Lines {lr.start}-{lr.end}")
|
||||
|
||||
# Example 2: Search for class definitions
|
||||
print("\n2. Search for Class Definitions")
|
||||
print("-" * 40)
|
||||
|
||||
result = fc.search("class Agent")
|
||||
print(f" Query: 'class Agent'")
|
||||
print(f" Files found: {result.total_files}")
|
||||
print(f" Search time: {result.search_time_ms}ms")
|
||||
|
||||
if result.files:
|
||||
print("\n Files containing 'class Agent':")
|
||||
for f in result.files[:5]:
|
||||
print(f" - {f.path} (relevance: {f.relevance_score:.2f})")
|
||||
|
||||
# Example 3: Search for imports
|
||||
print("\n3. Search for Specific Imports")
|
||||
print("-" * 40)
|
||||
|
||||
result = fc.search("from typing import")
|
||||
print(f" Query: 'from typing import'")
|
||||
print(f" Files found: {result.total_files}")
|
||||
print(f" Search time: {result.search_time_ms}ms")
|
||||
|
||||
# Example 4: Get formatted context string
|
||||
print("\n4. Get Formatted Context for Agent")
|
||||
print("-" * 40)
|
||||
|
||||
context = fc.get_context_for_agent("authentication", max_files=3)
|
||||
print(f" Context length: {len(context)} characters")
|
||||
print(f" Preview:")
|
||||
preview = context[:300].replace('\n', '\n ')
|
||||
print(f" {preview}...")
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("Fast Context provides rapid, parallel code search!")
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
Example 2: Parallel vs Sequential Search Comparison
|
||||
|
||||
This example demonstrates the speed advantage of Fast Context's parallel
|
||||
execution compared to traditional sequential search.
|
||||
|
||||
Key insight: Fast Context executes up to 8 searches simultaneously,
|
||||
making it 5-10x faster for complex queries.
|
||||
"""
|
||||
|
||||
import time
|
||||
import os
|
||||
from praisonaiagents.context.fast import FastContext
|
||||
from praisonaiagents.context.fast.search_tools import grep_search, glob_search
|
||||
|
||||
WORKSPACE = "/Users/praison/praisonai-package/src/praisonai-agents"
|
||||
|
||||
|
||||
def sequential_search(queries: list) -> dict:
|
||||
"""Traditional sequential search - one query at a time."""
|
||||
results = {}
|
||||
for query in queries:
|
||||
results[query] = grep_search(
|
||||
search_path=WORKSPACE,
|
||||
pattern=query,
|
||||
max_results=20
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def parallel_search_with_fast_context(queries: list) -> dict:
|
||||
"""Fast Context parallel search - multiple queries at once."""
|
||||
fc = FastContext(workspace_path=WORKSPACE)
|
||||
results = {}
|
||||
for query in queries:
|
||||
result = fc.search(query)
|
||||
results[query] = result
|
||||
return results
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 70)
|
||||
print("Parallel vs Sequential Search Comparison")
|
||||
print("=" * 70)
|
||||
|
||||
# Define multiple search queries
|
||||
queries = [
|
||||
"def __init__",
|
||||
"class Agent",
|
||||
"async def",
|
||||
"import logging",
|
||||
"from typing",
|
||||
]
|
||||
|
||||
print(f"\nSearching for {len(queries)} patterns in the codebase...")
|
||||
print(f"Workspace: {WORKSPACE}")
|
||||
|
||||
# Sequential search
|
||||
print("\n1. Sequential Search (Traditional)")
|
||||
print("-" * 40)
|
||||
|
||||
start = time.perf_counter()
|
||||
seq_results = sequential_search(queries)
|
||||
seq_time = (time.perf_counter() - start) * 1000
|
||||
|
||||
total_seq_matches = sum(len(r) for r in seq_results.values())
|
||||
print(f" Total matches: {total_seq_matches}")
|
||||
print(f" Time: {seq_time:.0f}ms")
|
||||
|
||||
# Fast Context search (with caching disabled for fair comparison)
|
||||
print("\n2. Fast Context Search (Parallel)")
|
||||
print("-" * 40)
|
||||
|
||||
fc = FastContext(workspace_path=WORKSPACE, cache_enabled=False)
|
||||
|
||||
start = time.perf_counter()
|
||||
fc_results = {}
|
||||
for query in queries:
|
||||
fc_results[query] = fc.search(query)
|
||||
fc_time = (time.perf_counter() - start) * 1000
|
||||
|
||||
total_fc_matches = sum(r.total_files for r in fc_results.values())
|
||||
print(f" Total files found: {total_fc_matches}")
|
||||
print(f" Time: {fc_time:.0f}ms")
|
||||
|
||||
# Comparison
|
||||
print("\n3. Comparison")
|
||||
print("-" * 40)
|
||||
|
||||
speedup = seq_time / fc_time if fc_time > 0 else float('inf')
|
||||
print(f" Sequential: {seq_time:.0f}ms")
|
||||
print(f" Fast Context: {fc_time:.0f}ms")
|
||||
print(f" Speedup: {speedup:.1f}x faster")
|
||||
|
||||
# Detailed results
|
||||
print("\n4. Results by Query")
|
||||
print("-" * 40)
|
||||
|
||||
for query in queries:
|
||||
seq_count = len(seq_results[query])
|
||||
fc_count = fc_results[query].total_files
|
||||
fc_ms = fc_results[query].search_time_ms
|
||||
print(f" '{query}':")
|
||||
print(f" Sequential: {seq_count} matches")
|
||||
print(f" Fast Context: {fc_count} files in {fc_ms}ms")
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print(f"Fast Context is {speedup:.1f}x faster due to parallel execution!")
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
Example 3: Caching Performance Demonstration
|
||||
|
||||
This example shows how Fast Context's caching dramatically improves
|
||||
performance for repeated queries.
|
||||
|
||||
Key benefits:
|
||||
- First query: Full search execution
|
||||
- Subsequent queries: Instant cache hit (0ms search time)
|
||||
- Configurable TTL (time-to-live)
|
||||
"""
|
||||
|
||||
import time
|
||||
from praisonaiagents.context.fast import FastContext
|
||||
|
||||
WORKSPACE = "/Users/praison/praisonai-package/src/praisonai-agents"
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 70)
|
||||
print("Fast Context Caching Performance")
|
||||
print("=" * 70)
|
||||
|
||||
# Create FastContext with caching enabled
|
||||
fc = FastContext(
|
||||
workspace_path=WORKSPACE,
|
||||
cache_enabled=True,
|
||||
cache_ttl=300 # 5 minutes
|
||||
)
|
||||
|
||||
query = "def execute"
|
||||
|
||||
print(f"\nQuery: '{query}'")
|
||||
print(f"Cache TTL: 300 seconds")
|
||||
|
||||
# First search - cache miss
|
||||
print("\n1. First Search (Cache Miss)")
|
||||
print("-" * 40)
|
||||
|
||||
start = time.perf_counter()
|
||||
result1 = fc.search(query)
|
||||
elapsed1 = (time.perf_counter() - start) * 1000
|
||||
|
||||
print(f" Files found: {result1.total_files}")
|
||||
print(f" From cache: {result1.from_cache}")
|
||||
print(f" Search time: {result1.search_time_ms}ms")
|
||||
print(f" Total time: {elapsed1:.1f}ms")
|
||||
|
||||
# Second search - cache hit
|
||||
print("\n2. Second Search (Cache Hit)")
|
||||
print("-" * 40)
|
||||
|
||||
start = time.perf_counter()
|
||||
result2 = fc.search(query)
|
||||
elapsed2 = (time.perf_counter() - start) * 1000
|
||||
|
||||
print(f" Files found: {result2.total_files}")
|
||||
print(f" From cache: {result2.from_cache}")
|
||||
print(f" Search time: {result2.search_time_ms}ms")
|
||||
print(f" Total time: {elapsed2:.1f}ms")
|
||||
|
||||
# Third search - still cached
|
||||
print("\n3. Third Search (Still Cached)")
|
||||
print("-" * 40)
|
||||
|
||||
start = time.perf_counter()
|
||||
result3 = fc.search(query)
|
||||
elapsed3 = (time.perf_counter() - start) * 1000
|
||||
|
||||
print(f" Files found: {result3.total_files}")
|
||||
print(f" From cache: {result3.from_cache}")
|
||||
print(f" Total time: {elapsed3:.1f}ms")
|
||||
|
||||
# Clear cache and search again
|
||||
print("\n4. After Cache Clear")
|
||||
print("-" * 40)
|
||||
|
||||
fc.clear_cache()
|
||||
print(" Cache cleared!")
|
||||
|
||||
start = time.perf_counter()
|
||||
result4 = fc.search(query)
|
||||
elapsed4 = (time.perf_counter() - start) * 1000
|
||||
|
||||
print(f" Files found: {result4.total_files}")
|
||||
print(f" From cache: {result4.from_cache}")
|
||||
print(f" Search time: {result4.search_time_ms}ms")
|
||||
print(f" Total time: {elapsed4:.1f}ms")
|
||||
|
||||
# Performance comparison
|
||||
print("\n5. Performance Summary")
|
||||
print("-" * 40)
|
||||
|
||||
cache_speedup = elapsed1 / elapsed2 if elapsed2 > 0 else float('inf')
|
||||
|
||||
print(f" First search (no cache): {elapsed1:.1f}ms")
|
||||
print(f" Cached searches: ~{elapsed2:.1f}ms")
|
||||
print(f" Cache speedup: {cache_speedup:.0f}x faster")
|
||||
|
||||
# Multiple different queries
|
||||
print("\n6. Multiple Query Caching")
|
||||
print("-" * 40)
|
||||
|
||||
queries = ["class Agent", "async def", "import os", "def __init__"]
|
||||
|
||||
# First pass - populate cache
|
||||
print(" First pass (populating cache):")
|
||||
start = time.perf_counter()
|
||||
for q in queries:
|
||||
fc.search(q)
|
||||
first_pass = (time.perf_counter() - start) * 1000
|
||||
print(f" Time: {first_pass:.0f}ms")
|
||||
|
||||
# Second pass - all cached
|
||||
print(" Second pass (all cached):")
|
||||
start = time.perf_counter()
|
||||
for q in queries:
|
||||
r = fc.search(q)
|
||||
assert r.from_cache, f"Expected cache hit for '{q}'"
|
||||
second_pass = (time.perf_counter() - start) * 1000
|
||||
print(f" Time: {second_pass:.1f}ms")
|
||||
|
||||
multi_speedup = first_pass / second_pass if second_pass > 0 else float('inf')
|
||||
print(f" Speedup: {multi_speedup:.0f}x faster")
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("Caching provides instant results for repeated queries!")
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
Example 4: Agent Integration with Fast Context
|
||||
|
||||
This example shows how to use Fast Context integrated with the Agent class
|
||||
for automatic code search delegation.
|
||||
|
||||
Benefits:
|
||||
- Seamless integration with existing Agent workflows
|
||||
- Automatic context injection for code-related queries
|
||||
- Configurable via Agent parameters or environment variables
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent
|
||||
|
||||
WORKSPACE = "/Users/praison/praisonai-package/src/praisonai-agents"
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 70)
|
||||
print("Agent Integration with Fast Context")
|
||||
print("=" * 70)
|
||||
|
||||
# Create an agent with Fast Context enabled
|
||||
print("\n1. Creating Agent with Fast Context")
|
||||
print("-" * 40)
|
||||
|
||||
agent = Agent(
|
||||
name="CodeAssistant",
|
||||
instructions="You are a helpful code assistant that can search and analyze code.",
|
||||
fast_context=True,
|
||||
fast_context_path=WORKSPACE,
|
||||
fast_context_model="gpt-4o-mini",
|
||||
fast_context_max_turns=4,
|
||||
fast_context_parallelism=8,
|
||||
fast_context_timeout=30.0,
|
||||
verbose=False
|
||||
)
|
||||
|
||||
print(f" Agent name: {agent.name}")
|
||||
print(f" Fast Context enabled: {agent.fast_context_enabled}")
|
||||
print(f" Fast Context path: {agent.fast_context_path}")
|
||||
print(f" Fast Context model: {agent.fast_context_model}")
|
||||
print(f" Max turns: {agent.fast_context_max_turns}")
|
||||
print(f" Parallelism: {agent.fast_context_parallelism}")
|
||||
|
||||
# Use delegate_to_fast_context for code search
|
||||
print("\n2. Delegating Code Search to Fast Context")
|
||||
print("-" * 40)
|
||||
|
||||
queries = [
|
||||
"find the Agent class definition",
|
||||
"search for authentication handlers",
|
||||
"locate memory management code",
|
||||
]
|
||||
|
||||
for query in queries:
|
||||
print(f"\n Query: '{query}'")
|
||||
context = agent.delegate_to_fast_context(query)
|
||||
|
||||
if context:
|
||||
# Show preview of context
|
||||
lines = context.split('\n')
|
||||
preview_lines = lines[:5]
|
||||
print(f" Found context ({len(context)} chars):")
|
||||
for line in preview_lines:
|
||||
print(f" {line[:60]}{'...' if len(line) > 60 else ''}")
|
||||
if len(lines) > 5:
|
||||
print(f" ... and {len(lines) - 5} more lines")
|
||||
else:
|
||||
print(" No relevant code found")
|
||||
|
||||
# Access the FastContext instance directly
|
||||
print("\n3. Direct FastContext Access")
|
||||
print("-" * 40)
|
||||
|
||||
fc = agent.fast_context
|
||||
if fc:
|
||||
result = fc.search("def chat")
|
||||
print(f" Direct search for 'def chat':")
|
||||
print(f" Files found: {result.total_files}")
|
||||
print(f" Search time: {result.search_time_ms}ms")
|
||||
|
||||
for f in result.files[:3]:
|
||||
print(f" - {f.path}")
|
||||
|
||||
# Show configuration options
|
||||
print("\n4. Configuration Options")
|
||||
print("-" * 40)
|
||||
|
||||
print(" Agent parameters:")
|
||||
print(" - fast_context=True # Enable Fast Context")
|
||||
print(" - fast_context_path='.' # Workspace path")
|
||||
print(" - fast_context_model='gpt-4o-mini' # Search model")
|
||||
print(" - fast_context_max_turns=4 # Max search turns")
|
||||
print(" - fast_context_parallelism=8 # Parallel calls")
|
||||
print(" - fast_context_timeout=30.0 # Timeout (seconds)")
|
||||
|
||||
print("\n Environment variables:")
|
||||
print(" - FAST_CONTEXT_MODEL")
|
||||
print(" - FAST_CONTEXT_MAX_TURNS")
|
||||
print(" - FAST_CONTEXT_PARALLELISM")
|
||||
print(" - FAST_CONTEXT_TIMEOUT")
|
||||
print(" - FAST_CONTEXT_CACHE")
|
||||
print(" - FAST_CONTEXT_CACHE_TTL")
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("Fast Context integrates seamlessly with Agent for code search!")
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
Example 5: File and Symbol Indexing
|
||||
|
||||
This example demonstrates the file and symbol indexing capabilities
|
||||
that power Fast Context's rapid search.
|
||||
|
||||
Features:
|
||||
- FileIndexer: Index file metadata for fast lookups
|
||||
- SymbolIndexer: Extract code symbols (functions, classes, imports)
|
||||
- Support for Python, JavaScript, TypeScript, Go, Rust, Java
|
||||
"""
|
||||
|
||||
import time
|
||||
from praisonaiagents.context.fast.indexer import (
|
||||
FileIndexer,
|
||||
SymbolIndexer,
|
||||
SymbolType
|
||||
)
|
||||
|
||||
WORKSPACE = "/Users/praison/praisonai-package/src/praisonai-agents"
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 70)
|
||||
print("File and Symbol Indexing")
|
||||
print("=" * 70)
|
||||
|
||||
# File Indexer
|
||||
print("\n1. File Indexer")
|
||||
print("-" * 40)
|
||||
|
||||
start = time.perf_counter()
|
||||
file_indexer = FileIndexer(workspace_path=WORKSPACE)
|
||||
file_count = file_indexer.index()
|
||||
file_time = (time.perf_counter() - start) * 1000
|
||||
|
||||
print(f" Indexed {file_count} files in {file_time:.0f}ms")
|
||||
|
||||
stats = file_indexer.get_stats()
|
||||
print(f" Total size: {stats['total_size_mb']} MB")
|
||||
print(f" Extensions: {list(stats['extensions'].keys())[:5]}")
|
||||
|
||||
# Find files by pattern
|
||||
print("\n Finding files by pattern:")
|
||||
py_files = file_indexer.find_by_pattern("**/*.py")
|
||||
print(f" - Python files (*.py): {len(py_files)}")
|
||||
|
||||
agent_files = file_indexer.find_by_pattern("**/agent*.py")
|
||||
print(f" - Agent files (agent*.py): {len(agent_files)}")
|
||||
|
||||
# Find files by name
|
||||
print("\n Finding files by name:")
|
||||
init_files = file_indexer.find_by_name("__init__.py", exact=True)
|
||||
print(f" - __init__.py files: {len(init_files)}")
|
||||
|
||||
agent_matches = file_indexer.find_by_name("agent")
|
||||
print(f" - Files containing 'agent': {len(agent_matches)}")
|
||||
|
||||
# Symbol Indexer
|
||||
print("\n2. Symbol Indexer")
|
||||
print("-" * 40)
|
||||
|
||||
start = time.perf_counter()
|
||||
symbol_indexer = SymbolIndexer(workspace_path=WORKSPACE)
|
||||
symbol_count = symbol_indexer.index()
|
||||
symbol_time = (time.perf_counter() - start) * 1000
|
||||
|
||||
print(f" Indexed {symbol_count} symbols in {symbol_time:.0f}ms")
|
||||
|
||||
stats = symbol_indexer.get_stats()
|
||||
print(f" Files indexed: {stats['total_files']}")
|
||||
print(f" Symbols by type:")
|
||||
for sym_type, count in stats['by_type'].items():
|
||||
print(f" - {sym_type}: {count}")
|
||||
|
||||
# Find symbols by name
|
||||
print("\n Finding symbols by name:")
|
||||
|
||||
agent_symbols = symbol_indexer.find_by_name("Agent", exact=True)
|
||||
print(f" - 'Agent' (exact): {len(agent_symbols)} symbols")
|
||||
for sym in agent_symbols[:3]:
|
||||
print(f" {sym.symbol_type.value}: {sym.name} in {sym.file_path}:{sym.line_number}")
|
||||
|
||||
chat_symbols = symbol_indexer.find_by_name("chat")
|
||||
print(f" - 'chat' (partial): {len(chat_symbols)} symbols")
|
||||
for sym in chat_symbols[:3]:
|
||||
print(f" {sym.symbol_type.value}: {sym.name} in {sym.file_path}:{sym.line_number}")
|
||||
|
||||
# Find symbols by type
|
||||
print("\n Finding symbols by type:")
|
||||
|
||||
classes = symbol_indexer.find_by_type(SymbolType.CLASS)
|
||||
print(f" - Classes: {len(classes)}")
|
||||
for cls in classes[:5]:
|
||||
print(f" class {cls.name} in {cls.file_path}:{cls.line_number}")
|
||||
|
||||
functions = symbol_indexer.find_by_type(SymbolType.FUNCTION)
|
||||
print(f" - Functions: {len(functions)}")
|
||||
|
||||
methods = symbol_indexer.find_by_type(SymbolType.METHOD)
|
||||
print(f" - Methods: {len(methods)}")
|
||||
|
||||
imports = symbol_indexer.find_by_type(SymbolType.IMPORT)
|
||||
print(f" - Imports: {len(imports)}")
|
||||
|
||||
# Performance comparison
|
||||
print("\n3. Performance Summary")
|
||||
print("-" * 40)
|
||||
|
||||
print(f" File indexing: {file_time:.0f}ms for {file_count} files")
|
||||
print(f" Symbol indexing: {symbol_time:.0f}ms for {symbol_count} symbols")
|
||||
print(f" Files per second: {file_count / (file_time / 1000):.0f}")
|
||||
print(f" Symbols per second: {symbol_count / (symbol_time / 1000):.0f}")
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("Indexing enables instant lookups for files and symbols!")
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
Example 6: Context Injection for Agents
|
||||
|
||||
This example demonstrates how Fast Context results can be formatted
|
||||
and injected into agent prompts for better code understanding.
|
||||
|
||||
Features:
|
||||
- Multiple format styles (markdown, xml, plain)
|
||||
- Token budget management
|
||||
- Precision over recall (avoid context pollution)
|
||||
"""
|
||||
|
||||
from praisonaiagents.context.fast import FastContext
|
||||
from praisonaiagents.context.fast.context_injector import (
|
||||
ContextInjector,
|
||||
InjectionConfig,
|
||||
inject_fast_context
|
||||
)
|
||||
|
||||
WORKSPACE = "/Users/praison/praisonai-package/src/praisonai-agents"
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 70)
|
||||
print("Context Injection for Agents")
|
||||
print("=" * 70)
|
||||
|
||||
# Search for code
|
||||
fc = FastContext(workspace_path=WORKSPACE)
|
||||
result = fc.search("class Agent")
|
||||
|
||||
print(f"\nSearch results: {result.total_files} files found")
|
||||
|
||||
# Markdown format (default)
|
||||
print("\n1. Markdown Format")
|
||||
print("-" * 40)
|
||||
|
||||
config_md = InjectionConfig(
|
||||
max_tokens=2000,
|
||||
max_files=3,
|
||||
max_lines_per_file=20,
|
||||
format_style="markdown",
|
||||
include_line_numbers=True,
|
||||
include_file_content=True,
|
||||
prioritize_precision=True
|
||||
)
|
||||
|
||||
injector_md = ContextInjector(config_md)
|
||||
md_context = injector_md.format_context(result)
|
||||
|
||||
print(f" Format: Markdown")
|
||||
print(f" Length: {len(md_context)} chars (~{len(md_context)//4} tokens)")
|
||||
print(f" Preview:")
|
||||
for line in md_context.split('\n')[:10]:
|
||||
print(f" {line[:70]}")
|
||||
print(" ...")
|
||||
|
||||
# XML format
|
||||
print("\n2. XML Format")
|
||||
print("-" * 40)
|
||||
|
||||
config_xml = InjectionConfig(
|
||||
max_tokens=2000,
|
||||
max_files=3,
|
||||
format_style="xml",
|
||||
prioritize_precision=True
|
||||
)
|
||||
|
||||
injector_xml = ContextInjector(config_xml)
|
||||
xml_context = injector_xml.format_context(result)
|
||||
|
||||
print(f" Format: XML")
|
||||
print(f" Length: {len(xml_context)} chars")
|
||||
print(f" Preview:")
|
||||
for line in xml_context.split('\n')[:10]:
|
||||
print(f" {line[:70]}")
|
||||
print(" ...")
|
||||
|
||||
# Plain text format
|
||||
print("\n3. Plain Text Format")
|
||||
print("-" * 40)
|
||||
|
||||
config_plain = InjectionConfig(
|
||||
max_tokens=2000,
|
||||
max_files=3,
|
||||
format_style="plain"
|
||||
)
|
||||
|
||||
injector_plain = ContextInjector(config_plain)
|
||||
plain_context = injector_plain.format_context(result)
|
||||
|
||||
print(f" Format: Plain")
|
||||
print(f" Length: {len(plain_context)} chars")
|
||||
print(f" Preview:")
|
||||
for line in plain_context.split('\n')[:8]:
|
||||
print(f" {line[:70]}")
|
||||
print(" ...")
|
||||
|
||||
# Inject into prompts
|
||||
print("\n4. Injecting into Prompts")
|
||||
print("-" * 40)
|
||||
|
||||
system_prompt = "You are a helpful code assistant."
|
||||
user_message = "Explain how the Agent class works."
|
||||
|
||||
injected = inject_fast_context(
|
||||
result=result,
|
||||
system_prompt=system_prompt,
|
||||
user_message=user_message,
|
||||
max_tokens=1500,
|
||||
format_style="markdown"
|
||||
)
|
||||
|
||||
print(f" Original system prompt: {len(system_prompt)} chars")
|
||||
print(f" Injected system prompt: {len(injected['system_prompt'])} chars")
|
||||
print(f" User message unchanged: {injected['user_message'] == user_message}")
|
||||
|
||||
# Token budget management
|
||||
print("\n5. Token Budget Management")
|
||||
print("-" * 40)
|
||||
|
||||
budgets = [500, 1000, 2000, 4000]
|
||||
|
||||
for budget in budgets:
|
||||
config = InjectionConfig(max_tokens=budget, max_files=10)
|
||||
injector = ContextInjector(config)
|
||||
context = injector.format_context(result)
|
||||
estimated_tokens = len(context) // 4
|
||||
print(f" Budget: {budget} tokens -> {len(context)} chars (~{estimated_tokens} tokens)")
|
||||
|
||||
# Precision mode
|
||||
print("\n6. Precision vs Recall")
|
||||
print("-" * 40)
|
||||
|
||||
# High precision (fewer, more relevant results)
|
||||
config_precision = InjectionConfig(
|
||||
max_tokens=2000,
|
||||
max_files=5,
|
||||
prioritize_precision=True
|
||||
)
|
||||
injector_precision = ContextInjector(config_precision)
|
||||
precision_context = injector_precision.format_context(result)
|
||||
|
||||
# Lower precision (more results)
|
||||
config_recall = InjectionConfig(
|
||||
max_tokens=2000,
|
||||
max_files=10,
|
||||
prioritize_precision=False
|
||||
)
|
||||
injector_recall = ContextInjector(config_recall)
|
||||
recall_context = injector_recall.format_context(result)
|
||||
|
||||
print(f" High precision mode: {len(precision_context)} chars")
|
||||
print(f" High recall mode: {len(recall_context)} chars")
|
||||
print(f" Precision mode is more focused for better agent understanding")
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("Context injection formats search results for optimal agent use!")
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
Example 7: Search Tools Deep Dive
|
||||
|
||||
This example demonstrates the individual search tools that power Fast Context:
|
||||
- grep_search: Pattern-based search with regex support
|
||||
- glob_search: File pattern matching
|
||||
- read_file: Read file contents with line ranges
|
||||
- list_directory: Directory listing with filtering
|
||||
"""
|
||||
|
||||
import time
|
||||
from praisonaiagents.context.fast.search_tools import (
|
||||
grep_search,
|
||||
glob_search,
|
||||
read_file,
|
||||
list_directory
|
||||
)
|
||||
|
||||
WORKSPACE = "/Users/praison/praisonai-package/src/praisonai-agents"
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 70)
|
||||
print("Search Tools Deep Dive")
|
||||
print("=" * 70)
|
||||
|
||||
# grep_search
|
||||
print("\n1. grep_search - Pattern Search")
|
||||
print("-" * 40)
|
||||
|
||||
# Simple pattern
|
||||
start = time.perf_counter()
|
||||
results = grep_search(
|
||||
search_path=WORKSPACE,
|
||||
pattern="def __init__",
|
||||
max_results=10
|
||||
)
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
|
||||
print(f" Pattern: 'def __init__'")
|
||||
print(f" Matches: {len(results)}")
|
||||
print(f" Time: {elapsed:.0f}ms")
|
||||
if results:
|
||||
print(f" First match: {results[0]['path']}:{results[0]['line_number']}")
|
||||
|
||||
# Regex pattern
|
||||
results = grep_search(
|
||||
search_path=WORKSPACE,
|
||||
pattern=r"class \w+Agent",
|
||||
is_regex=True,
|
||||
max_results=10
|
||||
)
|
||||
print(f"\n Regex: 'class \\w+Agent'")
|
||||
print(f" Matches: {len(results)}")
|
||||
for r in results[:3]:
|
||||
print(f" - {r['path']}:{r['line_number']}: {r['content'][:50]}...")
|
||||
|
||||
# Case sensitive
|
||||
results_sensitive = grep_search(
|
||||
search_path=WORKSPACE,
|
||||
pattern="Agent",
|
||||
case_sensitive=True,
|
||||
max_results=50
|
||||
)
|
||||
results_insensitive = grep_search(
|
||||
search_path=WORKSPACE,
|
||||
pattern="agent",
|
||||
case_sensitive=False,
|
||||
max_results=50
|
||||
)
|
||||
print(f"\n Case sensitive 'Agent': {len(results_sensitive)} matches")
|
||||
print(f" Case insensitive 'agent': {len(results_insensitive)} matches")
|
||||
|
||||
# With context lines
|
||||
results = grep_search(
|
||||
search_path=WORKSPACE,
|
||||
pattern="class Agent:",
|
||||
context_lines=2,
|
||||
max_results=3
|
||||
)
|
||||
print(f"\n With 2 context lines:")
|
||||
if results:
|
||||
print(f" {results[0]['path']}:")
|
||||
if results[0].get('context'):
|
||||
for line in results[0]['context'].split('\n')[:5]:
|
||||
print(f" {line[:60]}")
|
||||
|
||||
# glob_search
|
||||
print("\n2. glob_search - File Pattern Matching")
|
||||
print("-" * 40)
|
||||
|
||||
# Find Python files
|
||||
start = time.perf_counter()
|
||||
results = glob_search(
|
||||
search_path=WORKSPACE,
|
||||
pattern="**/*.py",
|
||||
max_results=100
|
||||
)
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
|
||||
print(f" Pattern: '**/*.py'")
|
||||
print(f" Files found: {len(results)}")
|
||||
print(f" Time: {elapsed:.0f}ms")
|
||||
|
||||
# Find specific files
|
||||
results = glob_search(
|
||||
search_path=WORKSPACE,
|
||||
pattern="**/agent*.py",
|
||||
max_results=20
|
||||
)
|
||||
print(f"\n Pattern: '**/agent*.py'")
|
||||
print(f" Files found: {len(results)}")
|
||||
for r in results[:5]:
|
||||
print(f" - {r['path']} ({r['size']} bytes)")
|
||||
|
||||
# Find test files
|
||||
results = glob_search(
|
||||
search_path=WORKSPACE,
|
||||
pattern="**/test_*.py",
|
||||
max_results=50
|
||||
)
|
||||
print(f"\n Pattern: '**/test_*.py'")
|
||||
print(f" Test files: {len(results)}")
|
||||
|
||||
# read_file
|
||||
print("\n3. read_file - Read File Contents")
|
||||
print("-" * 40)
|
||||
|
||||
# Read entire file
|
||||
result = read_file(
|
||||
filepath=f"{WORKSPACE}/praisonaiagents/__init__.py",
|
||||
max_lines=20
|
||||
)
|
||||
|
||||
print(f" File: praisonaiagents/__init__.py")
|
||||
print(f" Success: {result['success']}")
|
||||
print(f" Lines: {result.get('total_lines', 'N/A')}")
|
||||
if result['success']:
|
||||
lines = result['content'].split('\n')[:5]
|
||||
print(f" Preview:")
|
||||
for line in lines:
|
||||
print(f" {line[:60]}")
|
||||
|
||||
# Read specific line range
|
||||
result = read_file(
|
||||
filepath=f"{WORKSPACE}/praisonaiagents/agent/agent.py",
|
||||
start_line=1,
|
||||
end_line=20
|
||||
)
|
||||
|
||||
print(f"\n File: agent/agent.py (lines 1-20)")
|
||||
print(f" Success: {result['success']}")
|
||||
if result['success']:
|
||||
lines = result['content'].split('\n')[:5]
|
||||
print(f" Preview:")
|
||||
for line in lines:
|
||||
print(f" {line[:60]}")
|
||||
|
||||
# Read with context
|
||||
result = read_file(
|
||||
filepath=f"{WORKSPACE}/praisonaiagents/agent/agent.py",
|
||||
start_line=100,
|
||||
end_line=105,
|
||||
context_lines=3
|
||||
)
|
||||
|
||||
print(f"\n File: agent/agent.py (lines 100-105 with 3 context lines)")
|
||||
print(f" Success: {result['success']}")
|
||||
|
||||
# list_directory
|
||||
print("\n4. list_directory - Directory Listing")
|
||||
print("-" * 40)
|
||||
|
||||
# List root
|
||||
result = list_directory(
|
||||
dir_path=WORKSPACE,
|
||||
recursive=False,
|
||||
max_entries=20
|
||||
)
|
||||
|
||||
print(f" Directory: {WORKSPACE}")
|
||||
print(f" Success: {result['success']}")
|
||||
print(f" Entries: {len(result.get('entries', []))}")
|
||||
|
||||
dirs = [e for e in result.get('entries', []) if e.get('is_dir')]
|
||||
files = [e for e in result.get('entries', []) if not e.get('is_dir')]
|
||||
print(f" Directories: {len(dirs)}")
|
||||
print(f" Files: {len(files)}")
|
||||
|
||||
# List recursive with depth limit
|
||||
result = list_directory(
|
||||
dir_path=f"{WORKSPACE}/praisonaiagents",
|
||||
recursive=True,
|
||||
max_depth=2,
|
||||
max_entries=50
|
||||
)
|
||||
|
||||
print(f"\n Directory: praisonaiagents (recursive, depth=2)")
|
||||
print(f" Entries: {len(result.get('entries', []))}")
|
||||
|
||||
# Show directory structure
|
||||
print(f" Structure:")
|
||||
for entry in result.get('entries', [])[:10]:
|
||||
prefix = "📁" if entry.get('is_dir') else "📄"
|
||||
print(f" {prefix} {entry['name']}")
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("These search tools power Fast Context's rapid code search!")
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
Example 8: Comprehensive Benchmark
|
||||
|
||||
This example provides a comprehensive benchmark comparing Fast Context
|
||||
performance against traditional search approaches.
|
||||
|
||||
Metrics measured:
|
||||
- Search latency
|
||||
- Throughput (queries per second)
|
||||
- Cache effectiveness
|
||||
- Parallel execution speedup
|
||||
"""
|
||||
|
||||
import time
|
||||
import statistics
|
||||
from praisonaiagents.context.fast import FastContext
|
||||
from praisonaiagents.context.fast.search_tools import grep_search
|
||||
from praisonaiagents.context.fast.indexer import FileIndexer, SymbolIndexer
|
||||
|
||||
WORKSPACE = "/Users/praison/praisonai-package/src/praisonai-agents"
|
||||
|
||||
|
||||
def benchmark_search_latency():
|
||||
"""Benchmark search latency."""
|
||||
print("\n1. Search Latency Benchmark")
|
||||
print("-" * 50)
|
||||
|
||||
fc = FastContext(workspace_path=WORKSPACE, cache_enabled=False)
|
||||
|
||||
queries = [
|
||||
"def __init__",
|
||||
"class Agent",
|
||||
"import logging",
|
||||
"async def",
|
||||
"from typing import",
|
||||
]
|
||||
|
||||
# Warm up
|
||||
fc.search("warmup")
|
||||
|
||||
# Benchmark each query
|
||||
latencies = []
|
||||
for query in queries:
|
||||
times = []
|
||||
for _ in range(3): # 3 runs per query
|
||||
start = time.perf_counter()
|
||||
fc.search(query)
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
times.append(elapsed)
|
||||
|
||||
avg = statistics.mean(times)
|
||||
latencies.append(avg)
|
||||
print(f" '{query}': {avg:.1f}ms (min: {min(times):.1f}, max: {max(times):.1f})")
|
||||
|
||||
print(f"\n Average latency: {statistics.mean(latencies):.1f}ms")
|
||||
print(f" Median latency: {statistics.median(latencies):.1f}ms")
|
||||
print(f" P95 latency: {sorted(latencies)[int(len(latencies)*0.95)]:.1f}ms")
|
||||
|
||||
|
||||
def benchmark_throughput():
|
||||
"""Benchmark queries per second."""
|
||||
print("\n2. Throughput Benchmark")
|
||||
print("-" * 50)
|
||||
|
||||
fc = FastContext(workspace_path=WORKSPACE, cache_enabled=True)
|
||||
|
||||
queries = ["def", "class", "import", "return", "if", "for", "while", "try"]
|
||||
|
||||
# Cold run (no cache)
|
||||
fc.clear_cache()
|
||||
start = time.perf_counter()
|
||||
for query in queries:
|
||||
fc.search(query)
|
||||
cold_time = time.perf_counter() - start
|
||||
cold_qps = len(queries) / cold_time
|
||||
|
||||
print(f" Cold (no cache):")
|
||||
print(f" Queries: {len(queries)}")
|
||||
print(f" Time: {cold_time*1000:.0f}ms")
|
||||
print(f" Throughput: {cold_qps:.1f} queries/second")
|
||||
|
||||
# Warm run (cached)
|
||||
start = time.perf_counter()
|
||||
for _ in range(10): # 10 iterations
|
||||
for query in queries:
|
||||
fc.search(query)
|
||||
warm_time = time.perf_counter() - start
|
||||
warm_qps = (len(queries) * 10) / warm_time
|
||||
|
||||
print(f"\n Warm (cached):")
|
||||
print(f" Queries: {len(queries) * 10}")
|
||||
print(f" Time: {warm_time*1000:.0f}ms")
|
||||
print(f" Throughput: {warm_qps:.0f} queries/second")
|
||||
|
||||
print(f"\n Cache speedup: {warm_qps/cold_qps:.0f}x")
|
||||
|
||||
|
||||
def benchmark_cache_effectiveness():
|
||||
"""Benchmark cache hit rate and speedup."""
|
||||
print("\n3. Cache Effectiveness Benchmark")
|
||||
print("-" * 50)
|
||||
|
||||
fc = FastContext(workspace_path=WORKSPACE, cache_enabled=True)
|
||||
fc.clear_cache()
|
||||
|
||||
queries = ["Agent", "chat", "memory", "tool", "execute"]
|
||||
|
||||
# First pass - cache misses
|
||||
miss_times = []
|
||||
for query in queries:
|
||||
start = time.perf_counter()
|
||||
result = fc.search(query)
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
miss_times.append(elapsed)
|
||||
assert not result.from_cache
|
||||
|
||||
# Second pass - cache hits
|
||||
hit_times = []
|
||||
for query in queries:
|
||||
start = time.perf_counter()
|
||||
result = fc.search(query)
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
hit_times.append(elapsed)
|
||||
assert result.from_cache
|
||||
|
||||
avg_miss = statistics.mean(miss_times)
|
||||
avg_hit = statistics.mean(hit_times)
|
||||
|
||||
print(f" Cache miss (first query): {avg_miss:.1f}ms avg")
|
||||
print(f" Cache hit (repeat query): {avg_hit:.2f}ms avg")
|
||||
print(f" Cache speedup: {avg_miss/avg_hit:.0f}x faster")
|
||||
print(f" Time saved per cached query: {avg_miss - avg_hit:.1f}ms")
|
||||
|
||||
|
||||
def benchmark_parallel_execution():
|
||||
"""Benchmark parallel vs sequential execution."""
|
||||
print("\n4. Parallel Execution Benchmark")
|
||||
print("-" * 50)
|
||||
|
||||
queries = ["def", "class", "import", "return", "async"]
|
||||
|
||||
# Sequential (one at a time)
|
||||
start = time.perf_counter()
|
||||
for query in queries:
|
||||
grep_search(WORKSPACE, query, max_results=20)
|
||||
seq_time = (time.perf_counter() - start) * 1000
|
||||
|
||||
# Parallel (Fast Context)
|
||||
fc = FastContext(workspace_path=WORKSPACE, cache_enabled=False)
|
||||
start = time.perf_counter()
|
||||
for query in queries:
|
||||
fc.search(query)
|
||||
par_time = (time.perf_counter() - start) * 1000
|
||||
|
||||
print(f" Sequential: {seq_time:.0f}ms")
|
||||
print(f" Parallel (Fast Context): {par_time:.0f}ms")
|
||||
print(f" Speedup: {seq_time/par_time:.1f}x")
|
||||
|
||||
|
||||
def benchmark_indexing():
|
||||
"""Benchmark indexing performance."""
|
||||
print("\n5. Indexing Performance Benchmark")
|
||||
print("-" * 50)
|
||||
|
||||
# File indexing
|
||||
start = time.perf_counter()
|
||||
file_indexer = FileIndexer(workspace_path=WORKSPACE)
|
||||
file_count = file_indexer.index()
|
||||
file_time = (time.perf_counter() - start) * 1000
|
||||
|
||||
print(f" File Indexer:")
|
||||
print(f" Files: {file_count}")
|
||||
print(f" Time: {file_time:.0f}ms")
|
||||
print(f" Rate: {file_count/(file_time/1000):.0f} files/second")
|
||||
|
||||
# Symbol indexing
|
||||
start = time.perf_counter()
|
||||
symbol_indexer = SymbolIndexer(workspace_path=WORKSPACE)
|
||||
symbol_count = symbol_indexer.index()
|
||||
symbol_time = (time.perf_counter() - start) * 1000
|
||||
|
||||
print(f"\n Symbol Indexer:")
|
||||
print(f" Symbols: {symbol_count}")
|
||||
print(f" Time: {symbol_time:.0f}ms")
|
||||
print(f" Rate: {symbol_count/(symbol_time/1000):.0f} symbols/second")
|
||||
|
||||
# Lookup performance
|
||||
start = time.perf_counter()
|
||||
for _ in range(100):
|
||||
file_indexer.find_by_pattern("**/*.py")
|
||||
lookup_time = (time.perf_counter() - start) * 1000
|
||||
|
||||
print(f"\n Lookup Performance:")
|
||||
print(f" 100 pattern lookups: {lookup_time:.1f}ms")
|
||||
print(f" Per lookup: {lookup_time/100:.2f}ms")
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 70)
|
||||
print("Fast Context Comprehensive Benchmark")
|
||||
print("=" * 70)
|
||||
print(f"\nWorkspace: {WORKSPACE}")
|
||||
|
||||
benchmark_search_latency()
|
||||
benchmark_throughput()
|
||||
benchmark_cache_effectiveness()
|
||||
benchmark_parallel_execution()
|
||||
benchmark_indexing()
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("Summary")
|
||||
print("=" * 70)
|
||||
print("""
|
||||
Fast Context provides:
|
||||
✓ Sub-100ms search latency for most queries
|
||||
✓ 100+ queries/second with caching
|
||||
✓ 100x+ speedup for cached queries
|
||||
✓ 2-5x speedup from parallel execution
|
||||
✓ 1000+ files/second indexing rate
|
||||
|
||||
These benchmarks demonstrate why Fast Context is ideal for
|
||||
rapid code search in AI agent workflows.
|
||||
""")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
MCP Backward Compatibility Example
|
||||
|
||||
Demonstrates backward compatibility with older MCP servers
|
||||
using the deprecated HTTP+SSE transport (protocol version 2024-11-05).
|
||||
|
||||
Features:
|
||||
- Legacy SSE endpoint detection (/sse suffix)
|
||||
- Automatic transport negotiation
|
||||
- Protocol version support (2024-11-05 to 2025-11-25)
|
||||
|
||||
Protocol: MCP 2025-11-25 with 2024-11-05 compatibility
|
||||
"""
|
||||
|
||||
# Example code (requires running MCP servers):
|
||||
#
|
||||
# from praisonaiagents import Agent, MCP
|
||||
#
|
||||
# # Legacy SSE server (auto-detected via /sse suffix)
|
||||
# agent_legacy = Agent(
|
||||
# name="Legacy SSE Assistant",
|
||||
# tools=MCP("http://localhost:8080/sse")
|
||||
# )
|
||||
#
|
||||
# # Modern Streamable HTTP server
|
||||
# agent_modern = Agent(
|
||||
# name="Modern HTTP Assistant",
|
||||
# tools=MCP("http://localhost:8080/mcp")
|
||||
# )
|
||||
|
||||
if __name__ == "__main__":
|
||||
from praisonaiagents.mcp.mcp_transport import get_transport_type
|
||||
from praisonaiagents.mcp.mcp_compat import (
|
||||
detect_transport_support,
|
||||
is_legacy_sse_url,
|
||||
TransportNegotiator
|
||||
)
|
||||
from praisonaiagents.mcp.mcp_session import VALID_PROTOCOL_VERSIONS
|
||||
|
||||
print("MCP Backward Compatibility Example")
|
||||
print("=" * 50)
|
||||
|
||||
# URL detection
|
||||
print("\n1. URL-based Transport Detection:")
|
||||
urls = [
|
||||
"http://localhost:8080/sse",
|
||||
"https://api.example.com/sse",
|
||||
"http://localhost:8080/mcp",
|
||||
"https://api.example.com/mcp",
|
||||
]
|
||||
for url in urls:
|
||||
transport = get_transport_type(url)
|
||||
legacy = is_legacy_sse_url(url)
|
||||
print(f" {url}")
|
||||
print(f" Transport: {transport}, Legacy: {legacy}")
|
||||
|
||||
# Transport negotiation
|
||||
print("\n2. Transport Negotiation (HTTP response based):")
|
||||
responses = [
|
||||
(200, "application/json", "Streamable HTTP"),
|
||||
(200, "text/event-stream", "SSE stream"),
|
||||
(400, None, "Fallback to SSE"),
|
||||
(404, None, "Fallback to SSE"),
|
||||
(405, None, "Fallback to SSE"),
|
||||
]
|
||||
for status, content_type, desc in responses:
|
||||
detected = detect_transport_support(status, content_type)
|
||||
print(f" HTTP {status} ({desc}): {detected}")
|
||||
|
||||
# Protocol versions
|
||||
print("\n3. Supported Protocol Versions:")
|
||||
for version in sorted(VALID_PROTOCOL_VERSIONS):
|
||||
print(f" - {version}")
|
||||
|
||||
# Negotiator
|
||||
print("\n4. Transport Negotiator:")
|
||||
negotiator = TransportNegotiator("https://api.example.com/mcp")
|
||||
print(f" Base URL: {negotiator.base_url}")
|
||||
print(f" Try Streamable first: {negotiator.should_try_streamable_first()}")
|
||||
|
||||
negotiator_sse = TransportNegotiator("https://api.example.com/sse")
|
||||
print(f" SSE URL: {negotiator_sse.base_url}")
|
||||
print(f" Try Streamable first: {negotiator_sse.should_try_streamable_first()}")
|
||||
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
MCP Custom Transport Example
|
||||
|
||||
Demonstrates how to create custom MCP transports by extending
|
||||
the BaseTransport class.
|
||||
|
||||
Features:
|
||||
- BaseTransport abstract class
|
||||
- TransportRegistry for registration
|
||||
- TransportConfig for configuration
|
||||
- Pluggable transport mechanism
|
||||
|
||||
Protocol: MCP 2025-11-25
|
||||
"""
|
||||
|
||||
from praisonaiagents.mcp.mcp_transport import (
|
||||
BaseTransport,
|
||||
TransportConfig,
|
||||
TransportRegistry,
|
||||
get_default_registry
|
||||
)
|
||||
|
||||
# Custom transport implementation
|
||||
class MyCustomTransport(BaseTransport):
|
||||
"""Example custom transport implementation."""
|
||||
|
||||
def __init__(self, url: str, config: TransportConfig = None):
|
||||
self.url = url
|
||||
self.config = config or TransportConfig()
|
||||
self._connected = False
|
||||
|
||||
async def connect(self):
|
||||
"""Establish connection."""
|
||||
print(f"Connecting to {self.url}...")
|
||||
self._connected = True
|
||||
print("Connected!")
|
||||
|
||||
async def send(self, message: dict):
|
||||
"""Send JSON-RPC message."""
|
||||
if not self._connected:
|
||||
raise RuntimeError("Not connected")
|
||||
print(f"Sending: {message}")
|
||||
|
||||
async def receive(self) -> dict:
|
||||
"""Receive JSON-RPC message."""
|
||||
if not self._connected:
|
||||
raise RuntimeError("Not connected")
|
||||
return {"jsonrpc": "2.0", "result": "ok", "id": 1}
|
||||
|
||||
async def close(self):
|
||||
"""Close connection."""
|
||||
print("Closing connection...")
|
||||
self._connected = False
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
|
||||
print("MCP Custom Transport Example")
|
||||
print("=" * 50)
|
||||
|
||||
# 1. Transport configuration
|
||||
print("\n1. Transport Configuration:")
|
||||
config = TransportConfig(
|
||||
timeout=60,
|
||||
debug=True,
|
||||
retry_count=3,
|
||||
retry_delay=1.0,
|
||||
auth_token="my-token"
|
||||
)
|
||||
print(f" Timeout: {config.timeout}s")
|
||||
print(f" Debug: {config.debug}")
|
||||
print(f" Retry count: {config.retry_count}")
|
||||
|
||||
# 2. Default registry
|
||||
print("\n2. Default Transport Registry:")
|
||||
registry = get_default_registry()
|
||||
print(f" Registered transports: {registry.list_transports()}")
|
||||
|
||||
# 3. Register custom transport
|
||||
print("\n3. Registering Custom Transport:")
|
||||
custom_registry = TransportRegistry()
|
||||
custom_registry.register("my_custom", MyCustomTransport)
|
||||
print(f" Registered: {custom_registry.list_transports()}")
|
||||
|
||||
# 4. Use custom transport
|
||||
print("\n4. Using Custom Transport:")
|
||||
|
||||
async def demo():
|
||||
transport = MyCustomTransport("custom://localhost:8080", config)
|
||||
|
||||
async with transport:
|
||||
await transport.send({"jsonrpc": "2.0", "method": "test", "id": 1})
|
||||
response = await transport.receive()
|
||||
print(f" Response: {response}")
|
||||
|
||||
asyncio.run(demo())
|
||||
|
||||
# 5. BaseTransport interface
|
||||
print("\n5. BaseTransport Interface:")
|
||||
print(" Required methods:")
|
||||
print(" - async connect()")
|
||||
print(" - async send(message: dict)")
|
||||
print(" - async receive() -> dict")
|
||||
print(" - async close()")
|
||||
print(" - @property is_connected -> bool")
|
||||
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
MCP Resumability Example
|
||||
|
||||
Demonstrates SSE stream resumability for MCP transports.
|
||||
Resumability allows recovering from disconnections without losing messages.
|
||||
|
||||
Features:
|
||||
- Event ID tracking per stream
|
||||
- Last-Event-ID header on reconnection
|
||||
- Retry delay handling from server
|
||||
- Message replay on reconnection
|
||||
|
||||
Protocol: MCP 2025-11-25
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, MCP
|
||||
|
||||
# Agent with resumability support (automatic)
|
||||
agent = Agent(
|
||||
name="Resumable Assistant",
|
||||
instructions="You are a helpful assistant with resumable connections.",
|
||||
tools=MCP("https://api.example.com/mcp")
|
||||
)
|
||||
|
||||
# Resumability is automatic - the MCP client:
|
||||
# 1. Tracks event IDs from SSE streams (id: field)
|
||||
# 2. Stores last event ID per stream
|
||||
# 3. Includes Last-Event-ID header on reconnection
|
||||
# 4. Respects retry delay from server (retry: field)
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("MCP Resumability Example")
|
||||
print("=" * 40)
|
||||
print()
|
||||
print("SSE Event Format:")
|
||||
print(" id: event-123")
|
||||
print(" retry: 3000")
|
||||
print(" data: {\"jsonrpc\": \"2.0\", ...}")
|
||||
print()
|
||||
print("Resumability Flow:")
|
||||
print("1. Client receives SSE events with id: field")
|
||||
print("2. Client stores last event ID")
|
||||
print("3. On disconnection, client reconnects with Last-Event-ID header")
|
||||
print("4. Server replays messages after that event ID")
|
||||
print("5. Client respects retry: field for reconnection delay")
|
||||
print()
|
||||
print("This prevents message loss during network interruptions.")
|
||||
@@ -0,0 +1,82 @@
|
||||
"""MCP Schema Converter Example
|
||||
|
||||
Demonstrates how to convert Python functions to MCP-compatible schemas.
|
||||
|
||||
Usage:
|
||||
python mcp-schema-converter.py
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any, Optional
|
||||
from praisonaiagents.mcp import function_to_mcp_schema, get_tool_metadata
|
||||
|
||||
|
||||
# Example functions with various type annotations
|
||||
|
||||
def simple_search(query: str) -> str:
|
||||
"""Search for information."""
|
||||
return f"Results for {query}"
|
||||
|
||||
|
||||
def advanced_search(
|
||||
query: str,
|
||||
max_results: int = 10,
|
||||
include_images: bool = False,
|
||||
filters: Optional[Dict[str, str]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Advanced search with multiple options.
|
||||
|
||||
Args:
|
||||
query: The search query
|
||||
max_results: Maximum results to return
|
||||
include_images: Whether to include images
|
||||
filters: Optional filters to apply
|
||||
"""
|
||||
return {}
|
||||
|
||||
|
||||
def process_items(items: List[str], batch_size: int = 5) -> List[Dict[str, Any]]:
|
||||
"""Process a list of items.
|
||||
|
||||
Args:
|
||||
items: List of items to process
|
||||
batch_size: Size of each batch
|
||||
"""
|
||||
return []
|
||||
|
||||
|
||||
async def async_fetch(url: str, timeout: float = 30.0) -> Dict[str, Any]:
|
||||
"""Fetch data from URL asynchronously.
|
||||
|
||||
Args:
|
||||
url: The URL to fetch
|
||||
timeout: Request timeout in seconds
|
||||
"""
|
||||
return {}
|
||||
|
||||
|
||||
def main():
|
||||
functions = [simple_search, advanced_search, process_items, async_fetch]
|
||||
|
||||
print("=" * 60)
|
||||
print("MCP Schema Converter Demo")
|
||||
print("=" * 60)
|
||||
|
||||
for func in functions:
|
||||
print(f"\n### Function: {func.__name__}")
|
||||
|
||||
# Get metadata
|
||||
metadata = get_tool_metadata(func)
|
||||
print(f"Name: {metadata['name']}")
|
||||
print(f"Description: {metadata['description']}")
|
||||
|
||||
# Get full schema
|
||||
schema = function_to_mcp_schema(func)
|
||||
|
||||
print("\nMCP Schema:")
|
||||
import json
|
||||
print(json.dumps(schema, indent=2))
|
||||
print("-" * 40)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
MCP Security Example
|
||||
|
||||
Demonstrates security features for MCP transports.
|
||||
These features help prevent attacks like DNS rebinding.
|
||||
|
||||
Features:
|
||||
- Origin header validation
|
||||
- DNS rebinding prevention
|
||||
- Localhost binding for local servers
|
||||
- Authentication header support
|
||||
- Secure session ID generation
|
||||
|
||||
Protocol: MCP 2025-11-25
|
||||
"""
|
||||
|
||||
# Example code (requires running MCP servers):
|
||||
#
|
||||
# from praisonaiagents import Agent, MCP
|
||||
#
|
||||
# # Agent with authentication
|
||||
# agent = Agent(
|
||||
# name="Secure Assistant",
|
||||
# tools=MCP(
|
||||
# "https://api.example.com/mcp",
|
||||
# headers={"Authorization": "Bearer your-token"}
|
||||
# )
|
||||
# )
|
||||
#
|
||||
# # WebSocket with auth token
|
||||
# agent_ws = Agent(
|
||||
# name="Secure WebSocket Assistant",
|
||||
# tools=MCP(
|
||||
# "wss://api.example.com/mcp",
|
||||
# auth_token="Bearer your-secret-token"
|
||||
# )
|
||||
# )
|
||||
|
||||
# Using security utilities directly (no server required)
|
||||
if __name__ == "__main__":
|
||||
from praisonaiagents.mcp.mcp_security import (
|
||||
is_valid_origin,
|
||||
create_auth_header,
|
||||
generate_secure_session_id,
|
||||
SecurityConfig
|
||||
)
|
||||
|
||||
print("MCP Security Example")
|
||||
print("=" * 40)
|
||||
|
||||
# Origin validation (DNS rebinding prevention)
|
||||
print("\n1. Origin Validation:")
|
||||
allowed = ["localhost", "127.0.0.1", "example.com"]
|
||||
print(f" Allowed origins: {allowed}")
|
||||
print(f" 'http://localhost:8080' valid: {is_valid_origin('http://localhost:8080', allowed)}")
|
||||
print(f" 'https://evil.com' valid: {is_valid_origin('https://evil.com', allowed)}")
|
||||
|
||||
# Authentication headers
|
||||
print("\n2. Authentication Headers:")
|
||||
bearer = create_auth_header("my-token", auth_type="bearer")
|
||||
print(f" Bearer: {bearer}")
|
||||
basic = create_auth_header("user:pass", auth_type="basic")
|
||||
print(f" Basic: {basic}")
|
||||
|
||||
# Secure session IDs
|
||||
print("\n3. Secure Session IDs:")
|
||||
session_id = generate_secure_session_id()
|
||||
print(f" Generated: {session_id}")
|
||||
print(f" Length: {len(session_id)} chars")
|
||||
|
||||
# Security config
|
||||
print("\n4. Security Configuration:")
|
||||
config = SecurityConfig(
|
||||
allowed_origins=["localhost", "example.com"],
|
||||
require_auth=True
|
||||
)
|
||||
print(f" Validate origin: {config.validate_origin}")
|
||||
print(f" Require auth: {config.require_auth}")
|
||||
print(f" Bind address: {config.get_bind_address()}")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
MCP Session Management Example
|
||||
|
||||
Demonstrates session management features for MCP transports.
|
||||
Sessions allow stateful interactions with MCP servers.
|
||||
|
||||
Features:
|
||||
- Automatic session ID handling
|
||||
- Session expiration detection (HTTP 404)
|
||||
- Session termination via HTTP DELETE
|
||||
- Protocol version negotiation
|
||||
|
||||
Protocol: MCP 2025-11-25
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, MCP
|
||||
|
||||
# Basic agent with automatic session management
|
||||
agent = Agent(
|
||||
name="Session Assistant",
|
||||
instructions="You are a helpful assistant with session support.",
|
||||
tools=MCP("https://api.example.com/mcp")
|
||||
)
|
||||
|
||||
# Session management is automatic - the MCP client:
|
||||
# 1. Receives Mcp-Session-Id from server during initialization
|
||||
# 2. Includes session ID in all subsequent requests
|
||||
# 3. Handles HTTP 404 (session expired) by reinitializing
|
||||
# 4. Can terminate session explicitly when done
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("MCP Session Management Example")
|
||||
print("=" * 40)
|
||||
print()
|
||||
print("Session Lifecycle:")
|
||||
print("1. Client sends InitializeRequest")
|
||||
print("2. Server returns InitializeResult with Mcp-Session-Id header")
|
||||
print("3. Client includes Mcp-Session-Id in all subsequent requests")
|
||||
print("4. If HTTP 404 received, client reinitializes session")
|
||||
print("5. Client can send HTTP DELETE to terminate session")
|
||||
print()
|
||||
print("Headers used:")
|
||||
print("- Mcp-Session-Id: Session identifier")
|
||||
print("- Mcp-Protocol-Version: Protocol version (e.g., 2025-03-26)")
|
||||
@@ -0,0 +1,79 @@
|
||||
"""MCP Tools Server with Async Functions
|
||||
|
||||
Example showing how to expose async Python functions as MCP tools.
|
||||
|
||||
Usage:
|
||||
python mcp-tools-server-async.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from praisonaiagents.mcp import ToolsMCPServer
|
||||
|
||||
|
||||
async def async_search(query: str, delay: float = 0.1) -> dict:
|
||||
"""Async search function.
|
||||
|
||||
Args:
|
||||
query: The search query
|
||||
delay: Simulated delay in seconds
|
||||
"""
|
||||
await asyncio.sleep(delay) # Simulate async operation
|
||||
return {
|
||||
"query": query,
|
||||
"results": [f"Async result for {query}"]
|
||||
}
|
||||
|
||||
|
||||
async def async_fetch_url(url: str) -> dict:
|
||||
"""Fetch content from a URL asynchronously.
|
||||
|
||||
Args:
|
||||
url: The URL to fetch
|
||||
"""
|
||||
# Mock implementation - use aiohttp in production
|
||||
await asyncio.sleep(0.1)
|
||||
return {
|
||||
"url": url,
|
||||
"status": 200,
|
||||
"content": f"Content from {url}"
|
||||
}
|
||||
|
||||
|
||||
async def async_process_data(data: str, iterations: int = 3) -> dict:
|
||||
"""Process data asynchronously with multiple iterations.
|
||||
|
||||
Args:
|
||||
data: The data to process
|
||||
iterations: Number of processing iterations
|
||||
"""
|
||||
results = []
|
||||
for i in range(iterations):
|
||||
await asyncio.sleep(0.05)
|
||||
results.append(f"Iteration {i+1}: processed {data}")
|
||||
|
||||
return {
|
||||
"input": data,
|
||||
"iterations": iterations,
|
||||
"results": results
|
||||
}
|
||||
|
||||
|
||||
# Sync function can also be mixed with async
|
||||
def sync_calculate(a: int, b: int) -> dict:
|
||||
"""Synchronous calculation (can be mixed with async tools)."""
|
||||
return {"sum": a + b, "product": a * b}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
server = ToolsMCPServer(name="async-tools-server")
|
||||
|
||||
# Register both async and sync tools
|
||||
server.register_tools([
|
||||
async_search,
|
||||
async_fetch_url,
|
||||
async_process_data,
|
||||
sync_calculate
|
||||
])
|
||||
|
||||
print(f"Registered tools: {server.get_tool_names()}")
|
||||
server.run()
|
||||
@@ -0,0 +1,27 @@
|
||||
"""MCP Server with Built-in PraisonAI Tools
|
||||
|
||||
Expose PraisonAI's built-in tools as an MCP server.
|
||||
|
||||
Usage:
|
||||
# Set required API keys first
|
||||
export TAVILY_API_KEY=your_key
|
||||
|
||||
python mcp-tools-server-builtin.py
|
||||
"""
|
||||
|
||||
from praisonaiagents.mcp import launch_tools_mcp_server
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Expose built-in PraisonAI tools as MCP server
|
||||
# This allows external MCP clients to use these tools
|
||||
|
||||
launch_tools_mcp_server(
|
||||
tool_names=[
|
||||
"tavily_search", # Web search via Tavily
|
||||
"duckduckgo_search", # Web search via DuckDuckGo
|
||||
# Add more built-in tools as needed
|
||||
],
|
||||
name="praisonai-builtin-tools",
|
||||
transport="stdio" # Use "sse" for web clients
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Simple MCP Tools Server Example
|
||||
|
||||
Minimal example of exposing Python functions as MCP tools.
|
||||
|
||||
Usage:
|
||||
python mcp-tools-server-simple.py
|
||||
"""
|
||||
|
||||
from praisonaiagents.mcp import ToolsMCPServer
|
||||
|
||||
|
||||
def search(query: str) -> dict:
|
||||
"""Search for information."""
|
||||
return {"query": query, "results": [f"Result for {query}"]}
|
||||
|
||||
|
||||
def add(a: int, b: int) -> dict:
|
||||
"""Add two numbers."""
|
||||
return {"result": a + b}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
server = ToolsMCPServer(name="simple-tools")
|
||||
server.register_tools([search, add])
|
||||
server.run()
|
||||
@@ -0,0 +1,65 @@
|
||||
"""MCP Tools Server with SSE Transport
|
||||
|
||||
Expose Python functions as MCP tools via Server-Sent Events (SSE).
|
||||
This is useful for web-based clients and remote access.
|
||||
|
||||
Usage:
|
||||
python mcp-tools-server-sse.py
|
||||
|
||||
# Then connect from another agent:
|
||||
# agent = Agent(tools=MCP("http://localhost:8080/sse"))
|
||||
"""
|
||||
|
||||
from praisonaiagents.mcp import ToolsMCPServer
|
||||
|
||||
|
||||
def search_web(query: str, max_results: int = 5) -> dict:
|
||||
"""Search the web for information.
|
||||
|
||||
Args:
|
||||
query: The search query
|
||||
max_results: Maximum results to return
|
||||
"""
|
||||
return {
|
||||
"query": query,
|
||||
"results": [
|
||||
{"title": f"Result {i+1}", "url": f"https://example.com/{i+1}"}
|
||||
for i in range(max_results)
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def calculate(expression: str) -> dict:
|
||||
"""Evaluate a mathematical expression.
|
||||
|
||||
Args:
|
||||
expression: Math expression (e.g., "2 + 2 * 3")
|
||||
"""
|
||||
try:
|
||||
allowed = set("0123456789+-*/.() ")
|
||||
if not all(c in allowed for c in expression):
|
||||
return {"error": "Invalid characters"}
|
||||
return {"expression": expression, "result": eval(expression)}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def get_time() -> dict:
|
||||
"""Get the current time."""
|
||||
from datetime import datetime
|
||||
now = datetime.now()
|
||||
return {
|
||||
"time": now.strftime("%H:%M:%S"),
|
||||
"date": now.strftime("%Y-%m-%d"),
|
||||
"timestamp": now.isoformat()
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
server = ToolsMCPServer(name="sse-tools-server")
|
||||
server.register_tools([search_web, calculate, get_time])
|
||||
|
||||
print("Starting SSE MCP Server...")
|
||||
print("Connect with: MCP('http://localhost:8080/sse')")
|
||||
|
||||
server.run_sse(host="0.0.0.0", port=8080)
|
||||
@@ -0,0 +1,140 @@
|
||||
"""MCP Tools Server Example
|
||||
|
||||
Expose Python functions as MCP tools for external clients like Claude Desktop, Cursor, etc.
|
||||
|
||||
Usage:
|
||||
# Run as stdio server (for Claude Desktop)
|
||||
python mcp-tools-server.py
|
||||
|
||||
# Run as SSE server (for web clients)
|
||||
python mcp-tools-server.py --sse --port 8080
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from typing import List, Dict, Any
|
||||
|
||||
|
||||
# Define your custom tools as regular Python functions
|
||||
# Type hints and docstrings are used to generate MCP schemas
|
||||
|
||||
def search_web(query: str, max_results: int = 5) -> Dict[str, Any]:
|
||||
"""Search the web for information.
|
||||
|
||||
Args:
|
||||
query: The search query string
|
||||
max_results: Maximum number of results to return (default: 5)
|
||||
|
||||
Returns:
|
||||
Search results with titles and URLs
|
||||
"""
|
||||
# Mock implementation - replace with actual search logic
|
||||
return {
|
||||
"query": query,
|
||||
"results": [
|
||||
{"title": f"Result {i+1} for '{query}'", "url": f"https://example.com/{i+1}"}
|
||||
for i in range(max_results)
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def calculate(expression: str) -> Dict[str, Any]:
|
||||
"""Evaluate a mathematical expression.
|
||||
|
||||
Args:
|
||||
expression: A mathematical expression to evaluate (e.g., "2 + 2 * 3")
|
||||
|
||||
Returns:
|
||||
The result of the calculation
|
||||
"""
|
||||
try:
|
||||
# Safe evaluation of mathematical expressions
|
||||
allowed_chars = set("0123456789+-*/.() ")
|
||||
if not all(c in allowed_chars for c in expression):
|
||||
return {"error": "Invalid characters in expression"}
|
||||
|
||||
result = eval(expression) # Use safer alternative in production
|
||||
return {"expression": expression, "result": result}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def get_weather(city: str, units: str = "celsius") -> Dict[str, Any]:
|
||||
"""Get current weather for a city.
|
||||
|
||||
Args:
|
||||
city: Name of the city
|
||||
units: Temperature units - "celsius" or "fahrenheit" (default: celsius)
|
||||
|
||||
Returns:
|
||||
Weather information for the city
|
||||
"""
|
||||
# Mock implementation - replace with actual weather API
|
||||
return {
|
||||
"city": city,
|
||||
"temperature": 22 if units == "celsius" else 72,
|
||||
"units": units,
|
||||
"condition": "Sunny",
|
||||
"humidity": 45
|
||||
}
|
||||
|
||||
|
||||
def list_files(directory: str = ".", pattern: str = "*") -> List[str]:
|
||||
"""List files in a directory.
|
||||
|
||||
Args:
|
||||
directory: Directory path to list (default: current directory)
|
||||
pattern: Glob pattern to filter files (default: *)
|
||||
|
||||
Returns:
|
||||
List of file names matching the pattern
|
||||
"""
|
||||
import glob
|
||||
import os
|
||||
|
||||
try:
|
||||
full_pattern = os.path.join(directory, pattern)
|
||||
files = glob.glob(full_pattern)
|
||||
return [os.path.basename(f) for f in files]
|
||||
except Exception as e:
|
||||
return [f"Error: {str(e)}"]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Run PraisonAI Tools as MCP Server")
|
||||
parser.add_argument("--sse", action="store_true", help="Use SSE transport instead of stdio")
|
||||
parser.add_argument("--port", type=int, default=8080, help="Port for SSE server (default: 8080)")
|
||||
parser.add_argument("--host", type=str, default="0.0.0.0", help="Host for SSE server")
|
||||
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
|
||||
args = parser.parse_args()
|
||||
|
||||
from praisonaiagents.mcp import ToolsMCPServer
|
||||
|
||||
# Create server with custom name
|
||||
server = ToolsMCPServer(
|
||||
name="praisonai-custom-tools",
|
||||
debug=args.debug
|
||||
)
|
||||
|
||||
# Register all tools
|
||||
server.register_tools([
|
||||
search_web,
|
||||
calculate,
|
||||
get_weather,
|
||||
list_files
|
||||
])
|
||||
|
||||
# Print registered tools
|
||||
print(f"📦 Registered {len(server.tools)} tools:")
|
||||
for name in server.get_tool_names():
|
||||
print(f" - {name}")
|
||||
print()
|
||||
|
||||
# Run the server
|
||||
if args.sse:
|
||||
server.run_sse(host=args.host, port=args.port)
|
||||
else:
|
||||
server.run_stdio()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
MCP Transports Overview Example
|
||||
|
||||
Demonstrates all MCP transport types and auto-detection.
|
||||
The MCP class automatically selects the appropriate transport
|
||||
based on the URL pattern.
|
||||
|
||||
Transport Types:
|
||||
- stdio: Command strings (local subprocess)
|
||||
- Streamable HTTP: http:// or https:// URLs
|
||||
- WebSocket: ws:// or wss:// URLs
|
||||
- SSE (Legacy): URLs ending in /sse
|
||||
|
||||
Protocol: MCP 2025-11-25
|
||||
"""
|
||||
|
||||
# Example code (requires running MCP servers):
|
||||
#
|
||||
# from praisonaiagents import Agent, MCP
|
||||
#
|
||||
# # 1. stdio Transport - Local subprocess
|
||||
# agent_stdio = Agent(
|
||||
# name="Stdio Assistant",
|
||||
# tools=MCP("npx @modelcontextprotocol/server-memory")
|
||||
# )
|
||||
#
|
||||
# # 2. Streamable HTTP Transport
|
||||
# agent_http = Agent(
|
||||
# name="HTTP Assistant",
|
||||
# tools=MCP("https://api.example.com/mcp")
|
||||
# )
|
||||
#
|
||||
# # 3. WebSocket Transport
|
||||
# agent_ws = Agent(
|
||||
# name="WebSocket Assistant",
|
||||
# tools=MCP("wss://api.example.com/mcp")
|
||||
# )
|
||||
#
|
||||
# # 4. SSE Transport (Legacy)
|
||||
# agent_sse = Agent(
|
||||
# name="SSE Assistant",
|
||||
# tools=MCP("http://localhost:8080/sse")
|
||||
# )
|
||||
|
||||
if __name__ == "__main__":
|
||||
from praisonaiagents.mcp.mcp_transport import get_transport_type
|
||||
|
||||
print("MCP Transports Overview")
|
||||
print("=" * 50)
|
||||
print()
|
||||
print("Transport Auto-Detection:")
|
||||
print("-" * 50)
|
||||
|
||||
examples = [
|
||||
("npx @mcp/server-memory", "stdio"),
|
||||
("python3 server.py", "stdio"),
|
||||
("https://api.example.com/mcp", "http_stream"),
|
||||
("http://localhost:8080/mcp", "http_stream"),
|
||||
("ws://localhost:8080/mcp", "websocket"),
|
||||
("wss://api.example.com/mcp", "websocket"),
|
||||
("http://localhost:8080/sse", "sse"),
|
||||
("https://api.example.com/sse", "sse"),
|
||||
]
|
||||
|
||||
for url, expected in examples:
|
||||
detected = get_transport_type(url)
|
||||
status = "✓" if detected == expected else "✗"
|
||||
print(f" {status} '{url}'")
|
||||
print(f" → {detected}")
|
||||
|
||||
print()
|
||||
print("Transport Features:")
|
||||
print("-" * 50)
|
||||
print(" stdio:")
|
||||
print(" - Local subprocess communication")
|
||||
print(" - Newline-delimited JSON-RPC")
|
||||
print(" - Environment variable support")
|
||||
print()
|
||||
print(" Streamable HTTP:")
|
||||
print(" - Session management (Mcp-Session-Id)")
|
||||
print(" - Protocol versioning (Mcp-Protocol-Version)")
|
||||
print(" - SSE streaming with resumability")
|
||||
print()
|
||||
print(" WebSocket:")
|
||||
print(" - Bidirectional real-time")
|
||||
print(" - Auto-reconnect with exponential backoff")
|
||||
print(" - Authentication token support")
|
||||
print()
|
||||
print(" SSE (Legacy):")
|
||||
print(" - Backward compatibility")
|
||||
print(" - Protocol version 2024-11-05")
|
||||
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
MCP Streamable HTTP Transport Example
|
||||
|
||||
Demonstrates connecting to MCP servers via Streamable HTTP transport.
|
||||
This is the current standard transport (Protocol Revision 2025-11-25).
|
||||
|
||||
Features:
|
||||
- Single MCP endpoint for all communication
|
||||
- Session management via Mcp-Session-Id header
|
||||
- Protocol versioning via Mcp-Protocol-Version header
|
||||
- SSE streaming with resumability
|
||||
- Session termination via HTTP DELETE
|
||||
|
||||
Protocol: MCP 2025-11-25
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, MCP
|
||||
|
||||
# Basic Streamable HTTP connection
|
||||
agent = Agent(
|
||||
name="HTTP Stream Assistant",
|
||||
instructions="You are a helpful assistant connected via Streamable HTTP.",
|
||||
tools=MCP("https://api.example.com/mcp")
|
||||
)
|
||||
|
||||
# With authentication headers
|
||||
agent_auth = Agent(
|
||||
name="Authenticated Assistant",
|
||||
instructions="You are a helpful assistant with authenticated connection.",
|
||||
tools=MCP(
|
||||
"https://api.example.com/mcp",
|
||||
headers={"Authorization": "Bearer your-token"}
|
||||
)
|
||||
)
|
||||
|
||||
# With timeout and debug
|
||||
agent_debug = Agent(
|
||||
name="Debug Assistant",
|
||||
tools=MCP(
|
||||
"https://api.example.com/mcp",
|
||||
timeout=120,
|
||||
debug=True
|
||||
)
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Streamable HTTP MCP Transport Example")
|
||||
print("=" * 40)
|
||||
print("This example requires a Streamable HTTP MCP server running.")
|
||||
print("URL pattern: https://host:port/mcp")
|
||||
print()
|
||||
print("Features enabled:")
|
||||
print("- Mcp-Protocol-Version header (default: 2025-03-26)")
|
||||
print("- Mcp-Session-Id header (automatic)")
|
||||
print("- SSE streaming support")
|
||||
print("- Resumability via Last-Event-ID")
|
||||
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
MCP WebSocket Transport Example
|
||||
|
||||
Demonstrates connecting to MCP servers via WebSocket (ws:// or wss://).
|
||||
WebSocket transport provides bidirectional real-time communication,
|
||||
ideal for cloud deployments and long-lived connections.
|
||||
|
||||
Features:
|
||||
- Auto-detection of ws:// and wss:// URLs
|
||||
- Authentication token support
|
||||
- Auto-reconnect with exponential backoff
|
||||
- Ping/pong keepalive
|
||||
|
||||
Protocol: SEP-1288 (WebSocket Transport)
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, MCP
|
||||
|
||||
# Basic WebSocket connection
|
||||
agent = Agent(
|
||||
name="WebSocket Assistant",
|
||||
instructions="You are a helpful assistant connected via WebSocket.",
|
||||
tools=MCP("ws://localhost:8080/mcp")
|
||||
)
|
||||
|
||||
# Secure WebSocket with authentication
|
||||
agent_secure = Agent(
|
||||
name="Secure WebSocket Assistant",
|
||||
instructions="You are a helpful assistant with secure WebSocket connection.",
|
||||
tools=MCP(
|
||||
"wss://api.example.com/mcp",
|
||||
auth_token="Bearer your-secret-token",
|
||||
timeout=60
|
||||
)
|
||||
)
|
||||
|
||||
# WebSocket with custom options
|
||||
agent_custom = Agent(
|
||||
name="Custom WebSocket Assistant",
|
||||
tools=MCP(
|
||||
"wss://api.example.com/mcp",
|
||||
auth_token="your-token",
|
||||
timeout=120,
|
||||
debug=True
|
||||
)
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Note: Requires a running WebSocket MCP server
|
||||
# Example server: https://github.com/modelcontextprotocol/servers
|
||||
|
||||
print("WebSocket MCP Transport Example")
|
||||
print("=" * 40)
|
||||
print("This example requires a WebSocket MCP server running.")
|
||||
print("URL patterns: ws://host:port/path or wss://host:port/path")
|
||||
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Task Callbacks Example
|
||||
|
||||
Demonstrates using on_task_start and on_task_complete callbacks
|
||||
with PraisonAIAgents for workflow monitoring and logging.
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, Task, PraisonAIAgents
|
||||
|
||||
# Create an agent
|
||||
researcher = Agent(
|
||||
name="Researcher",
|
||||
role="Research Assistant",
|
||||
goal="Find and summarize information",
|
||||
llm="gpt-4o-mini"
|
||||
)
|
||||
|
||||
# Create tasks with variables
|
||||
task1 = Task(
|
||||
name="research",
|
||||
description="Research the topic: {{topic}}",
|
||||
expected_output="A brief summary of the topic",
|
||||
agent=researcher,
|
||||
variables={"topic": "artificial intelligence"}
|
||||
)
|
||||
|
||||
task2 = Task(
|
||||
name="summarize",
|
||||
description="Create a one-paragraph summary of the research.",
|
||||
expected_output="A concise summary",
|
||||
agent=researcher
|
||||
)
|
||||
|
||||
# Define callbacks
|
||||
def on_start(task, task_id):
|
||||
"""Called before each task starts."""
|
||||
print(f"🚀 Starting task: {task.name} (ID: {task_id})")
|
||||
|
||||
def on_complete(task, output):
|
||||
"""Called after each task completes."""
|
||||
print(f"✅ Completed task: {task.name}")
|
||||
print(f" Output preview: {str(output)[:100]}...")
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Create agents with callbacks
|
||||
agents = PraisonAIAgents(
|
||||
agents=[researcher],
|
||||
tasks=[task1, task2],
|
||||
process="workflow",
|
||||
on_task_start=on_start,
|
||||
on_task_complete=on_complete,
|
||||
variables={"global_var": "shared_value"} # Global variables
|
||||
)
|
||||
|
||||
# Run the workflow
|
||||
result = agents.start()
|
||||
print("\n🎉 Workflow completed!")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
Workflow Branching Example
|
||||
|
||||
Demonstrates conditional branching in workflows where steps can route
|
||||
to different next steps based on output content.
|
||||
"""
|
||||
|
||||
from praisonaiagents.memory.workflows import WorkflowManager, WorkflowStep, Workflow
|
||||
|
||||
# Create a workflow with branching
|
||||
workflow = Workflow(
|
||||
name="Decision Workflow",
|
||||
description="A workflow that branches based on validation result",
|
||||
steps=[
|
||||
WorkflowStep(
|
||||
name="validate",
|
||||
action="Check if the number 42 is positive. Reply with 'valid' or 'invalid'.",
|
||||
next_steps=["success_handler", "error_handler"],
|
||||
branch_condition={
|
||||
"valid": ["success_handler"],
|
||||
"invalid": ["error_handler"]
|
||||
}
|
||||
),
|
||||
WorkflowStep(
|
||||
name="success_handler",
|
||||
action="The validation passed! Generate a success message."
|
||||
),
|
||||
WorkflowStep(
|
||||
name="error_handler",
|
||||
action="The validation failed. Generate an error message."
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
print("Workflow completed!")
|
||||
for step_result in result["results"]:
|
||||
print(f" {step_result['step']}: {step_result['status']}")
|
||||
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Workflow Checkpoints Example
|
||||
|
||||
Demonstrates saving and resuming workflow execution using checkpoints.
|
||||
Useful for long-running workflows that may be interrupted.
|
||||
"""
|
||||
|
||||
from praisonaiagents.memory.workflows import WorkflowManager, WorkflowStep, Workflow
|
||||
|
||||
# Create a multi-step workflow
|
||||
workflow = Workflow(
|
||||
name="Long Process",
|
||||
description="A workflow with checkpoints for resumability",
|
||||
steps=[
|
||||
WorkflowStep(
|
||||
name="step1",
|
||||
action="Initialize the process and prepare data."
|
||||
),
|
||||
WorkflowStep(
|
||||
name="step2",
|
||||
action="Process the first batch of data."
|
||||
),
|
||||
WorkflowStep(
|
||||
name="step3",
|
||||
action="Process the second batch of data."
|
||||
),
|
||||
WorkflowStep(
|
||||
name="step4",
|
||||
action="Finalize and generate report."
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
manager = WorkflowManager()
|
||||
manager.workflows["Long Process"] = workflow
|
||||
|
||||
# Execute with checkpoint - saves after each step
|
||||
print("=== Starting workflow with checkpoint ===")
|
||||
result = manager.execute(
|
||||
"Long Process",
|
||||
default_llm="gpt-4o-mini",
|
||||
checkpoint="my-checkpoint" # Saves progress after each step
|
||||
)
|
||||
|
||||
print(f"Completed {len(result['results'])} steps")
|
||||
|
||||
# List available checkpoints
|
||||
print("\n=== Available checkpoints ===")
|
||||
checkpoints = manager.list_checkpoints()
|
||||
for cp in checkpoints:
|
||||
print(f" {cp['name']}: {cp['completed_steps']} steps completed")
|
||||
|
||||
# Resume from checkpoint (if workflow was interrupted)
|
||||
# result = manager.execute(
|
||||
# "Long Process",
|
||||
# default_llm="gpt-4o-mini",
|
||||
# resume="my-checkpoint" # Resumes from last saved state
|
||||
# )
|
||||
|
||||
# Clean up checkpoint
|
||||
manager.delete_checkpoint("my-checkpoint")
|
||||
print("\n✅ Checkpoint deleted")
|
||||
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
Workflow Early Stop Example
|
||||
|
||||
Demonstrates how to stop a workflow early using custom handler
|
||||
functions that return StepResult with stop_workflow=True.
|
||||
"""
|
||||
|
||||
from praisonaiagents.memory.workflows import (
|
||||
WorkflowManager, WorkflowStep, Workflow,
|
||||
WorkflowContext, StepResult
|
||||
)
|
||||
|
||||
# Custom validator that can stop the workflow
|
||||
def validate_data(context: WorkflowContext) -> StepResult:
|
||||
"""Check if data is valid and stop workflow if not."""
|
||||
data = context.variables.get("data", {})
|
||||
|
||||
if data.get("value", 0) < 0:
|
||||
return StepResult(
|
||||
output="❌ Validation failed: negative value detected. Stopping workflow.",
|
||||
stop_workflow=True # This stops the entire workflow
|
||||
)
|
||||
|
||||
return StepResult(
|
||||
output="✅ Validation passed: data is valid.",
|
||||
stop_workflow=False # Continue to next step
|
||||
)
|
||||
|
||||
# Create workflow with early stop capability
|
||||
workflow = Workflow(
|
||||
name="Data Processing",
|
||||
description="Process data with validation gate",
|
||||
variables={
|
||||
"data": {"value": -5} # Invalid data - will trigger early stop
|
||||
},
|
||||
steps=[
|
||||
WorkflowStep(
|
||||
name="validate",
|
||||
handler=validate_data # Custom function
|
||||
),
|
||||
WorkflowStep(
|
||||
name="process",
|
||||
action="Process the validated data." # Won't run if validation fails
|
||||
),
|
||||
WorkflowStep(
|
||||
name="report",
|
||||
action="Generate final report." # Won't run if validation fails
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
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)
|
||||
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']}")
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Workflow Loops Example
|
||||
|
||||
Demonstrates iterating over a list of items in a workflow step.
|
||||
Each item is processed individually with the loop variable.
|
||||
"""
|
||||
|
||||
from praisonaiagents.memory.workflows import WorkflowManager, WorkflowStep, Workflow
|
||||
|
||||
# Create a workflow with loop
|
||||
workflow = Workflow(
|
||||
name="Process Items",
|
||||
description="Process a list of items using loop",
|
||||
variables={
|
||||
"items": ["apple", "banana", "cherry"] # List to iterate over
|
||||
},
|
||||
steps=[
|
||||
WorkflowStep(
|
||||
name="process_each",
|
||||
action="Describe the fruit: {{item}}",
|
||||
loop_over="items", # Variable name containing the list
|
||||
loop_var="item" # Variable name for current item
|
||||
),
|
||||
WorkflowStep(
|
||||
name="summarize",
|
||||
action="Summarize all the fruits that were processed."
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
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")
|
||||
@@ -655,12 +655,23 @@ agent = Agent(tools=[my_tool])
|
||||
|
||||
## MCP (Model Context Protocol)
|
||||
|
||||
Connect to MCP servers for extended capabilities.
|
||||
Connect to MCP servers for extended capabilities. Supports **Protocol Revision 2025-11-25**.
|
||||
|
||||
### MCP Transports
|
||||
|
||||
| Transport | URL Pattern | Use Case |
|
||||
|-----------|-------------|----------|
|
||||
| **stdio** | Command string | Local NPX/Python servers |
|
||||
| **Streamable HTTP** | `http(s)://...` | Production deployments |
|
||||
| **WebSocket** | `ws://` or `wss://` | Real-time bidirectional |
|
||||
| **SSE (Legacy)** | `http(s)://.../sse` | Backward compatibility |
|
||||
|
||||
### MCP Client (Consume MCP Servers)
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent, MCP
|
||||
|
||||
# Local MCP server
|
||||
# stdio - Local MCP server
|
||||
agent = Agent(
|
||||
tools=MCP(
|
||||
command="npx",
|
||||
@@ -668,9 +679,19 @@ agent = Agent(
|
||||
)
|
||||
)
|
||||
|
||||
# Remote MCP server (SSE)
|
||||
# Streamable HTTP - Production server
|
||||
agent = Agent(
|
||||
tools=MCP(url="http://localhost:8080/sse")
|
||||
tools=MCP("https://api.example.com/mcp")
|
||||
)
|
||||
|
||||
# WebSocket - Real-time bidirectional (NEW)
|
||||
agent = Agent(
|
||||
tools=MCP("wss://api.example.com/mcp", auth_token="your-token")
|
||||
)
|
||||
|
||||
# SSE (Legacy) - Backward compatibility
|
||||
agent = Agent(
|
||||
tools=MCP("http://localhost:8080/sse")
|
||||
)
|
||||
|
||||
# With environment variables
|
||||
@@ -683,6 +704,39 @@ agent = Agent(
|
||||
)
|
||||
```
|
||||
|
||||
### MCP Features (Protocol 2025-11-25)
|
||||
|
||||
- **Session Management**: Automatic `Mcp-Session-Id` handling
|
||||
- **Protocol Versioning**: `Mcp-Protocol-Version` header on all requests
|
||||
- **Resumability**: SSE stream recovery via `Last-Event-ID`
|
||||
- **Security**: Origin validation, DNS rebinding prevention
|
||||
- **WebSocket**: Auto-reconnect with exponential backoff
|
||||
|
||||
### MCP Server (Expose Tools as MCP Server)
|
||||
|
||||
Expose your Python functions as MCP tools for external clients (Claude Desktop, Cursor, etc.).
|
||||
|
||||
```python
|
||||
from praisonaiagents.mcp import ToolsMCPServer
|
||||
|
||||
# Define your tools as regular Python functions
|
||||
def search_web(query: str, max_results: int = 5) -> dict:
|
||||
"""Search the web for information."""
|
||||
return {"results": [f"Result for {query}"]}
|
||||
|
||||
def calculate(expression: str) -> dict:
|
||||
"""Evaluate a mathematical expression."""
|
||||
return {"result": eval(expression)}
|
||||
|
||||
# Create and run MCP server
|
||||
server = ToolsMCPServer(name="my-tools")
|
||||
server.register_tools([search_web, calculate])
|
||||
server.run() # Starts stdio server (for Claude Desktop)
|
||||
|
||||
# Or run as SSE server for web clients
|
||||
# server.run_sse(host="0.0.0.0", port=8080)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Native Web Search
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
"""
|
||||
Fast Context Usage Examples.
|
||||
|
||||
This file demonstrates how to use the Fast Context feature for rapid code search.
|
||||
|
||||
Fast Context is inspired by Windsurf's SWE-grep approach:
|
||||
- Parallel tool execution (up to 8 concurrent calls)
|
||||
- Limited turns (max 4) for fast response
|
||||
- Lightweight model for search operations
|
||||
- Result caching for repeated queries
|
||||
"""
|
||||
|
||||
# Example 1: Basic FastContext Usage
|
||||
# ==================================
|
||||
|
||||
def example_basic_search():
|
||||
"""Basic search using FastContext."""
|
||||
from praisonaiagents.context.fast import FastContext
|
||||
|
||||
# Create FastContext instance
|
||||
fc = FastContext(workspace_path=".")
|
||||
|
||||
# Search for a pattern
|
||||
result = fc.search("def authenticate")
|
||||
|
||||
print(f"Found {result.total_files} files")
|
||||
print(f"Search time: {result.search_time_ms}ms")
|
||||
|
||||
# Print results
|
||||
for file_match in result.files[:5]:
|
||||
print(f" - {file_match.path}")
|
||||
for lr in file_match.line_ranges:
|
||||
print(f" Lines {lr.start}-{lr.end}")
|
||||
|
||||
|
||||
# Example 2: Search with Patterns
|
||||
# ===============================
|
||||
|
||||
def example_search_with_patterns():
|
||||
"""Search with include/exclude patterns."""
|
||||
from praisonaiagents.context.fast import FastContext
|
||||
|
||||
fc = FastContext(workspace_path=".")
|
||||
|
||||
# Search only in Python files
|
||||
result = fc.search(
|
||||
"class Agent",
|
||||
include_patterns=["**/*.py"],
|
||||
exclude_patterns=["**/tests/**", "**/test_*.py"]
|
||||
)
|
||||
|
||||
print(f"Found {result.total_files} files matching 'class Agent'")
|
||||
for f in result.files:
|
||||
print(f" - {f.path}")
|
||||
|
||||
|
||||
# Example 3: Get Context for Agent
|
||||
# ================================
|
||||
|
||||
def example_get_context_for_agent():
|
||||
"""Get formatted context string for an agent."""
|
||||
from praisonaiagents.context.fast import FastContext
|
||||
|
||||
fc = FastContext(workspace_path=".")
|
||||
|
||||
# Get context formatted for injection into an agent
|
||||
context = fc.get_context_for_agent(
|
||||
"authentication handlers",
|
||||
max_files=5,
|
||||
max_lines_per_file=50
|
||||
)
|
||||
|
||||
print("Context for agent:")
|
||||
print(context[:500] + "..." if len(context) > 500 else context)
|
||||
|
||||
|
||||
# Example 4: Agent with FastContext Integration
|
||||
# =============================================
|
||||
|
||||
def example_agent_with_fast_context():
|
||||
"""Use FastContext with an Agent."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Create agent with FastContext enabled
|
||||
agent = Agent(
|
||||
name="CodeAssistant",
|
||||
instructions="You are a helpful code assistant.",
|
||||
fast_context=True,
|
||||
fast_context_path=".", # Current directory
|
||||
fast_context_model="gpt-4o-mini",
|
||||
fast_context_max_turns=4,
|
||||
fast_context_parallelism=8
|
||||
)
|
||||
|
||||
# Use delegate_to_fast_context for code search
|
||||
context = agent.delegate_to_fast_context("find authentication code")
|
||||
|
||||
if context:
|
||||
print("Found relevant code context:")
|
||||
print(context[:300] + "...")
|
||||
else:
|
||||
print("No relevant code found")
|
||||
|
||||
|
||||
# Example 5: File Indexer
|
||||
# =======================
|
||||
|
||||
def example_file_indexer():
|
||||
"""Use FileIndexer for fast file lookups."""
|
||||
from praisonaiagents.context.fast.indexer import FileIndexer
|
||||
|
||||
# Create and run indexer
|
||||
indexer = FileIndexer(workspace_path=".")
|
||||
count = indexer.index()
|
||||
|
||||
print(f"Indexed {count} files")
|
||||
|
||||
# Find files by pattern
|
||||
py_files = indexer.find_by_pattern("**/*.py")
|
||||
print(f"Found {len(py_files)} Python files")
|
||||
|
||||
# Find files by extension
|
||||
js_files = indexer.find_by_extension(".js")
|
||||
print(f"Found {len(js_files)} JavaScript files")
|
||||
|
||||
# Get stats
|
||||
stats = indexer.get_stats()
|
||||
print(f"Total size: {stats['total_size_mb']} MB")
|
||||
|
||||
|
||||
# Example 6: Symbol Indexer
|
||||
# =========================
|
||||
|
||||
def example_symbol_indexer():
|
||||
"""Use SymbolIndexer to find code symbols."""
|
||||
from praisonaiagents.context.fast.indexer import SymbolIndexer, SymbolType
|
||||
|
||||
# Create and run indexer
|
||||
indexer = SymbolIndexer(workspace_path=".")
|
||||
count = indexer.index()
|
||||
|
||||
print(f"Indexed {count} symbols")
|
||||
|
||||
# Find symbols by name
|
||||
agent_symbols = indexer.find_by_name("Agent")
|
||||
print(f"Found {len(agent_symbols)} symbols containing 'Agent'")
|
||||
|
||||
# Find all classes
|
||||
classes = indexer.find_by_type(SymbolType.CLASS)
|
||||
print(f"Found {len(classes)} classes")
|
||||
|
||||
# Get stats
|
||||
stats = indexer.get_stats()
|
||||
print(f"Symbols by type: {stats['by_type']}")
|
||||
|
||||
|
||||
# Example 7: Context Injector
|
||||
# ===========================
|
||||
|
||||
def example_context_injector():
|
||||
"""Use ContextInjector to format results for agents."""
|
||||
from praisonaiagents.context.fast import FastContext
|
||||
from praisonaiagents.context.fast.context_injector import (
|
||||
ContextInjector,
|
||||
InjectionConfig
|
||||
)
|
||||
|
||||
# Search for code
|
||||
fc = FastContext(workspace_path=".")
|
||||
result = fc.search("def main")
|
||||
|
||||
# Configure injection
|
||||
config = InjectionConfig(
|
||||
max_tokens=2000,
|
||||
max_files=5,
|
||||
format_style="markdown", # or "xml", "plain"
|
||||
prioritize_precision=True
|
||||
)
|
||||
|
||||
# Inject into prompts
|
||||
injector = ContextInjector(config)
|
||||
formatted = injector.format_context(result)
|
||||
|
||||
print("Formatted context:")
|
||||
print(formatted[:500] + "..." if len(formatted) > 500 else formatted)
|
||||
|
||||
|
||||
# Example 8: Environment Variable Configuration
|
||||
# =============================================
|
||||
|
||||
def example_env_config():
|
||||
"""Configure FastContext via environment variables."""
|
||||
import os
|
||||
|
||||
# Set environment variables (usually done in shell or .env file)
|
||||
os.environ["FAST_CONTEXT_MODEL"] = "gpt-4o-mini"
|
||||
os.environ["FAST_CONTEXT_MAX_TURNS"] = "4"
|
||||
os.environ["FAST_CONTEXT_PARALLELISM"] = "8"
|
||||
os.environ["FAST_CONTEXT_TIMEOUT"] = "30.0"
|
||||
os.environ["FAST_CONTEXT_CACHE"] = "true"
|
||||
os.environ["FAST_CONTEXT_CACHE_TTL"] = "300"
|
||||
|
||||
from praisonaiagents.context.fast import FastContext
|
||||
|
||||
# FastContext will use environment variables as defaults
|
||||
fc = FastContext(workspace_path=".")
|
||||
|
||||
print(f"Model: {fc.model}")
|
||||
print(f"Max turns: {fc.max_turns}")
|
||||
print(f"Max parallel: {fc.max_parallel}")
|
||||
print(f"Cache enabled: {fc.cache_enabled}")
|
||||
|
||||
|
||||
# Example 9: Caching
|
||||
# ==================
|
||||
|
||||
def example_caching():
|
||||
"""Demonstrate result caching."""
|
||||
from praisonaiagents.context.fast import FastContext
|
||||
|
||||
fc = FastContext(
|
||||
workspace_path=".",
|
||||
cache_enabled=True,
|
||||
cache_ttl=300 # 5 minutes
|
||||
)
|
||||
|
||||
# First search - not cached
|
||||
result1 = fc.search("def main")
|
||||
print(f"First search: from_cache={result1.from_cache}, time={result1.search_time_ms}ms")
|
||||
|
||||
# Second search - cached
|
||||
result2 = fc.search("def main")
|
||||
print(f"Second search: from_cache={result2.from_cache}, time={result2.search_time_ms}ms")
|
||||
|
||||
# Clear cache
|
||||
fc.clear_cache()
|
||||
|
||||
# Third search - not cached
|
||||
result3 = fc.search("def main")
|
||||
print(f"Third search: from_cache={result3.from_cache}, time={result3.search_time_ms}ms")
|
||||
|
||||
|
||||
# Example 10: Context Manager
|
||||
# ===========================
|
||||
|
||||
def example_context_manager():
|
||||
"""Use FastContext as a context manager."""
|
||||
from praisonaiagents.context.fast import FastContext
|
||||
|
||||
with FastContext(workspace_path=".") as fc:
|
||||
result = fc.search("import")
|
||||
print(f"Found {result.total_files} files with imports")
|
||||
|
||||
# Resources are automatically cleaned up
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 60)
|
||||
print("Fast Context Examples")
|
||||
print("=" * 60)
|
||||
|
||||
print("\n1. Basic Search")
|
||||
print("-" * 40)
|
||||
example_basic_search()
|
||||
|
||||
print("\n2. Search with Patterns")
|
||||
print("-" * 40)
|
||||
example_search_with_patterns()
|
||||
|
||||
print("\n3. Get Context for Agent")
|
||||
print("-" * 40)
|
||||
example_get_context_for_agent()
|
||||
|
||||
print("\n5. File Indexer")
|
||||
print("-" * 40)
|
||||
example_file_indexer()
|
||||
|
||||
print("\n9. Caching")
|
||||
print("-" * 40)
|
||||
example_caching()
|
||||
|
||||
print("\n10. Context Manager")
|
||||
print("-" * 40)
|
||||
example_context_manager()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("All examples completed!")
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Example: Expose custom tools as MCP server.
|
||||
|
||||
This example demonstrates how to create an MCP server that exposes
|
||||
custom Python functions as MCP tools. These tools can then be used
|
||||
by any MCP client (Claude Desktop, Cursor, etc.).
|
||||
|
||||
Usage:
|
||||
# Run as stdio server (for Claude Desktop, etc.)
|
||||
python mcp_server_example.py
|
||||
|
||||
# Run as SSE server (for web clients)
|
||||
python mcp_server_example.py --sse --port 8080
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from typing import List, Dict, Any
|
||||
|
||||
|
||||
# Define your custom tools as regular Python functions
|
||||
# Type hints and docstrings are used to generate MCP schemas
|
||||
|
||||
def search_web(query: str, max_results: int = 5) -> Dict[str, Any]:
|
||||
"""Search the web for information.
|
||||
|
||||
Args:
|
||||
query: The search query string
|
||||
max_results: Maximum number of results to return (default: 5)
|
||||
|
||||
Returns:
|
||||
Search results with titles and URLs
|
||||
"""
|
||||
# This is a mock implementation - replace with actual search logic
|
||||
return {
|
||||
"query": query,
|
||||
"results": [
|
||||
{"title": f"Result {i+1} for '{query}'", "url": f"https://example.com/{i+1}"}
|
||||
for i in range(max_results)
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def calculate(expression: str) -> Dict[str, Any]:
|
||||
"""Evaluate a mathematical expression.
|
||||
|
||||
Args:
|
||||
expression: A mathematical expression to evaluate (e.g., "2 + 2 * 3")
|
||||
|
||||
Returns:
|
||||
The result of the calculation
|
||||
"""
|
||||
try:
|
||||
# Safe evaluation of mathematical expressions
|
||||
# In production, use a proper math parser
|
||||
allowed_chars = set("0123456789+-*/.() ")
|
||||
if not all(c in allowed_chars for c in expression):
|
||||
return {"error": "Invalid characters in expression"}
|
||||
|
||||
result = eval(expression) # Note: Use safer alternative in production
|
||||
return {"expression": expression, "result": result}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def get_weather(city: str, units: str = "celsius") -> Dict[str, Any]:
|
||||
"""Get current weather for a city.
|
||||
|
||||
Args:
|
||||
city: Name of the city
|
||||
units: Temperature units - "celsius" or "fahrenheit" (default: celsius)
|
||||
|
||||
Returns:
|
||||
Weather information for the city
|
||||
"""
|
||||
# Mock implementation - replace with actual weather API
|
||||
return {
|
||||
"city": city,
|
||||
"temperature": 22 if units == "celsius" else 72,
|
||||
"units": units,
|
||||
"condition": "Sunny",
|
||||
"humidity": 45
|
||||
}
|
||||
|
||||
|
||||
def list_files(directory: str = ".", pattern: str = "*") -> List[str]:
|
||||
"""List files in a directory.
|
||||
|
||||
Args:
|
||||
directory: Directory path to list (default: current directory)
|
||||
pattern: Glob pattern to filter files (default: *)
|
||||
|
||||
Returns:
|
||||
List of file names matching the pattern
|
||||
"""
|
||||
import glob
|
||||
import os
|
||||
|
||||
try:
|
||||
full_pattern = os.path.join(directory, pattern)
|
||||
files = glob.glob(full_pattern)
|
||||
return [os.path.basename(f) for f in files]
|
||||
except Exception as e:
|
||||
return [f"Error: {str(e)}"]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Run PraisonAI Tools as MCP Server")
|
||||
parser.add_argument("--sse", action="store_true", help="Use SSE transport instead of stdio")
|
||||
parser.add_argument("--port", type=int, default=8080, help="Port for SSE server (default: 8080)")
|
||||
parser.add_argument("--host", type=str, default="0.0.0.0", help="Host for SSE server")
|
||||
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Import MCP server components
|
||||
from praisonaiagents.mcp import ToolsMCPServer
|
||||
|
||||
# Create server with custom name
|
||||
server = ToolsMCPServer(
|
||||
name="praisonai-custom-tools",
|
||||
debug=args.debug
|
||||
)
|
||||
|
||||
# Register all tools
|
||||
server.register_tools([
|
||||
search_web,
|
||||
calculate,
|
||||
get_weather,
|
||||
list_files
|
||||
])
|
||||
|
||||
# Print registered tools
|
||||
print(f"📦 Registered {len(server.tools)} tools:")
|
||||
for name in server.get_tool_names():
|
||||
print(f" - {name}")
|
||||
print()
|
||||
|
||||
# Run the server
|
||||
if args.sse:
|
||||
server.run_sse(host=args.host, port=args.port)
|
||||
else:
|
||||
server.run_stdio()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -56,6 +56,23 @@ from .session import Session
|
||||
from .memory.memory import Memory
|
||||
from .guardrails import GuardrailResult, LLMGuardrail
|
||||
|
||||
# Fast Context support (lazy loaded to avoid performance impact)
|
||||
def __getattr__(name):
|
||||
"""Lazy load FastContext to avoid impacting package load time."""
|
||||
if name == "FastContext":
|
||||
from praisonaiagents.context.fast import FastContext
|
||||
return FastContext
|
||||
elif name == "FastContextResult":
|
||||
from praisonaiagents.context.fast import FastContextResult
|
||||
return FastContextResult
|
||||
elif name == "FileMatch":
|
||||
from praisonaiagents.context.fast import FileMatch
|
||||
return FileMatch
|
||||
elif name == "LineRange":
|
||||
from praisonaiagents.context.fast import LineRange
|
||||
return LineRange
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
# Planning mode support
|
||||
try:
|
||||
from .planning import (
|
||||
@@ -273,3 +290,11 @@ if _mcp_available:
|
||||
if FlowDisplay is not None:
|
||||
__all__.extend(['FlowDisplay', 'track_workflow'])
|
||||
|
||||
# Add FastContext exports (lazy loaded)
|
||||
__all__.extend([
|
||||
'FastContext',
|
||||
'FastContextResult',
|
||||
'FileMatch',
|
||||
'LineRange'
|
||||
])
|
||||
|
||||
@@ -242,7 +242,15 @@ class Agent:
|
||||
plan_mode: bool = False,
|
||||
planning: bool = False,
|
||||
planning_tools: Optional[List[Any]] = None,
|
||||
planning_reasoning: bool = False
|
||||
planning_reasoning: bool = False,
|
||||
fast_context: bool = False,
|
||||
fast_context_path: Optional[str] = None,
|
||||
fast_context_model: str = "gpt-4o-mini",
|
||||
fast_context_max_turns: int = 4,
|
||||
fast_context_parallelism: int = 8,
|
||||
fast_context_timeout: float = 30.0,
|
||||
history_in_context: Optional[int] = None,
|
||||
auto_save: Optional[str] = None
|
||||
):
|
||||
"""Initialize an Agent instance.
|
||||
|
||||
@@ -538,6 +546,10 @@ Your Goal: {self.goal}
|
||||
self.prompt_caching = prompt_caching
|
||||
self.claude_memory = claude_memory
|
||||
|
||||
# Session management
|
||||
self.history_in_context = history_in_context # Number of past sessions to include
|
||||
self.auto_save = auto_save # Session name for auto-saving
|
||||
|
||||
# Initialize rules manager for persistent context (like Cursor/Windsurf)
|
||||
self._rules_manager = None
|
||||
self._init_rules_manager()
|
||||
@@ -585,6 +597,15 @@ Your Goal: {self.goal}
|
||||
self._knowledge_config = knowledge_config
|
||||
self.knowledge = None # Will be initialized on first use
|
||||
|
||||
# Fast Context configuration (lazy loaded)
|
||||
self.fast_context_enabled = fast_context
|
||||
self.fast_context_path = fast_context_path or os.getcwd()
|
||||
self.fast_context_model = fast_context_model
|
||||
self.fast_context_max_turns = fast_context_max_turns
|
||||
self.fast_context_parallelism = fast_context_parallelism
|
||||
self.fast_context_timeout = fast_context_timeout
|
||||
self._fast_context_instance = None # Lazy loaded
|
||||
|
||||
@property
|
||||
def console(self):
|
||||
"""Lazily initialize Rich Console only when needed."""
|
||||
@@ -713,6 +734,57 @@ Your Goal: {self.goal}
|
||||
|
||||
return supports_prompt_caching(model_name)
|
||||
|
||||
@property
|
||||
def fast_context(self):
|
||||
"""Lazily initialize FastContext instance when needed.
|
||||
|
||||
Returns:
|
||||
FastContext instance or None if not enabled
|
||||
"""
|
||||
if not self.fast_context_enabled:
|
||||
return None
|
||||
|
||||
if self._fast_context_instance is None:
|
||||
try:
|
||||
from ..context.fast import FastContext
|
||||
self._fast_context_instance = FastContext(
|
||||
workspace_path=self.fast_context_path,
|
||||
model=self.fast_context_model,
|
||||
max_turns=self.fast_context_max_turns,
|
||||
max_parallel=self.fast_context_parallelism,
|
||||
timeout=self.fast_context_timeout,
|
||||
verbose=self.verbose
|
||||
)
|
||||
except ImportError:
|
||||
logging.warning("FastContext not available")
|
||||
return None
|
||||
|
||||
return self._fast_context_instance
|
||||
|
||||
def delegate_to_fast_context(self, query: str) -> Optional[str]:
|
||||
"""Delegate a code search query to FastContext subagent.
|
||||
|
||||
This method uses the FastContext subagent to rapidly search
|
||||
the codebase and return relevant context.
|
||||
|
||||
Args:
|
||||
query: Natural language search query
|
||||
|
||||
Returns:
|
||||
Formatted context string or None if FastContext not available
|
||||
"""
|
||||
if not self.fast_context_enabled or self.fast_context is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
result = self.fast_context.search(query)
|
||||
if result.total_files > 0:
|
||||
return self.fast_context.get_context_for_agent(query)
|
||||
return None
|
||||
except Exception as e:
|
||||
logging.warning(f"FastContext search failed: {e}")
|
||||
return None
|
||||
|
||||
def _init_rules_manager(self):
|
||||
"""
|
||||
Initialize RulesManager for persistent rules/instructions.
|
||||
@@ -2561,10 +2633,10 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
# Execute the step
|
||||
result = self.chat(step_prompt, **kwargs)
|
||||
results.append(result)
|
||||
results.append({"step": i + 1, "description": step.description, "result": result})
|
||||
|
||||
# Update context for next step
|
||||
context += f"\n\nStep {i + 1} result: {result[:500] if result else 'No result'}"
|
||||
# Update context for next step (use full result, not truncated)
|
||||
context += f"\n\nStep {i + 1} result: {result if result else 'No result'}"
|
||||
|
||||
console.print(f" [green]✅ Completed[/green]")
|
||||
|
||||
@@ -2572,26 +2644,116 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
console.print(f"[dim]Progress: [{'█' * bar_length}] 100%[/dim]")
|
||||
console.print(f"Completed {len(plan.steps)}/{len(plan.steps)} steps!\n")
|
||||
|
||||
# Return the final result
|
||||
return results[-1] if results else None
|
||||
# Compile all results into a comprehensive final output
|
||||
if len(results) > 1:
|
||||
# Create a compilation prompt
|
||||
all_results_text = "\n\n".join([
|
||||
f"## Step {r['step']}: {r['description']}\n\n{r['result']}"
|
||||
for r in results
|
||||
])
|
||||
|
||||
compilation_prompt = f"""You are tasked with compiling a comprehensive, detailed report from the following research steps.
|
||||
|
||||
IMPORTANT: Write a DETAILED and COMPREHENSIVE report. Do NOT summarize or compress the information.
|
||||
Include ALL relevant details, data, statistics, and findings from each step.
|
||||
Organize the information logically with clear sections and subsections.
|
||||
|
||||
## Research Results to Compile:
|
||||
|
||||
{all_results_text}
|
||||
|
||||
## Instructions:
|
||||
1. Combine all the information into a single, well-organized document
|
||||
2. Preserve ALL details, numbers, statistics, and specific findings
|
||||
3. Use clear headings and subheadings
|
||||
4. Do not omit any important information
|
||||
5. Make it comprehensive and detailed
|
||||
|
||||
Write the complete compiled report:"""
|
||||
|
||||
console.print("\n[bold blue]📝 COMPILING FINAL REPORT[/bold blue]")
|
||||
console.print("[dim]Creating comprehensive output from all steps...[/dim]\n")
|
||||
|
||||
final_result = self.chat(compilation_prompt, **kwargs)
|
||||
return final_result
|
||||
|
||||
# Return the single result if only one step
|
||||
return results[0]["result"] if results else None
|
||||
|
||||
def start(self, prompt: str, **kwargs):
|
||||
"""Start the agent with a prompt. This is a convenience method that wraps chat()."""
|
||||
# Load history from past sessions if history_in_context is set
|
||||
self._load_history_context()
|
||||
|
||||
# Check if planning mode is enabled
|
||||
if self.planning:
|
||||
return self._start_with_planning(prompt, **kwargs)
|
||||
|
||||
# Check if streaming is enabled (either from kwargs or agent's stream attribute)
|
||||
stream_enabled = kwargs.get('stream', getattr(self, 'stream', False))
|
||||
|
||||
if stream_enabled:
|
||||
result = self._start_with_planning(prompt, **kwargs)
|
||||
elif kwargs.get('stream', getattr(self, 'stream', False)):
|
||||
# Return a generator for streaming response
|
||||
return self._start_stream(prompt, **kwargs)
|
||||
result = self._start_stream(prompt, **kwargs)
|
||||
else:
|
||||
# Return regular chat response for backward compatibility
|
||||
# Explicitly pass the resolved stream parameter to avoid chat() method default
|
||||
kwargs['stream'] = stream_enabled
|
||||
return self.chat(prompt, **kwargs)
|
||||
kwargs['stream'] = False
|
||||
result = self.chat(prompt, **kwargs)
|
||||
|
||||
# Auto-save session if enabled
|
||||
self._auto_save_session()
|
||||
|
||||
return result
|
||||
|
||||
def _load_history_context(self):
|
||||
"""Load history from past sessions into context if history_in_context is set."""
|
||||
if not self.history_in_context or not self._memory_instance:
|
||||
return
|
||||
|
||||
try:
|
||||
sessions = self._memory_instance.list_sessions()
|
||||
if not sessions:
|
||||
return
|
||||
|
||||
# Get the last N sessions
|
||||
sessions_to_load = sessions[:self.history_in_context]
|
||||
|
||||
for session_info in reversed(sessions_to_load):
|
||||
try:
|
||||
session_data = self._memory_instance.resume_session(session_info["name"])
|
||||
history = session_data.get("conversation_history", [])
|
||||
|
||||
# Add history to chat_history with a marker
|
||||
for msg in history:
|
||||
if msg not in self.chat_history:
|
||||
# Mark as from history to avoid duplication
|
||||
msg_copy = msg.copy()
|
||||
msg_copy["_from_history"] = True
|
||||
self.chat_history.append(msg_copy)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if sessions_to_load:
|
||||
logging.debug(f"Loaded history from {len(sessions_to_load)} past session(s)")
|
||||
except Exception as e:
|
||||
logging.debug(f"Error loading history context: {e}")
|
||||
|
||||
def _auto_save_session(self):
|
||||
"""Auto-save session if auto_save is enabled."""
|
||||
if not self.auto_save or not self._memory_instance:
|
||||
return
|
||||
|
||||
try:
|
||||
# Filter out history markers before saving
|
||||
clean_history = [
|
||||
{k: v for k, v in msg.items() if k != "_from_history"}
|
||||
for msg in self.chat_history
|
||||
]
|
||||
|
||||
self._memory_instance.save_session(
|
||||
name=self.auto_save,
|
||||
conversation_history=clean_history,
|
||||
metadata={"agent_name": self.name, "user_id": self.user_id}
|
||||
)
|
||||
logging.debug(f"Auto-saved session: {self.auto_save}")
|
||||
except Exception as e:
|
||||
logging.debug(f"Error auto-saving session: {e}")
|
||||
|
||||
def _start_stream(self, prompt: str, **kwargs) -> Generator[str, None, None]:
|
||||
"""Stream generator for real-time response chunks."""
|
||||
|
||||
@@ -258,11 +258,24 @@ Expanded prompt:"""
|
||||
prompt_lower = prompt.lower().strip()
|
||||
return any(prompt_lower.startswith(verb) for verb in task_verbs)
|
||||
|
||||
def _is_technical_task(self, prompt: str) -> bool:
|
||||
"""Check if prompt is a technical/coding task."""
|
||||
technical_indicators = [
|
||||
"python", "javascript", "java", "code", "function", "class",
|
||||
"api", "database", "sql", "html", "css", "react", "node",
|
||||
"algorithm", "debug", "fix", "error", "bug", "test",
|
||||
"deploy", "server", "docker", "kubernetes", "aws", "azure",
|
||||
"typescript", "rust", "go", "c++", "c#", "ruby", "php",
|
||||
"script", "bash", "shell", "terminal", "command", "cli"
|
||||
]
|
||||
prompt_lower = prompt.lower()
|
||||
return any(ind in prompt_lower for ind in technical_indicators)
|
||||
|
||||
def _is_creative_task(self, prompt: str) -> bool:
|
||||
"""Check if prompt is a creative task."""
|
||||
"""Check if prompt is a creative task (non-technical)."""
|
||||
creative_indicators = [
|
||||
"poem", "story", "script", "song", "art", "creative",
|
||||
"fiction", "novel", "essay", "blog", "article", "write"
|
||||
"poem", "story", "song", "art", "creative",
|
||||
"fiction", "novel", "essay", "blog", "article"
|
||||
]
|
||||
prompt_lower = prompt.lower()
|
||||
return any(ind in prompt_lower for ind in creative_indicators)
|
||||
@@ -273,6 +286,10 @@ Expanded prompt:"""
|
||||
if self._is_short_prompt(prompt, threshold=5):
|
||||
return ExpandStrategy.DETAILED
|
||||
|
||||
# Technical/coding tasks get structured expansion (check before creative)
|
||||
if self._is_technical_task(prompt):
|
||||
return ExpandStrategy.STRUCTURED
|
||||
|
||||
# Creative tasks benefit from creative expansion
|
||||
if self._is_creative_task(prompt):
|
||||
return ExpandStrategy.CREATIVE
|
||||
@@ -330,6 +347,37 @@ Expanded prompt:"""
|
||||
current_date=current_date
|
||||
)
|
||||
|
||||
# Add tool usage instructions if tools are available
|
||||
if self.tools:
|
||||
# Build dynamic tool descriptions from docstrings
|
||||
tool_descriptions = []
|
||||
for tool in self.tools:
|
||||
tool_name = getattr(tool, '__name__', str(tool))
|
||||
tool_doc = getattr(tool, '__doc__', '')
|
||||
# Get first line of docstring as description
|
||||
if tool_doc:
|
||||
first_line = tool_doc.strip().split('\n')[0].strip()
|
||||
tool_descriptions.append(f"- {tool_name}: {first_line}")
|
||||
else:
|
||||
tool_descriptions.append(f"- {tool_name}")
|
||||
|
||||
tools_list = '\n'.join(tool_descriptions)
|
||||
|
||||
tool_instruction = f"""
|
||||
|
||||
IMPORTANT: You have the following tools available:
|
||||
{tools_list}
|
||||
|
||||
Before expanding this prompt, FIRST use the available tools to gather relevant current information about the topic. This will help you create a more informed and accurate expanded prompt.
|
||||
|
||||
Steps:
|
||||
1. Use the appropriate tool(s) to gather relevant information about the topic
|
||||
2. Use the gathered information to inform your expansion
|
||||
3. Create an expanded prompt that incorporates the latest context
|
||||
|
||||
"""
|
||||
expansion_prompt = tool_instruction + expansion_prompt
|
||||
|
||||
# Call the agent
|
||||
expanded = self._call_agent(expansion_prompt).strip()
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import os
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, Optional, List
|
||||
from typing import Any, Dict, Optional, List, Callable
|
||||
from pydantic import BaseModel
|
||||
from rich.text import Text
|
||||
from rich.panel import Panel
|
||||
@@ -127,7 +127,7 @@ def process_task_context(context_item, verbose=0, user_id=None):
|
||||
return str(context_item) # Fallback for unknown types
|
||||
|
||||
class PraisonAIAgents:
|
||||
def __init__(self, agents, tasks=None, verbose=0, completion_checker=None, max_retries=5, process="sequential", manager_llm=None, memory=False, memory_config=None, embedder=None, user_id=None, max_iter=10, stream=True, name: Optional[str] = None, planning: bool = False, planning_llm: Optional[str] = None, auto_approve_plan: bool = False, planning_tools: Optional[List] = None, planning_reasoning: bool = False):
|
||||
def __init__(self, agents, tasks=None, verbose=0, completion_checker=None, max_retries=5, process="sequential", manager_llm=None, memory=False, memory_config=None, embedder=None, user_id=None, max_iter=10, stream=True, name: Optional[str] = None, planning: bool = False, planning_llm: Optional[str] = None, auto_approve_plan: bool = False, planning_tools: Optional[List] = None, planning_reasoning: bool = False, on_task_start: Optional[Callable] = None, on_task_complete: Optional[Callable] = None, variables: Optional[Dict[str, Any]] = None):
|
||||
# Add check at the start if memory is requested
|
||||
if memory:
|
||||
try:
|
||||
@@ -162,6 +162,11 @@ class PraisonAIAgents:
|
||||
self.stream = stream
|
||||
self.name = name # Store the name for the Agents collection
|
||||
|
||||
# Callbacks for workflow execution
|
||||
self.on_task_start = on_task_start # Called before each task
|
||||
self.on_task_complete = on_task_complete # Called after each task
|
||||
self.variables = variables if variables else {} # Global variables for substitution
|
||||
|
||||
# Check for manager_llm in environment variable if not provided
|
||||
self.manager_llm = manager_llm or os.getenv('OPENAI_MODEL_NAME', 'gpt-5-nano')
|
||||
|
||||
@@ -661,13 +666,24 @@ Context:
|
||||
|
||||
executor_agent = task.agent
|
||||
|
||||
# Create agent from agent_config if provided and no agent assigned
|
||||
if executor_agent is None and getattr(task, 'agent_config', None):
|
||||
executor_agent = self._create_agent_from_config(task.agent_config)
|
||||
task.agent = executor_agent
|
||||
|
||||
# Set current agent for token tracking
|
||||
llm = getattr(executor_agent, 'llm', None) or getattr(executor_agent, 'llm_instance', None)
|
||||
if llm and hasattr(llm, 'set_current_agent'):
|
||||
llm.set_current_agent(executor_agent.name)
|
||||
|
||||
# Substitute variables in task description if provided
|
||||
task_description = task.description
|
||||
if getattr(task, 'variables', None):
|
||||
for key, value in task.variables.items():
|
||||
task_description = task_description.replace(f"{{{{{key}}}}}", str(value))
|
||||
|
||||
task_prompt = f"""
|
||||
You need to do the following task: {task.description}.
|
||||
You need to do the following task: {task_description}.
|
||||
Expected Output: {task.expected_output}.
|
||||
"""
|
||||
if task.context:
|
||||
@@ -825,6 +841,17 @@ Context:
|
||||
logger.info(f"Task with ID {task_id} is already completed")
|
||||
return
|
||||
|
||||
# Call on_task_start callback if provided
|
||||
if self.on_task_start:
|
||||
try:
|
||||
self.on_task_start(task, task_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in on_task_start callback: {e}")
|
||||
|
||||
# Apply global variables to task if not already set
|
||||
if self.variables and not getattr(task, 'variables', None):
|
||||
task.variables = self.variables
|
||||
|
||||
retries = 0
|
||||
while task.status != "completed" and retries < self.max_retries:
|
||||
logger.debug(f"Attempt {retries+1} for task {task_id}")
|
||||
@@ -857,6 +884,14 @@ Context:
|
||||
logger.exception(e)
|
||||
|
||||
self.save_output_to_file(task, task_output)
|
||||
|
||||
# Call on_task_complete callback if provided
|
||||
if self.on_task_complete:
|
||||
try:
|
||||
self.on_task_complete(task, task_output)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in on_task_complete callback: {e}")
|
||||
|
||||
if self.verbose >= 1:
|
||||
logger.info(f"Task {task_id} completed successfully.")
|
||||
else:
|
||||
@@ -1561,6 +1596,29 @@ Context:
|
||||
"""Get the current todo list."""
|
||||
return self._todo_list
|
||||
|
||||
def _create_agent_from_config(self, agent_config: dict) -> 'Agent':
|
||||
"""
|
||||
Create an Agent from a configuration dictionary.
|
||||
|
||||
Args:
|
||||
agent_config: Dict with keys like 'name', 'role', 'goal', 'backstory', 'llm', 'tools'
|
||||
|
||||
Returns:
|
||||
Agent instance
|
||||
"""
|
||||
from ..agent.agent import Agent
|
||||
|
||||
return Agent(
|
||||
name=agent_config.get('name', 'TaskAgent'),
|
||||
role=agent_config.get('role'),
|
||||
goal=agent_config.get('goal'),
|
||||
backstory=agent_config.get('backstory'),
|
||||
llm=agent_config.get('llm'),
|
||||
tools=agent_config.get('tools'),
|
||||
verbose=self.verbose >= 1,
|
||||
memory=self.memory
|
||||
)
|
||||
|
||||
def _get_planning_agent(self):
|
||||
"""Lazy load PlanningAgent."""
|
||||
if self._planning_agent is None and self.planning:
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
Context module for PraisonAI Agents.
|
||||
|
||||
This module provides context retrieval capabilities including:
|
||||
- Fast Context: Rapid parallel code search subagent
|
||||
"""
|
||||
|
||||
# Lazy imports to avoid impacting package load time
|
||||
def __getattr__(name):
|
||||
"""Lazy import to avoid impacting package performance."""
|
||||
if name == "FastContext":
|
||||
from praisonaiagents.context.fast.fast_context import FastContext
|
||||
return FastContext
|
||||
elif name == "FastContextResult":
|
||||
from praisonaiagents.context.fast.result import FastContextResult
|
||||
return FastContextResult
|
||||
elif name == "FileMatch":
|
||||
from praisonaiagents.context.fast.result import FileMatch
|
||||
return FileMatch
|
||||
elif name == "LineRange":
|
||||
from praisonaiagents.context.fast.result import LineRange
|
||||
return LineRange
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
__all__ = [
|
||||
"FastContext",
|
||||
"FastContextResult",
|
||||
"FileMatch",
|
||||
"LineRange",
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
Fast Context module for rapid parallel code search.
|
||||
|
||||
This module implements a specialized subagent that retrieves relevant code
|
||||
from a codebase using parallel tool calls, similar to Windsurf's Fast Context.
|
||||
|
||||
Key features:
|
||||
- Parallel tool execution (up to 8 concurrent calls)
|
||||
- Limited serial turns (max 4) for fast response
|
||||
- Restricted tool set (grep, glob, read) for safety
|
||||
- Returns files + line ranges (no summarization)
|
||||
"""
|
||||
|
||||
# Lazy imports to avoid impacting package load time
|
||||
def __getattr__(name):
|
||||
"""Lazy import to avoid impacting package performance."""
|
||||
if name == "FastContext":
|
||||
from praisonaiagents.context.fast.fast_context import FastContext
|
||||
return FastContext
|
||||
elif name == "FastContextResult":
|
||||
from praisonaiagents.context.fast.result import FastContextResult
|
||||
return FastContextResult
|
||||
elif name == "FileMatch":
|
||||
from praisonaiagents.context.fast.result import FileMatch
|
||||
return FileMatch
|
||||
elif name == "LineRange":
|
||||
from praisonaiagents.context.fast.result import LineRange
|
||||
return LineRange
|
||||
elif name == "FastContextAgent":
|
||||
from praisonaiagents.context.fast.fast_context_agent import FastContextAgent
|
||||
return FastContextAgent
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
__all__ = [
|
||||
"FastContext",
|
||||
"FastContextResult",
|
||||
"FileMatch",
|
||||
"LineRange",
|
||||
"FastContextAgent",
|
||||
]
|
||||
@@ -0,0 +1,383 @@
|
||||
"""
|
||||
Context Injector for Fast Context.
|
||||
|
||||
Injects FastContext results into the main agent with:
|
||||
- Token budget management
|
||||
- Context formatting for different models
|
||||
- Precision over recall (avoid context pollution)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, List, Dict, Any
|
||||
from dataclasses import dataclass
|
||||
|
||||
from praisonaiagents.context.fast.result import FastContextResult, FileMatch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class InjectionConfig:
|
||||
"""Configuration for context injection.
|
||||
|
||||
Attributes:
|
||||
max_tokens: Maximum tokens for injected context
|
||||
max_files: Maximum files to include
|
||||
max_lines_per_file: Maximum lines per file
|
||||
include_line_numbers: Whether to include line numbers
|
||||
include_file_content: Whether to include file content
|
||||
format_style: Format style ('markdown', 'xml', 'plain')
|
||||
prioritize_precision: If True, include fewer but more relevant results
|
||||
"""
|
||||
max_tokens: int = 4000
|
||||
max_files: int = 10
|
||||
max_lines_per_file: int = 100
|
||||
include_line_numbers: bool = True
|
||||
include_file_content: bool = True
|
||||
format_style: str = "markdown"
|
||||
prioritize_precision: bool = True
|
||||
|
||||
# Approximate tokens per character (conservative estimate)
|
||||
chars_per_token: float = 4.0
|
||||
|
||||
|
||||
class ContextInjector:
|
||||
"""Injects FastContext results into agent context.
|
||||
|
||||
Handles:
|
||||
- Token budget management
|
||||
- Result formatting
|
||||
- Context pollution prevention
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[InjectionConfig] = None):
|
||||
"""Initialize context injector.
|
||||
|
||||
Args:
|
||||
config: Injection configuration
|
||||
"""
|
||||
self.config = config or InjectionConfig()
|
||||
|
||||
def estimate_tokens(self, text: str) -> int:
|
||||
"""Estimate token count for text.
|
||||
|
||||
Args:
|
||||
text: Text to estimate
|
||||
|
||||
Returns:
|
||||
Estimated token count
|
||||
"""
|
||||
return int(len(text) / self.config.chars_per_token)
|
||||
|
||||
def inject(
|
||||
self,
|
||||
result: FastContextResult,
|
||||
system_prompt: Optional[str] = None,
|
||||
user_message: Optional[str] = None
|
||||
) -> Dict[str, str]:
|
||||
"""Inject FastContext results into agent context.
|
||||
|
||||
Args:
|
||||
result: FastContext search result
|
||||
system_prompt: Original system prompt
|
||||
user_message: Original user message
|
||||
|
||||
Returns:
|
||||
Dict with 'system_prompt' and 'user_message' keys
|
||||
"""
|
||||
# Format the context
|
||||
context_str = self.format_context(result)
|
||||
|
||||
# Inject into appropriate location
|
||||
if system_prompt:
|
||||
# Add to system prompt
|
||||
injected_system = self._inject_into_system_prompt(system_prompt, context_str)
|
||||
return {
|
||||
"system_prompt": injected_system,
|
||||
"user_message": user_message or ""
|
||||
}
|
||||
elif user_message:
|
||||
# Add to user message
|
||||
injected_user = self._inject_into_user_message(user_message, context_str)
|
||||
return {
|
||||
"system_prompt": "",
|
||||
"user_message": injected_user
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"system_prompt": context_str,
|
||||
"user_message": ""
|
||||
}
|
||||
|
||||
def format_context(self, result: FastContextResult) -> str:
|
||||
"""Format FastContext result as context string.
|
||||
|
||||
Args:
|
||||
result: FastContext search result
|
||||
|
||||
Returns:
|
||||
Formatted context string
|
||||
"""
|
||||
if self.config.format_style == "markdown":
|
||||
return self._format_markdown(result)
|
||||
elif self.config.format_style == "xml":
|
||||
return self._format_xml(result)
|
||||
else:
|
||||
return self._format_plain(result)
|
||||
|
||||
def _format_markdown(self, result: FastContextResult) -> str:
|
||||
"""Format result as markdown.
|
||||
|
||||
Args:
|
||||
result: FastContext search result
|
||||
|
||||
Returns:
|
||||
Markdown formatted string
|
||||
"""
|
||||
if not result.files:
|
||||
return ""
|
||||
|
||||
lines = ["# Relevant Code Context"]
|
||||
lines.append(f"*Found {result.total_files} file(s) in {result.search_time_ms}ms*\n")
|
||||
|
||||
token_budget = self.config.max_tokens
|
||||
files_included = 0
|
||||
|
||||
# Sort by relevance
|
||||
sorted_files = sorted(
|
||||
result.files,
|
||||
key=lambda f: f.relevance_score,
|
||||
reverse=True
|
||||
)
|
||||
|
||||
# Apply precision filter if enabled
|
||||
if self.config.prioritize_precision:
|
||||
sorted_files = [f for f in sorted_files if f.relevance_score >= 0.5]
|
||||
|
||||
for file_match in sorted_files:
|
||||
if files_included >= self.config.max_files:
|
||||
break
|
||||
|
||||
file_section = self._format_file_markdown(file_match)
|
||||
section_tokens = self.estimate_tokens(file_section)
|
||||
|
||||
if section_tokens > token_budget:
|
||||
# Try truncated version
|
||||
file_section = self._format_file_markdown(file_match, truncate=True)
|
||||
section_tokens = self.estimate_tokens(file_section)
|
||||
|
||||
if section_tokens > token_budget:
|
||||
continue
|
||||
|
||||
lines.append(file_section)
|
||||
token_budget -= section_tokens
|
||||
files_included += 1
|
||||
|
||||
if files_included < result.total_files:
|
||||
lines.append(f"\n*... and {result.total_files - files_included} more files*")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _format_file_markdown(
|
||||
self,
|
||||
file_match: FileMatch,
|
||||
truncate: bool = False
|
||||
) -> str:
|
||||
"""Format a single file match as markdown.
|
||||
|
||||
Args:
|
||||
file_match: File match to format
|
||||
truncate: Whether to truncate content
|
||||
|
||||
Returns:
|
||||
Markdown formatted string
|
||||
"""
|
||||
lines = [f"## `{file_match.path}`"]
|
||||
|
||||
if not self.config.include_file_content:
|
||||
if file_match.line_ranges:
|
||||
ranges = ", ".join(
|
||||
f"L{lr.start}-{lr.end}" for lr in file_match.line_ranges[:3]
|
||||
)
|
||||
lines.append(f"Relevant lines: {ranges}")
|
||||
return "\n".join(lines)
|
||||
|
||||
max_lines = self.config.max_lines_per_file
|
||||
if truncate:
|
||||
max_lines = max_lines // 2
|
||||
|
||||
lines_shown = 0
|
||||
for lr in file_match.line_ranges:
|
||||
if lines_shown >= max_lines:
|
||||
break
|
||||
|
||||
if self.config.include_line_numbers:
|
||||
lines.append(f"Lines {lr.start}-{lr.end}:")
|
||||
|
||||
if lr.content:
|
||||
content = lr.content
|
||||
content_lines = content.split("\n")
|
||||
|
||||
if len(content_lines) > max_lines - lines_shown:
|
||||
content_lines = content_lines[:max_lines - lines_shown]
|
||||
content = "\n".join(content_lines) + "\n..."
|
||||
|
||||
lines.append(f"```\n{content}\n```")
|
||||
lines_shown += len(content_lines)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _format_xml(self, result: FastContextResult) -> str:
|
||||
"""Format result as XML.
|
||||
|
||||
Args:
|
||||
result: FastContext search result
|
||||
|
||||
Returns:
|
||||
XML formatted string
|
||||
"""
|
||||
if not result.files:
|
||||
return ""
|
||||
|
||||
lines = ["<code_context>"]
|
||||
lines.append(f" <summary files=\"{result.total_files}\" search_time_ms=\"{result.search_time_ms}\"/>")
|
||||
|
||||
token_budget = self.config.max_tokens
|
||||
files_included = 0
|
||||
|
||||
sorted_files = sorted(
|
||||
result.files,
|
||||
key=lambda f: f.relevance_score,
|
||||
reverse=True
|
||||
)
|
||||
|
||||
for file_match in sorted_files[:self.config.max_files]:
|
||||
if files_included >= self.config.max_files:
|
||||
break
|
||||
|
||||
file_xml = self._format_file_xml(file_match)
|
||||
section_tokens = self.estimate_tokens(file_xml)
|
||||
|
||||
if section_tokens > token_budget:
|
||||
continue
|
||||
|
||||
lines.append(file_xml)
|
||||
token_budget -= section_tokens
|
||||
files_included += 1
|
||||
|
||||
lines.append("</code_context>")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _format_file_xml(self, file_match: FileMatch) -> str:
|
||||
"""Format a single file match as XML.
|
||||
|
||||
Args:
|
||||
file_match: File match to format
|
||||
|
||||
Returns:
|
||||
XML formatted string
|
||||
"""
|
||||
lines = [f' <file path="{file_match.path}" relevance="{file_match.relevance_score:.2f}">']
|
||||
|
||||
for lr in file_match.line_ranges[:3]:
|
||||
lines.append(f' <lines start="{lr.start}" end="{lr.end}">')
|
||||
if lr.content and self.config.include_file_content:
|
||||
# Escape XML special characters
|
||||
content = lr.content.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
lines.append(f" <![CDATA[{content}]]>")
|
||||
lines.append(" </lines>")
|
||||
|
||||
lines.append(" </file>")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _format_plain(self, result: FastContextResult) -> str:
|
||||
"""Format result as plain text.
|
||||
|
||||
Args:
|
||||
result: FastContext search result
|
||||
|
||||
Returns:
|
||||
Plain text formatted string
|
||||
"""
|
||||
if not result.files:
|
||||
return ""
|
||||
|
||||
lines = [f"Code Context ({result.total_files} files):"]
|
||||
|
||||
for file_match in result.files[:self.config.max_files]:
|
||||
lines.append(f"\nFile: {file_match.path}")
|
||||
|
||||
for lr in file_match.line_ranges[:3]:
|
||||
lines.append(f" Lines {lr.start}-{lr.end}")
|
||||
if lr.content and self.config.include_file_content:
|
||||
for line in lr.content.split("\n")[:10]:
|
||||
lines.append(f" {line}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _inject_into_system_prompt(
|
||||
self,
|
||||
system_prompt: str,
|
||||
context: str
|
||||
) -> str:
|
||||
"""Inject context into system prompt.
|
||||
|
||||
Args:
|
||||
system_prompt: Original system prompt
|
||||
context: Context to inject
|
||||
|
||||
Returns:
|
||||
Modified system prompt
|
||||
"""
|
||||
if not context:
|
||||
return system_prompt
|
||||
|
||||
# Add context section
|
||||
return f"{system_prompt}\n\n{context}"
|
||||
|
||||
def _inject_into_user_message(
|
||||
self,
|
||||
user_message: str,
|
||||
context: str
|
||||
) -> str:
|
||||
"""Inject context into user message.
|
||||
|
||||
Args:
|
||||
user_message: Original user message
|
||||
context: Context to inject
|
||||
|
||||
Returns:
|
||||
Modified user message
|
||||
"""
|
||||
if not context:
|
||||
return user_message
|
||||
|
||||
# Prepend context
|
||||
return f"{context}\n\n---\n\n{user_message}"
|
||||
|
||||
|
||||
def inject_fast_context(
|
||||
result: FastContextResult,
|
||||
system_prompt: Optional[str] = None,
|
||||
user_message: Optional[str] = None,
|
||||
max_tokens: int = 4000,
|
||||
format_style: str = "markdown"
|
||||
) -> Dict[str, str]:
|
||||
"""Convenience function to inject FastContext results.
|
||||
|
||||
Args:
|
||||
result: FastContext search result
|
||||
system_prompt: Original system prompt
|
||||
user_message: Original user message
|
||||
max_tokens: Maximum tokens for context
|
||||
format_style: Format style ('markdown', 'xml', 'plain')
|
||||
|
||||
Returns:
|
||||
Dict with 'system_prompt' and 'user_message' keys
|
||||
"""
|
||||
config = InjectionConfig(
|
||||
max_tokens=max_tokens,
|
||||
format_style=format_style
|
||||
)
|
||||
injector = ContextInjector(config)
|
||||
return injector.inject(result, system_prompt, user_message)
|
||||
@@ -0,0 +1,384 @@
|
||||
"""
|
||||
FastContext - High-level API for fast parallel code search.
|
||||
|
||||
This module provides the main FastContext class that can be used
|
||||
standalone or integrated with the main Agent class.
|
||||
|
||||
Example usage:
|
||||
# Standalone usage
|
||||
from praisonaiagents.context.fast import FastContext
|
||||
|
||||
fc = FastContext(workspace_path="/path/to/project")
|
||||
result = fc.search("find authentication handlers")
|
||||
print(result.to_context_string())
|
||||
|
||||
# With Agent integration
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="CodeAssistant",
|
||||
fast_context=True,
|
||||
fast_context_path="/path/to/project"
|
||||
)
|
||||
"""
|
||||
|
||||
import os
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from praisonaiagents.context.fast.result import FastContextResult, FileMatch, LineRange
|
||||
from praisonaiagents.context.fast.fast_context_agent import FastContextAgent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FastContext:
|
||||
"""High-level API for fast parallel code search.
|
||||
|
||||
FastContext provides rapid code search capabilities using:
|
||||
- Parallel tool execution (up to 8 concurrent calls)
|
||||
- Limited turns (max 4) for fast response
|
||||
- Optional LLM-powered intelligent search
|
||||
- Result caching for repeated queries
|
||||
|
||||
Attributes:
|
||||
workspace_path: Root directory for searches
|
||||
model: LLM model for intelligent search
|
||||
max_turns: Maximum search turns
|
||||
max_parallel: Maximum parallel tool calls
|
||||
cache_enabled: Whether to cache results
|
||||
"""
|
||||
|
||||
# Environment variable names for configuration
|
||||
ENV_MODEL = "FAST_CONTEXT_MODEL"
|
||||
ENV_MAX_TURNS = "FAST_CONTEXT_MAX_TURNS"
|
||||
ENV_MAX_PARALLEL = "FAST_CONTEXT_PARALLELISM"
|
||||
ENV_TIMEOUT = "FAST_CONTEXT_TIMEOUT"
|
||||
ENV_CACHE_ENABLED = "FAST_CONTEXT_CACHE"
|
||||
ENV_CACHE_TTL = "FAST_CONTEXT_CACHE_TTL"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace_path: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
max_turns: Optional[int] = None,
|
||||
max_parallel: Optional[int] = None,
|
||||
timeout: Optional[float] = None,
|
||||
cache_enabled: Optional[bool] = None,
|
||||
cache_ttl: Optional[int] = None,
|
||||
verbose: bool = False
|
||||
):
|
||||
"""Initialize FastContext.
|
||||
|
||||
Configuration can be set via parameters or environment variables.
|
||||
Parameters take precedence over environment variables.
|
||||
|
||||
Environment variables:
|
||||
FAST_CONTEXT_MODEL: LLM model name (default: gpt-4o-mini)
|
||||
FAST_CONTEXT_MAX_TURNS: Maximum search turns (default: 4)
|
||||
FAST_CONTEXT_PARALLELISM: Max parallel calls (default: 8)
|
||||
FAST_CONTEXT_TIMEOUT: Timeout in seconds (default: 30.0)
|
||||
FAST_CONTEXT_CACHE: Enable caching (default: true)
|
||||
FAST_CONTEXT_CACHE_TTL: Cache TTL in seconds (default: 300)
|
||||
|
||||
Args:
|
||||
workspace_path: Root directory for searches (defaults to cwd)
|
||||
model: LLM model for intelligent search
|
||||
max_turns: Maximum search turns
|
||||
max_parallel: Maximum parallel tool calls per turn
|
||||
timeout: Timeout per tool call in seconds
|
||||
cache_enabled: Whether to cache search results
|
||||
cache_ttl: Cache time-to-live in seconds
|
||||
verbose: If True, print debug information
|
||||
"""
|
||||
self.workspace_path = os.path.abspath(workspace_path or os.getcwd())
|
||||
|
||||
# Apply environment variable overrides with defaults
|
||||
self.model = model or os.environ.get(self.ENV_MODEL, "gpt-4o-mini")
|
||||
self.max_turns = max_turns if max_turns is not None else int(os.environ.get(self.ENV_MAX_TURNS, "4"))
|
||||
self.max_parallel = max_parallel if max_parallel is not None else int(os.environ.get(self.ENV_MAX_PARALLEL, "8"))
|
||||
self.timeout = timeout if timeout is not None else float(os.environ.get(self.ENV_TIMEOUT, "30.0"))
|
||||
|
||||
# Cache settings
|
||||
cache_env = os.environ.get(self.ENV_CACHE_ENABLED, "true").lower()
|
||||
self.cache_enabled = cache_enabled if cache_enabled is not None else cache_env in ("true", "1", "yes")
|
||||
self.cache_ttl = cache_ttl if cache_ttl is not None else int(os.environ.get(self.ENV_CACHE_TTL, "300"))
|
||||
|
||||
self.verbose = verbose
|
||||
|
||||
# Cache storage
|
||||
self._cache: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# Agent instance (lazy loaded)
|
||||
self._agent: Optional[FastContextAgent] = None
|
||||
|
||||
def _get_agent(self) -> FastContextAgent:
|
||||
"""Get or create FastContextAgent instance."""
|
||||
if self._agent is None:
|
||||
self._agent = FastContextAgent(
|
||||
workspace_path=self.workspace_path,
|
||||
max_turns=self.max_turns,
|
||||
max_parallel=self.max_parallel,
|
||||
model=self.model,
|
||||
timeout=self.timeout,
|
||||
verbose=self.verbose
|
||||
)
|
||||
return self._agent
|
||||
|
||||
def _get_cache_key(self, query: str, **kwargs) -> str:
|
||||
"""Generate cache key for a query."""
|
||||
key_data = {
|
||||
"query": query,
|
||||
"workspace": self.workspace_path,
|
||||
**kwargs
|
||||
}
|
||||
key_str = json.dumps(key_data, sort_keys=True)
|
||||
return hashlib.md5(key_str.encode()).hexdigest()
|
||||
|
||||
def _get_cached(self, cache_key: str) -> Optional[FastContextResult]:
|
||||
"""Get cached result if valid."""
|
||||
if not self.cache_enabled:
|
||||
return None
|
||||
|
||||
import time
|
||||
cached = self._cache.get(cache_key)
|
||||
if cached:
|
||||
if time.time() - cached["timestamp"] < self.cache_ttl:
|
||||
result = cached["result"]
|
||||
result.from_cache = True
|
||||
return result
|
||||
else:
|
||||
# Expired
|
||||
del self._cache[cache_key]
|
||||
return None
|
||||
|
||||
def _set_cached(self, cache_key: str, result: FastContextResult) -> None:
|
||||
"""Cache a result."""
|
||||
if not self.cache_enabled:
|
||||
return
|
||||
|
||||
import time
|
||||
self._cache[cache_key] = {
|
||||
"result": result,
|
||||
"timestamp": time.time()
|
||||
}
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
include_patterns: Optional[List[str]] = None,
|
||||
exclude_patterns: Optional[List[str]] = None,
|
||||
use_llm: bool = False
|
||||
) -> FastContextResult:
|
||||
"""Search for code relevant to the query.
|
||||
|
||||
Args:
|
||||
query: Search query (pattern or natural language)
|
||||
include_patterns: Glob patterns to include (e.g., ["*.py"])
|
||||
exclude_patterns: Glob patterns to exclude
|
||||
use_llm: If True, use LLM for intelligent search
|
||||
|
||||
Returns:
|
||||
FastContextResult with matching files and lines
|
||||
"""
|
||||
# Check cache
|
||||
cache_key = self._get_cache_key(
|
||||
query,
|
||||
include_patterns=include_patterns,
|
||||
exclude_patterns=exclude_patterns,
|
||||
use_llm=use_llm
|
||||
)
|
||||
cached = self._get_cached(cache_key)
|
||||
if cached:
|
||||
if self.verbose:
|
||||
logger.info(f"Cache hit for query: {query}")
|
||||
return cached
|
||||
|
||||
# Perform search
|
||||
agent = self._get_agent()
|
||||
|
||||
if use_llm:
|
||||
result = agent.search(query, include_patterns, exclude_patterns)
|
||||
else:
|
||||
result = agent.search_simple(query, include_patterns, exclude_patterns)
|
||||
|
||||
# Cache result
|
||||
self._set_cached(cache_key, result)
|
||||
|
||||
return result
|
||||
|
||||
def search_files(
|
||||
self,
|
||||
pattern: str,
|
||||
max_results: int = 50
|
||||
) -> FastContextResult:
|
||||
"""Search for files matching a glob pattern.
|
||||
|
||||
Args:
|
||||
pattern: Glob pattern (e.g., "**/*.py", "src/*.js")
|
||||
max_results: Maximum results to return
|
||||
|
||||
Returns:
|
||||
FastContextResult with matching files
|
||||
"""
|
||||
from praisonaiagents.context.fast.search_tools import glob_search
|
||||
|
||||
result = FastContextResult(query=pattern)
|
||||
result.start_timer()
|
||||
|
||||
matches = glob_search(
|
||||
self.workspace_path,
|
||||
pattern,
|
||||
max_results=max_results
|
||||
)
|
||||
|
||||
for match in matches:
|
||||
result.add_file(FileMatch(
|
||||
path=match["path"],
|
||||
relevance_score=1.0
|
||||
))
|
||||
|
||||
result.stop_timer()
|
||||
result.turns_used = 1
|
||||
result.total_tool_calls = 1
|
||||
|
||||
return result
|
||||
|
||||
def read_context(
|
||||
self,
|
||||
filepath: str,
|
||||
start_line: Optional[int] = None,
|
||||
end_line: Optional[int] = None,
|
||||
context_lines: int = 5
|
||||
) -> Optional[str]:
|
||||
"""Read file content with context.
|
||||
|
||||
Args:
|
||||
filepath: Path to file (relative to workspace)
|
||||
start_line: Starting line (1-indexed)
|
||||
end_line: Ending line (1-indexed)
|
||||
context_lines: Additional context lines
|
||||
|
||||
Returns:
|
||||
File content string or None if error
|
||||
"""
|
||||
from praisonaiagents.context.fast.search_tools import read_file
|
||||
|
||||
full_path = filepath
|
||||
if not os.path.isabs(filepath):
|
||||
full_path = os.path.join(self.workspace_path, filepath)
|
||||
|
||||
result = read_file(
|
||||
full_path,
|
||||
start_line=start_line,
|
||||
end_line=end_line,
|
||||
context_lines=context_lines
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
return result["content"]
|
||||
return None
|
||||
|
||||
def get_context_for_agent(
|
||||
self,
|
||||
query: str,
|
||||
max_files: int = 10,
|
||||
max_lines_per_file: int = 100,
|
||||
include_content: bool = True
|
||||
) -> str:
|
||||
"""Get formatted context string for an agent.
|
||||
|
||||
This method searches for relevant code and formats it
|
||||
as a context string that can be injected into an agent's
|
||||
system prompt or message.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
max_files: Maximum files to include
|
||||
max_lines_per_file: Maximum lines per file
|
||||
include_content: Whether to include file content
|
||||
|
||||
Returns:
|
||||
Formatted context string
|
||||
"""
|
||||
result = self.search(query)
|
||||
|
||||
if not result.files:
|
||||
return f"No relevant code found for: {query}"
|
||||
|
||||
lines = [f"# Relevant Code Context for: {query}"]
|
||||
lines.append(f"Found {result.total_files} relevant file(s) in {result.search_time_ms}ms\n")
|
||||
|
||||
for i, file_match in enumerate(result.files[:max_files]):
|
||||
lines.append(f"## {file_match.path}")
|
||||
|
||||
if include_content and file_match.line_ranges:
|
||||
for lr in file_match.line_ranges:
|
||||
# Limit lines
|
||||
if lr.line_count > max_lines_per_file:
|
||||
lr = LineRange(
|
||||
start=lr.start,
|
||||
end=lr.start + max_lines_per_file - 1,
|
||||
content=lr.content,
|
||||
relevance_score=lr.relevance_score
|
||||
)
|
||||
|
||||
lines.append(f"Lines {lr.start}-{lr.end}:")
|
||||
|
||||
# Read content if not already present
|
||||
if lr.content:
|
||||
lines.append(f"```\n{lr.content}\n```")
|
||||
else:
|
||||
content = self.read_context(
|
||||
file_match.path,
|
||||
start_line=lr.start,
|
||||
end_line=lr.end
|
||||
)
|
||||
if content:
|
||||
lines.append(f"```\n{content}\n```")
|
||||
|
||||
lines.append("")
|
||||
|
||||
if len(result.files) > max_files:
|
||||
lines.append(f"... and {len(result.files) - max_files} more files")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear the result cache."""
|
||||
self._cache.clear()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close resources."""
|
||||
if self._agent:
|
||||
self._agent.close()
|
||||
self._agent = None
|
||||
self._cache.clear()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
|
||||
|
||||
# Convenience function for quick searches
|
||||
def fast_search(
|
||||
query: str,
|
||||
workspace_path: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> FastContextResult:
|
||||
"""Convenience function for quick code search.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
workspace_path: Root directory (defaults to cwd)
|
||||
**kwargs: Additional arguments for FastContext
|
||||
|
||||
Returns:
|
||||
FastContextResult with matching files
|
||||
"""
|
||||
with FastContext(workspace_path=workspace_path, **kwargs) as fc:
|
||||
return fc.search(query)
|
||||
@@ -0,0 +1,392 @@
|
||||
"""
|
||||
FastContextAgent - Specialized subagent for rapid code search.
|
||||
|
||||
This agent is designed for fast context retrieval using:
|
||||
- Parallel tool execution (up to 8 concurrent calls)
|
||||
- Limited turns (max 4) for fast response
|
||||
- Lightweight model (gpt-4o-mini by default)
|
||||
- Restricted tool set (grep, glob, read, list_dir)
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from praisonaiagents.context.fast.result import (
|
||||
FastContextResult,
|
||||
FileMatch,
|
||||
LineRange
|
||||
)
|
||||
from praisonaiagents.context.fast.search_tools import (
|
||||
grep_search,
|
||||
glob_search,
|
||||
read_file,
|
||||
list_directory,
|
||||
FAST_CONTEXT_TOOLS
|
||||
)
|
||||
from praisonaiagents.context.fast.parallel_executor import (
|
||||
ToolCallBatch,
|
||||
ParallelSearchCoordinator
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FastContextAgent:
|
||||
"""Specialized agent for fast parallel code search.
|
||||
|
||||
This agent performs rapid code search using parallel tool calls
|
||||
and a lightweight model optimized for retrieval tasks.
|
||||
|
||||
Attributes:
|
||||
workspace_path: Root directory for searches
|
||||
max_turns: Maximum search turns (default: 4)
|
||||
max_parallel: Maximum parallel tool calls (default: 8)
|
||||
model: LLM model for intelligent search (default: gpt-4o-mini)
|
||||
timeout: Timeout per tool call in seconds
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace_path: str,
|
||||
max_turns: int = 4,
|
||||
max_parallel: int = 8,
|
||||
model: str = "gpt-4o-mini",
|
||||
timeout: float = 30.0,
|
||||
verbose: bool = False
|
||||
):
|
||||
"""Initialize FastContextAgent.
|
||||
|
||||
Args:
|
||||
workspace_path: Root directory for searches
|
||||
max_turns: Maximum search turns
|
||||
max_parallel: Maximum parallel tool calls per turn
|
||||
model: LLM model name for intelligent search
|
||||
timeout: Timeout per tool call in seconds
|
||||
verbose: If True, print debug information
|
||||
"""
|
||||
self.workspace_path = os.path.abspath(workspace_path)
|
||||
self.max_turns = max_turns
|
||||
self.max_parallel = max_parallel
|
||||
self.model = model
|
||||
self.timeout = timeout
|
||||
self.verbose = verbose
|
||||
|
||||
# Initialize coordinator
|
||||
self.coordinator = ParallelSearchCoordinator(
|
||||
max_parallel=max_parallel,
|
||||
max_turns=max_turns,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
# LLM instance (lazy loaded)
|
||||
self._llm = None
|
||||
|
||||
def _get_llm(self):
|
||||
"""Get or create LLM instance (lazy loading)."""
|
||||
if self._llm is None:
|
||||
try:
|
||||
from praisonaiagents.llm import LLM
|
||||
self._llm = LLM(model=self.model)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not initialize LLM: {e}")
|
||||
self._llm = None
|
||||
return self._llm
|
||||
|
||||
def get_tools(self) -> List[Dict[str, Any]]:
|
||||
"""Get tool definitions for LLM function calling.
|
||||
|
||||
Returns:
|
||||
List of tool definition dictionaries
|
||||
"""
|
||||
return FAST_CONTEXT_TOOLS
|
||||
|
||||
def execute_tool(self, tool_name: str, **kwargs) -> Any:
|
||||
"""Execute a single tool.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool
|
||||
**kwargs: Tool arguments
|
||||
|
||||
Returns:
|
||||
Tool result
|
||||
"""
|
||||
# Prepend workspace path if needed
|
||||
if tool_name in ("grep_search", "glob_search"):
|
||||
if "search_path" not in kwargs or kwargs["search_path"] == ".":
|
||||
kwargs["search_path"] = self.workspace_path
|
||||
elif not os.path.isabs(kwargs["search_path"]):
|
||||
kwargs["search_path"] = os.path.join(
|
||||
self.workspace_path, kwargs["search_path"]
|
||||
)
|
||||
elif tool_name == "list_directory":
|
||||
if "dir_path" not in kwargs or kwargs["dir_path"] == ".":
|
||||
kwargs["dir_path"] = self.workspace_path
|
||||
elif not os.path.isabs(kwargs["dir_path"]):
|
||||
kwargs["dir_path"] = os.path.join(
|
||||
self.workspace_path, kwargs["dir_path"]
|
||||
)
|
||||
elif tool_name == "read_file":
|
||||
if "filepath" in kwargs and not os.path.isabs(kwargs["filepath"]):
|
||||
kwargs["filepath"] = os.path.join(
|
||||
self.workspace_path, kwargs["filepath"]
|
||||
)
|
||||
|
||||
tools = {
|
||||
"grep_search": grep_search,
|
||||
"glob_search": glob_search,
|
||||
"read_file": read_file,
|
||||
"list_directory": list_directory
|
||||
}
|
||||
|
||||
if tool_name not in tools:
|
||||
return {"error": f"Unknown tool: {tool_name}"}
|
||||
|
||||
try:
|
||||
return tools[tool_name](**kwargs)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
def search_simple(
|
||||
self,
|
||||
query: str,
|
||||
include_patterns: Optional[List[str]] = None,
|
||||
exclude_patterns: Optional[List[str]] = None,
|
||||
max_results: int = 50
|
||||
) -> FastContextResult:
|
||||
"""Perform a simple search without LLM.
|
||||
|
||||
This is a fast, direct search that doesn't use the LLM
|
||||
for query understanding. Good for simple pattern searches.
|
||||
|
||||
Args:
|
||||
query: Search pattern
|
||||
include_patterns: Glob patterns to include
|
||||
exclude_patterns: Glob patterns to exclude
|
||||
max_results: Maximum results per tool
|
||||
|
||||
Returns:
|
||||
FastContextResult with matching files and lines
|
||||
"""
|
||||
result = FastContextResult(query=query)
|
||||
result.start_timer()
|
||||
|
||||
# Execute grep search
|
||||
grep_results = grep_search(
|
||||
search_path=self.workspace_path,
|
||||
pattern=query,
|
||||
include_patterns=include_patterns,
|
||||
exclude_patterns=exclude_patterns,
|
||||
max_results=max_results,
|
||||
context_lines=2
|
||||
)
|
||||
|
||||
# Convert to FastContextResult format
|
||||
for match in grep_results:
|
||||
file_match = FileMatch(
|
||||
path=match["path"],
|
||||
relevance_score=1.0,
|
||||
match_count=1
|
||||
)
|
||||
file_match.add_line_range(LineRange(
|
||||
start=max(1, match["line_number"] - 2),
|
||||
end=match["line_number"] + 2,
|
||||
content=match.get("context"),
|
||||
relevance_score=1.0
|
||||
))
|
||||
result.add_file(file_match)
|
||||
|
||||
result.stop_timer()
|
||||
result.turns_used = 1
|
||||
result.total_tool_calls = 1
|
||||
|
||||
return result
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
include_patterns: Optional[List[str]] = None,
|
||||
exclude_patterns: Optional[List[str]] = None
|
||||
) -> FastContextResult:
|
||||
"""Perform intelligent search using LLM.
|
||||
|
||||
Uses the LLM to understand the query and plan search strategy,
|
||||
then executes parallel tool calls to find relevant code.
|
||||
|
||||
Args:
|
||||
query: Natural language search query
|
||||
include_patterns: Glob patterns to include
|
||||
exclude_patterns: Glob patterns to exclude
|
||||
|
||||
Returns:
|
||||
FastContextResult with matching files and lines
|
||||
"""
|
||||
result = FastContextResult(query=query)
|
||||
result.start_timer()
|
||||
|
||||
llm = self._get_llm()
|
||||
|
||||
# If no LLM available, fall back to simple search
|
||||
if llm is None:
|
||||
logger.info("LLM not available, falling back to simple search")
|
||||
return self.search_simple(query, include_patterns, exclude_patterns)
|
||||
|
||||
self.coordinator.start()
|
||||
|
||||
# System prompt for the search agent
|
||||
system_prompt = f"""You are a fast code search agent. Your task is to find relevant code in a codebase.
|
||||
|
||||
Workspace: {self.workspace_path}
|
||||
|
||||
You have access to these tools:
|
||||
- grep_search: Search for patterns in files
|
||||
- glob_search: Find files by pattern
|
||||
- read_file: Read file contents
|
||||
- list_directory: List directory contents
|
||||
|
||||
Strategy:
|
||||
1. Start with broad searches (glob, list_directory) to understand structure
|
||||
2. Use grep_search to find specific patterns
|
||||
3. Use read_file to get detailed context for relevant files
|
||||
4. Execute up to {self.max_parallel} tool calls in parallel per turn
|
||||
5. Complete search in {self.max_turns} turns or less
|
||||
|
||||
Return files and line ranges that are relevant to the query.
|
||||
Be efficient - use parallel tool calls to explore multiple paths at once."""
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": f"Find code relevant to: {query}"}
|
||||
]
|
||||
|
||||
# Add file pattern constraints if provided
|
||||
if include_patterns:
|
||||
messages[1]["content"] += f"\nOnly search in files matching: {include_patterns}"
|
||||
if exclude_patterns:
|
||||
messages[1]["content"] += f"\nExclude files matching: {exclude_patterns}"
|
||||
|
||||
try:
|
||||
# Multi-turn search loop
|
||||
for turn in range(self.max_turns):
|
||||
if self.verbose:
|
||||
logger.info(f"Search turn {turn + 1}/{self.max_turns}")
|
||||
|
||||
# Get LLM response with tool calls
|
||||
response = llm.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=messages,
|
||||
tools=[{"type": "function", "function": t} for t in self.get_tools()],
|
||||
tool_choice="auto"
|
||||
)
|
||||
|
||||
message = response.choices[0].message
|
||||
|
||||
# Check if we have tool calls
|
||||
if not message.tool_calls:
|
||||
# No more tool calls, search complete
|
||||
break
|
||||
|
||||
# Execute tool calls in parallel
|
||||
batch = ToolCallBatch(max_size=self.max_parallel)
|
||||
tool_call_map = {} # Map tool call ID to batch index
|
||||
|
||||
for tool_call in message.tool_calls[:self.max_parallel]:
|
||||
try:
|
||||
args = json.loads(tool_call.function.arguments)
|
||||
batch.add(tool_call.function.name, **args)
|
||||
tool_call_map[tool_call.id] = len(batch.tasks) - 1
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
# Execute batch
|
||||
if batch.tasks:
|
||||
tool_results = self.coordinator.execute_turn_sync(batch)
|
||||
|
||||
# Process results
|
||||
messages.append({"role": "assistant", "content": None, "tool_calls": [
|
||||
{
|
||||
"id": tc.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc.function.name,
|
||||
"arguments": tc.function.arguments
|
||||
}
|
||||
}
|
||||
for tc in message.tool_calls[:self.max_parallel]
|
||||
]})
|
||||
|
||||
# Add tool results to messages
|
||||
for tool_call in message.tool_calls[:self.max_parallel]:
|
||||
idx = tool_call_map.get(tool_call.id)
|
||||
if idx is not None and idx < len(tool_results):
|
||||
tool_result = tool_results[idx]
|
||||
|
||||
# Process grep results into FastContextResult
|
||||
if tool_call.function.name == "grep_search":
|
||||
if isinstance(tool_result, list):
|
||||
for match in tool_result:
|
||||
file_match = FileMatch(
|
||||
path=match.get("path", ""),
|
||||
relevance_score=1.0,
|
||||
match_count=1
|
||||
)
|
||||
line_num = match.get("line_number", 1)
|
||||
file_match.add_line_range(LineRange(
|
||||
start=max(1, line_num - 2),
|
||||
end=line_num + 2,
|
||||
content=match.get("context"),
|
||||
relevance_score=1.0
|
||||
))
|
||||
result.add_file(file_match)
|
||||
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call.id,
|
||||
"content": json.dumps(tool_result) if not isinstance(tool_result, str) else tool_result
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during LLM search: {e}")
|
||||
# Fall back to simple search
|
||||
return self.search_simple(query, include_patterns, exclude_patterns)
|
||||
|
||||
finally:
|
||||
self.coordinator.stop()
|
||||
|
||||
result.stop_timer()
|
||||
result.turns_used = self.coordinator.turns_used
|
||||
result.total_tool_calls = self.coordinator.total_tool_calls
|
||||
result.sort_by_relevance()
|
||||
|
||||
return result
|
||||
|
||||
async def search_async(
|
||||
self,
|
||||
query: str,
|
||||
include_patterns: Optional[List[str]] = None,
|
||||
exclude_patterns: Optional[List[str]] = None
|
||||
) -> FastContextResult:
|
||||
"""Async version of search.
|
||||
|
||||
Args:
|
||||
query: Natural language search query
|
||||
include_patterns: Glob patterns to include
|
||||
exclude_patterns: Glob patterns to exclude
|
||||
|
||||
Returns:
|
||||
FastContextResult with matching files and lines
|
||||
"""
|
||||
# For now, wrap sync version
|
||||
# TODO: Implement true async with async LLM calls
|
||||
return self.search(query, include_patterns, exclude_patterns)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close resources."""
|
||||
self.coordinator.close()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
Indexer module for Fast Context.
|
||||
|
||||
Provides file and symbol indexing capabilities for fast code search.
|
||||
"""
|
||||
|
||||
def __getattr__(name):
|
||||
"""Lazy load indexer classes."""
|
||||
if name == "FileIndexer":
|
||||
from .file_indexer import FileIndexer
|
||||
return FileIndexer
|
||||
elif name == "SymbolIndexer":
|
||||
from .symbol_indexer import SymbolIndexer
|
||||
return SymbolIndexer
|
||||
elif name == "Symbol":
|
||||
from .symbol_indexer import Symbol
|
||||
return Symbol
|
||||
elif name == "SymbolType":
|
||||
from .symbol_indexer import SymbolType
|
||||
return SymbolType
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
__all__ = ["FileIndexer", "SymbolIndexer", "Symbol", "SymbolType"]
|
||||
@@ -0,0 +1,445 @@
|
||||
"""
|
||||
File Indexer for Fast Context.
|
||||
|
||||
Provides file indexing capabilities for faster code search:
|
||||
- Indexes file paths and metadata
|
||||
- Supports .gitignore and .praisonignore patterns
|
||||
- Incremental updates on file changes
|
||||
- Memory-efficient storage
|
||||
"""
|
||||
|
||||
import os
|
||||
import fnmatch
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Set, Optional, Any
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileInfo:
|
||||
"""Information about an indexed file.
|
||||
|
||||
Attributes:
|
||||
path: Relative path from workspace root
|
||||
absolute_path: Absolute file path
|
||||
size: File size in bytes
|
||||
modified: Last modification timestamp
|
||||
extension: File extension (lowercase)
|
||||
hash: Content hash for change detection
|
||||
"""
|
||||
path: str
|
||||
absolute_path: str
|
||||
size: int
|
||||
modified: float
|
||||
extension: str
|
||||
hash: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"path": self.path,
|
||||
"absolute_path": self.absolute_path,
|
||||
"size": self.size,
|
||||
"modified": self.modified,
|
||||
"extension": self.extension,
|
||||
"hash": self.hash
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "FileInfo":
|
||||
"""Create from dictionary."""
|
||||
return cls(**data)
|
||||
|
||||
|
||||
class FileIndexer:
|
||||
"""Indexes files in a workspace for fast search.
|
||||
|
||||
The indexer maintains an in-memory index of files that can be
|
||||
used to quickly find files by pattern without filesystem traversal.
|
||||
|
||||
Attributes:
|
||||
workspace_path: Root directory to index
|
||||
files: Dictionary mapping relative paths to FileInfo
|
||||
extensions: Set of indexed file extensions
|
||||
"""
|
||||
|
||||
# Default extensions to index
|
||||
DEFAULT_EXTENSIONS = {
|
||||
".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".go", ".rs",
|
||||
".c", ".cpp", ".h", ".hpp", ".cs", ".rb", ".php", ".swift",
|
||||
".kt", ".scala", ".r", ".m", ".mm", ".sql", ".sh", ".bash",
|
||||
".yaml", ".yml", ".json", ".xml", ".html", ".css", ".scss",
|
||||
".md", ".txt", ".rst", ".toml", ".ini", ".cfg", ".conf"
|
||||
}
|
||||
|
||||
# Default patterns to ignore
|
||||
DEFAULT_IGNORE_PATTERNS = {
|
||||
"__pycache__", "*.pyc", "*.pyo", ".git", ".svn", ".hg",
|
||||
"node_modules", "venv", ".venv", "env", ".env",
|
||||
"dist", "build", ".cache", ".pytest_cache",
|
||||
"*.egg-info", "*.egg", "*.whl", "*.so", "*.dll", "*.dylib"
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace_path: str,
|
||||
extensions: Optional[Set[str]] = None,
|
||||
ignore_patterns: Optional[Set[str]] = None,
|
||||
respect_gitignore: bool = True,
|
||||
max_file_size: int = 10 * 1024 * 1024, # 10MB
|
||||
compute_hashes: bool = False
|
||||
):
|
||||
"""Initialize file indexer.
|
||||
|
||||
Args:
|
||||
workspace_path: Root directory to index
|
||||
extensions: File extensions to index (None = all text files)
|
||||
ignore_patterns: Patterns to ignore
|
||||
respect_gitignore: Whether to respect .gitignore
|
||||
max_file_size: Maximum file size to index
|
||||
compute_hashes: Whether to compute content hashes
|
||||
"""
|
||||
self.workspace_path = os.path.abspath(workspace_path)
|
||||
self.extensions = extensions or self.DEFAULT_EXTENSIONS
|
||||
self.ignore_patterns = ignore_patterns or self.DEFAULT_IGNORE_PATTERNS.copy()
|
||||
self.respect_gitignore = respect_gitignore
|
||||
self.max_file_size = max_file_size
|
||||
self.compute_hashes = compute_hashes
|
||||
|
||||
# Index storage
|
||||
self.files: Dict[str, FileInfo] = {}
|
||||
self.by_extension: Dict[str, List[str]] = {}
|
||||
|
||||
# Gitignore patterns
|
||||
self._gitignore_patterns: Set[str] = set()
|
||||
|
||||
# Index metadata
|
||||
self.last_indexed: Optional[float] = None
|
||||
self.total_files: int = 0
|
||||
self.total_size: int = 0
|
||||
|
||||
def _load_gitignore(self) -> None:
|
||||
"""Load patterns from .gitignore and .praisonignore."""
|
||||
self._gitignore_patterns.clear()
|
||||
|
||||
for filename in [".gitignore", ".praisonignore"]:
|
||||
filepath = os.path.join(self.workspace_path, filename)
|
||||
if os.path.exists(filepath):
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#'):
|
||||
self._gitignore_patterns.add(line)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _should_ignore(self, path: str, is_dir: bool = False) -> bool:
|
||||
"""Check if a path should be ignored.
|
||||
|
||||
Args:
|
||||
path: Relative path from workspace
|
||||
is_dir: Whether the path is a directory
|
||||
|
||||
Returns:
|
||||
True if path should be ignored
|
||||
"""
|
||||
name = os.path.basename(path)
|
||||
parts = Path(path).parts
|
||||
|
||||
# Check default ignore patterns
|
||||
for pattern in self.ignore_patterns:
|
||||
if fnmatch.fnmatch(name, pattern):
|
||||
return True
|
||||
if any(fnmatch.fnmatch(part, pattern) for part in parts):
|
||||
return True
|
||||
|
||||
# Check gitignore patterns
|
||||
if self.respect_gitignore:
|
||||
for pattern in self._gitignore_patterns:
|
||||
# Handle directory patterns
|
||||
if pattern.endswith('/'):
|
||||
dir_pattern = pattern.rstrip('/')
|
||||
if is_dir and fnmatch.fnmatch(name, dir_pattern):
|
||||
return True
|
||||
if any(fnmatch.fnmatch(part, dir_pattern) for part in parts):
|
||||
return True
|
||||
# Handle ** patterns
|
||||
elif '**' in pattern:
|
||||
fnmatch_pattern = pattern.replace('**/', '*').replace('**', '*')
|
||||
if fnmatch.fnmatch(path, fnmatch_pattern):
|
||||
return True
|
||||
# Handle simple patterns
|
||||
else:
|
||||
if fnmatch.fnmatch(name, pattern):
|
||||
return True
|
||||
if fnmatch.fnmatch(path, pattern):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _compute_hash(self, filepath: str) -> Optional[str]:
|
||||
"""Compute MD5 hash of file content.
|
||||
|
||||
Args:
|
||||
filepath: Path to file
|
||||
|
||||
Returns:
|
||||
MD5 hash string or None on error
|
||||
"""
|
||||
if not self.compute_hashes:
|
||||
return None
|
||||
|
||||
try:
|
||||
hasher = hashlib.md5()
|
||||
with open(filepath, 'rb') as f:
|
||||
for chunk in iter(lambda: f.read(8192), b''):
|
||||
hasher.update(chunk)
|
||||
return hasher.hexdigest()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def index(self) -> int:
|
||||
"""Index all files in the workspace.
|
||||
|
||||
Returns:
|
||||
Number of files indexed
|
||||
"""
|
||||
start_time = time.perf_counter()
|
||||
|
||||
# Load gitignore patterns
|
||||
if self.respect_gitignore:
|
||||
self._load_gitignore()
|
||||
|
||||
# Clear existing index
|
||||
self.files.clear()
|
||||
self.by_extension.clear()
|
||||
self.total_files = 0
|
||||
self.total_size = 0
|
||||
|
||||
# Walk directory tree
|
||||
for root, dirs, files in os.walk(self.workspace_path):
|
||||
# Get relative path
|
||||
rel_root = os.path.relpath(root, self.workspace_path)
|
||||
if rel_root == '.':
|
||||
rel_root = ''
|
||||
|
||||
# Filter directories in-place
|
||||
dirs[:] = [d for d in dirs if not self._should_ignore(
|
||||
os.path.join(rel_root, d) if rel_root else d, is_dir=True
|
||||
)]
|
||||
|
||||
for filename in files:
|
||||
rel_path = os.path.join(rel_root, filename) if rel_root else filename
|
||||
abs_path = os.path.join(root, filename)
|
||||
|
||||
# Check if should ignore
|
||||
if self._should_ignore(rel_path):
|
||||
continue
|
||||
|
||||
# Check extension
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
if self.extensions and ext not in self.extensions:
|
||||
continue
|
||||
|
||||
try:
|
||||
stat = os.stat(abs_path)
|
||||
|
||||
# Check file size
|
||||
if stat.st_size > self.max_file_size:
|
||||
continue
|
||||
|
||||
# Create file info
|
||||
file_info = FileInfo(
|
||||
path=rel_path,
|
||||
absolute_path=abs_path,
|
||||
size=stat.st_size,
|
||||
modified=stat.st_mtime,
|
||||
extension=ext,
|
||||
hash=self._compute_hash(abs_path)
|
||||
)
|
||||
|
||||
# Add to index
|
||||
self.files[rel_path] = file_info
|
||||
|
||||
# Add to extension index
|
||||
if ext not in self.by_extension:
|
||||
self.by_extension[ext] = []
|
||||
self.by_extension[ext].append(rel_path)
|
||||
|
||||
self.total_files += 1
|
||||
self.total_size += stat.st_size
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Error indexing {abs_path}: {e}")
|
||||
continue
|
||||
|
||||
self.last_indexed = time.perf_counter()
|
||||
elapsed = self.last_indexed - start_time
|
||||
|
||||
logger.info(f"Indexed {self.total_files} files ({self.total_size / 1024 / 1024:.2f} MB) in {elapsed:.2f}s")
|
||||
|
||||
return self.total_files
|
||||
|
||||
def find_by_pattern(self, pattern: str) -> List[FileInfo]:
|
||||
"""Find files matching a glob pattern.
|
||||
|
||||
Args:
|
||||
pattern: Glob pattern (e.g., "**/*.py", "src/*.js")
|
||||
|
||||
Returns:
|
||||
List of matching FileInfo objects
|
||||
"""
|
||||
results = []
|
||||
|
||||
for rel_path, file_info in self.files.items():
|
||||
if fnmatch.fnmatch(rel_path, pattern):
|
||||
results.append(file_info)
|
||||
elif fnmatch.fnmatch(os.path.basename(rel_path), pattern):
|
||||
results.append(file_info)
|
||||
|
||||
return results
|
||||
|
||||
def find_by_extension(self, extension: str) -> List[FileInfo]:
|
||||
"""Find files by extension.
|
||||
|
||||
Args:
|
||||
extension: File extension (with or without dot)
|
||||
|
||||
Returns:
|
||||
List of matching FileInfo objects
|
||||
"""
|
||||
if not extension.startswith('.'):
|
||||
extension = '.' + extension
|
||||
extension = extension.lower()
|
||||
|
||||
paths = self.by_extension.get(extension, [])
|
||||
return [self.files[p] for p in paths if p in self.files]
|
||||
|
||||
def find_by_name(self, name: str, exact: bool = False) -> List[FileInfo]:
|
||||
"""Find files by name.
|
||||
|
||||
Args:
|
||||
name: Filename to search for
|
||||
exact: If True, match exact name; otherwise, partial match
|
||||
|
||||
Returns:
|
||||
List of matching FileInfo objects
|
||||
"""
|
||||
results = []
|
||||
name_lower = name.lower()
|
||||
|
||||
for rel_path, file_info in self.files.items():
|
||||
filename = os.path.basename(rel_path).lower()
|
||||
if exact:
|
||||
if filename == name_lower:
|
||||
results.append(file_info)
|
||||
else:
|
||||
if name_lower in filename:
|
||||
results.append(file_info)
|
||||
|
||||
return results
|
||||
|
||||
def get_file(self, path: str) -> Optional[FileInfo]:
|
||||
"""Get file info by path.
|
||||
|
||||
Args:
|
||||
path: Relative path from workspace
|
||||
|
||||
Returns:
|
||||
FileInfo or None if not found
|
||||
"""
|
||||
return self.files.get(path)
|
||||
|
||||
def is_stale(self, max_age: float = 60.0) -> bool:
|
||||
"""Check if index is stale.
|
||||
|
||||
Args:
|
||||
max_age: Maximum age in seconds
|
||||
|
||||
Returns:
|
||||
True if index is stale or not indexed
|
||||
"""
|
||||
if self.last_indexed is None:
|
||||
return True
|
||||
return (time.perf_counter() - self.last_indexed) > max_age
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""Get index statistics.
|
||||
|
||||
Returns:
|
||||
Dictionary with index stats
|
||||
"""
|
||||
return {
|
||||
"workspace_path": self.workspace_path,
|
||||
"total_files": self.total_files,
|
||||
"total_size_bytes": self.total_size,
|
||||
"total_size_mb": round(self.total_size / 1024 / 1024, 2),
|
||||
"extensions": dict(sorted(
|
||||
((ext, len(paths)) for ext, paths in self.by_extension.items()),
|
||||
key=lambda x: x[1],
|
||||
reverse=True
|
||||
)),
|
||||
"last_indexed": self.last_indexed
|
||||
}
|
||||
|
||||
def save_index(self, filepath: str) -> None:
|
||||
"""Save index to file.
|
||||
|
||||
Args:
|
||||
filepath: Path to save index
|
||||
"""
|
||||
data = {
|
||||
"workspace_path": self.workspace_path,
|
||||
"last_indexed": self.last_indexed,
|
||||
"files": {path: info.to_dict() for path, info in self.files.items()}
|
||||
}
|
||||
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
def load_index(self, filepath: str) -> bool:
|
||||
"""Load index from file.
|
||||
|
||||
Args:
|
||||
filepath: Path to load index from
|
||||
|
||||
Returns:
|
||||
True if loaded successfully
|
||||
"""
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Verify workspace path matches
|
||||
if data.get("workspace_path") != self.workspace_path:
|
||||
return False
|
||||
|
||||
self.last_indexed = data.get("last_indexed")
|
||||
self.files.clear()
|
||||
self.by_extension.clear()
|
||||
self.total_files = 0
|
||||
self.total_size = 0
|
||||
|
||||
for path, info_dict in data.get("files", {}).items():
|
||||
file_info = FileInfo.from_dict(info_dict)
|
||||
self.files[path] = file_info
|
||||
|
||||
ext = file_info.extension
|
||||
if ext not in self.by_extension:
|
||||
self.by_extension[ext] = []
|
||||
self.by_extension[ext].append(path)
|
||||
|
||||
self.total_files += 1
|
||||
self.total_size += file_info.size
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"Error loading index: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,321 @@
|
||||
"""
|
||||
File Watcher for Incremental Indexing.
|
||||
|
||||
Monitors file system changes and triggers incremental index updates:
|
||||
- Watches for file creation, modification, deletion
|
||||
- Debounces rapid changes
|
||||
- Supports background indexing
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
import threading
|
||||
from typing import Dict, Set, Callable, Optional, Any
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChangeType(Enum):
|
||||
"""Types of file system changes."""
|
||||
CREATED = "created"
|
||||
MODIFIED = "modified"
|
||||
DELETED = "deleted"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileChange:
|
||||
"""Represents a file system change.
|
||||
|
||||
Attributes:
|
||||
path: Path to the changed file
|
||||
change_type: Type of change
|
||||
timestamp: When the change was detected
|
||||
"""
|
||||
path: str
|
||||
change_type: ChangeType
|
||||
timestamp: float
|
||||
|
||||
|
||||
class FileWatcher:
|
||||
"""Watches for file system changes and triggers callbacks.
|
||||
|
||||
Uses polling-based approach for cross-platform compatibility.
|
||||
For production use, consider using watchdog library.
|
||||
|
||||
Attributes:
|
||||
workspace_path: Root directory to watch
|
||||
extensions: File extensions to watch
|
||||
poll_interval: Seconds between polls
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace_path: str,
|
||||
extensions: Optional[Set[str]] = None,
|
||||
poll_interval: float = 1.0,
|
||||
debounce_delay: float = 0.5,
|
||||
on_change: Optional[Callable[[FileChange], None]] = None
|
||||
):
|
||||
"""Initialize file watcher.
|
||||
|
||||
Args:
|
||||
workspace_path: Root directory to watch
|
||||
extensions: File extensions to watch (None = all)
|
||||
poll_interval: Seconds between polls
|
||||
debounce_delay: Delay before processing changes
|
||||
on_change: Callback for file changes
|
||||
"""
|
||||
self.workspace_path = os.path.abspath(workspace_path)
|
||||
self.extensions = extensions or {
|
||||
'.py', '.js', '.ts', '.jsx', '.tsx', '.go', '.rs', '.java',
|
||||
'.c', '.cpp', '.h', '.hpp', '.cs', '.rb', '.php', '.swift'
|
||||
}
|
||||
self.poll_interval = poll_interval
|
||||
self.debounce_delay = debounce_delay
|
||||
self.on_change = on_change
|
||||
|
||||
# State tracking
|
||||
self._file_mtimes: Dict[str, float] = {}
|
||||
self._pending_changes: Dict[str, FileChange] = {}
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def _scan_files(self) -> Dict[str, float]:
|
||||
"""Scan workspace and get file modification times.
|
||||
|
||||
Returns:
|
||||
Dict mapping file paths to modification times
|
||||
"""
|
||||
mtimes = {}
|
||||
|
||||
for root, dirs, files in os.walk(self.workspace_path):
|
||||
# Skip hidden directories
|
||||
dirs[:] = [d for d in dirs if not d.startswith('.')]
|
||||
|
||||
for filename in files:
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
if ext in self.extensions:
|
||||
file_path = os.path.join(root, filename)
|
||||
try:
|
||||
mtimes[file_path] = os.path.getmtime(file_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return mtimes
|
||||
|
||||
def _detect_changes(self, new_mtimes: Dict[str, float]) -> None:
|
||||
"""Detect changes between old and new file states.
|
||||
|
||||
Args:
|
||||
new_mtimes: New file modification times
|
||||
"""
|
||||
old_paths = set(self._file_mtimes.keys())
|
||||
new_paths = set(new_mtimes.keys())
|
||||
|
||||
now = time.time()
|
||||
|
||||
# Detect created files
|
||||
for path in new_paths - old_paths:
|
||||
self._add_pending_change(FileChange(
|
||||
path=path,
|
||||
change_type=ChangeType.CREATED,
|
||||
timestamp=now
|
||||
))
|
||||
|
||||
# Detect deleted files
|
||||
for path in old_paths - new_paths:
|
||||
self._add_pending_change(FileChange(
|
||||
path=path,
|
||||
change_type=ChangeType.DELETED,
|
||||
timestamp=now
|
||||
))
|
||||
|
||||
# Detect modified files
|
||||
for path in old_paths & new_paths:
|
||||
if new_mtimes[path] > self._file_mtimes[path]:
|
||||
self._add_pending_change(FileChange(
|
||||
path=path,
|
||||
change_type=ChangeType.MODIFIED,
|
||||
timestamp=now
|
||||
))
|
||||
|
||||
self._file_mtimes = new_mtimes
|
||||
|
||||
def _add_pending_change(self, change: FileChange) -> None:
|
||||
"""Add a change to pending queue (with debouncing).
|
||||
|
||||
Args:
|
||||
change: File change to add
|
||||
"""
|
||||
with self._lock:
|
||||
self._pending_changes[change.path] = change
|
||||
|
||||
def _process_pending_changes(self) -> None:
|
||||
"""Process pending changes after debounce delay."""
|
||||
with self._lock:
|
||||
now = time.time()
|
||||
to_process = []
|
||||
|
||||
for path, change in list(self._pending_changes.items()):
|
||||
if now - change.timestamp >= self.debounce_delay:
|
||||
to_process.append(change)
|
||||
del self._pending_changes[path]
|
||||
|
||||
# Process changes outside lock
|
||||
for change in to_process:
|
||||
if self.on_change:
|
||||
try:
|
||||
self.on_change(change)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing change {change.path}: {e}")
|
||||
|
||||
def _poll_loop(self) -> None:
|
||||
"""Main polling loop."""
|
||||
# Initial scan
|
||||
self._file_mtimes = self._scan_files()
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
# Scan for changes
|
||||
new_mtimes = self._scan_files()
|
||||
self._detect_changes(new_mtimes)
|
||||
|
||||
# Process pending changes
|
||||
self._process_pending_changes()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in file watcher: {e}")
|
||||
|
||||
time.sleep(self.poll_interval)
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start watching for file changes."""
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
|
||||
self._thread.start()
|
||||
logger.info(f"File watcher started for {self.workspace_path}")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop watching for file changes."""
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2.0)
|
||||
self._thread = None
|
||||
logger.info("File watcher stopped")
|
||||
|
||||
def __enter__(self):
|
||||
self.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.stop()
|
||||
|
||||
|
||||
class IncrementalIndexer:
|
||||
"""Manages incremental index updates based on file changes.
|
||||
|
||||
Combines FileIndexer/SymbolIndexer with FileWatcher for
|
||||
automatic index updates when files change.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace_path: str,
|
||||
file_indexer: Optional[Any] = None,
|
||||
symbol_indexer: Optional[Any] = None,
|
||||
poll_interval: float = 2.0
|
||||
):
|
||||
"""Initialize incremental indexer.
|
||||
|
||||
Args:
|
||||
workspace_path: Root directory to index
|
||||
file_indexer: FileIndexer instance (optional)
|
||||
symbol_indexer: SymbolIndexer instance (optional)
|
||||
poll_interval: Seconds between file system polls
|
||||
"""
|
||||
self.workspace_path = os.path.abspath(workspace_path)
|
||||
self.file_indexer = file_indexer
|
||||
self.symbol_indexer = symbol_indexer
|
||||
|
||||
self._watcher = FileWatcher(
|
||||
workspace_path=workspace_path,
|
||||
poll_interval=poll_interval,
|
||||
on_change=self._handle_change
|
||||
)
|
||||
|
||||
# Statistics
|
||||
self.changes_processed = 0
|
||||
self.last_change_time: Optional[float] = None
|
||||
|
||||
def _handle_change(self, change: FileChange) -> None:
|
||||
"""Handle a file change.
|
||||
|
||||
Args:
|
||||
change: File change to process
|
||||
"""
|
||||
rel_path = os.path.relpath(change.path, self.workspace_path)
|
||||
logger.debug(f"Processing {change.change_type.value}: {rel_path}")
|
||||
|
||||
if change.change_type == ChangeType.DELETED:
|
||||
# Remove from indexes
|
||||
if self.file_indexer and hasattr(self.file_indexer, 'files'):
|
||||
self.file_indexer.files.pop(rel_path, None)
|
||||
if self.symbol_indexer and hasattr(self.symbol_indexer, 'symbols'):
|
||||
self.symbol_indexer.symbols.pop(rel_path, None)
|
||||
else:
|
||||
# Re-index the file
|
||||
if self.file_indexer and hasattr(self.file_indexer, 'index_file'):
|
||||
# FileIndexer doesn't have index_file, so we skip
|
||||
pass
|
||||
if self.symbol_indexer and hasattr(self.symbol_indexer, 'index_file'):
|
||||
self.symbol_indexer.index_file(change.path)
|
||||
|
||||
self.changes_processed += 1
|
||||
self.last_change_time = time.time()
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start incremental indexing."""
|
||||
# Initial full index
|
||||
if self.file_indexer:
|
||||
self.file_indexer.index()
|
||||
if self.symbol_indexer:
|
||||
self.symbol_indexer.index()
|
||||
|
||||
# Start watching
|
||||
self._watcher.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop incremental indexing."""
|
||||
self._watcher.stop()
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""Get indexer statistics.
|
||||
|
||||
Returns:
|
||||
Dictionary with stats
|
||||
"""
|
||||
stats = {
|
||||
"changes_processed": self.changes_processed,
|
||||
"last_change_time": self.last_change_time
|
||||
}
|
||||
|
||||
if self.file_indexer:
|
||||
stats["file_indexer"] = self.file_indexer.get_stats()
|
||||
if self.symbol_indexer:
|
||||
stats["symbol_indexer"] = self.symbol_indexer.get_stats()
|
||||
|
||||
return stats
|
||||
|
||||
def __enter__(self):
|
||||
self.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.stop()
|
||||
@@ -0,0 +1,669 @@
|
||||
"""
|
||||
Symbol Indexer for Fast Context.
|
||||
|
||||
Extracts code symbols from source files:
|
||||
- Python: functions, classes, imports
|
||||
- JavaScript/TypeScript: functions, classes, imports, exports
|
||||
- Go: functions, types, imports
|
||||
- Rust: functions, structs, impl blocks, use statements
|
||||
- Java: classes, methods, imports
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Set, Optional, Any
|
||||
from pathlib import Path
|
||||
from enum import Enum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SymbolType(Enum):
|
||||
"""Types of code symbols."""
|
||||
FUNCTION = "function"
|
||||
CLASS = "class"
|
||||
METHOD = "method"
|
||||
IMPORT = "import"
|
||||
EXPORT = "export"
|
||||
VARIABLE = "variable"
|
||||
CONSTANT = "constant"
|
||||
TYPE = "type"
|
||||
STRUCT = "struct"
|
||||
INTERFACE = "interface"
|
||||
MODULE = "module"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Symbol:
|
||||
"""Represents a code symbol.
|
||||
|
||||
Attributes:
|
||||
name: Symbol name
|
||||
symbol_type: Type of symbol
|
||||
file_path: File containing the symbol
|
||||
line_number: Line number (1-indexed)
|
||||
end_line: End line number (for multi-line symbols)
|
||||
signature: Full signature (for functions/methods)
|
||||
parent: Parent symbol name (for methods)
|
||||
docstring: Documentation string if available
|
||||
"""
|
||||
name: str
|
||||
symbol_type: SymbolType
|
||||
file_path: str
|
||||
line_number: int
|
||||
end_line: Optional[int] = None
|
||||
signature: Optional[str] = None
|
||||
parent: Optional[str] = None
|
||||
docstring: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"name": self.name,
|
||||
"type": self.symbol_type.value,
|
||||
"file_path": self.file_path,
|
||||
"line_number": self.line_number,
|
||||
"end_line": self.end_line,
|
||||
"signature": self.signature,
|
||||
"parent": self.parent,
|
||||
"docstring": self.docstring
|
||||
}
|
||||
|
||||
|
||||
class LanguageParser:
|
||||
"""Base class for language-specific parsers."""
|
||||
|
||||
def parse(self, content: str, file_path: str) -> List[Symbol]:
|
||||
"""Parse content and extract symbols.
|
||||
|
||||
Args:
|
||||
content: File content
|
||||
file_path: Path to the file
|
||||
|
||||
Returns:
|
||||
List of extracted symbols
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class PythonParser(LanguageParser):
|
||||
"""Parser for Python files."""
|
||||
|
||||
# Regex patterns for Python
|
||||
FUNCTION_PATTERN = re.compile(
|
||||
r'^(\s*)def\s+(\w+)\s*\((.*?)\)\s*(?:->.*?)?:',
|
||||
re.MULTILINE
|
||||
)
|
||||
CLASS_PATTERN = re.compile(
|
||||
r'^(\s*)class\s+(\w+)\s*(?:\((.*?)\))?:',
|
||||
re.MULTILINE
|
||||
)
|
||||
IMPORT_PATTERN = re.compile(
|
||||
r'^(?:from\s+(\S+)\s+)?import\s+(.+)$',
|
||||
re.MULTILINE
|
||||
)
|
||||
ASYNC_FUNCTION_PATTERN = re.compile(
|
||||
r'^(\s*)async\s+def\s+(\w+)\s*\((.*?)\)\s*(?:->.*?)?:',
|
||||
re.MULTILINE
|
||||
)
|
||||
|
||||
def parse(self, content: str, file_path: str) -> List[Symbol]:
|
||||
symbols = []
|
||||
lines = content.split('\n')
|
||||
|
||||
# Track class context for methods
|
||||
current_class = None
|
||||
class_indent = 0
|
||||
|
||||
# Parse classes
|
||||
for match in self.CLASS_PATTERN.finditer(content):
|
||||
indent = len(match.group(1))
|
||||
name = match.group(2)
|
||||
bases = match.group(3) or ""
|
||||
line_num = content[:match.start()].count('\n') + 1
|
||||
|
||||
symbols.append(Symbol(
|
||||
name=name,
|
||||
symbol_type=SymbolType.CLASS,
|
||||
file_path=file_path,
|
||||
line_number=line_num,
|
||||
signature=f"class {name}({bases})" if bases else f"class {name}"
|
||||
))
|
||||
|
||||
# Parse functions (including async)
|
||||
for pattern in [self.FUNCTION_PATTERN, self.ASYNC_FUNCTION_PATTERN]:
|
||||
for match in pattern.finditer(content):
|
||||
indent = len(match.group(1))
|
||||
name = match.group(2)
|
||||
params = match.group(3)
|
||||
line_num = content[:match.start()].count('\n') + 1
|
||||
|
||||
# Determine if it's a method (inside a class)
|
||||
is_method = indent > 0
|
||||
parent = None
|
||||
|
||||
if is_method:
|
||||
# Find parent class
|
||||
for sym in symbols:
|
||||
if sym.symbol_type == SymbolType.CLASS:
|
||||
if sym.line_number < line_num:
|
||||
parent = sym.name
|
||||
|
||||
symbol_type = SymbolType.METHOD if is_method else SymbolType.FUNCTION
|
||||
is_async = "async" in match.group(0)
|
||||
prefix = "async def" if is_async else "def"
|
||||
|
||||
symbols.append(Symbol(
|
||||
name=name,
|
||||
symbol_type=symbol_type,
|
||||
file_path=file_path,
|
||||
line_number=line_num,
|
||||
signature=f"{prefix} {name}({params})",
|
||||
parent=parent
|
||||
))
|
||||
|
||||
# Parse imports
|
||||
for match in self.IMPORT_PATTERN.finditer(content):
|
||||
from_module = match.group(1)
|
||||
imports = match.group(2)
|
||||
line_num = content[:match.start()].count('\n') + 1
|
||||
|
||||
if from_module:
|
||||
sig = f"from {from_module} import {imports}"
|
||||
else:
|
||||
sig = f"import {imports}"
|
||||
|
||||
symbols.append(Symbol(
|
||||
name=imports.split(',')[0].strip().split(' ')[0],
|
||||
symbol_type=SymbolType.IMPORT,
|
||||
file_path=file_path,
|
||||
line_number=line_num,
|
||||
signature=sig
|
||||
))
|
||||
|
||||
return symbols
|
||||
|
||||
|
||||
class JavaScriptParser(LanguageParser):
|
||||
"""Parser for JavaScript/TypeScript files."""
|
||||
|
||||
FUNCTION_PATTERN = re.compile(
|
||||
r'(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\((.*?)\)',
|
||||
re.MULTILINE
|
||||
)
|
||||
ARROW_FUNCTION_PATTERN = re.compile(
|
||||
r'(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\((.*?)\)\s*=>',
|
||||
re.MULTILINE
|
||||
)
|
||||
CLASS_PATTERN = re.compile(
|
||||
r'(?:export\s+)?class\s+(\w+)(?:\s+extends\s+(\w+))?',
|
||||
re.MULTILINE
|
||||
)
|
||||
IMPORT_PATTERN = re.compile(
|
||||
r'import\s+(?:{([^}]+)}|(\w+))\s+from\s+[\'"]([^\'"]+)[\'"]',
|
||||
re.MULTILINE
|
||||
)
|
||||
EXPORT_PATTERN = re.compile(
|
||||
r'export\s+(?:default\s+)?(?:const|let|var|function|class)\s+(\w+)',
|
||||
re.MULTILINE
|
||||
)
|
||||
|
||||
def parse(self, content: str, file_path: str) -> List[Symbol]:
|
||||
symbols = []
|
||||
|
||||
# Parse functions
|
||||
for match in self.FUNCTION_PATTERN.finditer(content):
|
||||
name = match.group(1)
|
||||
params = match.group(2)
|
||||
line_num = content[:match.start()].count('\n') + 1
|
||||
|
||||
symbols.append(Symbol(
|
||||
name=name,
|
||||
symbol_type=SymbolType.FUNCTION,
|
||||
file_path=file_path,
|
||||
line_number=line_num,
|
||||
signature=f"function {name}({params})"
|
||||
))
|
||||
|
||||
# Parse arrow functions
|
||||
for match in self.ARROW_FUNCTION_PATTERN.finditer(content):
|
||||
name = match.group(1)
|
||||
params = match.group(2)
|
||||
line_num = content[:match.start()].count('\n') + 1
|
||||
|
||||
symbols.append(Symbol(
|
||||
name=name,
|
||||
symbol_type=SymbolType.FUNCTION,
|
||||
file_path=file_path,
|
||||
line_number=line_num,
|
||||
signature=f"const {name} = ({params}) =>"
|
||||
))
|
||||
|
||||
# Parse classes
|
||||
for match in self.CLASS_PATTERN.finditer(content):
|
||||
name = match.group(1)
|
||||
extends = match.group(2)
|
||||
line_num = content[:match.start()].count('\n') + 1
|
||||
|
||||
sig = f"class {name}"
|
||||
if extends:
|
||||
sig += f" extends {extends}"
|
||||
|
||||
symbols.append(Symbol(
|
||||
name=name,
|
||||
symbol_type=SymbolType.CLASS,
|
||||
file_path=file_path,
|
||||
line_number=line_num,
|
||||
signature=sig
|
||||
))
|
||||
|
||||
# Parse imports
|
||||
for match in self.IMPORT_PATTERN.finditer(content):
|
||||
named = match.group(1)
|
||||
default = match.group(2)
|
||||
module = match.group(3)
|
||||
line_num = content[:match.start()].count('\n') + 1
|
||||
|
||||
name = default or (named.split(',')[0].strip() if named else module)
|
||||
|
||||
symbols.append(Symbol(
|
||||
name=name,
|
||||
symbol_type=SymbolType.IMPORT,
|
||||
file_path=file_path,
|
||||
line_number=line_num,
|
||||
signature=f"import from '{module}'"
|
||||
))
|
||||
|
||||
# Parse exports
|
||||
for match in self.EXPORT_PATTERN.finditer(content):
|
||||
name = match.group(1)
|
||||
line_num = content[:match.start()].count('\n') + 1
|
||||
|
||||
symbols.append(Symbol(
|
||||
name=name,
|
||||
symbol_type=SymbolType.EXPORT,
|
||||
file_path=file_path,
|
||||
line_number=line_num,
|
||||
signature=f"export {name}"
|
||||
))
|
||||
|
||||
return symbols
|
||||
|
||||
|
||||
class GoParser(LanguageParser):
|
||||
"""Parser for Go files."""
|
||||
|
||||
FUNCTION_PATTERN = re.compile(
|
||||
r'^func\s+(?:\((\w+)\s+\*?(\w+)\)\s+)?(\w+)\s*\((.*?)\)',
|
||||
re.MULTILINE
|
||||
)
|
||||
TYPE_PATTERN = re.compile(
|
||||
r'^type\s+(\w+)\s+(struct|interface)',
|
||||
re.MULTILINE
|
||||
)
|
||||
IMPORT_PATTERN = re.compile(
|
||||
r'import\s+(?:\(\s*([\s\S]*?)\s*\)|"([^"]+)")',
|
||||
re.MULTILINE
|
||||
)
|
||||
|
||||
def parse(self, content: str, file_path: str) -> List[Symbol]:
|
||||
symbols = []
|
||||
|
||||
# Parse functions
|
||||
for match in self.FUNCTION_PATTERN.finditer(content):
|
||||
receiver_name = match.group(1)
|
||||
receiver_type = match.group(2)
|
||||
name = match.group(3)
|
||||
params = match.group(4)
|
||||
line_num = content[:match.start()].count('\n') + 1
|
||||
|
||||
if receiver_type:
|
||||
# Method
|
||||
symbols.append(Symbol(
|
||||
name=name,
|
||||
symbol_type=SymbolType.METHOD,
|
||||
file_path=file_path,
|
||||
line_number=line_num,
|
||||
signature=f"func ({receiver_name} {receiver_type}) {name}({params})",
|
||||
parent=receiver_type
|
||||
))
|
||||
else:
|
||||
# Function
|
||||
symbols.append(Symbol(
|
||||
name=name,
|
||||
symbol_type=SymbolType.FUNCTION,
|
||||
file_path=file_path,
|
||||
line_number=line_num,
|
||||
signature=f"func {name}({params})"
|
||||
))
|
||||
|
||||
# Parse types
|
||||
for match in self.TYPE_PATTERN.finditer(content):
|
||||
name = match.group(1)
|
||||
kind = match.group(2)
|
||||
line_num = content[:match.start()].count('\n') + 1
|
||||
|
||||
symbol_type = SymbolType.STRUCT if kind == "struct" else SymbolType.INTERFACE
|
||||
|
||||
symbols.append(Symbol(
|
||||
name=name,
|
||||
symbol_type=symbol_type,
|
||||
file_path=file_path,
|
||||
line_number=line_num,
|
||||
signature=f"type {name} {kind}"
|
||||
))
|
||||
|
||||
return symbols
|
||||
|
||||
|
||||
class RustParser(LanguageParser):
|
||||
"""Parser for Rust files."""
|
||||
|
||||
FUNCTION_PATTERN = re.compile(
|
||||
r'^(\s*)(?:pub\s+)?(?:async\s+)?fn\s+(\w+)\s*(?:<[^>]*>)?\s*\((.*?)\)',
|
||||
re.MULTILINE
|
||||
)
|
||||
STRUCT_PATTERN = re.compile(
|
||||
r'^(?:pub\s+)?struct\s+(\w+)',
|
||||
re.MULTILINE
|
||||
)
|
||||
IMPL_PATTERN = re.compile(
|
||||
r'^impl(?:<[^>]*>)?\s+(?:(\w+)\s+for\s+)?(\w+)',
|
||||
re.MULTILINE
|
||||
)
|
||||
USE_PATTERN = re.compile(
|
||||
r'^use\s+(.+);',
|
||||
re.MULTILINE
|
||||
)
|
||||
|
||||
def parse(self, content: str, file_path: str) -> List[Symbol]:
|
||||
symbols = []
|
||||
|
||||
# Parse functions
|
||||
for match in self.FUNCTION_PATTERN.finditer(content):
|
||||
indent = len(match.group(1))
|
||||
name = match.group(2)
|
||||
params = match.group(3)
|
||||
line_num = content[:match.start()].count('\n') + 1
|
||||
|
||||
is_method = indent > 0
|
||||
symbol_type = SymbolType.METHOD if is_method else SymbolType.FUNCTION
|
||||
|
||||
symbols.append(Symbol(
|
||||
name=name,
|
||||
symbol_type=symbol_type,
|
||||
file_path=file_path,
|
||||
line_number=line_num,
|
||||
signature=f"fn {name}({params})"
|
||||
))
|
||||
|
||||
# Parse structs
|
||||
for match in self.STRUCT_PATTERN.finditer(content):
|
||||
name = match.group(1)
|
||||
line_num = content[:match.start()].count('\n') + 1
|
||||
|
||||
symbols.append(Symbol(
|
||||
name=name,
|
||||
symbol_type=SymbolType.STRUCT,
|
||||
file_path=file_path,
|
||||
line_number=line_num,
|
||||
signature=f"struct {name}"
|
||||
))
|
||||
|
||||
# Parse impl blocks
|
||||
for match in self.IMPL_PATTERN.finditer(content):
|
||||
trait_name = match.group(1)
|
||||
type_name = match.group(2)
|
||||
line_num = content[:match.start()].count('\n') + 1
|
||||
|
||||
if trait_name:
|
||||
sig = f"impl {trait_name} for {type_name}"
|
||||
else:
|
||||
sig = f"impl {type_name}"
|
||||
|
||||
symbols.append(Symbol(
|
||||
name=type_name,
|
||||
symbol_type=SymbolType.TYPE,
|
||||
file_path=file_path,
|
||||
line_number=line_num,
|
||||
signature=sig
|
||||
))
|
||||
|
||||
return symbols
|
||||
|
||||
|
||||
class JavaParser(LanguageParser):
|
||||
"""Parser for Java files."""
|
||||
|
||||
CLASS_PATTERN = re.compile(
|
||||
r'(?:public\s+)?(?:abstract\s+)?(?:final\s+)?class\s+(\w+)(?:\s+extends\s+(\w+))?(?:\s+implements\s+([^{]+))?',
|
||||
re.MULTILINE
|
||||
)
|
||||
METHOD_PATTERN = re.compile(
|
||||
r'(?:public|private|protected)?\s*(?:static\s+)?(?:final\s+)?(?:\w+(?:<[^>]+>)?)\s+(\w+)\s*\((.*?)\)',
|
||||
re.MULTILINE
|
||||
)
|
||||
IMPORT_PATTERN = re.compile(
|
||||
r'^import\s+(?:static\s+)?([^;]+);',
|
||||
re.MULTILINE
|
||||
)
|
||||
|
||||
def parse(self, content: str, file_path: str) -> List[Symbol]:
|
||||
symbols = []
|
||||
|
||||
# Parse classes
|
||||
for match in self.CLASS_PATTERN.finditer(content):
|
||||
name = match.group(1)
|
||||
extends = match.group(2)
|
||||
implements = match.group(3)
|
||||
line_num = content[:match.start()].count('\n') + 1
|
||||
|
||||
sig = f"class {name}"
|
||||
if extends:
|
||||
sig += f" extends {extends}"
|
||||
if implements:
|
||||
sig += f" implements {implements.strip()}"
|
||||
|
||||
symbols.append(Symbol(
|
||||
name=name,
|
||||
symbol_type=SymbolType.CLASS,
|
||||
file_path=file_path,
|
||||
line_number=line_num,
|
||||
signature=sig
|
||||
))
|
||||
|
||||
# Parse methods
|
||||
for match in self.METHOD_PATTERN.finditer(content):
|
||||
name = match.group(1)
|
||||
params = match.group(2)
|
||||
line_num = content[:match.start()].count('\n') + 1
|
||||
|
||||
# Skip constructors and common keywords
|
||||
if name in ('if', 'while', 'for', 'switch', 'catch'):
|
||||
continue
|
||||
|
||||
symbols.append(Symbol(
|
||||
name=name,
|
||||
symbol_type=SymbolType.METHOD,
|
||||
file_path=file_path,
|
||||
line_number=line_num,
|
||||
signature=f"{name}({params})"
|
||||
))
|
||||
|
||||
# Parse imports
|
||||
for match in self.IMPORT_PATTERN.finditer(content):
|
||||
import_path = match.group(1)
|
||||
line_num = content[:match.start()].count('\n') + 1
|
||||
|
||||
# Get the last part of the import
|
||||
name = import_path.split('.')[-1]
|
||||
|
||||
symbols.append(Symbol(
|
||||
name=name,
|
||||
symbol_type=SymbolType.IMPORT,
|
||||
file_path=file_path,
|
||||
line_number=line_num,
|
||||
signature=f"import {import_path}"
|
||||
))
|
||||
|
||||
return symbols
|
||||
|
||||
|
||||
class SymbolIndexer:
|
||||
"""Indexes code symbols from source files.
|
||||
|
||||
Supports multiple languages:
|
||||
- Python (.py)
|
||||
- JavaScript (.js, .jsx)
|
||||
- TypeScript (.ts, .tsx)
|
||||
- Go (.go)
|
||||
- Rust (.rs)
|
||||
- Java (.java)
|
||||
"""
|
||||
|
||||
# Map file extensions to parsers
|
||||
PARSERS = {
|
||||
'.py': PythonParser,
|
||||
'.js': JavaScriptParser,
|
||||
'.jsx': JavaScriptParser,
|
||||
'.ts': JavaScriptParser,
|
||||
'.tsx': JavaScriptParser,
|
||||
'.go': GoParser,
|
||||
'.rs': RustParser,
|
||||
'.java': JavaParser,
|
||||
}
|
||||
|
||||
def __init__(self, workspace_path: str):
|
||||
"""Initialize symbol indexer.
|
||||
|
||||
Args:
|
||||
workspace_path: Root directory to index
|
||||
"""
|
||||
self.workspace_path = os.path.abspath(workspace_path)
|
||||
self.symbols: Dict[str, List[Symbol]] = {} # file_path -> symbols
|
||||
self.by_name: Dict[str, List[Symbol]] = {} # symbol_name -> symbols
|
||||
self.by_type: Dict[SymbolType, List[Symbol]] = {} # type -> symbols
|
||||
|
||||
# Initialize parsers
|
||||
self._parsers = {ext: parser() for ext, parser in self.PARSERS.items()}
|
||||
|
||||
def index_file(self, file_path: str) -> List[Symbol]:
|
||||
"""Index symbols from a single file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
|
||||
Returns:
|
||||
List of extracted symbols
|
||||
"""
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
parser = self._parsers.get(ext)
|
||||
|
||||
if not parser:
|
||||
return []
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
content = f.read()
|
||||
|
||||
rel_path = os.path.relpath(file_path, self.workspace_path)
|
||||
symbols = parser.parse(content, rel_path)
|
||||
|
||||
# Store in indexes
|
||||
self.symbols[rel_path] = symbols
|
||||
|
||||
for sym in symbols:
|
||||
# By name
|
||||
if sym.name not in self.by_name:
|
||||
self.by_name[sym.name] = []
|
||||
self.by_name[sym.name].append(sym)
|
||||
|
||||
# By type
|
||||
if sym.symbol_type not in self.by_type:
|
||||
self.by_type[sym.symbol_type] = []
|
||||
self.by_type[sym.symbol_type].append(sym)
|
||||
|
||||
return symbols
|
||||
except Exception as e:
|
||||
logger.debug(f"Error indexing {file_path}: {e}")
|
||||
return []
|
||||
|
||||
def index(self) -> int:
|
||||
"""Index all supported files in workspace.
|
||||
|
||||
Returns:
|
||||
Total number of symbols indexed
|
||||
"""
|
||||
total_symbols = 0
|
||||
|
||||
for root, dirs, files in os.walk(self.workspace_path):
|
||||
# Skip hidden directories
|
||||
dirs[:] = [d for d in dirs if not d.startswith('.')]
|
||||
|
||||
for filename in files:
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
if ext in self.PARSERS:
|
||||
file_path = os.path.join(root, filename)
|
||||
symbols = self.index_file(file_path)
|
||||
total_symbols += len(symbols)
|
||||
|
||||
logger.info(f"Indexed {total_symbols} symbols from {len(self.symbols)} files")
|
||||
return total_symbols
|
||||
|
||||
def find_by_name(self, name: str, exact: bool = False) -> List[Symbol]:
|
||||
"""Find symbols by name.
|
||||
|
||||
Args:
|
||||
name: Symbol name to search
|
||||
exact: If True, match exact name
|
||||
|
||||
Returns:
|
||||
List of matching symbols
|
||||
"""
|
||||
if exact:
|
||||
return self.by_name.get(name, [])
|
||||
|
||||
results = []
|
||||
name_lower = name.lower()
|
||||
for sym_name, symbols in self.by_name.items():
|
||||
if name_lower in sym_name.lower():
|
||||
results.extend(symbols)
|
||||
return results
|
||||
|
||||
def find_by_type(self, symbol_type: SymbolType) -> List[Symbol]:
|
||||
"""Find symbols by type.
|
||||
|
||||
Args:
|
||||
symbol_type: Type of symbol
|
||||
|
||||
Returns:
|
||||
List of matching symbols
|
||||
"""
|
||||
return self.by_type.get(symbol_type, [])
|
||||
|
||||
def find_in_file(self, file_path: str) -> List[Symbol]:
|
||||
"""Find all symbols in a file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
|
||||
Returns:
|
||||
List of symbols in the file
|
||||
"""
|
||||
return self.symbols.get(file_path, [])
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""Get indexer statistics.
|
||||
|
||||
Returns:
|
||||
Dictionary with stats
|
||||
"""
|
||||
type_counts = {t.value: len(syms) for t, syms in self.by_type.items()}
|
||||
|
||||
return {
|
||||
"total_files": len(self.symbols),
|
||||
"total_symbols": sum(len(s) for s in self.symbols.values()),
|
||||
"by_type": type_counts
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
"""
|
||||
Parallel execution engine for Fast Context.
|
||||
|
||||
Provides async parallel execution of search tools with:
|
||||
- Configurable parallelism (default: 8 concurrent calls)
|
||||
- Timeout handling per tool call
|
||||
- Result aggregation
|
||||
- Error isolation (one failure doesn't affect others)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import List, Dict, Any, Optional, Callable
|
||||
import logging
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ToolCallBatch:
|
||||
"""Batch of tool calls to execute in parallel.
|
||||
|
||||
Attributes:
|
||||
tasks: List of task dictionaries
|
||||
max_size: Maximum batch size (default: 8)
|
||||
"""
|
||||
|
||||
def __init__(self, max_size: int = 8):
|
||||
"""Initialize batch.
|
||||
|
||||
Args:
|
||||
max_size: Maximum number of tasks in batch
|
||||
"""
|
||||
self.tasks: List[Dict[str, Any]] = []
|
||||
self.max_size = max_size
|
||||
|
||||
def add(self, tool_name: str, **kwargs) -> bool:
|
||||
"""Add a tool call to the batch.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool to call
|
||||
**kwargs: Tool arguments
|
||||
|
||||
Returns:
|
||||
True if added, False if batch is full
|
||||
"""
|
||||
if self.is_full:
|
||||
return False
|
||||
|
||||
self.tasks.append({
|
||||
"tool": tool_name,
|
||||
"args": kwargs
|
||||
})
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_full(self) -> bool:
|
||||
"""Check if batch is at max capacity."""
|
||||
return len(self.tasks) >= self.max_size
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all tasks from batch."""
|
||||
self.tasks.clear()
|
||||
|
||||
|
||||
class ParallelExecutor:
|
||||
"""Executes search tools in parallel.
|
||||
|
||||
Uses asyncio for concurrent execution with a thread pool
|
||||
for CPU-bound operations.
|
||||
|
||||
Attributes:
|
||||
max_parallel: Maximum concurrent executions
|
||||
timeout: Timeout per tool call in seconds
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_parallel: int = 8,
|
||||
timeout: float = 30.0
|
||||
):
|
||||
"""Initialize executor.
|
||||
|
||||
Args:
|
||||
max_parallel: Maximum concurrent tool calls
|
||||
timeout: Timeout per tool call in seconds
|
||||
"""
|
||||
self.max_parallel = max_parallel
|
||||
self.timeout = timeout
|
||||
self._executor: Optional[ThreadPoolExecutor] = None
|
||||
|
||||
def _get_executor(self) -> ThreadPoolExecutor:
|
||||
"""Get or create thread pool executor."""
|
||||
if self._executor is None:
|
||||
self._executor = ThreadPoolExecutor(max_workers=self.max_parallel)
|
||||
return self._executor
|
||||
|
||||
def _get_tool_function(self, tool_name: str) -> Optional[Callable]:
|
||||
"""Get tool function by name.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool
|
||||
|
||||
Returns:
|
||||
Tool function or None if not found
|
||||
"""
|
||||
# Import here to avoid circular imports
|
||||
from praisonaiagents.context.fast.search_tools import (
|
||||
grep_search,
|
||||
glob_search,
|
||||
read_file,
|
||||
list_directory
|
||||
)
|
||||
|
||||
tools = {
|
||||
"grep_search": grep_search,
|
||||
"glob_search": glob_search,
|
||||
"read_file": read_file,
|
||||
"list_directory": list_directory
|
||||
}
|
||||
|
||||
return tools.get(tool_name)
|
||||
|
||||
async def _execute_single(
|
||||
self,
|
||||
task: Dict[str, Any],
|
||||
loop: asyncio.AbstractEventLoop
|
||||
) -> Any:
|
||||
"""Execute a single tool call.
|
||||
|
||||
Args:
|
||||
task: Task dictionary with 'tool' and 'args'
|
||||
loop: Event loop for thread execution
|
||||
|
||||
Returns:
|
||||
Tool result or error dictionary
|
||||
"""
|
||||
tool_name = task.get("tool")
|
||||
args = task.get("args", {})
|
||||
|
||||
tool_func = self._get_tool_function(tool_name)
|
||||
if tool_func is None:
|
||||
return {"error": f"Unknown tool: {tool_name}"}
|
||||
|
||||
try:
|
||||
# Run in thread pool to avoid blocking
|
||||
executor = self._get_executor()
|
||||
result = await asyncio.wait_for(
|
||||
loop.run_in_executor(executor, lambda: tool_func(**args)),
|
||||
timeout=self.timeout
|
||||
)
|
||||
return result
|
||||
except asyncio.TimeoutError:
|
||||
return {"error": f"Timeout executing {tool_name}", "timeout": True}
|
||||
except Exception as e:
|
||||
logger.debug(f"Error executing {tool_name}: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
async def execute(self, tasks: List[Dict[str, Any]]) -> List[Any]:
|
||||
"""Execute multiple tool calls in parallel.
|
||||
|
||||
Args:
|
||||
tasks: List of task dictionaries, each with 'tool' and 'args'
|
||||
|
||||
Returns:
|
||||
List of results in same order as tasks
|
||||
"""
|
||||
if not tasks:
|
||||
return []
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
# Create semaphore to limit concurrency
|
||||
semaphore = asyncio.Semaphore(self.max_parallel)
|
||||
|
||||
async def bounded_execute(task: Dict[str, Any]) -> Any:
|
||||
async with semaphore:
|
||||
return await self._execute_single(task, loop)
|
||||
|
||||
# Execute all tasks concurrently
|
||||
results = await asyncio.gather(
|
||||
*[bounded_execute(task) for task in tasks],
|
||||
return_exceptions=True
|
||||
)
|
||||
|
||||
# Convert exceptions to error dicts
|
||||
processed_results = []
|
||||
for result in results:
|
||||
if isinstance(result, Exception):
|
||||
processed_results.append({"error": str(result)})
|
||||
else:
|
||||
processed_results.append(result)
|
||||
|
||||
return processed_results
|
||||
|
||||
def execute_sync(self, tasks: List[Dict[str, Any]]) -> List[Any]:
|
||||
"""Synchronous wrapper for execute.
|
||||
|
||||
Args:
|
||||
tasks: List of task dictionaries
|
||||
|
||||
Returns:
|
||||
List of results
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
# If already in async context, create new loop
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
future = pool.submit(asyncio.run, self.execute(tasks))
|
||||
return future.result()
|
||||
else:
|
||||
return loop.run_until_complete(self.execute(tasks))
|
||||
except RuntimeError:
|
||||
# No event loop, create one
|
||||
return asyncio.run(self.execute(tasks))
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the executor and release resources."""
|
||||
if self._executor is not None:
|
||||
self._executor.shutdown(wait=False)
|
||||
self._executor = None
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
|
||||
|
||||
class ParallelSearchCoordinator:
|
||||
"""Coordinates parallel search operations across multiple turns.
|
||||
|
||||
Implements the Fast Context search strategy:
|
||||
- Up to 8 parallel tool calls per turn
|
||||
- Maximum 4 turns
|
||||
- Aggregates results across turns
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_parallel: int = 8,
|
||||
max_turns: int = 4,
|
||||
timeout: float = 30.0
|
||||
):
|
||||
"""Initialize coordinator.
|
||||
|
||||
Args:
|
||||
max_parallel: Max parallel calls per turn
|
||||
max_turns: Maximum number of turns
|
||||
timeout: Timeout per tool call
|
||||
"""
|
||||
self.max_parallel = max_parallel
|
||||
self.max_turns = max_turns
|
||||
self.timeout = timeout
|
||||
self.executor = ParallelExecutor(max_parallel, timeout)
|
||||
|
||||
# Track execution stats
|
||||
self.total_tool_calls = 0
|
||||
self.turns_used = 0
|
||||
self.start_time: Optional[float] = None
|
||||
self.end_time: Optional[float] = None
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start timing the search."""
|
||||
self.start_time = time.perf_counter()
|
||||
self.total_tool_calls = 0
|
||||
self.turns_used = 0
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop timing the search."""
|
||||
self.end_time = time.perf_counter()
|
||||
|
||||
@property
|
||||
def elapsed_ms(self) -> int:
|
||||
"""Get elapsed time in milliseconds."""
|
||||
if self.start_time is None:
|
||||
return 0
|
||||
end = self.end_time or time.perf_counter()
|
||||
return int((end - self.start_time) * 1000)
|
||||
|
||||
async def execute_turn(self, batch: ToolCallBatch) -> List[Any]:
|
||||
"""Execute a single turn of parallel tool calls.
|
||||
|
||||
Args:
|
||||
batch: Batch of tool calls
|
||||
|
||||
Returns:
|
||||
List of results
|
||||
"""
|
||||
if self.turns_used >= self.max_turns:
|
||||
logger.warning("Maximum turns reached")
|
||||
return []
|
||||
|
||||
self.turns_used += 1
|
||||
self.total_tool_calls += len(batch.tasks)
|
||||
|
||||
results = await self.executor.execute(batch.tasks)
|
||||
return results
|
||||
|
||||
def execute_turn_sync(self, batch: ToolCallBatch) -> List[Any]:
|
||||
"""Synchronous wrapper for execute_turn.
|
||||
|
||||
Args:
|
||||
batch: Batch of tool calls
|
||||
|
||||
Returns:
|
||||
List of results
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
future = pool.submit(asyncio.run, self.execute_turn(batch))
|
||||
return future.result()
|
||||
else:
|
||||
return loop.run_until_complete(self.execute_turn(batch))
|
||||
except RuntimeError:
|
||||
return asyncio.run(self.execute_turn(batch))
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close resources."""
|
||||
self.executor.close()
|
||||
@@ -0,0 +1,210 @@
|
||||
"""
|
||||
Result classes for Fast Context.
|
||||
|
||||
These dataclasses represent the output of Fast Context searches,
|
||||
providing structured file and line range information.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
import time
|
||||
|
||||
|
||||
@dataclass
|
||||
class LineRange:
|
||||
"""Represents a range of lines in a file.
|
||||
|
||||
Attributes:
|
||||
start: Starting line number (1-indexed)
|
||||
end: Ending line number (1-indexed, inclusive)
|
||||
content: Optional content of the lines
|
||||
relevance_score: Score indicating relevance (0.0-1.0)
|
||||
"""
|
||||
start: int
|
||||
end: int
|
||||
content: Optional[str] = None
|
||||
relevance_score: float = 1.0
|
||||
|
||||
def __post_init__(self):
|
||||
if self.start < 1:
|
||||
self.start = 1
|
||||
if self.end < self.start:
|
||||
self.end = self.start
|
||||
|
||||
@property
|
||||
def line_count(self) -> int:
|
||||
"""Number of lines in this range."""
|
||||
return self.end - self.start + 1
|
||||
|
||||
def overlaps(self, other: "LineRange") -> bool:
|
||||
"""Check if this range overlaps with another."""
|
||||
return not (self.end < other.start or self.start > other.end)
|
||||
|
||||
def merge(self, other: "LineRange") -> "LineRange":
|
||||
"""Merge with another overlapping range."""
|
||||
if not self.overlaps(other):
|
||||
raise ValueError("Cannot merge non-overlapping ranges")
|
||||
return LineRange(
|
||||
start=min(self.start, other.start),
|
||||
end=max(self.end, other.end),
|
||||
content=None, # Content needs to be re-fetched
|
||||
relevance_score=max(self.relevance_score, other.relevance_score)
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileMatch:
|
||||
"""Represents a file match from Fast Context search.
|
||||
|
||||
Attributes:
|
||||
path: Absolute or relative path to the file
|
||||
line_ranges: List of relevant line ranges in the file
|
||||
relevance_score: Overall relevance score (0.0-1.0)
|
||||
match_count: Number of pattern matches found
|
||||
"""
|
||||
path: str
|
||||
line_ranges: List[LineRange] = field(default_factory=list)
|
||||
relevance_score: float = 1.0
|
||||
match_count: int = 0
|
||||
|
||||
def add_line_range(self, line_range: LineRange) -> None:
|
||||
"""Add a line range, merging with existing overlapping ranges."""
|
||||
# Find overlapping ranges
|
||||
overlapping = []
|
||||
non_overlapping = []
|
||||
for existing in self.line_ranges:
|
||||
if existing.overlaps(line_range):
|
||||
overlapping.append(existing)
|
||||
else:
|
||||
non_overlapping.append(existing)
|
||||
|
||||
# Merge all overlapping ranges
|
||||
merged = line_range
|
||||
for overlap in overlapping:
|
||||
merged = merged.merge(overlap)
|
||||
|
||||
self.line_ranges = non_overlapping + [merged]
|
||||
self.line_ranges.sort(key=lambda r: r.start)
|
||||
|
||||
@property
|
||||
def total_lines(self) -> int:
|
||||
"""Total number of lines across all ranges."""
|
||||
return sum(r.line_count for r in self.line_ranges)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FastContextResult:
|
||||
"""Result of a Fast Context search operation.
|
||||
|
||||
Attributes:
|
||||
files: List of matching files with line ranges
|
||||
query: The original search query
|
||||
search_time_ms: Time taken for the search in milliseconds
|
||||
turns_used: Number of turns used by the subagent
|
||||
total_tool_calls: Total number of tool calls made
|
||||
from_cache: Whether result was retrieved from cache
|
||||
"""
|
||||
files: List[FileMatch] = field(default_factory=list)
|
||||
query: str = ""
|
||||
search_time_ms: int = 0
|
||||
turns_used: int = 0
|
||||
total_tool_calls: int = 0
|
||||
from_cache: bool = False
|
||||
|
||||
_start_time: float = field(default=0.0, repr=False)
|
||||
|
||||
def start_timer(self) -> None:
|
||||
"""Start the search timer."""
|
||||
self._start_time = time.perf_counter()
|
||||
|
||||
def stop_timer(self) -> None:
|
||||
"""Stop the timer and record elapsed time."""
|
||||
if self._start_time > 0:
|
||||
elapsed = time.perf_counter() - self._start_time
|
||||
self.search_time_ms = int(elapsed * 1000)
|
||||
|
||||
def add_file(self, file_match: FileMatch) -> None:
|
||||
"""Add a file match, merging with existing if same path."""
|
||||
for existing in self.files:
|
||||
if existing.path == file_match.path:
|
||||
# Merge line ranges
|
||||
for lr in file_match.line_ranges:
|
||||
existing.add_line_range(lr)
|
||||
existing.relevance_score = max(
|
||||
existing.relevance_score,
|
||||
file_match.relevance_score
|
||||
)
|
||||
existing.match_count += file_match.match_count
|
||||
return
|
||||
self.files.append(file_match)
|
||||
|
||||
def sort_by_relevance(self) -> None:
|
||||
"""Sort files by relevance score (highest first)."""
|
||||
self.files.sort(key=lambda f: f.relevance_score, reverse=True)
|
||||
|
||||
@property
|
||||
def total_files(self) -> int:
|
||||
"""Total number of files found."""
|
||||
return len(self.files)
|
||||
|
||||
@property
|
||||
def total_lines(self) -> int:
|
||||
"""Total number of lines across all files."""
|
||||
return sum(f.total_lines for f in self.files)
|
||||
|
||||
def to_context_string(self, max_files: int = 10, include_content: bool = True) -> str:
|
||||
"""Convert result to a context string for the main agent.
|
||||
|
||||
Args:
|
||||
max_files: Maximum number of files to include
|
||||
include_content: Whether to include line content
|
||||
|
||||
Returns:
|
||||
Formatted context string
|
||||
"""
|
||||
if not self.files:
|
||||
return "No relevant files found."
|
||||
|
||||
lines = [f"Found {self.total_files} relevant file(s):"]
|
||||
|
||||
for i, file_match in enumerate(self.files[:max_files]):
|
||||
lines.append(f"\n## {file_match.path}")
|
||||
if file_match.line_ranges:
|
||||
for lr in file_match.line_ranges:
|
||||
lines.append(f" Lines {lr.start}-{lr.end}")
|
||||
if include_content and lr.content:
|
||||
# Indent content
|
||||
content_lines = lr.content.split('\n')
|
||||
for cl in content_lines:
|
||||
lines.append(f" {cl}")
|
||||
|
||||
if len(self.files) > max_files:
|
||||
lines.append(f"\n... and {len(self.files) - max_files} more files")
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
"files": [
|
||||
{
|
||||
"path": f.path,
|
||||
"relevance_score": f.relevance_score,
|
||||
"match_count": f.match_count,
|
||||
"line_ranges": [
|
||||
{
|
||||
"start": lr.start,
|
||||
"end": lr.end,
|
||||
"relevance_score": lr.relevance_score
|
||||
}
|
||||
for lr in f.line_ranges
|
||||
]
|
||||
}
|
||||
for f in self.files
|
||||
],
|
||||
"query": self.query,
|
||||
"search_time_ms": self.search_time_ms,
|
||||
"turns_used": self.turns_used,
|
||||
"total_tool_calls": self.total_tool_calls,
|
||||
"from_cache": self.from_cache
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
"""
|
||||
Search Strategy for Fast Context.
|
||||
|
||||
Orchestrates multi-turn search with:
|
||||
- Turn 1-3: Exploration (grep, glob, directory listing)
|
||||
- Turn 4: Final answer (file list + line ranges)
|
||||
- Early termination if sufficient context found
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Dict, Any, Optional, Set
|
||||
from enum import Enum
|
||||
|
||||
from praisonaiagents.context.fast.result import FastContextResult, FileMatch, LineRange
|
||||
from praisonaiagents.context.fast.parallel_executor import ToolCallBatch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SearchPhase(Enum):
|
||||
"""Phases of the search strategy."""
|
||||
DISCOVERY = "discovery" # Turn 1: Discover structure
|
||||
EXPLORATION = "exploration" # Turn 2-3: Deep search
|
||||
REFINEMENT = "refinement" # Turn 4: Final answer
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchState:
|
||||
"""Tracks the state of a multi-turn search.
|
||||
|
||||
Attributes:
|
||||
query: Original search query
|
||||
current_turn: Current turn number (1-indexed)
|
||||
max_turns: Maximum turns allowed
|
||||
phase: Current search phase
|
||||
discovered_files: Files found so far
|
||||
explored_paths: Paths already explored
|
||||
result: Accumulated search result
|
||||
should_terminate: Whether to terminate early
|
||||
"""
|
||||
query: str
|
||||
current_turn: int = 0
|
||||
max_turns: int = 4
|
||||
phase: SearchPhase = SearchPhase.DISCOVERY
|
||||
discovered_files: Set[str] = field(default_factory=set)
|
||||
explored_paths: Set[str] = field(default_factory=set)
|
||||
result: FastContextResult = field(default_factory=FastContextResult)
|
||||
should_terminate: bool = False
|
||||
|
||||
# Thresholds for early termination
|
||||
min_files_for_termination: int = 5
|
||||
max_files_before_refinement: int = 20
|
||||
|
||||
def advance_turn(self) -> None:
|
||||
"""Advance to the next turn and update phase."""
|
||||
self.current_turn += 1
|
||||
|
||||
if self.current_turn == 1:
|
||||
self.phase = SearchPhase.DISCOVERY
|
||||
elif self.current_turn < self.max_turns:
|
||||
self.phase = SearchPhase.EXPLORATION
|
||||
else:
|
||||
self.phase = SearchPhase.REFINEMENT
|
||||
|
||||
def check_early_termination(self) -> bool:
|
||||
"""Check if search should terminate early.
|
||||
|
||||
Returns:
|
||||
True if search should terminate
|
||||
"""
|
||||
# Terminate if we have enough high-quality results
|
||||
if len(self.discovered_files) >= self.min_files_for_termination:
|
||||
high_relevance = sum(
|
||||
1 for f in self.result.files
|
||||
if f.relevance_score >= 0.8
|
||||
)
|
||||
if high_relevance >= 3:
|
||||
self.should_terminate = True
|
||||
return True
|
||||
|
||||
# Terminate if we've explored everything
|
||||
if self.current_turn >= 2 and not self._has_unexplored_paths():
|
||||
self.should_terminate = True
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _has_unexplored_paths(self) -> bool:
|
||||
"""Check if there are unexplored paths."""
|
||||
# Simple heuristic: if we found files but haven't explored many paths
|
||||
return len(self.discovered_files) > len(self.explored_paths) * 2
|
||||
|
||||
|
||||
class SearchStrategy:
|
||||
"""Orchestrates multi-turn search strategy.
|
||||
|
||||
Implements the Fast Context search approach:
|
||||
- Turn 1: Discovery - understand codebase structure
|
||||
- Turn 2-3: Exploration - deep search for relevant code
|
||||
- Turn 4: Refinement - finalize results
|
||||
|
||||
Supports early termination when sufficient context is found.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace_path: str,
|
||||
max_turns: int = 4,
|
||||
max_parallel: int = 8
|
||||
):
|
||||
"""Initialize search strategy.
|
||||
|
||||
Args:
|
||||
workspace_path: Root directory for searches
|
||||
max_turns: Maximum search turns
|
||||
max_parallel: Maximum parallel tool calls per turn
|
||||
"""
|
||||
self.workspace_path = workspace_path
|
||||
self.max_turns = max_turns
|
||||
self.max_parallel = max_parallel
|
||||
|
||||
def create_state(self, query: str) -> SearchState:
|
||||
"""Create a new search state.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
|
||||
Returns:
|
||||
New SearchState instance
|
||||
"""
|
||||
state = SearchState(query=query, max_turns=self.max_turns)
|
||||
state.result.query = query
|
||||
return state
|
||||
|
||||
def plan_discovery_turn(self, state: SearchState) -> ToolCallBatch:
|
||||
"""Plan the discovery turn (Turn 1).
|
||||
|
||||
Focus on understanding codebase structure:
|
||||
- List root directory
|
||||
- Find common file patterns
|
||||
- Initial grep for query terms
|
||||
|
||||
Args:
|
||||
state: Current search state
|
||||
|
||||
Returns:
|
||||
Batch of tool calls to execute
|
||||
"""
|
||||
batch = ToolCallBatch(max_size=self.max_parallel)
|
||||
query = state.query
|
||||
|
||||
# List root directory
|
||||
batch.add("list_directory", dir_path=self.workspace_path, recursive=False)
|
||||
|
||||
# Find Python files (most common)
|
||||
batch.add("glob_search", search_path=self.workspace_path, pattern="**/*.py", max_results=20)
|
||||
|
||||
# Initial grep for query terms
|
||||
query_words = query.lower().split()
|
||||
for word in query_words[:3]: # First 3 words
|
||||
if len(word) >= 3: # Skip short words
|
||||
batch.add("grep_search", search_path=self.workspace_path, pattern=word, max_results=10)
|
||||
if batch.is_full:
|
||||
break
|
||||
|
||||
return batch
|
||||
|
||||
def plan_exploration_turn(self, state: SearchState) -> ToolCallBatch:
|
||||
"""Plan an exploration turn (Turn 2-3).
|
||||
|
||||
Deep search based on discovery results:
|
||||
- Search in discovered directories
|
||||
- Read promising files
|
||||
- Follow references
|
||||
|
||||
Args:
|
||||
state: Current search state
|
||||
|
||||
Returns:
|
||||
Batch of tool calls to execute
|
||||
"""
|
||||
batch = ToolCallBatch(max_size=self.max_parallel)
|
||||
|
||||
# Read top files found so far
|
||||
for file_match in state.result.files[:4]:
|
||||
if file_match.path not in state.explored_paths:
|
||||
batch.add("read_file", filepath=file_match.path, context_lines=5)
|
||||
state.explored_paths.add(file_match.path)
|
||||
if batch.is_full:
|
||||
break
|
||||
|
||||
# Search in unexplored directories
|
||||
unexplored_dirs = self._find_unexplored_dirs(state)
|
||||
for dir_path in unexplored_dirs[:2]:
|
||||
batch.add("list_directory", dir_path=dir_path, recursive=True, max_depth=2)
|
||||
if batch.is_full:
|
||||
break
|
||||
|
||||
# Additional grep searches with refined patterns
|
||||
if not batch.is_full:
|
||||
refined_patterns = self._generate_refined_patterns(state)
|
||||
for pattern in refined_patterns[:2]:
|
||||
batch.add("grep_search", search_path=self.workspace_path, pattern=pattern, max_results=15)
|
||||
if batch.is_full:
|
||||
break
|
||||
|
||||
return batch
|
||||
|
||||
def plan_refinement_turn(self, state: SearchState) -> ToolCallBatch:
|
||||
"""Plan the refinement turn (Turn 4).
|
||||
|
||||
Finalize results:
|
||||
- Read remaining important files
|
||||
- Get specific line ranges
|
||||
|
||||
Args:
|
||||
state: Current search state
|
||||
|
||||
Returns:
|
||||
Batch of tool calls to execute
|
||||
"""
|
||||
batch = ToolCallBatch(max_size=self.max_parallel)
|
||||
|
||||
# Read top files that haven't been fully explored
|
||||
for file_match in state.result.files[:self.max_parallel]:
|
||||
if file_match.path not in state.explored_paths:
|
||||
# Read specific line ranges if available
|
||||
if file_match.line_ranges:
|
||||
for lr in file_match.line_ranges[:2]:
|
||||
batch.add(
|
||||
"read_file",
|
||||
filepath=file_match.path,
|
||||
start_line=max(1, lr.start - 5),
|
||||
end_line=lr.end + 5
|
||||
)
|
||||
if batch.is_full:
|
||||
break
|
||||
else:
|
||||
batch.add("read_file", filepath=file_match.path)
|
||||
|
||||
state.explored_paths.add(file_match.path)
|
||||
if batch.is_full:
|
||||
break
|
||||
|
||||
return batch
|
||||
|
||||
def plan_next_turn(self, state: SearchState) -> Optional[ToolCallBatch]:
|
||||
"""Plan the next turn based on current state.
|
||||
|
||||
Args:
|
||||
state: Current search state
|
||||
|
||||
Returns:
|
||||
Batch of tool calls, or None if search should end
|
||||
"""
|
||||
state.advance_turn()
|
||||
|
||||
# Check for early termination
|
||||
if state.should_terminate or state.current_turn > state.max_turns:
|
||||
return None
|
||||
|
||||
if state.check_early_termination():
|
||||
return None
|
||||
|
||||
# Plan based on phase
|
||||
if state.phase == SearchPhase.DISCOVERY:
|
||||
return self.plan_discovery_turn(state)
|
||||
elif state.phase == SearchPhase.EXPLORATION:
|
||||
return self.plan_exploration_turn(state)
|
||||
else: # REFINEMENT
|
||||
return self.plan_refinement_turn(state)
|
||||
|
||||
def process_results(
|
||||
self,
|
||||
state: SearchState,
|
||||
tool_results: List[Any]
|
||||
) -> None:
|
||||
"""Process results from a turn and update state.
|
||||
|
||||
Args:
|
||||
state: Current search state
|
||||
tool_results: Results from tool execution
|
||||
"""
|
||||
for result in tool_results:
|
||||
if isinstance(result, list):
|
||||
# Grep or glob results
|
||||
for item in result:
|
||||
if isinstance(item, dict):
|
||||
path = item.get("path", "")
|
||||
if path:
|
||||
state.discovered_files.add(path)
|
||||
|
||||
# Create or update file match
|
||||
file_match = FileMatch(
|
||||
path=path,
|
||||
relevance_score=0.8,
|
||||
match_count=1
|
||||
)
|
||||
|
||||
# Add line range if available
|
||||
line_num = item.get("line_number")
|
||||
if line_num:
|
||||
file_match.add_line_range(LineRange(
|
||||
start=max(1, line_num - 2),
|
||||
end=line_num + 2,
|
||||
content=item.get("context"),
|
||||
relevance_score=0.8
|
||||
))
|
||||
|
||||
state.result.add_file(file_match)
|
||||
|
||||
elif isinstance(result, dict):
|
||||
# Read file or list directory result
|
||||
if result.get("success"):
|
||||
path = result.get("path", "")
|
||||
if path:
|
||||
state.explored_paths.add(path)
|
||||
|
||||
# Handle directory listing
|
||||
entries = result.get("entries", [])
|
||||
for entry in entries:
|
||||
if not entry.get("is_dir"):
|
||||
state.discovered_files.add(entry.get("path", ""))
|
||||
|
||||
def _find_unexplored_dirs(self, state: SearchState) -> List[str]:
|
||||
"""Find directories that haven't been explored.
|
||||
|
||||
Args:
|
||||
state: Current search state
|
||||
|
||||
Returns:
|
||||
List of unexplored directory paths
|
||||
"""
|
||||
import os
|
||||
|
||||
# Extract unique directories from discovered files
|
||||
dirs = set()
|
||||
for file_path in state.discovered_files:
|
||||
dir_path = os.path.dirname(file_path)
|
||||
if dir_path and dir_path not in state.explored_paths:
|
||||
dirs.add(dir_path)
|
||||
|
||||
return list(dirs)[:5] # Limit to 5
|
||||
|
||||
def _generate_refined_patterns(self, state: SearchState) -> List[str]:
|
||||
"""Generate refined search patterns based on results.
|
||||
|
||||
Args:
|
||||
state: Current search state
|
||||
|
||||
Returns:
|
||||
List of refined search patterns
|
||||
"""
|
||||
patterns = []
|
||||
query_words = state.query.lower().split()
|
||||
|
||||
# Combine query words
|
||||
if len(query_words) >= 2:
|
||||
patterns.append(f"{query_words[0]}.*{query_words[1]}")
|
||||
|
||||
# Add common code patterns
|
||||
for word in query_words[:2]:
|
||||
if len(word) >= 3:
|
||||
patterns.append(f"def {word}")
|
||||
patterns.append(f"class {word}")
|
||||
|
||||
return patterns[:4]
|
||||
@@ -0,0 +1,636 @@
|
||||
"""
|
||||
Search tools for Fast Context.
|
||||
|
||||
Provides cross-platform compatible search tools:
|
||||
- grep_search: Pattern search in files
|
||||
- glob_search: File pattern matching
|
||||
- read_file: Read file contents with line range support
|
||||
- list_directory: List directory contents
|
||||
|
||||
These tools are designed to be fast, safe, and cross-platform compatible.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import fnmatch
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional, Set
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _load_gitignore_patterns(root_path: str) -> Set[str]:
|
||||
"""Load patterns from .gitignore file.
|
||||
|
||||
Args:
|
||||
root_path: Root directory to search for .gitignore
|
||||
|
||||
Returns:
|
||||
Set of gitignore patterns
|
||||
"""
|
||||
patterns = set()
|
||||
gitignore_path = Path(root_path) / ".gitignore"
|
||||
|
||||
if gitignore_path.exists():
|
||||
try:
|
||||
with open(gitignore_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
# Skip empty lines and comments
|
||||
if line and not line.startswith('#'):
|
||||
patterns.add(line)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Also check for .praisonignore
|
||||
praisonignore_path = Path(root_path) / ".praisonignore"
|
||||
if praisonignore_path.exists():
|
||||
try:
|
||||
with open(praisonignore_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#'):
|
||||
patterns.add(line)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return patterns
|
||||
|
||||
|
||||
def _should_ignore(path: str, root_path: str, patterns: Set[str]) -> bool:
|
||||
"""Check if a path should be ignored based on gitignore patterns.
|
||||
|
||||
Args:
|
||||
path: Path to check
|
||||
root_path: Root directory
|
||||
patterns: Set of gitignore patterns
|
||||
|
||||
Returns:
|
||||
True if path should be ignored
|
||||
"""
|
||||
if not patterns:
|
||||
return False
|
||||
|
||||
rel_path = os.path.relpath(path, root_path)
|
||||
path_parts = Path(rel_path).parts
|
||||
|
||||
for pattern in patterns:
|
||||
# Handle directory patterns (ending with /)
|
||||
if pattern.endswith('/'):
|
||||
dir_pattern = pattern.rstrip('/')
|
||||
if any(fnmatch.fnmatch(part, dir_pattern) for part in path_parts):
|
||||
return True
|
||||
# Handle ** patterns
|
||||
elif '**' in pattern:
|
||||
# Convert gitignore pattern to fnmatch pattern
|
||||
fnmatch_pattern = pattern.replace('**/', '*').replace('**', '*')
|
||||
if fnmatch.fnmatch(rel_path, fnmatch_pattern):
|
||||
return True
|
||||
# Handle simple patterns
|
||||
else:
|
||||
# Check against filename
|
||||
if fnmatch.fnmatch(os.path.basename(path), pattern):
|
||||
return True
|
||||
# Check against relative path
|
||||
if fnmatch.fnmatch(rel_path, pattern):
|
||||
return True
|
||||
# Check if any path component matches
|
||||
if any(fnmatch.fnmatch(part, pattern) for part in path_parts):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _is_binary_file(filepath: str) -> bool:
|
||||
"""Check if a file is binary.
|
||||
|
||||
Args:
|
||||
filepath: Path to the file
|
||||
|
||||
Returns:
|
||||
True if file appears to be binary
|
||||
"""
|
||||
try:
|
||||
with open(filepath, 'rb') as f:
|
||||
chunk = f.read(8192)
|
||||
# Check for null bytes (common in binary files)
|
||||
if b'\x00' in chunk:
|
||||
return True
|
||||
# Check for high ratio of non-text characters
|
||||
text_chars = bytearray({7, 8, 9, 10, 12, 13, 27} | set(range(0x20, 0x100)) - {0x7f})
|
||||
non_text = sum(1 for byte in chunk if byte not in text_chars)
|
||||
if len(chunk) > 0 and non_text / len(chunk) > 0.3:
|
||||
return True
|
||||
except Exception:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def grep_search(
|
||||
search_path: str,
|
||||
pattern: str,
|
||||
is_regex: bool = False,
|
||||
case_sensitive: bool = False,
|
||||
include_patterns: Optional[List[str]] = None,
|
||||
exclude_patterns: Optional[List[str]] = None,
|
||||
max_results: int = 100,
|
||||
context_lines: int = 0,
|
||||
respect_gitignore: bool = True
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search for pattern in files.
|
||||
|
||||
Args:
|
||||
search_path: Directory or file to search
|
||||
pattern: Search pattern (string or regex)
|
||||
is_regex: If True, treat pattern as regex
|
||||
case_sensitive: If True, search is case sensitive
|
||||
include_patterns: Glob patterns to include (e.g., ["*.py"])
|
||||
exclude_patterns: Glob patterns to exclude (e.g., ["**/test_*"])
|
||||
max_results: Maximum number of results to return
|
||||
context_lines: Number of context lines before/after match
|
||||
respect_gitignore: If True, respect .gitignore patterns
|
||||
|
||||
Returns:
|
||||
List of match dictionaries with path, line_number, content, context
|
||||
"""
|
||||
results = []
|
||||
search_path = os.path.abspath(search_path)
|
||||
|
||||
# Compile pattern
|
||||
if is_regex:
|
||||
flags = 0 if case_sensitive else re.IGNORECASE
|
||||
try:
|
||||
regex = re.compile(pattern, flags)
|
||||
except re.error as e:
|
||||
logger.warning(f"Invalid regex pattern: {e}")
|
||||
return []
|
||||
else:
|
||||
if case_sensitive:
|
||||
regex = re.compile(re.escape(pattern))
|
||||
else:
|
||||
regex = re.compile(re.escape(pattern), re.IGNORECASE)
|
||||
|
||||
# Load gitignore patterns
|
||||
gitignore_patterns = set()
|
||||
if respect_gitignore:
|
||||
gitignore_patterns = _load_gitignore_patterns(search_path)
|
||||
|
||||
# Get files to search
|
||||
if os.path.isfile(search_path):
|
||||
files_to_search = [search_path]
|
||||
else:
|
||||
files_to_search = []
|
||||
for root, dirs, files in os.walk(search_path):
|
||||
# Filter directories in-place to skip ignored ones
|
||||
if respect_gitignore:
|
||||
dirs[:] = [d for d in dirs if not _should_ignore(
|
||||
os.path.join(root, d), search_path, gitignore_patterns
|
||||
)]
|
||||
|
||||
for filename in files:
|
||||
filepath = os.path.join(root, filename)
|
||||
|
||||
# Check gitignore
|
||||
if respect_gitignore and _should_ignore(filepath, search_path, gitignore_patterns):
|
||||
continue
|
||||
|
||||
# Check include patterns
|
||||
if include_patterns:
|
||||
rel_path = os.path.relpath(filepath, search_path)
|
||||
if not any(fnmatch.fnmatch(rel_path, p) or fnmatch.fnmatch(filename, p)
|
||||
for p in include_patterns):
|
||||
continue
|
||||
|
||||
# Check exclude patterns
|
||||
if exclude_patterns:
|
||||
rel_path = os.path.relpath(filepath, search_path)
|
||||
if any(fnmatch.fnmatch(rel_path, p) or fnmatch.fnmatch(filename, p)
|
||||
for p in exclude_patterns):
|
||||
continue
|
||||
|
||||
files_to_search.append(filepath)
|
||||
|
||||
# Search files
|
||||
for filepath in files_to_search:
|
||||
if len(results) >= max_results:
|
||||
break
|
||||
|
||||
# Skip binary files
|
||||
if _is_binary_file(filepath):
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
if len(results) >= max_results:
|
||||
break
|
||||
|
||||
if regex.search(line):
|
||||
# Get context
|
||||
start_ctx = max(0, i - context_lines)
|
||||
end_ctx = min(len(lines), i + context_lines + 1)
|
||||
context = ''.join(lines[start_ctx:end_ctx])
|
||||
|
||||
results.append({
|
||||
"path": os.path.relpath(filepath, search_path),
|
||||
"absolute_path": filepath,
|
||||
"line_number": i + 1, # 1-indexed
|
||||
"content": line.rstrip('\n\r'),
|
||||
"context": context.rstrip('\n\r') if context_lines > 0 else None,
|
||||
"context_start": start_ctx + 1 if context_lines > 0 else None,
|
||||
"context_end": end_ctx if context_lines > 0 else None
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"Error reading {filepath}: {e}")
|
||||
continue
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def glob_search(
|
||||
search_path: str,
|
||||
pattern: str,
|
||||
max_results: int = 100,
|
||||
include_dirs: bool = False,
|
||||
respect_gitignore: bool = True
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search for files matching glob pattern.
|
||||
|
||||
Args:
|
||||
search_path: Directory to search
|
||||
pattern: Glob pattern (e.g., "**/*.py", "src/*.js")
|
||||
max_results: Maximum number of results
|
||||
include_dirs: If True, include directories in results
|
||||
respect_gitignore: If True, respect .gitignore patterns
|
||||
|
||||
Returns:
|
||||
List of file info dictionaries with path, size, is_dir
|
||||
"""
|
||||
results = []
|
||||
search_path = os.path.abspath(search_path)
|
||||
|
||||
# Load gitignore patterns
|
||||
gitignore_patterns = set()
|
||||
if respect_gitignore:
|
||||
gitignore_patterns = _load_gitignore_patterns(search_path)
|
||||
|
||||
# Use pathlib for glob
|
||||
root = Path(search_path)
|
||||
|
||||
try:
|
||||
for match in root.glob(pattern):
|
||||
if len(results) >= max_results:
|
||||
break
|
||||
|
||||
# Check gitignore
|
||||
if respect_gitignore and _should_ignore(str(match), search_path, gitignore_patterns):
|
||||
continue
|
||||
|
||||
# Skip directories unless requested
|
||||
if match.is_dir() and not include_dirs:
|
||||
continue
|
||||
|
||||
# Skip binary files for file results
|
||||
if match.is_file() and _is_binary_file(str(match)):
|
||||
continue
|
||||
|
||||
try:
|
||||
stat = match.stat()
|
||||
results.append({
|
||||
"path": str(match.relative_to(root)),
|
||||
"absolute_path": str(match),
|
||||
"size": stat.st_size if match.is_file() else None,
|
||||
"is_dir": match.is_dir(),
|
||||
"modified": stat.st_mtime
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug(f"Error in glob search: {e}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def read_file(
|
||||
filepath: str,
|
||||
start_line: Optional[int] = None,
|
||||
end_line: Optional[int] = None,
|
||||
context_lines: int = 0,
|
||||
max_lines: int = 500
|
||||
) -> Dict[str, Any]:
|
||||
"""Read file contents with optional line range.
|
||||
|
||||
Args:
|
||||
filepath: Path to the file
|
||||
start_line: Starting line (1-indexed, inclusive)
|
||||
end_line: Ending line (1-indexed, inclusive)
|
||||
context_lines: Additional context lines before/after range
|
||||
max_lines: Maximum lines to return
|
||||
|
||||
Returns:
|
||||
Dictionary with content, line info, and metadata
|
||||
"""
|
||||
filepath = os.path.abspath(filepath)
|
||||
|
||||
if not os.path.exists(filepath):
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"File not found: {filepath}",
|
||||
"path": filepath
|
||||
}
|
||||
|
||||
if os.path.isdir(filepath):
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Path is a directory: {filepath}",
|
||||
"path": filepath
|
||||
}
|
||||
|
||||
if _is_binary_file(filepath):
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Binary file: {filepath}",
|
||||
"path": filepath
|
||||
}
|
||||
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
total_lines = len(lines)
|
||||
|
||||
# Determine line range
|
||||
if start_line is None:
|
||||
start_line = 1
|
||||
if end_line is None:
|
||||
end_line = total_lines
|
||||
|
||||
# Apply context
|
||||
actual_start = max(1, start_line - context_lines)
|
||||
actual_end = min(total_lines, end_line + context_lines)
|
||||
|
||||
# Limit lines
|
||||
if actual_end - actual_start + 1 > max_lines:
|
||||
actual_end = actual_start + max_lines - 1
|
||||
|
||||
# Extract content (convert to 0-indexed)
|
||||
content_lines = lines[actual_start - 1:actual_end]
|
||||
content = ''.join(content_lines)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"path": filepath,
|
||||
"content": content.rstrip('\n'),
|
||||
"start_line": actual_start,
|
||||
"end_line": actual_end,
|
||||
"total_lines": total_lines,
|
||||
"lines_returned": len(content_lines)
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"path": filepath
|
||||
}
|
||||
|
||||
|
||||
def list_directory(
|
||||
dir_path: str,
|
||||
recursive: bool = False,
|
||||
max_depth: int = 10,
|
||||
max_entries: int = 500,
|
||||
include_hidden: bool = False,
|
||||
respect_gitignore: bool = True
|
||||
) -> Dict[str, Any]:
|
||||
"""List directory contents.
|
||||
|
||||
Args:
|
||||
dir_path: Directory path
|
||||
recursive: If True, list recursively
|
||||
max_depth: Maximum recursion depth
|
||||
max_entries: Maximum entries to return
|
||||
include_hidden: If True, include hidden files/dirs
|
||||
respect_gitignore: If True, respect .gitignore patterns
|
||||
|
||||
Returns:
|
||||
Dictionary with entries list and metadata
|
||||
"""
|
||||
dir_path = os.path.abspath(dir_path)
|
||||
|
||||
if not os.path.exists(dir_path):
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Directory not found: {dir_path}",
|
||||
"path": dir_path
|
||||
}
|
||||
|
||||
if not os.path.isdir(dir_path):
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Not a directory: {dir_path}",
|
||||
"path": dir_path
|
||||
}
|
||||
|
||||
# Load gitignore patterns
|
||||
gitignore_patterns = set()
|
||||
if respect_gitignore:
|
||||
gitignore_patterns = _load_gitignore_patterns(dir_path)
|
||||
|
||||
entries = []
|
||||
|
||||
def _list_dir(path: str, depth: int):
|
||||
if depth > max_depth or len(entries) >= max_entries:
|
||||
return
|
||||
|
||||
try:
|
||||
for entry in os.scandir(path):
|
||||
if len(entries) >= max_entries:
|
||||
break
|
||||
|
||||
# Skip hidden files unless requested
|
||||
if not include_hidden and entry.name.startswith('.'):
|
||||
continue
|
||||
|
||||
# Check gitignore
|
||||
if respect_gitignore and _should_ignore(entry.path, dir_path, gitignore_patterns):
|
||||
continue
|
||||
|
||||
try:
|
||||
stat = entry.stat()
|
||||
entries.append({
|
||||
"name": entry.name,
|
||||
"path": os.path.relpath(entry.path, dir_path),
|
||||
"absolute_path": entry.path,
|
||||
"is_dir": entry.is_dir(),
|
||||
"size": stat.st_size if entry.is_file() else None,
|
||||
"modified": stat.st_mtime
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Recurse into directories
|
||||
if recursive and entry.is_dir():
|
||||
_list_dir(entry.path, depth + 1)
|
||||
except PermissionError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug(f"Error listing {path}: {e}")
|
||||
|
||||
_list_dir(dir_path, 0)
|
||||
|
||||
# Sort entries: directories first, then by name
|
||||
entries.sort(key=lambda e: (not e["is_dir"], e["name"].lower()))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"path": dir_path,
|
||||
"entries": entries,
|
||||
"total_entries": len(entries),
|
||||
"truncated": len(entries) >= max_entries
|
||||
}
|
||||
|
||||
|
||||
# Tool definitions for LLM function calling
|
||||
FAST_CONTEXT_TOOLS = [
|
||||
{
|
||||
"name": "grep_search",
|
||||
"description": "Search for a pattern in files within a directory. Returns matching lines with file paths and line numbers.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"search_path": {
|
||||
"type": "string",
|
||||
"description": "Directory or file path to search"
|
||||
},
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Search pattern (text or regex)"
|
||||
},
|
||||
"is_regex": {
|
||||
"type": "boolean",
|
||||
"description": "If true, treat pattern as regex",
|
||||
"default": False
|
||||
},
|
||||
"case_sensitive": {
|
||||
"type": "boolean",
|
||||
"description": "If true, search is case sensitive",
|
||||
"default": False
|
||||
},
|
||||
"include_patterns": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Glob patterns to include (e.g., ['*.py'])"
|
||||
},
|
||||
"max_results": {
|
||||
"type": "integer",
|
||||
"description": "Maximum results to return",
|
||||
"default": 50
|
||||
}
|
||||
},
|
||||
"required": ["search_path", "pattern"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "glob_search",
|
||||
"description": "Find files matching a glob pattern. Returns file paths and metadata.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"search_path": {
|
||||
"type": "string",
|
||||
"description": "Directory to search"
|
||||
},
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Glob pattern (e.g., '**/*.py', 'src/*.js')"
|
||||
},
|
||||
"max_results": {
|
||||
"type": "integer",
|
||||
"description": "Maximum results to return",
|
||||
"default": 50
|
||||
}
|
||||
},
|
||||
"required": ["search_path", "pattern"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "read_file",
|
||||
"description": "Read contents of a file, optionally with specific line range.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filepath": {
|
||||
"type": "string",
|
||||
"description": "Path to the file"
|
||||
},
|
||||
"start_line": {
|
||||
"type": "integer",
|
||||
"description": "Starting line number (1-indexed)"
|
||||
},
|
||||
"end_line": {
|
||||
"type": "integer",
|
||||
"description": "Ending line number (1-indexed)"
|
||||
},
|
||||
"context_lines": {
|
||||
"type": "integer",
|
||||
"description": "Additional context lines",
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"required": ["filepath"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_directory",
|
||||
"description": "List contents of a directory.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dir_path": {
|
||||
"type": "string",
|
||||
"description": "Directory path"
|
||||
},
|
||||
"recursive": {
|
||||
"type": "boolean",
|
||||
"description": "If true, list recursively",
|
||||
"default": False
|
||||
},
|
||||
"max_depth": {
|
||||
"type": "integer",
|
||||
"description": "Maximum recursion depth",
|
||||
"default": 3
|
||||
}
|
||||
},
|
||||
"required": ["dir_path"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def execute_tool(tool_name: str, **kwargs) -> Dict[str, Any]:
|
||||
"""Execute a search tool by name.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool to execute
|
||||
**kwargs: Tool arguments
|
||||
|
||||
Returns:
|
||||
Tool result dictionary
|
||||
"""
|
||||
tools = {
|
||||
"grep_search": grep_search,
|
||||
"glob_search": glob_search,
|
||||
"read_file": read_file,
|
||||
"list_directory": list_directory
|
||||
}
|
||||
|
||||
if tool_name not in tools:
|
||||
return {"error": f"Unknown tool: {tool_name}"}
|
||||
|
||||
try:
|
||||
return tools[tool_name](**kwargs)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
@@ -2,8 +2,65 @@
|
||||
Model Context Protocol (MCP) integration for PraisonAI Agents.
|
||||
|
||||
This package provides classes and utilities for connecting to MCP servers
|
||||
using different transport methods (stdio, SSE, etc.).
|
||||
using different transport methods:
|
||||
- stdio: Standard input/output (subprocess)
|
||||
- sse: Server-Sent Events (legacy HTTP+SSE)
|
||||
- http_stream: Streamable HTTP (current standard)
|
||||
- websocket: WebSocket (SEP-1288)
|
||||
|
||||
Protocol Revision: 2025-11-25
|
||||
"""
|
||||
from .mcp import MCP
|
||||
|
||||
__all__ = ["MCP"]
|
||||
# Lazy imports for optional components
|
||||
def __getattr__(name):
|
||||
"""Lazy import for optional modules."""
|
||||
if name == "WebSocketMCPClient":
|
||||
from .mcp_websocket import WebSocketMCPClient
|
||||
return WebSocketMCPClient
|
||||
elif name == "SessionManager":
|
||||
from .mcp_session import SessionManager
|
||||
return SessionManager
|
||||
elif name == "SecurityConfig":
|
||||
from .mcp_security import SecurityConfig
|
||||
return SecurityConfig
|
||||
elif name == "BaseTransport":
|
||||
from .mcp_transport import BaseTransport
|
||||
return BaseTransport
|
||||
elif name == "TransportConfig":
|
||||
from .mcp_transport import TransportConfig
|
||||
return TransportConfig
|
||||
elif name == "ToolsMCPServer":
|
||||
from .mcp_server import ToolsMCPServer
|
||||
return ToolsMCPServer
|
||||
elif name == "launch_tools_mcp_server":
|
||||
from .mcp_server import launch_tools_mcp_server
|
||||
return launch_tools_mcp_server
|
||||
elif name == "function_to_mcp_schema":
|
||||
from .mcp_utils import function_to_mcp_schema
|
||||
return function_to_mcp_schema
|
||||
elif name == "get_tool_metadata":
|
||||
from .mcp_utils import get_tool_metadata
|
||||
return get_tool_metadata
|
||||
elif name == "python_type_to_json_schema":
|
||||
from .mcp_utils import python_type_to_json_schema
|
||||
return python_type_to_json_schema
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
__all__ = [
|
||||
# Client
|
||||
"MCP",
|
||||
# Server (lazy loaded)
|
||||
"ToolsMCPServer",
|
||||
"launch_tools_mcp_server",
|
||||
# Utilities (lazy loaded)
|
||||
"function_to_mcp_schema",
|
||||
"get_tool_metadata",
|
||||
"python_type_to_json_schema",
|
||||
# Transport components (lazy loaded)
|
||||
"WebSocketMCPClient",
|
||||
"SessionManager",
|
||||
"SecurityConfig",
|
||||
"BaseTransport",
|
||||
"TransportConfig",
|
||||
]
|
||||
@@ -227,6 +227,26 @@ class MCP:
|
||||
self.timeout = timeout
|
||||
self.debug = debug
|
||||
|
||||
# Check if this is a WebSocket URL (ws:// or wss://)
|
||||
if isinstance(command_or_string, str) and re.match(r'^wss?://', command_or_string):
|
||||
from .mcp_websocket import WebSocketMCPClient
|
||||
# Extract auth token if provided
|
||||
auth_token = kwargs.pop('auth_token', None)
|
||||
|
||||
self.websocket_client = WebSocketMCPClient(
|
||||
command_or_string,
|
||||
debug=debug,
|
||||
timeout=timeout,
|
||||
auth_token=auth_token,
|
||||
options=kwargs
|
||||
)
|
||||
self._tools = list(self.websocket_client.tools)
|
||||
self.is_sse = False
|
||||
self.is_http_stream = False
|
||||
self.is_websocket = True
|
||||
self.is_npx = False
|
||||
return
|
||||
|
||||
# Check if this is an HTTP URL
|
||||
if isinstance(command_or_string, str) and re.match(r'^https?://', command_or_string):
|
||||
# Determine transport type based on URL or kwargs
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
MCP Backward Compatibility Module.
|
||||
|
||||
This module provides utilities for maintaining backward compatibility
|
||||
with older MCP servers and the deprecated HTTP+SSE transport from
|
||||
protocol version 2024-11-05.
|
||||
|
||||
Per MCP specification, clients wanting to support older servers should:
|
||||
1. Attempt POST to server URL with InitializeRequest
|
||||
2. If it fails with 400, 404, or 405, fall back to legacy SSE
|
||||
3. Issue GET to server URL expecting SSE stream with endpoint event
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def detect_transport_support(
|
||||
response_status: int,
|
||||
content_type: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
Detect which transport a server supports based on response.
|
||||
|
||||
Per MCP spec backward compatibility guide:
|
||||
- Success (200) with JSON indicates Streamable HTTP
|
||||
- 400, 404, 405 indicates need for legacy SSE fallback
|
||||
|
||||
Args:
|
||||
response_status: HTTP response status code
|
||||
content_type: Response Content-Type header
|
||||
|
||||
Returns:
|
||||
Transport type: "http_stream" or "sse"
|
||||
"""
|
||||
# Check for fallback indicators
|
||||
if response_status in (400, 404, 405):
|
||||
return "sse"
|
||||
|
||||
# Successful response indicates Streamable HTTP support
|
||||
if response_status == 200:
|
||||
return "http_stream"
|
||||
|
||||
# Default to SSE for unknown cases
|
||||
return "sse"
|
||||
|
||||
|
||||
def is_legacy_sse_url(url: str) -> bool:
|
||||
"""
|
||||
Check if URL indicates legacy SSE transport.
|
||||
|
||||
Legacy SSE URLs typically end with /sse.
|
||||
|
||||
Args:
|
||||
url: Server URL
|
||||
|
||||
Returns:
|
||||
True if URL indicates legacy SSE
|
||||
"""
|
||||
return url.rstrip('/').endswith('/sse')
|
||||
|
||||
|
||||
def get_legacy_sse_endpoint(base_url: str) -> str:
|
||||
"""
|
||||
Get the legacy SSE endpoint URL.
|
||||
|
||||
Args:
|
||||
base_url: Base server URL
|
||||
|
||||
Returns:
|
||||
SSE endpoint URL
|
||||
"""
|
||||
base = base_url.rstrip('/')
|
||||
if not base.endswith('/sse'):
|
||||
return f"{base}/sse"
|
||||
return base
|
||||
|
||||
|
||||
def get_streamable_http_endpoint(base_url: str) -> str:
|
||||
"""
|
||||
Get the Streamable HTTP endpoint URL.
|
||||
|
||||
Args:
|
||||
base_url: Base server URL
|
||||
|
||||
Returns:
|
||||
MCP endpoint URL
|
||||
"""
|
||||
base = base_url.rstrip('/')
|
||||
# Remove /sse suffix if present
|
||||
if base.endswith('/sse'):
|
||||
base = base[:-4]
|
||||
if not base.endswith('/mcp'):
|
||||
return f"{base}/mcp"
|
||||
return base
|
||||
|
||||
|
||||
class TransportNegotiator:
|
||||
"""
|
||||
Handles automatic transport negotiation with MCP servers.
|
||||
|
||||
This class implements the backward compatibility algorithm
|
||||
from the MCP specification for detecting server capabilities.
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str):
|
||||
"""
|
||||
Initialize negotiator.
|
||||
|
||||
Args:
|
||||
base_url: Server base URL
|
||||
"""
|
||||
self.base_url = base_url
|
||||
self._detected_transport: Optional[str] = None
|
||||
|
||||
@property
|
||||
def detected_transport(self) -> Optional[str]:
|
||||
"""Get the detected transport type."""
|
||||
return self._detected_transport
|
||||
|
||||
def set_detected_transport(self, transport: str) -> None:
|
||||
"""
|
||||
Set the detected transport type.
|
||||
|
||||
Args:
|
||||
transport: Transport type ("http_stream" or "sse")
|
||||
"""
|
||||
self._detected_transport = transport
|
||||
|
||||
def get_endpoint_url(self) -> str:
|
||||
"""
|
||||
Get the appropriate endpoint URL based on detected transport.
|
||||
|
||||
Returns:
|
||||
Endpoint URL for the detected transport
|
||||
"""
|
||||
if self._detected_transport == "sse":
|
||||
return get_legacy_sse_endpoint(self.base_url)
|
||||
return get_streamable_http_endpoint(self.base_url)
|
||||
|
||||
def should_try_streamable_first(self) -> bool:
|
||||
"""
|
||||
Determine if Streamable HTTP should be tried first.
|
||||
|
||||
Per MCP spec, clients should try Streamable HTTP first
|
||||
unless the URL explicitly indicates legacy SSE.
|
||||
|
||||
Returns:
|
||||
True if should try Streamable HTTP first
|
||||
"""
|
||||
return not is_legacy_sse_url(self.base_url)
|
||||
@@ -200,16 +200,31 @@ class HTTPStreamTransport:
|
||||
|
||||
This transport provides a single endpoint for all MCP communication,
|
||||
supporting both batch (JSON) and streaming (SSE) response modes.
|
||||
|
||||
Per MCP Protocol Revision 2025-11-25:
|
||||
- Includes MCP-Protocol-Version header on all requests
|
||||
- Handles session management via Mcp-Session-Id header
|
||||
- Supports SSE resumability via Last-Event-ID
|
||||
"""
|
||||
|
||||
# Default protocol version for backward compatibility
|
||||
DEFAULT_PROTOCOL_VERSION = '2025-03-26'
|
||||
|
||||
def __init__(self, base_url: str, session_id: Optional[str] = None, options: Optional[Dict[str, Any]] = None):
|
||||
self.base_url = base_url
|
||||
self.session_id = session_id
|
||||
self.options = options or {}
|
||||
self.response_mode = self.options.get('responseMode', 'batch')
|
||||
self.protocol_version = self.options.get('protocol_version', self.DEFAULT_PROTOCOL_VERSION)
|
||||
|
||||
# Track last event ID for resumability
|
||||
self.last_event_id: Optional[str] = None
|
||||
self._retry_delay_ms: int = 3000 # Default retry delay
|
||||
|
||||
self.headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json, text/event-stream'
|
||||
'Accept': 'application/json, text/event-stream',
|
||||
'Mcp-Protocol-Version': self.protocol_version # Required per spec
|
||||
}
|
||||
if session_id:
|
||||
self.headers['Mcp-Session-Id'] = session_id
|
||||
@@ -281,7 +296,7 @@ class HTTPStreamTransport:
|
||||
raise
|
||||
|
||||
async def _process_sse_response(self, response):
|
||||
"""Process SSE response stream."""
|
||||
"""Process SSE response stream with resumability support."""
|
||||
buffer = ""
|
||||
async for chunk in response.content:
|
||||
buffer += chunk.decode('utf-8')
|
||||
@@ -291,12 +306,34 @@ class HTTPStreamTransport:
|
||||
event, buffer = buffer.split("\n\n", 1)
|
||||
lines = event.strip().split("\n")
|
||||
|
||||
# Parse SSE event
|
||||
data = None
|
||||
for line in lines:
|
||||
if line.startswith("data: "):
|
||||
data = line[6:] # Remove "data: " prefix
|
||||
# Parse SSE event fields per spec
|
||||
event_id = None
|
||||
data_lines = []
|
||||
retry_value = None
|
||||
|
||||
for line in lines:
|
||||
if line.startswith("id:"):
|
||||
# Track event ID for resumability
|
||||
event_id = line[3:].strip()
|
||||
elif line.startswith("data:"):
|
||||
data_lines.append(line[5:].strip())
|
||||
elif line.startswith("retry:"):
|
||||
# Handle retry field per SSE spec
|
||||
try:
|
||||
retry_value = int(line[6:].strip())
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Update last event ID for resumability
|
||||
if event_id:
|
||||
self.last_event_id = event_id
|
||||
|
||||
# Update retry delay if provided
|
||||
if retry_value is not None:
|
||||
self._retry_delay_ms = retry_value
|
||||
|
||||
# Process data if present
|
||||
data = '\n'.join(data_lines) if data_lines else None
|
||||
if data:
|
||||
try:
|
||||
message = json.loads(data)
|
||||
@@ -310,13 +347,49 @@ class HTTPStreamTransport:
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"Failed to parse SSE event: {data}")
|
||||
|
||||
async def terminate_session(self) -> bool:
|
||||
"""
|
||||
Terminate the session via HTTP DELETE.
|
||||
|
||||
Per MCP spec: Clients that no longer need a particular session
|
||||
SHOULD send an HTTP DELETE to the MCP endpoint with the
|
||||
Mcp-Session-Id header to explicitly terminate the session.
|
||||
|
||||
Returns:
|
||||
True if session was terminated, False if server doesn't support it
|
||||
"""
|
||||
if not self._session or not self.session_id:
|
||||
return False
|
||||
|
||||
try:
|
||||
headers = {
|
||||
'Mcp-Session-Id': self.session_id,
|
||||
'Mcp-Protocol-Version': self.protocol_version
|
||||
}
|
||||
async with self._session.delete(self.base_url, headers=headers) as response:
|
||||
if response.status == 405:
|
||||
# Server doesn't allow client-initiated session termination
|
||||
logger.debug("Server does not allow session termination via DELETE")
|
||||
return False
|
||||
elif response.status in (200, 202, 204):
|
||||
# Session terminated successfully
|
||||
self.session_id = None
|
||||
self.headers.pop('Mcp-Session-Id', None)
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Unexpected response to DELETE: {response.status}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error terminating session: {e}")
|
||||
return False
|
||||
|
||||
async def start_sse_listener(self):
|
||||
"""Start listening for SSE events from the server."""
|
||||
if self._sse_task is None or self._sse_task.done():
|
||||
self._sse_task = asyncio.create_task(self._sse_listener())
|
||||
|
||||
async def _sse_listener(self):
|
||||
"""Background task to listen for SSE events."""
|
||||
"""Background task to listen for SSE events with resumability support."""
|
||||
while True:
|
||||
try:
|
||||
# Check if we should stop
|
||||
@@ -324,18 +397,28 @@ class HTTPStreamTransport:
|
||||
break
|
||||
|
||||
url = self.base_url
|
||||
if self.session_id:
|
||||
# Add session as query parameter for SSE connection
|
||||
url = f"{url}?session={self.session_id}"
|
||||
|
||||
# Build headers per MCP spec
|
||||
headers = {
|
||||
'Accept': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache'
|
||||
'Cache-Control': 'no-cache',
|
||||
'Mcp-Protocol-Version': self.protocol_version # Required per spec
|
||||
}
|
||||
if self.session_id:
|
||||
headers['Mcp-Session-Id'] = self.session_id
|
||||
|
||||
# Include Last-Event-ID for resumability per spec
|
||||
if self.last_event_id:
|
||||
headers['Last-Event-ID'] = self.last_event_id
|
||||
|
||||
async with self._session.get(url, headers=headers) as response:
|
||||
# Handle session expiration (HTTP 404)
|
||||
if response.status == 404:
|
||||
logger.warning("Session expired (HTTP 404), need to reinitialize")
|
||||
self.session_id = None
|
||||
self.headers.pop('Mcp-Session-Id', None)
|
||||
break
|
||||
|
||||
buffer = ""
|
||||
async for chunk in response.content:
|
||||
# Check if we should stop
|
||||
@@ -349,12 +432,32 @@ class HTTPStreamTransport:
|
||||
event, buffer = buffer.split("\n\n", 1)
|
||||
lines = event.strip().split("\n")
|
||||
|
||||
# Parse SSE event
|
||||
data = None
|
||||
for line in lines:
|
||||
if line.startswith("data: "):
|
||||
data = line[6:] # Remove "data: " prefix
|
||||
# Parse SSE event fields
|
||||
event_id = None
|
||||
data_lines = []
|
||||
retry_value = None
|
||||
|
||||
for line in lines:
|
||||
if line.startswith("id:"):
|
||||
event_id = line[3:].strip()
|
||||
elif line.startswith("data:"):
|
||||
data_lines.append(line[5:].strip())
|
||||
elif line.startswith("retry:"):
|
||||
try:
|
||||
retry_value = int(line[6:].strip())
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Update last event ID for resumability
|
||||
if event_id:
|
||||
self.last_event_id = event_id
|
||||
|
||||
# Update retry delay if provided
|
||||
if retry_value is not None:
|
||||
self._retry_delay_ms = retry_value
|
||||
|
||||
# Process data
|
||||
data = '\n'.join(data_lines) if data_lines else None
|
||||
if data:
|
||||
try:
|
||||
message = json.loads(data)
|
||||
@@ -368,7 +471,8 @@ class HTTPStreamTransport:
|
||||
except Exception as e:
|
||||
if not (hasattr(self, '_closing') and self._closing):
|
||||
logger.error(f"SSE listener error: {e}")
|
||||
await asyncio.sleep(1) # Reconnect after 1 second
|
||||
# Use retry delay from server or default
|
||||
await asyncio.sleep(self._retry_delay_ms / 1000.0)
|
||||
else:
|
||||
break
|
||||
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
MCP Security Module.
|
||||
|
||||
This module provides security utilities for MCP transports,
|
||||
implementing security best practices from MCP Protocol
|
||||
Revision 2025-11-25.
|
||||
|
||||
Security features:
|
||||
- Origin header validation (DNS rebinding prevention)
|
||||
- Localhost binding recommendations
|
||||
- Authentication header support
|
||||
- Secure session ID generation
|
||||
"""
|
||||
|
||||
import secrets
|
||||
import base64
|
||||
from typing import Optional, Dict, List, Set
|
||||
from urllib.parse import urlparse
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
# Localhost addresses
|
||||
LOCALHOST_ADDRESSES: Set[str] = {
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
'::1', # IPv6 localhost
|
||||
}
|
||||
|
||||
|
||||
def is_valid_origin(
|
||||
origin: Optional[str],
|
||||
allowed_origins: List[str],
|
||||
allow_missing: bool = False
|
||||
) -> bool:
|
||||
"""
|
||||
Validate Origin header against allowed origins.
|
||||
|
||||
Per MCP spec, servers MUST validate the Origin header on all
|
||||
incoming connections to prevent DNS rebinding attacks.
|
||||
|
||||
Args:
|
||||
origin: The Origin header value
|
||||
allowed_origins: List of allowed origin hosts
|
||||
allow_missing: Whether to allow missing Origin header
|
||||
|
||||
Returns:
|
||||
True if origin is valid, False otherwise
|
||||
"""
|
||||
if not origin:
|
||||
return allow_missing
|
||||
|
||||
# Extract host from origin
|
||||
host = extract_origin_host(origin)
|
||||
if not host:
|
||||
return False
|
||||
|
||||
# Check against allowed origins
|
||||
return host in allowed_origins
|
||||
|
||||
|
||||
def extract_origin_host(origin: str) -> Optional[str]:
|
||||
"""
|
||||
Extract the host from an Origin header value.
|
||||
|
||||
Args:
|
||||
origin: Origin header value (e.g., "https://example.com:443")
|
||||
|
||||
Returns:
|
||||
Host portion of the origin, or None if invalid
|
||||
"""
|
||||
if not origin:
|
||||
return None
|
||||
|
||||
try:
|
||||
parsed = urlparse(origin)
|
||||
return parsed.hostname
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def is_localhost_address(address: str) -> bool:
|
||||
"""
|
||||
Check if an address is a localhost address.
|
||||
|
||||
Args:
|
||||
address: IP address or hostname
|
||||
|
||||
Returns:
|
||||
True if localhost, False otherwise
|
||||
"""
|
||||
return address.lower() in LOCALHOST_ADDRESSES
|
||||
|
||||
|
||||
def should_bind_localhost_only(is_local: bool = True) -> bool:
|
||||
"""
|
||||
Determine if server should bind to localhost only.
|
||||
|
||||
Per MCP spec, when running locally, servers SHOULD bind only
|
||||
to localhost (127.0.0.1) rather than all network interfaces.
|
||||
|
||||
Args:
|
||||
is_local: Whether this is a local development server
|
||||
|
||||
Returns:
|
||||
True if should bind to localhost only
|
||||
"""
|
||||
return is_local
|
||||
|
||||
|
||||
def is_potential_dns_rebinding(origin: str, server_host: str) -> bool:
|
||||
"""
|
||||
Detect potential DNS rebinding attack.
|
||||
|
||||
DNS rebinding occurs when an external origin tries to access
|
||||
a local server through DNS manipulation.
|
||||
|
||||
Args:
|
||||
origin: The Origin header value
|
||||
server_host: The host the server is bound to
|
||||
|
||||
Returns:
|
||||
True if potential DNS rebinding attempt
|
||||
"""
|
||||
origin_host = extract_origin_host(origin)
|
||||
if not origin_host:
|
||||
return True # Treat invalid origin as suspicious
|
||||
|
||||
# If server is on localhost, only localhost origins are safe
|
||||
if is_localhost_address(server_host):
|
||||
return not is_localhost_address(origin_host)
|
||||
|
||||
# For non-localhost servers, check if origin matches server
|
||||
return origin_host != server_host
|
||||
|
||||
|
||||
def create_auth_header(
|
||||
credentials: str,
|
||||
auth_type: str = "bearer",
|
||||
header_name: str = "Authorization"
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Create an authentication header.
|
||||
|
||||
Args:
|
||||
credentials: The credentials/token
|
||||
auth_type: Type of auth ("bearer", "basic", "custom")
|
||||
header_name: Custom header name (for auth_type="custom")
|
||||
|
||||
Returns:
|
||||
Dictionary with authentication header
|
||||
"""
|
||||
if auth_type.lower() == "bearer":
|
||||
return {"Authorization": f"Bearer {credentials}"}
|
||||
|
||||
elif auth_type.lower() == "basic":
|
||||
# Base64 encode for Basic auth
|
||||
encoded = base64.b64encode(credentials.encode()).decode()
|
||||
return {"Authorization": f"Basic {encoded}"}
|
||||
|
||||
elif auth_type.lower() == "custom":
|
||||
return {header_name: credentials}
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown auth_type: {auth_type}")
|
||||
|
||||
|
||||
def validate_auth_header(headers: Dict[str, str]) -> bool:
|
||||
"""
|
||||
Validate that authentication header is present.
|
||||
|
||||
Args:
|
||||
headers: Request headers dictionary
|
||||
|
||||
Returns:
|
||||
True if Authorization header is present
|
||||
"""
|
||||
# Check for Authorization header (case-insensitive)
|
||||
for key in headers:
|
||||
if key.lower() == 'authorization':
|
||||
return bool(headers[key])
|
||||
return False
|
||||
|
||||
|
||||
def generate_secure_session_id(length: int = 32) -> str:
|
||||
"""
|
||||
Generate a cryptographically secure session ID.
|
||||
|
||||
Per MCP spec, session IDs SHOULD be globally unique and
|
||||
cryptographically secure (e.g., securely generated UUID,
|
||||
JWT, or cryptographic hash).
|
||||
|
||||
The ID MUST only contain visible ASCII characters (0x21-0x7E).
|
||||
|
||||
Args:
|
||||
length: Desired length of session ID (minimum 32)
|
||||
|
||||
Returns:
|
||||
Secure session ID string
|
||||
"""
|
||||
# Ensure minimum length for security
|
||||
length = max(length, 32)
|
||||
|
||||
# Use URL-safe base64 which only uses visible ASCII
|
||||
# Generate extra bytes to account for base64 expansion
|
||||
num_bytes = (length * 3) // 4 + 1
|
||||
random_bytes = secrets.token_bytes(num_bytes)
|
||||
|
||||
# Encode to URL-safe base64 (uses A-Z, a-z, 0-9, -, _)
|
||||
session_id = base64.urlsafe_b64encode(random_bytes).decode()
|
||||
|
||||
# Trim to desired length
|
||||
return session_id[:length]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SecurityConfig:
|
||||
"""
|
||||
Security configuration for MCP transports.
|
||||
|
||||
This class holds security settings that can be applied
|
||||
to MCP server implementations.
|
||||
|
||||
Attributes:
|
||||
validate_origin: Whether to validate Origin header
|
||||
allow_missing_origin: Whether to allow missing Origin
|
||||
allowed_origins: List of allowed origin hosts
|
||||
require_auth: Whether authentication is required
|
||||
bind_localhost_only: Whether to bind to localhost only
|
||||
"""
|
||||
validate_origin: bool = True
|
||||
allow_missing_origin: bool = False
|
||||
allowed_origins: List[str] = field(default_factory=lambda: ["localhost", "127.0.0.1"])
|
||||
require_auth: bool = False
|
||||
bind_localhost_only: bool = True
|
||||
|
||||
def is_origin_allowed(self, origin: Optional[str]) -> bool:
|
||||
"""Check if an origin is allowed by this config."""
|
||||
if not self.validate_origin:
|
||||
return True
|
||||
return is_valid_origin(
|
||||
origin,
|
||||
self.allowed_origins,
|
||||
self.allow_missing_origin
|
||||
)
|
||||
|
||||
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"
|
||||
@@ -0,0 +1,356 @@
|
||||
"""MCP Server wrapper for exposing PraisonAI tools as MCP servers.
|
||||
|
||||
This module provides a simple way to expose Python functions as MCP tools
|
||||
that can be consumed by any MCP client (Claude Desktop, Cursor, etc.).
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.mcp import ToolsMCPServer
|
||||
|
||||
def search(query: str) -> str:
|
||||
'''Search the web.'''
|
||||
return f"Results for {query}"
|
||||
|
||||
server = ToolsMCPServer(name="my-tools")
|
||||
server.register_tool(search)
|
||||
server.run() # Starts MCP server (stdio by default)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from .mcp_utils import function_to_mcp_schema
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ToolsMCPServer:
|
||||
"""MCP Server that exposes Python functions as MCP tools.
|
||||
|
||||
This class wraps Python functions and exposes them as MCP tools
|
||||
using the FastMCP library. It supports both sync and async functions.
|
||||
|
||||
Example:
|
||||
server = ToolsMCPServer(name="my-tools")
|
||||
|
||||
def search(query: str) -> str:
|
||||
'''Search the web.'''
|
||||
return f"Results for {query}"
|
||||
|
||||
server.register_tool(search)
|
||||
server.run() # Starts stdio server
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "praisonai-tools",
|
||||
tools: Optional[List[Callable]] = None,
|
||||
debug: bool = False
|
||||
):
|
||||
"""Initialize the MCP server.
|
||||
|
||||
Args:
|
||||
name: Name of the MCP server
|
||||
tools: Optional list of tools to register on init
|
||||
debug: Enable debug logging
|
||||
"""
|
||||
self.name = name
|
||||
self.tools: List[Callable] = []
|
||||
self._tool_map: Dict[str, Callable] = {}
|
||||
self._fastmcp = None
|
||||
self._debug = debug
|
||||
|
||||
if debug:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
# Register initial tools if provided
|
||||
if tools:
|
||||
self.register_tools(tools)
|
||||
|
||||
def register_tool(self, func: Callable) -> None:
|
||||
"""Register a single tool function.
|
||||
|
||||
Args:
|
||||
func: The function to register as an MCP tool
|
||||
"""
|
||||
if not callable(func):
|
||||
raise TypeError(f"Expected callable, got {type(func)}")
|
||||
|
||||
self.tools.append(func)
|
||||
tool_name = getattr(func, '__mcp_name__', None) or func.__name__
|
||||
self._tool_map[tool_name] = func
|
||||
|
||||
logger.debug(f"Registered tool: {tool_name}")
|
||||
|
||||
def register_tools(self, funcs: List[Callable]) -> None:
|
||||
"""Register multiple tool functions.
|
||||
|
||||
Args:
|
||||
funcs: List of functions to register as MCP tools
|
||||
"""
|
||||
for func in funcs:
|
||||
self.register_tool(func)
|
||||
|
||||
def get_tool_schemas(self) -> List[Dict[str, Any]]:
|
||||
"""Get MCP schemas for all registered tools.
|
||||
|
||||
Returns:
|
||||
List of MCP tool schema dictionaries
|
||||
"""
|
||||
return [function_to_mcp_schema(tool) for tool in self.tools]
|
||||
|
||||
def get_tool_names(self) -> List[str]:
|
||||
"""Get names of all registered tools.
|
||||
|
||||
Returns:
|
||||
List of tool names
|
||||
"""
|
||||
return list(self._tool_map.keys())
|
||||
|
||||
def execute_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Any:
|
||||
"""Execute a registered tool synchronously.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool to execute
|
||||
arguments: Arguments to pass to the tool
|
||||
|
||||
Returns:
|
||||
Result from the tool execution
|
||||
|
||||
Raises:
|
||||
ValueError: If tool is not found
|
||||
"""
|
||||
if tool_name not in self._tool_map:
|
||||
raise ValueError(f"Tool '{tool_name}' not found")
|
||||
|
||||
func = self._tool_map[tool_name]
|
||||
|
||||
# Handle async functions
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
return loop.run_until_complete(func(**arguments))
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
return func(**arguments)
|
||||
|
||||
async def execute_tool_async(self, tool_name: str, arguments: Dict[str, Any]) -> Any:
|
||||
"""Execute a registered tool asynchronously.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool to execute
|
||||
arguments: Arguments to pass to the tool
|
||||
|
||||
Returns:
|
||||
Result from the tool execution
|
||||
|
||||
Raises:
|
||||
ValueError: If tool is not found
|
||||
"""
|
||||
if tool_name not in self._tool_map:
|
||||
raise ValueError(f"Tool '{tool_name}' not found")
|
||||
|
||||
func = self._tool_map[tool_name]
|
||||
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
return await func(**arguments)
|
||||
|
||||
# Run sync function in executor
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, lambda: func(**arguments))
|
||||
|
||||
def get_fastmcp(self):
|
||||
"""Get or create FastMCP instance with registered tools.
|
||||
|
||||
Returns:
|
||||
FastMCP instance with all tools registered
|
||||
"""
|
||||
if self._fastmcp is None:
|
||||
self._fastmcp = self._create_fastmcp()
|
||||
return self._fastmcp
|
||||
|
||||
def _create_fastmcp(self):
|
||||
"""Create FastMCP instance and register all tools.
|
||||
|
||||
Returns:
|
||||
Configured FastMCP instance
|
||||
"""
|
||||
try:
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"FastMCP not available. Install with: pip install mcp"
|
||||
)
|
||||
|
||||
mcp = FastMCP(self.name)
|
||||
|
||||
# Register each tool with FastMCP
|
||||
for func in self.tools:
|
||||
self._register_tool_with_fastmcp(mcp, func)
|
||||
|
||||
return mcp
|
||||
|
||||
def _register_tool_with_fastmcp(self, mcp, func: Callable) -> None:
|
||||
"""Register a single function with FastMCP.
|
||||
|
||||
Args:
|
||||
mcp: FastMCP instance
|
||||
func: Function to register
|
||||
"""
|
||||
# Get tool metadata
|
||||
tool_name = getattr(func, '__mcp_name__', None) or func.__name__
|
||||
description = getattr(func, '__mcp_description__', None)
|
||||
if description is None:
|
||||
docstring = inspect.getdoc(func)
|
||||
description = docstring.split('\n')[0] if docstring else tool_name
|
||||
|
||||
# Create wrapper that preserves signature
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
@mcp.tool(name=tool_name, description=description)
|
||||
async def tool_wrapper(**kwargs):
|
||||
return await func(**kwargs)
|
||||
else:
|
||||
@mcp.tool(name=tool_name, description=description)
|
||||
def tool_wrapper(**kwargs):
|
||||
return func(**kwargs)
|
||||
|
||||
# Copy signature for proper schema generation
|
||||
tool_wrapper.__signature__ = inspect.signature(func)
|
||||
tool_wrapper.__annotations__ = getattr(func, '__annotations__', {})
|
||||
|
||||
def run(self, transport: str = "stdio") -> None:
|
||||
"""Run the MCP server.
|
||||
|
||||
Args:
|
||||
transport: Transport type - "stdio" or "sse"
|
||||
"""
|
||||
if transport == "stdio":
|
||||
self.run_stdio()
|
||||
elif transport == "sse":
|
||||
self.run_sse()
|
||||
else:
|
||||
raise ValueError(f"Unknown transport: {transport}. Use 'stdio' or 'sse'")
|
||||
|
||||
def run_stdio(self) -> None:
|
||||
"""Run the MCP server using stdio transport."""
|
||||
mcp = self.get_fastmcp()
|
||||
|
||||
print(f"🚀 Starting MCP server '{self.name}' with stdio transport")
|
||||
print(f"🛠️ Available tools: {', '.join(self.get_tool_names())}")
|
||||
|
||||
mcp.run()
|
||||
|
||||
def run_sse(self, host: str = "0.0.0.0", port: int = 8080) -> None:
|
||||
"""Run the MCP server using SSE transport.
|
||||
|
||||
Args:
|
||||
host: Host to bind to
|
||||
port: Port to listen on
|
||||
"""
|
||||
try:
|
||||
import uvicorn
|
||||
from mcp.server.sse import SseServerTransport
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request
|
||||
from starlette.routing import Mount, Route
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
f"SSE dependencies not available: {e}. "
|
||||
"Install with: pip install uvicorn starlette"
|
||||
)
|
||||
|
||||
mcp = self.get_fastmcp()
|
||||
|
||||
# Set up SSE transport
|
||||
sse_path = "/sse"
|
||||
messages_path = "/messages/"
|
||||
|
||||
sse_transport = SseServerTransport(messages_path)
|
||||
|
||||
async def handle_sse(request: Request):
|
||||
async with sse_transport.connect_sse(
|
||||
request.scope, request.receive, request._send
|
||||
) as (read_stream, write_stream):
|
||||
await mcp._mcp_server.run(
|
||||
read_stream,
|
||||
write_stream,
|
||||
mcp._mcp_server.create_initialization_options()
|
||||
)
|
||||
|
||||
app = Starlette(
|
||||
debug=self._debug,
|
||||
routes=[
|
||||
Route(sse_path, endpoint=handle_sse),
|
||||
Mount(messages_path, app=sse_transport.handle_post_message),
|
||||
]
|
||||
)
|
||||
|
||||
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())}")
|
||||
|
||||
uvicorn.run(app, host=host, port=port)
|
||||
|
||||
|
||||
def launch_tools_mcp_server(
|
||||
tools: Optional[List[Callable]] = None,
|
||||
tool_names: Optional[List[str]] = None,
|
||||
name: str = "praisonai-tools",
|
||||
transport: str = "stdio",
|
||||
host: str = "0.0.0.0",
|
||||
port: int = 8080,
|
||||
debug: bool = False
|
||||
) -> None:
|
||||
"""Convenience function to launch an MCP server with tools.
|
||||
|
||||
Args:
|
||||
tools: List of tool functions to expose
|
||||
tool_names: List of tool names to load from praisonaiagents.tools
|
||||
name: Name of the MCP server
|
||||
transport: Transport type - "stdio" or "sse"
|
||||
host: Host for SSE transport
|
||||
port: Port for SSE transport
|
||||
debug: Enable debug logging
|
||||
|
||||
Example:
|
||||
# With custom tools
|
||||
def my_search(query: str) -> str:
|
||||
return f"Results for {query}"
|
||||
|
||||
launch_tools_mcp_server(tools=[my_search])
|
||||
|
||||
# With built-in tools
|
||||
launch_tools_mcp_server(tool_names=["tavily_search", "exa_search"])
|
||||
"""
|
||||
server = ToolsMCPServer(name=name, debug=debug)
|
||||
|
||||
# Register custom tools
|
||||
if tools:
|
||||
server.register_tools(tools)
|
||||
|
||||
# Load and register tools by name
|
||||
if tool_names:
|
||||
from praisonaiagents import tools as praison_tools
|
||||
|
||||
for tool_name in tool_names:
|
||||
if hasattr(praison_tools, tool_name):
|
||||
tool_func = getattr(praison_tools, tool_name)
|
||||
if callable(tool_func):
|
||||
server.register_tool(tool_func)
|
||||
else:
|
||||
logger.warning(f"Tool '{tool_name}' is not callable")
|
||||
else:
|
||||
logger.warning(f"Tool '{tool_name}' not found in praisonaiagents.tools")
|
||||
|
||||
if not server.tools:
|
||||
raise ValueError("No tools registered. Provide 'tools' or 'tool_names'.")
|
||||
|
||||
# Run the server
|
||||
if transport == "stdio":
|
||||
server.run_stdio()
|
||||
elif transport == "sse":
|
||||
server.run_sse(host=host, port=port)
|
||||
else:
|
||||
raise ValueError(f"Unknown transport: {transport}")
|
||||
@@ -0,0 +1,457 @@
|
||||
"""
|
||||
MCP Session Management Module.
|
||||
|
||||
This module provides session management utilities for MCP transports,
|
||||
implementing the session management requirements from MCP Protocol
|
||||
Revision 2025-11-25.
|
||||
|
||||
Features:
|
||||
- Session ID validation (visible ASCII 0x21-0x7E)
|
||||
- Session lifecycle management
|
||||
- Protocol version header handling
|
||||
- Session expiration detection
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
|
||||
# Valid protocol versions
|
||||
VALID_PROTOCOL_VERSIONS = {
|
||||
'2024-11-05', # Original HTTP+SSE
|
||||
'2025-03-26', # Streamable HTTP introduction
|
||||
'2025-06-18', # Updates
|
||||
'2025-11-25', # Current revision
|
||||
}
|
||||
|
||||
# Default protocol version for backward compatibility
|
||||
DEFAULT_PROTOCOL_VERSION = '2025-03-26'
|
||||
|
||||
|
||||
def is_valid_session_id(session_id: Optional[str]) -> bool:
|
||||
"""
|
||||
Validate MCP session ID per specification.
|
||||
|
||||
Session IDs MUST only contain visible ASCII characters (0x21 to 0x7E).
|
||||
This excludes space (0x20), control characters (0x00-0x1F), and DEL (0x7F).
|
||||
|
||||
Args:
|
||||
session_id: The session ID to validate
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
if session_id is None or session_id == "":
|
||||
return False
|
||||
|
||||
# Check each character is in visible ASCII range (0x21-0x7E)
|
||||
for char in session_id:
|
||||
code = ord(char)
|
||||
if code < 0x21 or code > 0x7E:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def is_valid_protocol_version(version: Optional[str]) -> bool:
|
||||
"""
|
||||
Validate MCP protocol version string.
|
||||
|
||||
Args:
|
||||
version: Protocol version string (e.g., '2025-11-25')
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
if not version:
|
||||
return False
|
||||
|
||||
# Check format: YYYY-MM-DD
|
||||
if not re.match(r'^\d{4}-\d{2}-\d{2}$', version):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def is_session_expired(status_code: int) -> bool:
|
||||
"""
|
||||
Check if HTTP status code indicates session expiration.
|
||||
|
||||
Per MCP spec, HTTP 404 indicates the session has been terminated.
|
||||
|
||||
Args:
|
||||
status_code: HTTP response status code
|
||||
|
||||
Returns:
|
||||
True if session is expired, False otherwise
|
||||
"""
|
||||
return status_code == 404
|
||||
|
||||
|
||||
def extract_session_id(headers: Dict[str, str]) -> Optional[str]:
|
||||
"""
|
||||
Extract session ID from HTTP response headers.
|
||||
|
||||
The header name is case-insensitive per HTTP spec.
|
||||
|
||||
Args:
|
||||
headers: HTTP response headers dictionary
|
||||
|
||||
Returns:
|
||||
Session ID if present, None otherwise
|
||||
"""
|
||||
# Check various case combinations
|
||||
for key in headers:
|
||||
if key.lower() == 'mcp-session-id':
|
||||
return headers[key]
|
||||
return None
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""
|
||||
Manages MCP session state for HTTP transports.
|
||||
|
||||
This class handles:
|
||||
- Session ID storage and validation
|
||||
- Protocol version negotiation
|
||||
- Session lifecycle (active, expired, terminated)
|
||||
- Header generation for requests
|
||||
|
||||
Example:
|
||||
```python
|
||||
manager = SessionManager()
|
||||
|
||||
# After receiving InitializeResult with session ID
|
||||
manager.set_session_id("abc123")
|
||||
|
||||
# Get headers for subsequent requests
|
||||
headers = manager.get_headers()
|
||||
# {'Mcp-Session-Id': 'abc123', 'Mcp-Protocol-Version': '2025-03-26'}
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, protocol_version: Optional[str] = None):
|
||||
"""
|
||||
Initialize session manager.
|
||||
|
||||
Args:
|
||||
protocol_version: Initial protocol version (defaults to 2025-03-26)
|
||||
"""
|
||||
self._session_id: Optional[str] = None
|
||||
self._protocol_version: str = protocol_version or DEFAULT_PROTOCOL_VERSION
|
||||
self._is_expired: bool = False
|
||||
self._metadata: Dict[str, Any] = {}
|
||||
|
||||
@property
|
||||
def session_id(self) -> Optional[str]:
|
||||
"""Get current session ID."""
|
||||
return self._session_id
|
||||
|
||||
@property
|
||||
def protocol_version(self) -> str:
|
||||
"""Get current protocol version."""
|
||||
return self._protocol_version
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
"""Check if session is active (has ID and not expired)."""
|
||||
return self._session_id is not None and not self._is_expired
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if session has expired."""
|
||||
return self._is_expired
|
||||
|
||||
def set_session_id(self, session_id: str) -> None:
|
||||
"""
|
||||
Set the session ID.
|
||||
|
||||
Args:
|
||||
session_id: The session ID from server
|
||||
|
||||
Raises:
|
||||
ValueError: If session ID is invalid
|
||||
"""
|
||||
if not is_valid_session_id(session_id):
|
||||
raise ValueError("Invalid session ID: must contain only visible ASCII (0x21-0x7E)")
|
||||
|
||||
self._session_id = session_id
|
||||
self._is_expired = False
|
||||
|
||||
def set_protocol_version(self, version: str) -> None:
|
||||
"""
|
||||
Set the negotiated protocol version.
|
||||
|
||||
Args:
|
||||
version: Protocol version string
|
||||
|
||||
Raises:
|
||||
ValueError: If version format is invalid
|
||||
"""
|
||||
if not is_valid_protocol_version(version):
|
||||
raise ValueError(f"Invalid protocol version format: {version}")
|
||||
|
||||
self._protocol_version = version
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the session (logout/disconnect)."""
|
||||
self._session_id = None
|
||||
self._is_expired = False
|
||||
self._metadata = {}
|
||||
|
||||
def mark_expired(self) -> None:
|
||||
"""Mark the session as expired (received HTTP 404)."""
|
||||
self._is_expired = True
|
||||
|
||||
def get_headers(self) -> Dict[str, str]:
|
||||
"""
|
||||
Get HTTP headers for MCP requests.
|
||||
|
||||
Returns:
|
||||
Dictionary of headers to include in requests
|
||||
"""
|
||||
headers = {
|
||||
'Mcp-Protocol-Version': self._protocol_version
|
||||
}
|
||||
|
||||
if self._session_id:
|
||||
headers['Mcp-Session-Id'] = self._session_id
|
||||
|
||||
return headers
|
||||
|
||||
def update_from_response(self, headers: Dict[str, str], status_code: int = 200) -> None:
|
||||
"""
|
||||
Update session state from HTTP response.
|
||||
|
||||
Args:
|
||||
headers: Response headers
|
||||
status_code: HTTP status code
|
||||
"""
|
||||
# Check for session expiration
|
||||
if is_session_expired(status_code):
|
||||
self.mark_expired()
|
||||
return
|
||||
|
||||
# Extract and update session ID if present
|
||||
new_session_id = extract_session_id(headers)
|
||||
if new_session_id:
|
||||
self.set_session_id(new_session_id)
|
||||
|
||||
def set_metadata(self, key: str, value: Any) -> None:
|
||||
"""Store arbitrary metadata with the session."""
|
||||
self._metadata[key] = value
|
||||
|
||||
def get_metadata(self, key: str, default: Any = None) -> Any:
|
||||
"""Retrieve metadata from the session."""
|
||||
return self._metadata.get(key, default)
|
||||
|
||||
|
||||
class ResumabilityManager:
|
||||
"""
|
||||
Manages SSE stream resumability for MCP transports.
|
||||
|
||||
This class handles:
|
||||
- Event ID tracking per stream
|
||||
- Last-Event-ID header generation
|
||||
- Retry delay handling
|
||||
|
||||
Per MCP spec, event IDs should be assigned per-stream to act as
|
||||
a cursor within that particular stream.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize resumability manager."""
|
||||
self._last_event_ids: Dict[str, str] = {} # stream_id -> last_event_id
|
||||
self._retry_delay: int = 3000 # Default retry delay in ms
|
||||
|
||||
def set_last_event_id(self, stream_id: str, event_id: str) -> None:
|
||||
"""
|
||||
Store the last received event ID for a stream.
|
||||
|
||||
Args:
|
||||
stream_id: Identifier for the SSE stream
|
||||
event_id: The event ID from the SSE event
|
||||
"""
|
||||
self._last_event_ids[stream_id] = event_id
|
||||
|
||||
def get_last_event_id(self, stream_id: str) -> Optional[str]:
|
||||
"""
|
||||
Get the last received event ID for a stream.
|
||||
|
||||
Args:
|
||||
stream_id: Identifier for the SSE stream
|
||||
|
||||
Returns:
|
||||
Last event ID if available, None otherwise
|
||||
"""
|
||||
return self._last_event_ids.get(stream_id)
|
||||
|
||||
def set_retry_delay(self, delay_ms: int) -> None:
|
||||
"""
|
||||
Set the retry delay from SSE retry field.
|
||||
|
||||
Args:
|
||||
delay_ms: Delay in milliseconds
|
||||
"""
|
||||
self._retry_delay = delay_ms
|
||||
|
||||
def get_retry_delay(self) -> int:
|
||||
"""Get current retry delay in milliseconds."""
|
||||
return self._retry_delay
|
||||
|
||||
def get_resume_headers(self, stream_id: str) -> Dict[str, str]:
|
||||
"""
|
||||
Get headers for resuming a stream.
|
||||
|
||||
Args:
|
||||
stream_id: Identifier for the SSE stream
|
||||
|
||||
Returns:
|
||||
Headers dict with Last-Event-ID if available
|
||||
"""
|
||||
headers = {}
|
||||
last_event_id = self.get_last_event_id(stream_id)
|
||||
if last_event_id:
|
||||
headers['Last-Event-ID'] = last_event_id
|
||||
return headers
|
||||
|
||||
def clear_stream(self, stream_id: str) -> None:
|
||||
"""Clear tracking for a specific stream."""
|
||||
self._last_event_ids.pop(stream_id, None)
|
||||
|
||||
def clear_all(self) -> None:
|
||||
"""Clear all stream tracking."""
|
||||
self._last_event_ids.clear()
|
||||
|
||||
|
||||
def parse_sse_event(event_text: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Parse an SSE event into its components.
|
||||
|
||||
SSE events have the format:
|
||||
id: <event-id>
|
||||
retry: <milliseconds>
|
||||
data: <data-line>
|
||||
data: <data-line-2>
|
||||
|
||||
(blank line ends event)
|
||||
|
||||
Args:
|
||||
event_text: Raw SSE event text
|
||||
|
||||
Returns:
|
||||
Dictionary with 'id', 'retry', 'data', 'event' fields as available
|
||||
"""
|
||||
result: Dict[str, Any] = {}
|
||||
data_lines = []
|
||||
|
||||
for line in event_text.strip().split('\n'):
|
||||
if line.startswith('id:'):
|
||||
result['id'] = line[3:].strip()
|
||||
elif line.startswith('retry:'):
|
||||
try:
|
||||
result['retry'] = int(line[6:].strip())
|
||||
except ValueError:
|
||||
pass
|
||||
elif line.startswith('data:'):
|
||||
data_lines.append(line[5:].strip())
|
||||
elif line.startswith('event:'):
|
||||
result['event'] = line[6:].strip()
|
||||
|
||||
# Join multiple data lines with newlines
|
||||
if data_lines:
|
||||
result['data'] = '\n'.join(data_lines)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class EventStore:
|
||||
"""
|
||||
Optional event store for message redelivery support.
|
||||
|
||||
This class stores events per stream to enable replay when
|
||||
clients reconnect with Last-Event-ID header.
|
||||
|
||||
Note: This is an in-memory store suitable for single-server
|
||||
deployments. For multi-node deployments, use an external
|
||||
store (Redis, database, etc.).
|
||||
"""
|
||||
|
||||
def __init__(self, max_events: int = 1000):
|
||||
"""
|
||||
Initialize event store.
|
||||
|
||||
Args:
|
||||
max_events: Maximum events to store per stream
|
||||
"""
|
||||
self.max_events = max_events
|
||||
self._streams: Dict[str, list] = {} # stream_id -> [(event_id, data), ...]
|
||||
|
||||
def store(self, stream_id: str, event_id: str, data: Any) -> None:
|
||||
"""
|
||||
Store an event for potential replay.
|
||||
|
||||
Args:
|
||||
stream_id: Identifier for the SSE stream
|
||||
event_id: The event ID
|
||||
data: The event data
|
||||
"""
|
||||
if stream_id not in self._streams:
|
||||
self._streams[stream_id] = []
|
||||
|
||||
self._streams[stream_id].append((event_id, data))
|
||||
|
||||
# Trim to max_events
|
||||
if len(self._streams[stream_id]) > self.max_events:
|
||||
self._streams[stream_id] = self._streams[stream_id][-self.max_events:]
|
||||
|
||||
def get_events_after(self, stream_id: str, after_event_id: str) -> list:
|
||||
"""
|
||||
Get events after a specific event ID.
|
||||
|
||||
Args:
|
||||
stream_id: Identifier for the SSE stream
|
||||
after_event_id: The Last-Event-ID from client
|
||||
|
||||
Returns:
|
||||
List of event data after the specified event
|
||||
"""
|
||||
if stream_id not in self._streams:
|
||||
return []
|
||||
|
||||
events = self._streams[stream_id]
|
||||
|
||||
# Find the index of the after_event_id
|
||||
start_index = None
|
||||
for i, (event_id, _) in enumerate(events):
|
||||
if event_id == after_event_id:
|
||||
start_index = i + 1
|
||||
break
|
||||
|
||||
if start_index is None:
|
||||
return []
|
||||
|
||||
return [data for _, data in events[start_index:]]
|
||||
|
||||
def get_all_events(self, stream_id: str) -> list:
|
||||
"""
|
||||
Get all stored events for a stream.
|
||||
|
||||
Args:
|
||||
stream_id: Identifier for the SSE stream
|
||||
|
||||
Returns:
|
||||
List of all event data
|
||||
"""
|
||||
if stream_id not in self._streams:
|
||||
return []
|
||||
|
||||
return [data for _, data in self._streams[stream_id]]
|
||||
|
||||
def clear_stream(self, stream_id: str) -> None:
|
||||
"""Clear events for a specific stream."""
|
||||
self._streams.pop(stream_id, None)
|
||||
|
||||
def clear_all(self) -> None:
|
||||
"""Clear all stored events."""
|
||||
self._streams.clear()
|
||||
@@ -0,0 +1,298 @@
|
||||
"""
|
||||
MCP Transport Abstraction Layer.
|
||||
|
||||
This module provides a common interface for all MCP transports,
|
||||
enabling easy switching between transport types and custom
|
||||
transport implementations.
|
||||
|
||||
Transport types:
|
||||
- stdio: Standard input/output (subprocess)
|
||||
- sse: Server-Sent Events (legacy HTTP+SSE)
|
||||
- http_stream: Streamable HTTP (current standard)
|
||||
- websocket: WebSocket (SEP-1288)
|
||||
"""
|
||||
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, Optional, List, Type
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class TransportConfig:
|
||||
"""
|
||||
Configuration for MCP transports.
|
||||
|
||||
Attributes:
|
||||
timeout: Connection/operation timeout in seconds
|
||||
debug: Enable debug logging
|
||||
retry_count: Number of retry attempts
|
||||
retry_delay: Base delay between retries in seconds
|
||||
auth_token: Optional authentication token
|
||||
headers: Additional headers for HTTP transports
|
||||
"""
|
||||
timeout: int = 60
|
||||
debug: bool = False
|
||||
retry_count: int = 3
|
||||
retry_delay: float = 1.0
|
||||
auth_token: Optional[str] = None
|
||||
headers: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
class BaseTransport(ABC):
|
||||
"""
|
||||
Abstract base class for MCP transports.
|
||||
|
||||
All MCP transports must implement this interface to ensure
|
||||
consistent behavior across transport types.
|
||||
|
||||
The transport is responsible for:
|
||||
- Establishing and maintaining connections
|
||||
- Sending JSON-RPC messages
|
||||
- Receiving JSON-RPC messages
|
||||
- Proper cleanup on close
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def connect(self) -> None:
|
||||
"""
|
||||
Establish connection to the MCP server.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If connection fails
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def send(self, message: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Send a JSON-RPC message to the server.
|
||||
|
||||
Args:
|
||||
message: JSON-RPC message dictionary
|
||||
|
||||
Raises:
|
||||
RuntimeError: If not connected
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def receive(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Receive a JSON-RPC message from the server.
|
||||
|
||||
Returns:
|
||||
JSON-RPC message dictionary
|
||||
|
||||
Raises:
|
||||
RuntimeError: If not connected
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def close(self) -> None:
|
||||
"""
|
||||
Close the connection and cleanup resources.
|
||||
"""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if transport is currently connected."""
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Async context manager entry."""
|
||||
await self.connect()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Async context manager exit."""
|
||||
await self.close()
|
||||
|
||||
|
||||
def get_transport_type(target: str) -> str:
|
||||
"""
|
||||
Determine the appropriate transport type for a target.
|
||||
|
||||
Args:
|
||||
target: URL or command string
|
||||
|
||||
Returns:
|
||||
Transport type string: "stdio", "sse", "http_stream", or "websocket"
|
||||
"""
|
||||
if not isinstance(target, str):
|
||||
return "stdio"
|
||||
|
||||
# WebSocket URLs
|
||||
if re.match(r'^wss?://', target, re.IGNORECASE):
|
||||
return "websocket"
|
||||
|
||||
# HTTP URLs
|
||||
if re.match(r'^https?://', target, re.IGNORECASE):
|
||||
# Legacy SSE endpoint
|
||||
if target.endswith('/sse'):
|
||||
return "sse"
|
||||
# Default to Streamable HTTP
|
||||
return "http_stream"
|
||||
|
||||
# Everything else is stdio (command)
|
||||
return "stdio"
|
||||
|
||||
|
||||
class TransportRegistry:
|
||||
"""
|
||||
Registry for MCP transport implementations.
|
||||
|
||||
This allows registration of custom transports and provides
|
||||
a central place to look up transport classes by name.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize empty registry."""
|
||||
self._transports: Dict[str, Type[BaseTransport]] = {}
|
||||
|
||||
def register(self, name: str, transport_class: Type[BaseTransport]) -> None:
|
||||
"""
|
||||
Register a transport class.
|
||||
|
||||
Args:
|
||||
name: Transport name (e.g., "websocket")
|
||||
transport_class: Transport class (must extend BaseTransport)
|
||||
"""
|
||||
if not issubclass(transport_class, BaseTransport):
|
||||
raise TypeError(f"{transport_class} must extend BaseTransport")
|
||||
self._transports[name] = transport_class
|
||||
|
||||
def get(self, name: str) -> Optional[Type[BaseTransport]]:
|
||||
"""
|
||||
Get a registered transport class.
|
||||
|
||||
Args:
|
||||
name: Transport name
|
||||
|
||||
Returns:
|
||||
Transport class or None if not found
|
||||
"""
|
||||
return self._transports.get(name)
|
||||
|
||||
def list_transports(self) -> List[str]:
|
||||
"""
|
||||
List all registered transport names.
|
||||
|
||||
Returns:
|
||||
List of transport names
|
||||
"""
|
||||
return list(self._transports.keys())
|
||||
|
||||
def unregister(self, name: str) -> None:
|
||||
"""
|
||||
Unregister a transport.
|
||||
|
||||
Args:
|
||||
name: Transport name to remove
|
||||
"""
|
||||
self._transports.pop(name, None)
|
||||
|
||||
|
||||
# Placeholder transport classes for registry
|
||||
# These are minimal implementations to satisfy the registry
|
||||
|
||||
class StdioTransportPlaceholder(BaseTransport):
|
||||
"""Placeholder for stdio transport."""
|
||||
async def connect(self): pass
|
||||
async def send(self, message): pass
|
||||
async def receive(self): return {}
|
||||
async def close(self): pass
|
||||
@property
|
||||
def is_connected(self): return False
|
||||
|
||||
|
||||
class SSETransportPlaceholder(BaseTransport):
|
||||
"""Placeholder for SSE transport."""
|
||||
async def connect(self): pass
|
||||
async def send(self, message): pass
|
||||
async def receive(self): return {}
|
||||
async def close(self): pass
|
||||
@property
|
||||
def is_connected(self): return False
|
||||
|
||||
|
||||
class HTTPStreamTransportPlaceholder(BaseTransport):
|
||||
"""Placeholder for HTTP Stream transport."""
|
||||
async def connect(self): pass
|
||||
async def send(self, message): pass
|
||||
async def receive(self): return {}
|
||||
async def close(self): pass
|
||||
@property
|
||||
def is_connected(self): return False
|
||||
|
||||
|
||||
class WebSocketTransportPlaceholder(BaseTransport):
|
||||
"""Placeholder for WebSocket transport."""
|
||||
async def connect(self): pass
|
||||
async def send(self, message): pass
|
||||
async def receive(self): return {}
|
||||
async def close(self): pass
|
||||
@property
|
||||
def is_connected(self): return False
|
||||
|
||||
|
||||
# Default registry instance
|
||||
_default_registry: Optional[TransportRegistry] = None
|
||||
|
||||
|
||||
def get_default_registry() -> TransportRegistry:
|
||||
"""
|
||||
Get the default transport registry with built-in transports.
|
||||
|
||||
Returns:
|
||||
TransportRegistry with default transports registered
|
||||
"""
|
||||
global _default_registry
|
||||
|
||||
if _default_registry is None:
|
||||
_default_registry = TransportRegistry()
|
||||
|
||||
# Register default transports
|
||||
_default_registry.register("stdio", StdioTransportPlaceholder)
|
||||
_default_registry.register("sse", SSETransportPlaceholder)
|
||||
_default_registry.register("http_stream", HTTPStreamTransportPlaceholder)
|
||||
_default_registry.register("websocket", WebSocketTransportPlaceholder)
|
||||
|
||||
return _default_registry
|
||||
|
||||
|
||||
def create_transport(
|
||||
target: str,
|
||||
config: Optional[TransportConfig] = None,
|
||||
registry: Optional[TransportRegistry] = None
|
||||
) -> BaseTransport:
|
||||
"""
|
||||
Create a transport instance for the given target.
|
||||
|
||||
Args:
|
||||
target: URL or command string
|
||||
config: Optional transport configuration
|
||||
registry: Optional custom registry (uses default if None)
|
||||
|
||||
Returns:
|
||||
Transport instance
|
||||
|
||||
Raises:
|
||||
ValueError: If no suitable transport found
|
||||
"""
|
||||
if registry is None:
|
||||
registry = get_default_registry()
|
||||
|
||||
if config is None:
|
||||
config = TransportConfig()
|
||||
|
||||
transport_type = get_transport_type(target)
|
||||
transport_class = registry.get(transport_type)
|
||||
|
||||
if transport_class is None:
|
||||
raise ValueError(f"No transport registered for type: {transport_type}")
|
||||
|
||||
return transport_class()
|
||||
@@ -0,0 +1,238 @@
|
||||
"""MCP utility functions for converting Python functions to MCP tool schemas.
|
||||
|
||||
This module provides utilities to convert Python functions with type hints
|
||||
into MCP-compatible tool schemas for exposing as MCP server tools.
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.mcp.mcp_utils import function_to_mcp_schema
|
||||
|
||||
def my_tool(query: str, max_results: int = 10) -> str:
|
||||
'''Search for information.'''
|
||||
return "results"
|
||||
|
||||
schema = function_to_mcp_schema(my_tool)
|
||||
# Returns MCP-compatible tool schema
|
||||
"""
|
||||
|
||||
import inspect
|
||||
from typing import Any, Callable, Dict, List, Union, get_type_hints, get_origin, get_args
|
||||
|
||||
|
||||
def python_type_to_json_schema(python_type: Any) -> Dict[str, Any]:
|
||||
"""Convert a Python type annotation to JSON Schema.
|
||||
|
||||
Args:
|
||||
python_type: A Python type annotation (e.g., str, int, List[str], Optional[int])
|
||||
|
||||
Returns:
|
||||
JSON Schema dictionary representing the type
|
||||
"""
|
||||
# Handle None type
|
||||
if python_type is None or python_type is type(None):
|
||||
return {"type": "null"}
|
||||
|
||||
# Handle Any type
|
||||
if python_type is Any:
|
||||
return {}
|
||||
|
||||
# Handle basic types
|
||||
type_mapping = {
|
||||
str: {"type": "string"},
|
||||
int: {"type": "integer"},
|
||||
float: {"type": "number"},
|
||||
bool: {"type": "boolean"},
|
||||
bytes: {"type": "string"},
|
||||
}
|
||||
|
||||
if python_type in type_mapping:
|
||||
return type_mapping[python_type]
|
||||
|
||||
# Handle generic types (List, Dict, Optional, Union)
|
||||
origin = get_origin(python_type)
|
||||
args = get_args(python_type)
|
||||
|
||||
# Handle List[T]
|
||||
if origin is list:
|
||||
if args:
|
||||
return {
|
||||
"type": "array",
|
||||
"items": python_type_to_json_schema(args[0])
|
||||
}
|
||||
return {"type": "array", "items": {}}
|
||||
|
||||
# Handle Dict[K, V]
|
||||
if origin is dict:
|
||||
return {"type": "object"}
|
||||
|
||||
# Handle Optional[T] (Union[T, None])
|
||||
if origin is Union:
|
||||
# Filter out None type
|
||||
non_none_args = [arg for arg in args if arg is not type(None)]
|
||||
if len(non_none_args) == 1:
|
||||
# Optional[T] case
|
||||
return python_type_to_json_schema(non_none_args[0])
|
||||
elif len(non_none_args) > 1:
|
||||
# Union of multiple types
|
||||
return {"oneOf": [python_type_to_json_schema(arg) for arg in non_none_args]}
|
||||
return {}
|
||||
|
||||
# Handle tuple
|
||||
if origin is tuple:
|
||||
if args:
|
||||
return {
|
||||
"type": "array",
|
||||
"items": [python_type_to_json_schema(arg) for arg in args]
|
||||
}
|
||||
return {"type": "array"}
|
||||
|
||||
# Handle set
|
||||
if origin is set:
|
||||
if args:
|
||||
return {
|
||||
"type": "array",
|
||||
"items": python_type_to_json_schema(args[0]),
|
||||
"uniqueItems": True
|
||||
}
|
||||
return {"type": "array", "uniqueItems": True}
|
||||
|
||||
# Default to object for unknown types
|
||||
return {"type": "object"}
|
||||
|
||||
|
||||
def get_tool_metadata(func: Callable) -> Dict[str, str]:
|
||||
"""Extract metadata from a function for MCP tool registration.
|
||||
|
||||
Checks for custom attributes (__mcp_name__, __mcp_description__) first,
|
||||
then falls back to function name and docstring.
|
||||
|
||||
Args:
|
||||
func: The function to extract metadata from
|
||||
|
||||
Returns:
|
||||
Dictionary with 'name' and 'description' keys
|
||||
"""
|
||||
# Get name - check for custom attribute first
|
||||
name = getattr(func, '__mcp_name__', None) or func.__name__
|
||||
|
||||
# Get description - check for custom attribute first
|
||||
description = getattr(func, '__mcp_description__', None)
|
||||
|
||||
if description is None:
|
||||
# Extract from docstring
|
||||
docstring = inspect.getdoc(func)
|
||||
if docstring:
|
||||
# Use first line of docstring as description
|
||||
description = docstring.split('\n')[0].strip()
|
||||
else:
|
||||
# Fall back to function name
|
||||
description = name
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"description": description
|
||||
}
|
||||
|
||||
|
||||
def function_to_mcp_schema(func: Callable) -> Dict[str, Any]:
|
||||
"""Convert a Python function to an MCP tool schema.
|
||||
|
||||
This function inspects the function's signature, type hints, and docstring
|
||||
to generate a complete MCP-compatible tool schema.
|
||||
|
||||
Args:
|
||||
func: The function to convert
|
||||
|
||||
Returns:
|
||||
MCP tool schema dictionary with:
|
||||
- name: Tool name
|
||||
- description: Tool description
|
||||
- inputSchema: JSON Schema for parameters
|
||||
|
||||
Example:
|
||||
def search(query: str, max_results: int = 10) -> str:
|
||||
'''Search the web for information.'''
|
||||
return "results"
|
||||
|
||||
schema = function_to_mcp_schema(search)
|
||||
# {
|
||||
# "name": "search",
|
||||
# "description": "Search the web for information.",
|
||||
# "inputSchema": {
|
||||
# "type": "object",
|
||||
# "properties": {
|
||||
# "query": {"type": "string"},
|
||||
# "max_results": {"type": "integer"}
|
||||
# },
|
||||
# "required": ["query"]
|
||||
# }
|
||||
# }
|
||||
"""
|
||||
# Get metadata
|
||||
metadata = get_tool_metadata(func)
|
||||
|
||||
# Get function signature
|
||||
sig = inspect.signature(func)
|
||||
|
||||
# Try to get type hints
|
||||
try:
|
||||
type_hints = get_type_hints(func)
|
||||
except Exception:
|
||||
type_hints = {}
|
||||
|
||||
# Build properties and required list
|
||||
properties = {}
|
||||
required = []
|
||||
|
||||
for param_name, param in sig.parameters.items():
|
||||
# Skip self, cls, and special parameters
|
||||
if param_name in ('self', 'cls'):
|
||||
continue
|
||||
|
||||
# Skip *args and **kwargs
|
||||
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
||||
continue
|
||||
|
||||
# Get type annotation
|
||||
param_type = type_hints.get(param_name, param.annotation)
|
||||
|
||||
if param_type is inspect.Parameter.empty:
|
||||
# No type annotation - default to string
|
||||
properties[param_name] = {"type": "string"}
|
||||
else:
|
||||
properties[param_name] = python_type_to_json_schema(param_type)
|
||||
|
||||
# Check if parameter is required (no default value)
|
||||
if param.default is inspect.Parameter.empty:
|
||||
# Check if it's Optional type
|
||||
origin = get_origin(param_type) if param_type is not inspect.Parameter.empty else None
|
||||
args = get_args(param_type) if param_type is not inspect.Parameter.empty else ()
|
||||
|
||||
is_optional = origin is Union and type(None) in args
|
||||
|
||||
if not is_optional:
|
||||
required.append(param_name)
|
||||
|
||||
# Build the schema
|
||||
schema = {
|
||||
"name": metadata["name"],
|
||||
"description": metadata["description"],
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": required
|
||||
}
|
||||
}
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
def functions_to_mcp_tools(functions: List[Callable]) -> List[Dict[str, Any]]:
|
||||
"""Convert multiple functions to MCP tool schemas.
|
||||
|
||||
Args:
|
||||
functions: List of functions to convert
|
||||
|
||||
Returns:
|
||||
List of MCP tool schemas
|
||||
"""
|
||||
return [function_to_mcp_schema(func) for func in functions]
|
||||
@@ -0,0 +1,596 @@
|
||||
"""
|
||||
WebSocket Transport for MCP (Model Context Protocol).
|
||||
|
||||
This module provides WebSocket-based transport for connecting to MCP servers.
|
||||
WebSocket transport offers:
|
||||
- Long-lived bidirectional communication
|
||||
- Better cloud provider support (hibernatable connections)
|
||||
- Lower protocol overhead compared to HTTP
|
||||
- Session persistence across network interruptions
|
||||
|
||||
This is an optional transport - the 'websockets' package is only imported
|
||||
when ws:// or wss:// URLs are used, ensuring no performance impact on
|
||||
existing stdio/SSE/HTTP transports.
|
||||
|
||||
Based on SEP-1288: WebSocket Transport for MCP (in review).
|
||||
Protocol Revision: 2025-11-25
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import inspect
|
||||
import re
|
||||
from typing import Any, Dict, Optional, List
|
||||
|
||||
logger = logging.getLogger("mcp-websocket")
|
||||
|
||||
|
||||
def is_websocket_url(url: str) -> bool:
|
||||
"""
|
||||
Check if a URL is a WebSocket URL.
|
||||
|
||||
Args:
|
||||
url: The URL string to check
|
||||
|
||||
Returns:
|
||||
True if the URL starts with ws:// or wss://, False otherwise
|
||||
"""
|
||||
if not isinstance(url, str):
|
||||
return False
|
||||
return bool(re.match(r'^wss?://', url, re.IGNORECASE))
|
||||
|
||||
|
||||
def calculate_backoff(attempt: int, base_delay: float = 1.0, max_delay: float = 60.0) -> float:
|
||||
"""
|
||||
Calculate exponential backoff delay for reconnection.
|
||||
|
||||
Args:
|
||||
attempt: The retry attempt number (0-indexed)
|
||||
base_delay: Base delay in seconds
|
||||
max_delay: Maximum delay cap in seconds
|
||||
|
||||
Returns:
|
||||
Delay in seconds for this attempt
|
||||
"""
|
||||
delay = base_delay * (2 ** attempt)
|
||||
return min(delay, max_delay)
|
||||
|
||||
|
||||
# Global event loop for WebSocket operations (similar to other MCP transports)
|
||||
_event_loop = None
|
||||
_loop_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_event_loop():
|
||||
"""Get or create a global event loop for WebSocket operations."""
|
||||
global _event_loop
|
||||
with _loop_lock:
|
||||
if _event_loop is None or _event_loop.is_closed():
|
||||
_event_loop = asyncio.new_event_loop()
|
||||
return _event_loop
|
||||
|
||||
|
||||
class WebSocketTransport:
|
||||
"""
|
||||
Low-level WebSocket transport for MCP communication.
|
||||
|
||||
This class handles the WebSocket connection and JSON-RPC message framing.
|
||||
It supports:
|
||||
- Secure (wss://) and insecure (ws://) connections
|
||||
- Session ID management
|
||||
- Authentication via subprotocol or headers
|
||||
- Automatic reconnection with exponential backoff
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
session_id: Optional[str] = None,
|
||||
auth_token: Optional[str] = None,
|
||||
max_retries: int = 3,
|
||||
retry_delay: float = 1.0,
|
||||
timeout: float = 30.0,
|
||||
ping_interval: float = 30.0,
|
||||
ping_timeout: float = 10.0
|
||||
):
|
||||
"""
|
||||
Initialize WebSocket transport.
|
||||
|
||||
Args:
|
||||
url: WebSocket URL (ws:// or wss://)
|
||||
session_id: Optional MCP session ID
|
||||
auth_token: Optional authentication token
|
||||
max_retries: Maximum reconnection attempts
|
||||
retry_delay: Base delay between retries (exponential backoff)
|
||||
timeout: Connection timeout in seconds
|
||||
ping_interval: Interval between ping messages (keepalive)
|
||||
ping_timeout: Timeout for ping response
|
||||
"""
|
||||
self.url = url
|
||||
self.session_id = session_id
|
||||
self.auth_token = auth_token
|
||||
self.max_retries = max_retries
|
||||
self.retry_delay = retry_delay
|
||||
self.timeout = timeout
|
||||
self.ping_interval = ping_interval
|
||||
self.ping_timeout = ping_timeout
|
||||
|
||||
self._ws = None
|
||||
self._closed = False
|
||||
self._message_queue: asyncio.Queue = asyncio.Queue()
|
||||
self._receive_task = None
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Async context manager entry - establish connection."""
|
||||
await self.connect()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Async context manager exit - close connection."""
|
||||
await self.close()
|
||||
|
||||
async def connect(self):
|
||||
"""Establish WebSocket connection."""
|
||||
try:
|
||||
# Lazy import websockets
|
||||
import websockets
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"websockets package is required for WebSocket transport. "
|
||||
"Install it with: pip install websockets"
|
||||
)
|
||||
|
||||
# Build connection options
|
||||
extra_headers = {}
|
||||
subprotocols = []
|
||||
|
||||
# Add session ID if present
|
||||
if self.session_id:
|
||||
extra_headers['Mcp-Session-Id'] = self.session_id
|
||||
|
||||
# Add auth token via subprotocol (SEP-1288 recommendation)
|
||||
if self.auth_token:
|
||||
# Smuggle auth token in subprotocol list
|
||||
# Format: "mcp-auth-<base64-encoded-token>"
|
||||
import base64
|
||||
encoded_token = base64.b64encode(self.auth_token.encode()).decode()
|
||||
subprotocols.append(f"mcp-auth-{encoded_token}")
|
||||
|
||||
# Always include MCP subprotocol
|
||||
subprotocols.append("mcp")
|
||||
|
||||
# Connect with retry logic
|
||||
last_error = None
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
self._ws = await asyncio.wait_for(
|
||||
websockets.connect(
|
||||
self.url,
|
||||
extra_headers=extra_headers if extra_headers else None,
|
||||
subprotocols=subprotocols if subprotocols else None,
|
||||
ping_interval=self.ping_interval,
|
||||
ping_timeout=self.ping_timeout,
|
||||
close_timeout=10
|
||||
),
|
||||
timeout=self.timeout
|
||||
)
|
||||
logger.debug(f"WebSocket connected to {self.url}")
|
||||
|
||||
# Start background receive task
|
||||
self._receive_task = asyncio.create_task(self._receive_loop())
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
if attempt < self.max_retries:
|
||||
delay = calculate_backoff(attempt, self.retry_delay)
|
||||
logger.warning(f"WebSocket connection failed, retrying in {delay}s: {e}")
|
||||
await asyncio.sleep(delay)
|
||||
else:
|
||||
raise ConnectionError(f"Failed to connect after {self.max_retries + 1} attempts: {last_error}")
|
||||
|
||||
async def _receive_loop(self):
|
||||
"""Background task to receive messages and queue them."""
|
||||
try:
|
||||
async for message in self._ws:
|
||||
try:
|
||||
data = json.loads(message)
|
||||
await self._message_queue.put(data)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse WebSocket message: {e}")
|
||||
except Exception as e:
|
||||
if not self._closed:
|
||||
logger.error(f"WebSocket receive error: {e}")
|
||||
await self._message_queue.put({"error": str(e)})
|
||||
|
||||
async def send(self, message: Dict[str, Any]):
|
||||
"""
|
||||
Send a JSON-RPC message over WebSocket.
|
||||
|
||||
Args:
|
||||
message: JSON-RPC message dictionary
|
||||
"""
|
||||
if self._ws is None:
|
||||
raise RuntimeError("WebSocket not connected")
|
||||
|
||||
data = json.dumps(message)
|
||||
await self._ws.send(data)
|
||||
logger.debug(f"Sent: {message.get('method', message.get('id', 'response'))}")
|
||||
|
||||
async def receive(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Receive a JSON-RPC message from WebSocket.
|
||||
|
||||
Returns:
|
||||
Parsed JSON-RPC message dictionary
|
||||
"""
|
||||
message = await self._message_queue.get()
|
||||
if "error" in message and len(message) == 1:
|
||||
raise ConnectionError(message["error"])
|
||||
return message
|
||||
|
||||
async def close(self):
|
||||
"""Close WebSocket connection."""
|
||||
self._closed = True
|
||||
|
||||
if self._receive_task:
|
||||
self._receive_task.cancel()
|
||||
try:
|
||||
await self._receive_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if self._ws:
|
||||
await self._ws.close()
|
||||
self._ws = None
|
||||
|
||||
logger.debug("WebSocket connection closed")
|
||||
|
||||
|
||||
class WebSocketMCPTool:
|
||||
"""
|
||||
A wrapper for an MCP tool that can be used with praisonaiagents.
|
||||
|
||||
This class wraps a tool from a WebSocket MCP server and makes it
|
||||
compatible with the Agent class tool interface.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
description: str,
|
||||
session: Any, # ClientSession from mcp package
|
||||
input_schema: Optional[Dict[str, Any]] = None,
|
||||
timeout: int = 60
|
||||
):
|
||||
"""
|
||||
Initialize WebSocket MCP tool wrapper.
|
||||
|
||||
Args:
|
||||
name: Tool name
|
||||
description: Tool description
|
||||
session: MCP ClientSession for making calls
|
||||
input_schema: JSON Schema for tool input
|
||||
timeout: Timeout for tool calls in seconds
|
||||
"""
|
||||
self.name = name
|
||||
self.__name__ = name
|
||||
self.__qualname__ = name
|
||||
self.__doc__ = description
|
||||
self.description = description
|
||||
self.session = session
|
||||
self.input_schema = input_schema or {}
|
||||
self.timeout = timeout
|
||||
|
||||
# Build function signature from input schema
|
||||
self.__signature__ = self._build_signature()
|
||||
|
||||
def _build_signature(self) -> inspect.Signature:
|
||||
"""Build function signature from input schema."""
|
||||
params = []
|
||||
|
||||
if self.input_schema and 'properties' in self.input_schema:
|
||||
required = self.input_schema.get('required', [])
|
||||
|
||||
for param_name, prop_schema in self.input_schema['properties'].items():
|
||||
# Determine type annotation
|
||||
prop_type = prop_schema.get('type', 'string') if isinstance(prop_schema, dict) else 'string'
|
||||
annotation = self._json_type_to_python(prop_type)
|
||||
|
||||
# Determine default value
|
||||
default = inspect.Parameter.empty if param_name in required else None
|
||||
|
||||
params.append(
|
||||
inspect.Parameter(
|
||||
name=param_name,
|
||||
kind=inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||||
default=default,
|
||||
annotation=annotation
|
||||
)
|
||||
)
|
||||
|
||||
return inspect.Signature(params)
|
||||
|
||||
def _json_type_to_python(self, json_type: str) -> type:
|
||||
"""Convert JSON Schema type to Python type."""
|
||||
type_map = {
|
||||
'string': str,
|
||||
'integer': int,
|
||||
'number': float,
|
||||
'boolean': bool,
|
||||
'array': list,
|
||||
'object': dict
|
||||
}
|
||||
return type_map.get(json_type, Any)
|
||||
|
||||
def __call__(self, **kwargs):
|
||||
"""
|
||||
Call the tool synchronously.
|
||||
|
||||
This wraps the async call for synchronous usage.
|
||||
"""
|
||||
logger.debug(f"Tool {self.name} called with args: {kwargs}")
|
||||
|
||||
loop = get_event_loop()
|
||||
future = asyncio.run_coroutine_threadsafe(self._async_call(**kwargs), loop)
|
||||
|
||||
try:
|
||||
return future.result(timeout=self.timeout)
|
||||
except Exception as e:
|
||||
logger.error(f"Error calling tool {self.name}: {e}")
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
async def _async_call(self, **kwargs):
|
||||
"""Call the tool asynchronously."""
|
||||
logger.debug(f"Async calling tool {self.name} with args: {kwargs}")
|
||||
|
||||
try:
|
||||
result = await self.session.call_tool(self.name, kwargs)
|
||||
|
||||
# Extract text from result
|
||||
if hasattr(result, 'content') and result.content:
|
||||
if hasattr(result.content[0], 'text'):
|
||||
return result.content[0].text
|
||||
return str(result.content[0])
|
||||
return str(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in _async_call for {self.name}: {e}")
|
||||
raise
|
||||
|
||||
def _fix_array_schemas(self, schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Fix array schemas by adding missing 'items' attribute.
|
||||
|
||||
OpenAI requires array types to specify item types.
|
||||
"""
|
||||
if not isinstance(schema, dict):
|
||||
return schema
|
||||
|
||||
fixed_schema = schema.copy()
|
||||
|
||||
# Fix array types at current level
|
||||
if fixed_schema.get("type") == "array" and "items" not in fixed_schema:
|
||||
fixed_schema["items"] = {"type": "string"}
|
||||
|
||||
# Recursively fix nested schemas
|
||||
if "properties" in fixed_schema:
|
||||
fixed_properties = {}
|
||||
for prop_name, prop_schema in fixed_schema["properties"].items():
|
||||
fixed_properties[prop_name] = self._fix_array_schemas(prop_schema)
|
||||
fixed_schema["properties"] = fixed_properties
|
||||
|
||||
if "items" in fixed_schema:
|
||||
fixed_schema["items"] = self._fix_array_schemas(fixed_schema["items"])
|
||||
|
||||
return fixed_schema
|
||||
|
||||
def to_openai_tool(self) -> Dict[str, Any]:
|
||||
"""Convert the tool to OpenAI function calling format."""
|
||||
fixed_schema = self._fix_array_schemas(self.input_schema)
|
||||
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"parameters": fixed_schema
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class WebSocketMCPClient:
|
||||
"""
|
||||
High-level client for connecting to MCP servers over WebSocket.
|
||||
|
||||
This client handles:
|
||||
- Connection establishment and lifecycle
|
||||
- Tool discovery and wrapping
|
||||
- Session management
|
||||
- Automatic reconnection
|
||||
|
||||
Example:
|
||||
```python
|
||||
from praisonaiagents.mcp.mcp_websocket import WebSocketMCPClient
|
||||
|
||||
client = WebSocketMCPClient("ws://localhost:8080/mcp")
|
||||
for tool in client.tools:
|
||||
print(tool.name)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
server_url: str,
|
||||
debug: bool = False,
|
||||
timeout: int = 60,
|
||||
auth_token: Optional[str] = None,
|
||||
options: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
"""
|
||||
Initialize WebSocket MCP client.
|
||||
|
||||
Args:
|
||||
server_url: WebSocket URL (ws:// or wss://)
|
||||
debug: Enable debug logging
|
||||
timeout: Timeout for operations in seconds
|
||||
auth_token: Optional authentication token
|
||||
options: Additional configuration options
|
||||
"""
|
||||
self.server_url = server_url
|
||||
self.debug = debug
|
||||
self.timeout = timeout
|
||||
self.auth_token = auth_token
|
||||
self.options = options or {}
|
||||
|
||||
self.session = None
|
||||
self.transport = None
|
||||
self.tools: List[WebSocketMCPTool] = []
|
||||
self._closed = False
|
||||
|
||||
# Configure logging
|
||||
if debug:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
else:
|
||||
logger.setLevel(logging.WARNING)
|
||||
|
||||
# Initialize connection
|
||||
self._initialize()
|
||||
|
||||
def _initialize(self):
|
||||
"""Initialize the connection and discover tools."""
|
||||
loop = get_event_loop()
|
||||
|
||||
# Start event loop in background thread if not running
|
||||
def run_event_loop():
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_forever()
|
||||
|
||||
self.loop_thread = threading.Thread(target=run_event_loop, daemon=True)
|
||||
self.loop_thread.start()
|
||||
|
||||
# Run initialization
|
||||
future = asyncio.run_coroutine_threadsafe(self._async_initialize(), loop)
|
||||
self.tools = future.result(timeout=self.timeout)
|
||||
|
||||
async def _async_initialize(self):
|
||||
"""Asynchronously initialize connection and discover tools."""
|
||||
logger.debug(f"Connecting to MCP server at {self.server_url}")
|
||||
|
||||
# Lazy import mcp package
|
||||
try:
|
||||
from mcp import ClientSession
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"mcp package is required. Install it with: pip install mcp"
|
||||
)
|
||||
|
||||
# Create transport
|
||||
self.transport = WebSocketTransport(
|
||||
self.server_url,
|
||||
auth_token=self.auth_token,
|
||||
timeout=self.timeout
|
||||
)
|
||||
await self.transport.connect()
|
||||
|
||||
# Create read/write streams for ClientSession
|
||||
# The ClientSession expects async iterators for read/write
|
||||
read_stream = self._create_read_stream()
|
||||
write_stream = self._create_write_stream()
|
||||
|
||||
# Create and initialize session
|
||||
self._session_context = ClientSession(read_stream, write_stream)
|
||||
self.session = await self._session_context.__aenter__()
|
||||
|
||||
await self.session.initialize()
|
||||
|
||||
# Discover tools
|
||||
logger.debug("Listing tools...")
|
||||
response = await self.session.list_tools()
|
||||
tools_data = response.tools
|
||||
logger.debug(f"Found {len(tools_data)} tools: {[t.name for t in tools_data]}")
|
||||
|
||||
# Create tool wrappers
|
||||
tools = []
|
||||
for tool in tools_data:
|
||||
input_schema = tool.inputSchema if hasattr(tool, 'inputSchema') else None
|
||||
wrapper = WebSocketMCPTool(
|
||||
name=tool.name,
|
||||
description=tool.description if hasattr(tool, 'description') else f"Call the {tool.name} tool",
|
||||
session=self.session,
|
||||
input_schema=input_schema,
|
||||
timeout=self.timeout
|
||||
)
|
||||
tools.append(wrapper)
|
||||
|
||||
return tools
|
||||
|
||||
def _create_read_stream(self):
|
||||
"""Create async iterator for reading from transport."""
|
||||
async def read():
|
||||
while not self._closed:
|
||||
try:
|
||||
message = await self.transport.receive()
|
||||
yield message
|
||||
except Exception as e:
|
||||
if not self._closed:
|
||||
logger.error(f"Read error: {e}")
|
||||
break
|
||||
return read()
|
||||
|
||||
def _create_write_stream(self):
|
||||
"""Create write function for transport."""
|
||||
async def write(message):
|
||||
if hasattr(message, 'to_dict'):
|
||||
message = message.to_dict()
|
||||
await self.transport.send(message)
|
||||
return write
|
||||
|
||||
def __iter__(self):
|
||||
"""Return iterator over tools."""
|
||||
return iter(self.tools)
|
||||
|
||||
def to_openai_tools(self) -> List[Dict[str, Any]]:
|
||||
"""Convert all tools to OpenAI format."""
|
||||
return [tool.to_openai_tool() for tool in self.tools]
|
||||
|
||||
async def aclose(self):
|
||||
"""Async cleanup."""
|
||||
if self._closed:
|
||||
return
|
||||
|
||||
self._closed = True
|
||||
|
||||
try:
|
||||
if hasattr(self, '_session_context') and self._session_context:
|
||||
await self._session_context.__aexit__(None, None, None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if self.transport:
|
||||
await self.transport.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
"""Synchronous cleanup."""
|
||||
if self._closed:
|
||||
return
|
||||
|
||||
try:
|
||||
loop = get_event_loop()
|
||||
if not loop.is_closed():
|
||||
future = asyncio.run_coroutine_threadsafe(self.aclose(), loop)
|
||||
future.result(timeout=5)
|
||||
except Exception:
|
||||
self._closed = True
|
||||
|
||||
def __del__(self):
|
||||
"""Cleanup on garbage collection."""
|
||||
try:
|
||||
if not self._closed:
|
||||
self.close()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -15,6 +15,8 @@ This module provides memory management capabilities including:
|
||||
- Auto-generated memories (like Windsurf Cascade)
|
||||
- Workflows (like Windsurf)
|
||||
- Hooks (like Windsurf Cascade Hooks)
|
||||
- Docs management (like Cursor docs)
|
||||
- MCP config management (like Cursor .cursor/mcp/)
|
||||
|
||||
Memory Providers:
|
||||
- FileMemory: Zero-dependency JSON file-based storage (default)
|
||||
@@ -23,10 +25,14 @@ Memory Providers:
|
||||
- AutoMemory: Automatic memory extraction from conversations
|
||||
- WorkflowManager: Multi-step workflow execution
|
||||
- HooksManager: Pre/post operation hooks
|
||||
- DocsManager: Documentation context management
|
||||
- MCPConfigManager: MCP server configuration management
|
||||
"""
|
||||
|
||||
from .file_memory import FileMemory, create_memory
|
||||
from .rules_manager import RulesManager, Rule, create_rules_manager
|
||||
from .docs_manager import DocsManager, Doc
|
||||
from .mcp_config import MCPConfigManager, MCPConfig
|
||||
|
||||
# Lazy imports for optional modules to avoid dependency issues and improve startup time
|
||||
def __getattr__(name):
|
||||
@@ -51,6 +57,19 @@ def __getattr__(name):
|
||||
if name == "WorkflowStep":
|
||||
from .workflows import WorkflowStep
|
||||
return WorkflowStep
|
||||
if name == "WorkflowContext":
|
||||
from .workflows import WorkflowContext
|
||||
return WorkflowContext
|
||||
if name == "StepResult":
|
||||
from .workflows import StepResult
|
||||
return StepResult
|
||||
# Backward compatibility aliases
|
||||
if name == "StepInput":
|
||||
from .workflows import WorkflowContext
|
||||
return WorkflowContext
|
||||
if name == "StepOutput":
|
||||
from .workflows import StepResult
|
||||
return StepResult
|
||||
if name == "create_workflow_manager":
|
||||
from .workflows import create_workflow_manager
|
||||
return create_workflow_manager
|
||||
@@ -63,6 +82,7 @@ def __getattr__(name):
|
||||
if name == "create_hooks_manager":
|
||||
from .hooks import create_hooks_manager
|
||||
return create_hooks_manager
|
||||
# DocsManager and MCPConfigManager are already imported at module level
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
__all__ = [
|
||||
@@ -82,9 +102,17 @@ __all__ = [
|
||||
"WorkflowManager",
|
||||
"Workflow",
|
||||
"WorkflowStep",
|
||||
"WorkflowContext",
|
||||
"StepResult",
|
||||
"create_workflow_manager",
|
||||
# Hooks
|
||||
"HooksManager",
|
||||
"HookResult",
|
||||
"create_hooks_manager",
|
||||
# Docs management
|
||||
"DocsManager",
|
||||
"Doc",
|
||||
# MCP config management
|
||||
"MCPConfigManager",
|
||||
"MCPConfig",
|
||||
]
|
||||
@@ -0,0 +1,431 @@
|
||||
"""
|
||||
Docs Manager for PraisonAI Agents.
|
||||
|
||||
Provides reusable documentation context support similar to:
|
||||
- Cursor Notepads (deprecated in favor of docs)
|
||||
- Cursor .cursor/docs/
|
||||
- Project documentation for AI context
|
||||
|
||||
Features:
|
||||
- Auto-discovery of docs from .praison/docs/
|
||||
- Support for markdown, text, and code files
|
||||
- @doc mentions for including specific docs
|
||||
- Priority ordering for docs
|
||||
- Character limits for context window management
|
||||
|
||||
Storage Structure:
|
||||
.praison/docs/
|
||||
├── project-overview.md # Project overview
|
||||
├── architecture.md # Architecture decisions
|
||||
├── api-reference.md # API documentation
|
||||
└── coding-standards.md # Coding guidelines
|
||||
|
||||
Doc File Format (YAML frontmatter + Markdown):
|
||||
---
|
||||
description: Project architecture overview
|
||||
priority: 10
|
||||
tags: [architecture, design]
|
||||
---
|
||||
|
||||
# Architecture Overview
|
||||
This project uses...
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Literal
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Doc:
|
||||
"""A single documentation file with metadata and content."""
|
||||
name: str
|
||||
content: str
|
||||
description: str = ""
|
||||
priority: int = 0
|
||||
tags: List[str] = field(default_factory=list)
|
||||
file_path: Optional[str] = None
|
||||
|
||||
def matches_tag(self, tag: str) -> bool:
|
||||
"""Check if this doc has a specific tag."""
|
||||
return tag.lower() in [t.lower() for t in self.tags]
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"content": self.content,
|
||||
"description": self.description,
|
||||
"priority": self.priority,
|
||||
"tags": self.tags,
|
||||
"file_path": self.file_path
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Doc':
|
||||
return cls(**data)
|
||||
|
||||
|
||||
class DocsManager:
|
||||
"""
|
||||
Manages documentation context for AI agents.
|
||||
|
||||
Provides:
|
||||
- Auto-discovery of docs from .praison/docs/
|
||||
- Global docs from ~/.praison/docs/
|
||||
- @doc mentions for including specific docs
|
||||
- Priority-based ordering
|
||||
"""
|
||||
|
||||
DOCS_DIR_NAME = ".praison/docs"
|
||||
SUPPORTED_EXTENSIONS = [".md", ".txt", ".rst"]
|
||||
|
||||
# Maximum characters per doc (context window management)
|
||||
MAX_DOC_CHARS = 12000
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace_path: Optional[str] = None,
|
||||
global_docs_path: Optional[str] = None,
|
||||
verbose: int = 0
|
||||
):
|
||||
"""
|
||||
Initialize DocsManager.
|
||||
|
||||
Args:
|
||||
workspace_path: Path to workspace/project root
|
||||
global_docs_path: Path to global docs (default: ~/.praison/docs)
|
||||
verbose: Verbosity level
|
||||
"""
|
||||
self.workspace_path = Path(workspace_path) if workspace_path else Path.cwd()
|
||||
self.global_docs_path = Path(global_docs_path) if global_docs_path else Path.home() / ".praison" / "docs"
|
||||
self.verbose = verbose
|
||||
|
||||
self._docs: Dict[str, Doc] = {}
|
||||
self._load_all_docs()
|
||||
|
||||
def _log(self, msg: str, level: int = logging.INFO):
|
||||
"""Log message if verbose."""
|
||||
if self.verbose >= 1:
|
||||
logger.log(level, msg)
|
||||
|
||||
def _parse_frontmatter(self, content: str) -> tuple:
|
||||
"""
|
||||
Parse YAML frontmatter from content.
|
||||
|
||||
Returns:
|
||||
Tuple of (frontmatter_dict, remaining_content)
|
||||
"""
|
||||
frontmatter = {}
|
||||
remaining = content
|
||||
|
||||
# Check for YAML frontmatter
|
||||
if content.startswith("---"):
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
try:
|
||||
import yaml
|
||||
frontmatter = yaml.safe_load(parts[1]) or {}
|
||||
remaining = parts[2].strip()
|
||||
except Exception:
|
||||
# If YAML parsing fails, use content as-is
|
||||
pass
|
||||
|
||||
return frontmatter, remaining
|
||||
|
||||
def _load_doc_file(self, file_path: Path, scope: str = "workspace") -> Optional[Doc]:
|
||||
"""Load a single doc file."""
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
frontmatter, doc_content = self._parse_frontmatter(content)
|
||||
|
||||
name = file_path.stem
|
||||
|
||||
# Determine priority based on scope
|
||||
base_priority = 100 if scope == "workspace" else 0
|
||||
|
||||
doc = Doc(
|
||||
name=name,
|
||||
content=doc_content,
|
||||
description=frontmatter.get("description", ""),
|
||||
priority=frontmatter.get("priority", base_priority),
|
||||
tags=frontmatter.get("tags", []),
|
||||
file_path=str(file_path)
|
||||
)
|
||||
|
||||
return doc
|
||||
except Exception as e:
|
||||
self._log(f"Failed to load doc {file_path}: {e}", logging.WARNING)
|
||||
return None
|
||||
|
||||
def _load_docs_from_dir(self, docs_dir: Path, scope: str = "workspace"):
|
||||
"""Load all docs from a directory."""
|
||||
if not docs_dir.exists():
|
||||
return
|
||||
|
||||
for ext in self.SUPPORTED_EXTENSIONS:
|
||||
for file_path in docs_dir.glob(f"*{ext}"):
|
||||
if file_path.is_file():
|
||||
doc = self._load_doc_file(file_path, scope)
|
||||
if doc:
|
||||
key = f"{scope}:{doc.name}"
|
||||
self._docs[key] = doc
|
||||
self._log(f"Loaded doc: {key}")
|
||||
|
||||
def _load_all_docs(self):
|
||||
"""Load all docs from global and workspace directories."""
|
||||
self._docs.clear()
|
||||
|
||||
# 1. Load global docs (lowest priority)
|
||||
if self.global_docs_path.exists():
|
||||
self._load_docs_from_dir(self.global_docs_path, "global")
|
||||
for key, doc in self._docs.items():
|
||||
if key.startswith("global:"):
|
||||
doc.priority = doc.priority - 1000 # Lower priority for global
|
||||
|
||||
# 2. Load workspace docs
|
||||
workspace_docs_dir = self.workspace_path / self.DOCS_DIR_NAME.replace("/", os.sep)
|
||||
if workspace_docs_dir.exists():
|
||||
self._load_docs_from_dir(workspace_docs_dir, "workspace")
|
||||
|
||||
self._log(f"Loaded {len(self._docs)} docs total")
|
||||
|
||||
def reload(self):
|
||||
"""Reload all docs from disk."""
|
||||
self._load_all_docs()
|
||||
|
||||
def get_all_docs(self) -> List[Doc]:
|
||||
"""Get all loaded docs, sorted by priority."""
|
||||
docs = list(self._docs.values())
|
||||
docs.sort(key=lambda d: d.priority, reverse=True)
|
||||
return docs
|
||||
|
||||
def get_doc(self, name: str) -> Optional[Doc]:
|
||||
"""Get a specific doc by name."""
|
||||
# Try workspace first, then global
|
||||
for scope in ["workspace", "global"]:
|
||||
key = f"{scope}:{name}"
|
||||
if key in self._docs:
|
||||
return self._docs[key]
|
||||
return None
|
||||
|
||||
def get_docs_by_tag(self, tag: str) -> List[Doc]:
|
||||
"""Get all docs with a specific tag."""
|
||||
docs = [d for d in self._docs.values() if d.matches_tag(tag)]
|
||||
docs.sort(key=lambda d: d.priority, reverse=True)
|
||||
return docs
|
||||
|
||||
def get_docs_for_context(
|
||||
self,
|
||||
include_docs: Optional[List[str]] = None,
|
||||
include_tags: Optional[List[str]] = None,
|
||||
max_chars: int = 50000
|
||||
) -> List[Doc]:
|
||||
"""
|
||||
Get docs for inclusion in agent context.
|
||||
|
||||
Args:
|
||||
include_docs: Specific doc names to include (via @doc mentions)
|
||||
include_tags: Tags to filter by
|
||||
max_chars: Maximum total characters
|
||||
|
||||
Returns:
|
||||
List of docs sorted by priority
|
||||
"""
|
||||
active = []
|
||||
|
||||
# Include specific docs by name
|
||||
if include_docs:
|
||||
for name in include_docs:
|
||||
doc = self.get_doc(name)
|
||||
if doc and doc not in active:
|
||||
active.append(doc)
|
||||
|
||||
# Include docs by tag
|
||||
if include_tags:
|
||||
for tag in include_tags:
|
||||
for doc in self.get_docs_by_tag(tag):
|
||||
if doc not in active:
|
||||
active.append(doc)
|
||||
|
||||
# If no specific docs requested, include all high-priority docs
|
||||
if not include_docs and not include_tags:
|
||||
for doc in self.get_all_docs():
|
||||
if doc.priority >= 100: # Only high-priority docs by default
|
||||
active.append(doc)
|
||||
|
||||
# Sort by priority
|
||||
active.sort(key=lambda d: d.priority, reverse=True)
|
||||
|
||||
return active
|
||||
|
||||
def format_docs_for_prompt(
|
||||
self,
|
||||
include_docs: Optional[List[str]] = None,
|
||||
include_tags: Optional[List[str]] = None,
|
||||
max_chars: int = 50000
|
||||
) -> str:
|
||||
"""
|
||||
Format docs as a string for inclusion in prompts.
|
||||
|
||||
Args:
|
||||
include_docs: Specific doc names to include
|
||||
include_tags: Tags to filter by
|
||||
max_chars: Maximum total characters
|
||||
|
||||
Returns:
|
||||
Formatted docs string
|
||||
"""
|
||||
active_docs = self.get_docs_for_context(include_docs, include_tags, max_chars)
|
||||
|
||||
if not active_docs:
|
||||
return ""
|
||||
|
||||
parts = ["# Project Documentation\n"]
|
||||
total_chars = len(parts[0])
|
||||
|
||||
for doc in active_docs:
|
||||
if total_chars >= max_chars:
|
||||
break
|
||||
|
||||
doc_text = doc.content.strip()
|
||||
if not doc_text:
|
||||
continue
|
||||
|
||||
# Add doc with header
|
||||
if doc.description:
|
||||
header = f"## {doc.name}: {doc.description}"
|
||||
else:
|
||||
header = f"## {doc.name}"
|
||||
|
||||
section = f"{header}\n{doc_text}\n"
|
||||
|
||||
if total_chars + len(section) <= max_chars:
|
||||
parts.append(section)
|
||||
total_chars += len(section)
|
||||
else:
|
||||
# Truncate last doc
|
||||
remaining = max_chars - total_chars
|
||||
if remaining > 100:
|
||||
parts.append(section[:remaining] + "\n... (truncated)")
|
||||
break
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
def create_doc(
|
||||
self,
|
||||
name: str,
|
||||
content: str,
|
||||
description: str = "",
|
||||
priority: int = 0,
|
||||
tags: Optional[List[str]] = None,
|
||||
scope: Literal["global", "workspace"] = "workspace"
|
||||
) -> Doc:
|
||||
"""
|
||||
Create a new doc file.
|
||||
|
||||
Args:
|
||||
name: Doc name (used as filename)
|
||||
content: Doc content (markdown)
|
||||
description: Short description
|
||||
priority: Priority (higher = applied first)
|
||||
tags: Tags for categorization
|
||||
scope: Where to save (global or workspace)
|
||||
|
||||
Returns:
|
||||
Created Doc object
|
||||
"""
|
||||
# Determine save path
|
||||
if scope == "global":
|
||||
docs_dir = self.global_docs_path
|
||||
else:
|
||||
docs_dir = self.workspace_path / self.DOCS_DIR_NAME.replace("/", os.sep)
|
||||
|
||||
docs_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_path = docs_dir / f"{name}.md"
|
||||
|
||||
# Build frontmatter
|
||||
frontmatter_lines = ["---"]
|
||||
if description:
|
||||
frontmatter_lines.append(f'description: "{description}"')
|
||||
if priority != 0:
|
||||
frontmatter_lines.append(f"priority: {priority}")
|
||||
if tags:
|
||||
tags_str = ", ".join(f'"{t}"' for t in tags)
|
||||
frontmatter_lines.append(f"tags: [{tags_str}]")
|
||||
frontmatter_lines.append("---")
|
||||
frontmatter_lines.append("")
|
||||
|
||||
# Write file
|
||||
full_content = "\n".join(frontmatter_lines) + content
|
||||
file_path.write_text(full_content, encoding="utf-8")
|
||||
|
||||
# Create and register doc
|
||||
doc = Doc(
|
||||
name=name,
|
||||
content=content,
|
||||
description=description,
|
||||
priority=priority,
|
||||
tags=tags or [],
|
||||
file_path=str(file_path)
|
||||
)
|
||||
|
||||
self._docs[f"{scope}:{name}"] = doc
|
||||
self._log(f"Created doc '{name}' at {file_path}")
|
||||
|
||||
return doc
|
||||
|
||||
def delete_doc(self, name: str, scope: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Delete a doc file.
|
||||
|
||||
Args:
|
||||
name: Doc name to delete
|
||||
scope: Scope to delete from (None = try both)
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
scopes = [scope] if scope else ["workspace", "global"]
|
||||
|
||||
for s in scopes:
|
||||
key = f"{s}:{name}"
|
||||
if key in self._docs:
|
||||
doc = self._docs[key]
|
||||
if doc.file_path:
|
||||
try:
|
||||
Path(doc.file_path).unlink()
|
||||
del self._docs[key]
|
||||
self._log(f"Deleted doc '{name}' from {s}")
|
||||
return True
|
||||
except Exception as e:
|
||||
self._log(f"Failed to delete doc file: {e}", logging.ERROR)
|
||||
|
||||
return False
|
||||
|
||||
def list_docs(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List all docs with metadata.
|
||||
|
||||
Returns:
|
||||
List of doc info dicts
|
||||
"""
|
||||
docs = []
|
||||
for key, doc in self._docs.items():
|
||||
scope = key.split(":")[0]
|
||||
docs.append({
|
||||
"name": doc.name,
|
||||
"scope": scope,
|
||||
"description": doc.description,
|
||||
"priority": doc.priority,
|
||||
"tags": doc.tags,
|
||||
"file_path": doc.file_path,
|
||||
"content_length": len(doc.content)
|
||||
})
|
||||
|
||||
# Sort by priority
|
||||
docs.sort(key=lambda d: d["priority"], reverse=True)
|
||||
return docs
|
||||
@@ -0,0 +1,369 @@
|
||||
"""
|
||||
MCP Config Manager for PraisonAI Agents.
|
||||
|
||||
Provides JSON-based MCP server configuration support similar to:
|
||||
- Cursor .cursor/mcp/*.mcpjson
|
||||
- Windsurf MCP configuration
|
||||
|
||||
Features:
|
||||
- Auto-discovery of MCP configs from .praison/mcp/
|
||||
- JSON config files for MCP servers
|
||||
- Environment variable support
|
||||
- Easy server management
|
||||
|
||||
Storage Structure:
|
||||
.praison/mcp/
|
||||
├── filesystem.json # Filesystem MCP server
|
||||
├── brave-search.json # Brave search MCP server
|
||||
└── postgres.json # PostgreSQL MCP server
|
||||
|
||||
Config File Format (JSON):
|
||||
{
|
||||
"name": "filesystem",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "."],
|
||||
"env": {
|
||||
"SOME_VAR": "value"
|
||||
},
|
||||
"enabled": true,
|
||||
"description": "Filesystem access for the agent"
|
||||
}
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPConfig:
|
||||
"""A single MCP server configuration."""
|
||||
name: str
|
||||
command: str
|
||||
args: List[str] = field(default_factory=list)
|
||||
env: Dict[str, str] = field(default_factory=dict)
|
||||
enabled: bool = True
|
||||
description: str = ""
|
||||
file_path: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"command": self.command,
|
||||
"args": self.args,
|
||||
"env": self.env,
|
||||
"enabled": self.enabled,
|
||||
"description": self.description
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any], file_path: Optional[str] = None) -> 'MCPConfig':
|
||||
return cls(
|
||||
name=data.get("name", ""),
|
||||
command=data.get("command", ""),
|
||||
args=data.get("args", []),
|
||||
env=data.get("env", {}),
|
||||
enabled=data.get("enabled", True),
|
||||
description=data.get("description", ""),
|
||||
file_path=file_path
|
||||
)
|
||||
|
||||
def to_mcp_instance(self):
|
||||
"""
|
||||
Convert this config to an MCP instance.
|
||||
|
||||
Returns:
|
||||
MCP instance ready for use with agents
|
||||
"""
|
||||
try:
|
||||
from praisonaiagents.tools.mcp import MCP
|
||||
|
||||
# Resolve environment variables
|
||||
resolved_env = {}
|
||||
for key, value in self.env.items():
|
||||
if value.startswith("$"):
|
||||
# Environment variable reference
|
||||
env_var = value[1:]
|
||||
resolved_env[key] = os.environ.get(env_var, value)
|
||||
else:
|
||||
resolved_env[key] = value
|
||||
|
||||
return MCP(
|
||||
command=self.command,
|
||||
args=self.args,
|
||||
env=resolved_env if resolved_env else None
|
||||
)
|
||||
except ImportError:
|
||||
logger.warning("MCP tools not available")
|
||||
return None
|
||||
|
||||
|
||||
class MCPConfigManager:
|
||||
"""
|
||||
Manages MCP server configurations for AI agents.
|
||||
|
||||
Provides:
|
||||
- Auto-discovery of MCP configs from .praison/mcp/
|
||||
- Global configs from ~/.praison/mcp/
|
||||
- Easy server management
|
||||
"""
|
||||
|
||||
MCP_DIR_NAME = ".praison/mcp"
|
||||
SUPPORTED_EXTENSIONS = [".json", ".mcpjson"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace_path: Optional[str] = None,
|
||||
global_mcp_path: Optional[str] = None,
|
||||
verbose: int = 0
|
||||
):
|
||||
"""
|
||||
Initialize MCPConfigManager.
|
||||
|
||||
Args:
|
||||
workspace_path: Path to workspace/project root
|
||||
global_mcp_path: Path to global MCP configs (default: ~/.praison/mcp)
|
||||
verbose: Verbosity level
|
||||
"""
|
||||
self.workspace_path = Path(workspace_path) if workspace_path else Path.cwd()
|
||||
self.global_mcp_path = Path(global_mcp_path) if global_mcp_path else Path.home() / ".praison" / "mcp"
|
||||
self.verbose = verbose
|
||||
|
||||
self._configs: Dict[str, MCPConfig] = {}
|
||||
self._load_all_configs()
|
||||
|
||||
def _log(self, msg: str, level: int = logging.INFO):
|
||||
"""Log message if verbose."""
|
||||
if self.verbose >= 1:
|
||||
logger.log(level, msg)
|
||||
|
||||
def _load_config_file(self, file_path: Path, scope: str = "workspace") -> Optional[MCPConfig]:
|
||||
"""Load a single MCP config file."""
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
data = json.loads(content)
|
||||
|
||||
# Use filename as name if not specified
|
||||
if not data.get("name"):
|
||||
data["name"] = file_path.stem
|
||||
|
||||
config = MCPConfig.from_dict(data, str(file_path))
|
||||
return config
|
||||
except json.JSONDecodeError as e:
|
||||
self._log(f"Invalid JSON in {file_path}: {e}", logging.WARNING)
|
||||
return None
|
||||
except Exception as e:
|
||||
self._log(f"Failed to load MCP config {file_path}: {e}", logging.WARNING)
|
||||
return None
|
||||
|
||||
def _load_configs_from_dir(self, mcp_dir: Path, scope: str = "workspace"):
|
||||
"""Load all MCP configs from a directory."""
|
||||
if not mcp_dir.exists():
|
||||
return
|
||||
|
||||
for ext in self.SUPPORTED_EXTENSIONS:
|
||||
for file_path in mcp_dir.glob(f"*{ext}"):
|
||||
if file_path.is_file():
|
||||
config = self._load_config_file(file_path, scope)
|
||||
if config:
|
||||
key = f"{scope}:{config.name}"
|
||||
self._configs[key] = config
|
||||
self._log(f"Loaded MCP config: {key}")
|
||||
|
||||
def _load_all_configs(self):
|
||||
"""Load all MCP configs from global and workspace directories."""
|
||||
self._configs.clear()
|
||||
|
||||
# 1. Load global configs
|
||||
if self.global_mcp_path.exists():
|
||||
self._load_configs_from_dir(self.global_mcp_path, "global")
|
||||
|
||||
# 2. Load workspace configs (override global)
|
||||
workspace_mcp_dir = self.workspace_path / self.MCP_DIR_NAME.replace("/", os.sep)
|
||||
if workspace_mcp_dir.exists():
|
||||
self._load_configs_from_dir(workspace_mcp_dir, "workspace")
|
||||
|
||||
self._log(f"Loaded {len(self._configs)} MCP configs total")
|
||||
|
||||
def reload(self):
|
||||
"""Reload all configs from disk."""
|
||||
self._load_all_configs()
|
||||
|
||||
def get_all_configs(self) -> List[MCPConfig]:
|
||||
"""Get all loaded MCP configs."""
|
||||
return list(self._configs.values())
|
||||
|
||||
def get_enabled_configs(self) -> List[MCPConfig]:
|
||||
"""Get all enabled MCP configs."""
|
||||
return [c for c in self._configs.values() if c.enabled]
|
||||
|
||||
def get_config(self, name: str) -> Optional[MCPConfig]:
|
||||
"""Get a specific MCP config by name."""
|
||||
# Try workspace first, then global
|
||||
for scope in ["workspace", "global"]:
|
||||
key = f"{scope}:{name}"
|
||||
if key in self._configs:
|
||||
return self._configs[key]
|
||||
return None
|
||||
|
||||
def get_mcp_tools(self, names: Optional[List[str]] = None) -> List:
|
||||
"""
|
||||
Get MCP instances for use with agents.
|
||||
|
||||
Args:
|
||||
names: Specific config names to load (None = all enabled)
|
||||
|
||||
Returns:
|
||||
List of MCP instances
|
||||
"""
|
||||
tools = []
|
||||
|
||||
if names:
|
||||
configs = [self.get_config(name) for name in names]
|
||||
configs = [c for c in configs if c and c.enabled]
|
||||
else:
|
||||
configs = self.get_enabled_configs()
|
||||
|
||||
for config in configs:
|
||||
mcp = config.to_mcp_instance()
|
||||
if mcp:
|
||||
tools.append(mcp)
|
||||
|
||||
return tools
|
||||
|
||||
def create_config(
|
||||
self,
|
||||
name: str,
|
||||
command: str,
|
||||
args: Optional[List[str]] = None,
|
||||
env: Optional[Dict[str, str]] = None,
|
||||
enabled: bool = True,
|
||||
description: str = "",
|
||||
scope: str = "workspace"
|
||||
) -> MCPConfig:
|
||||
"""
|
||||
Create a new MCP config file.
|
||||
|
||||
Args:
|
||||
name: Config name (used as filename)
|
||||
command: Command to run (e.g., "npx")
|
||||
args: Command arguments
|
||||
env: Environment variables
|
||||
enabled: Whether the config is enabled
|
||||
description: Description of the MCP server
|
||||
scope: Where to save (global or workspace)
|
||||
|
||||
Returns:
|
||||
Created MCPConfig object
|
||||
"""
|
||||
# Determine save path
|
||||
if scope == "global":
|
||||
mcp_dir = self.global_mcp_path
|
||||
else:
|
||||
mcp_dir = self.workspace_path / self.MCP_DIR_NAME.replace("/", os.sep)
|
||||
|
||||
mcp_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_path = mcp_dir / f"{name}.json"
|
||||
|
||||
# Build config
|
||||
config = MCPConfig(
|
||||
name=name,
|
||||
command=command,
|
||||
args=args or [],
|
||||
env=env or {},
|
||||
enabled=enabled,
|
||||
description=description,
|
||||
file_path=str(file_path)
|
||||
)
|
||||
|
||||
# Write file
|
||||
file_path.write_text(
|
||||
json.dumps(config.to_dict(), indent=2),
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
self._configs[f"{scope}:{name}"] = config
|
||||
self._log(f"Created MCP config '{name}' at {file_path}")
|
||||
|
||||
return config
|
||||
|
||||
def delete_config(self, name: str, scope: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Delete an MCP config file.
|
||||
|
||||
Args:
|
||||
name: Config name to delete
|
||||
scope: Scope to delete from (None = try both)
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
scopes = [scope] if scope else ["workspace", "global"]
|
||||
|
||||
for s in scopes:
|
||||
key = f"{s}:{name}"
|
||||
if key in self._configs:
|
||||
config = self._configs[key]
|
||||
if config.file_path:
|
||||
try:
|
||||
Path(config.file_path).unlink()
|
||||
del self._configs[key]
|
||||
self._log(f"Deleted MCP config '{name}' from {s}")
|
||||
return True
|
||||
except Exception as e:
|
||||
self._log(f"Failed to delete config file: {e}", logging.ERROR)
|
||||
|
||||
return False
|
||||
|
||||
def enable_config(self, name: str) -> bool:
|
||||
"""Enable an MCP config."""
|
||||
config = self.get_config(name)
|
||||
if config:
|
||||
config.enabled = True
|
||||
self._save_config(config)
|
||||
return True
|
||||
return False
|
||||
|
||||
def disable_config(self, name: str) -> bool:
|
||||
"""Disable an MCP config."""
|
||||
config = self.get_config(name)
|
||||
if config:
|
||||
config.enabled = False
|
||||
self._save_config(config)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _save_config(self, config: MCPConfig):
|
||||
"""Save a config back to its file."""
|
||||
if config.file_path:
|
||||
Path(config.file_path).write_text(
|
||||
json.dumps(config.to_dict(), indent=2),
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
def list_configs(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List all MCP configs with metadata.
|
||||
|
||||
Returns:
|
||||
List of config info dicts
|
||||
"""
|
||||
configs = []
|
||||
for key, config in self._configs.items():
|
||||
scope = key.split(":")[0]
|
||||
configs.append({
|
||||
"name": config.name,
|
||||
"scope": scope,
|
||||
"command": config.command,
|
||||
"args": config.args,
|
||||
"enabled": config.enabled,
|
||||
"description": config.description,
|
||||
"file_path": config.file_path
|
||||
})
|
||||
|
||||
return configs
|
||||
File diff suppressed because it is too large.
Load diff
@@ -44,7 +44,9 @@ class Task:
|
||||
retain_full_context: bool = False, # By default, only use previous task output, not all previous tasks
|
||||
guardrail: Optional[Union[Callable[[TaskOutput], Tuple[bool, Any]], str]] = None,
|
||||
max_retries: int = 3,
|
||||
retry_count: int = 0
|
||||
retry_count: int = 0,
|
||||
agent_config: Optional[Dict[str, Any]] = None,
|
||||
variables: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
# Add check if memory config is provided
|
||||
if memory is not None or (config and config.get('memory_config')):
|
||||
@@ -89,6 +91,8 @@ class Task:
|
||||
self.retry_count = retry_count
|
||||
self._guardrail_fn = None
|
||||
self.validation_feedback = None # Store validation failure feedback for retry attempts
|
||||
self.agent_config = agent_config # Per-task agent configuration {role, goal, backstory, llm}
|
||||
self.variables = variables if variables else {} # Variables for substitution in description
|
||||
|
||||
# Set logger level based on config verbose level
|
||||
verbose = self.config.get("verbose", 0)
|
||||
|
||||
@@ -208,6 +208,16 @@ TOOL_MAPPINGS = {
|
||||
'exa_answer_async': ('.exa_tools', None),
|
||||
'ExaTools': ('.exa_tools', 'ExaTools'),
|
||||
'exa_tools': ('.exa_tools', None),
|
||||
|
||||
# Crawl4AI Tools (async web crawling)
|
||||
'crawl4ai': ('.crawl4ai_tools', None),
|
||||
'crawl4ai_many': ('.crawl4ai_tools', None),
|
||||
'crawl4ai_extract': ('.crawl4ai_tools', None),
|
||||
'crawl4ai_llm_extract': ('.crawl4ai_tools', None),
|
||||
'crawl4ai_sync': ('.crawl4ai_tools', None),
|
||||
'crawl4ai_extract_sync': ('.crawl4ai_tools', None),
|
||||
'Crawl4AITools': ('.crawl4ai_tools', 'Crawl4AITools'),
|
||||
'crawl4ai_tools': ('.crawl4ai_tools', None),
|
||||
}
|
||||
|
||||
_instances = {} # Cache for class instances
|
||||
@@ -219,8 +229,8 @@ def __getattr__(name: str) -> Any:
|
||||
|
||||
module_path, class_name = TOOL_MAPPINGS[name]
|
||||
|
||||
# Return class itself (not instance) for ClaudeMemoryTool, TavilyTools, YouTools, ExaTools
|
||||
if name in ('ClaudeMemoryTool', 'TavilyTools', 'YouTools', 'ExaTools'):
|
||||
# Return class itself (not instance) for ClaudeMemoryTool, TavilyTools, YouTools, ExaTools, Crawl4AITools
|
||||
if name in ('ClaudeMemoryTool', 'TavilyTools', 'YouTools', 'ExaTools', 'Crawl4AITools'):
|
||||
module = import_module(module_path, __package__)
|
||||
return getattr(module, class_name)
|
||||
|
||||
@@ -242,11 +252,13 @@ def __getattr__(name: str) -> Any:
|
||||
'tavily_search_async', 'tavily_extract_async',
|
||||
'ydc', 'ydc_search', 'ydc_contents', 'ydc_news', 'ydc_images',
|
||||
'exa', 'exa_search', 'exa_search_contents', 'exa_find_similar', 'exa_answer',
|
||||
'exa_search_async', 'exa_search_contents_async', 'exa_answer_async'
|
||||
'exa_search_async', 'exa_search_contents_async', 'exa_answer_async',
|
||||
'crawl4ai', 'crawl4ai_many', 'crawl4ai_extract', 'crawl4ai_llm_extract',
|
||||
'crawl4ai_sync', 'crawl4ai_extract_sync'
|
||||
]:
|
||||
return getattr(module, name)
|
||||
if name in ['file_tools', 'pandas_tools', 'wikipedia_tools',
|
||||
'newspaper_tools', 'arxiv_tools', 'spider_tools', 'duckdb_tools', 'mongodb_tools', 'csv_tools', 'json_tools', 'excel_tools', 'xml_tools', 'yaml_tools', 'calculator_tools', 'python_tools', 'shell_tools', 'cot_tools', 'tavily_tools', 'youdotcom_tools', 'exa_tools']:
|
||||
'newspaper_tools', 'arxiv_tools', 'spider_tools', 'duckdb_tools', 'mongodb_tools', 'csv_tools', 'json_tools', 'excel_tools', 'xml_tools', 'yaml_tools', 'calculator_tools', 'python_tools', 'shell_tools', 'cot_tools', 'tavily_tools', 'youdotcom_tools', 'exa_tools', 'crawl4ai_tools']:
|
||||
return module # Returns the callable module
|
||||
return getattr(module, name)
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,630 @@
|
||||
"""Crawl4AI web crawling and extraction tools.
|
||||
|
||||
Crawl4AI provides async web crawling with JavaScript rendering, content extraction,
|
||||
and LLM-based data extraction capabilities.
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.tools import crawl4ai, crawl4ai_extract
|
||||
|
||||
# Simple crawl
|
||||
result = await crawl4ai("https://example.com")
|
||||
print(result["markdown"])
|
||||
|
||||
# Extract structured data
|
||||
result = await crawl4ai_extract(
|
||||
"https://example.com/products",
|
||||
schema={"name": "Products", "baseSelector": ".product", ...}
|
||||
)
|
||||
|
||||
# Or use the class directly
|
||||
from praisonaiagents.tools import Crawl4AITools
|
||||
tools = Crawl4AITools()
|
||||
result = await tools.crawl("https://example.com")
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any, Optional, Union
|
||||
import logging
|
||||
import asyncio
|
||||
from importlib import util
|
||||
|
||||
|
||||
def _check_crawl4ai_available() -> tuple[bool, Optional[str]]:
|
||||
"""Check if Crawl4AI is available.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_available, error_message)
|
||||
"""
|
||||
if util.find_spec("crawl4ai") is None:
|
||||
return False, "crawl4ai package is not installed. Install it with: pip install crawl4ai && crawl4ai-setup"
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
class Crawl4AITools:
|
||||
"""Crawl4AI web crawling and extraction tools.
|
||||
|
||||
Provides methods for:
|
||||
- crawl: Basic web crawling with markdown output
|
||||
- crawl_many: Crawl multiple URLs concurrently
|
||||
- extract_css: Extract data using CSS selectors
|
||||
- extract_llm: Extract data using LLM
|
||||
|
||||
Example:
|
||||
from praisonaiagents.tools import Crawl4AITools
|
||||
|
||||
tools = Crawl4AITools()
|
||||
result = await tools.crawl("https://example.com")
|
||||
print(result["markdown"])
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
headless: bool = True,
|
||||
verbose: bool = False,
|
||||
browser_type: str = "chromium"
|
||||
):
|
||||
"""Initialize Crawl4AITools.
|
||||
|
||||
Args:
|
||||
headless: Run browser in headless mode (default True)
|
||||
verbose: Enable verbose logging (default False)
|
||||
browser_type: Browser type - "chromium", "firefox", or "webkit"
|
||||
"""
|
||||
self.headless = headless
|
||||
self.verbose = verbose
|
||||
self.browser_type = browser_type
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self._crawler = None
|
||||
|
||||
async def _get_crawler(self):
|
||||
"""Get or create AsyncWebCrawler instance."""
|
||||
is_available, error = _check_crawl4ai_available()
|
||||
if not is_available:
|
||||
raise ImportError(error)
|
||||
|
||||
from crawl4ai import AsyncWebCrawler, BrowserConfig
|
||||
|
||||
if self._crawler is None:
|
||||
browser_config = BrowserConfig(
|
||||
headless=self.headless,
|
||||
verbose=self.verbose,
|
||||
browser_type=self.browser_type
|
||||
)
|
||||
self._crawler = AsyncWebCrawler(config=browser_config)
|
||||
await self._crawler.start()
|
||||
|
||||
return self._crawler
|
||||
|
||||
async def close(self):
|
||||
"""Close the crawler and release resources."""
|
||||
if self._crawler is not None:
|
||||
await self._crawler.close()
|
||||
self._crawler = None
|
||||
|
||||
async def crawl(
|
||||
self,
|
||||
url: str,
|
||||
word_count_threshold: int = 10,
|
||||
css_selector: Optional[str] = None,
|
||||
excluded_tags: Optional[List[str]] = None,
|
||||
exclude_external_links: bool = False,
|
||||
js_code: Optional[Union[str, List[str]]] = None,
|
||||
wait_for: Optional[str] = None,
|
||||
screenshot: bool = False,
|
||||
pdf: bool = False,
|
||||
bypass_cache: bool = True
|
||||
) -> Dict[str, Any]:
|
||||
"""Crawl a single URL and return content.
|
||||
|
||||
Args:
|
||||
url: The URL to crawl
|
||||
word_count_threshold: Minimum words per text block (default 10)
|
||||
css_selector: CSS selector to focus on specific content
|
||||
excluded_tags: HTML tags to exclude (e.g., ["nav", "footer"])
|
||||
exclude_external_links: Remove external links from content
|
||||
js_code: JavaScript code to execute before crawling
|
||||
wait_for: Wait condition - "css:selector" or "js:() => boolean"
|
||||
screenshot: Capture screenshot (base64)
|
||||
pdf: Generate PDF
|
||||
bypass_cache: Skip cache and fetch fresh content (default True)
|
||||
|
||||
Returns:
|
||||
Dict with 'markdown', 'html', 'cleaned_html', 'links', 'media', etc.
|
||||
"""
|
||||
try:
|
||||
crawler = await self._get_crawler()
|
||||
|
||||
from crawl4ai import CrawlerRunConfig, CacheMode
|
||||
|
||||
config = CrawlerRunConfig(
|
||||
word_count_threshold=word_count_threshold,
|
||||
css_selector=css_selector,
|
||||
excluded_tags=excluded_tags or [],
|
||||
exclude_external_links=exclude_external_links,
|
||||
js_code=js_code,
|
||||
wait_for=wait_for,
|
||||
screenshot=screenshot,
|
||||
pdf=pdf,
|
||||
cache_mode=CacheMode.BYPASS if bypass_cache else CacheMode.ENABLED
|
||||
)
|
||||
|
||||
result = await crawler.arun(url=url, config=config)
|
||||
|
||||
if not result.success:
|
||||
return {"error": f"Crawl failed: {result.error_message}", "success": False}
|
||||
|
||||
response = {
|
||||
"success": True,
|
||||
"url": result.url,
|
||||
"markdown": result.markdown.raw_markdown if hasattr(result.markdown, 'raw_markdown') else str(result.markdown),
|
||||
"html": result.html,
|
||||
"cleaned_html": result.cleaned_html,
|
||||
"links": result.links,
|
||||
"media": result.media,
|
||||
"metadata": result.metadata
|
||||
}
|
||||
|
||||
if hasattr(result.markdown, 'fit_markdown') and result.markdown.fit_markdown:
|
||||
response["fit_markdown"] = result.markdown.fit_markdown
|
||||
|
||||
if screenshot and result.screenshot:
|
||||
response["screenshot"] = result.screenshot
|
||||
|
||||
if pdf and result.pdf:
|
||||
response["pdf"] = result.pdf
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Crawl4AI crawl error: {e}")
|
||||
return {"error": f"Crawl4AI crawl error: {str(e)}", "success": False}
|
||||
|
||||
async def crawl_many(
|
||||
self,
|
||||
urls: List[str],
|
||||
word_count_threshold: int = 10,
|
||||
css_selector: Optional[str] = None,
|
||||
bypass_cache: bool = True,
|
||||
stream: bool = False
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Crawl multiple URLs concurrently.
|
||||
|
||||
Args:
|
||||
urls: List of URLs to crawl
|
||||
word_count_threshold: Minimum words per text block
|
||||
css_selector: CSS selector to focus on specific content
|
||||
bypass_cache: Skip cache and fetch fresh content
|
||||
stream: If True, returns async generator
|
||||
|
||||
Returns:
|
||||
List of crawl results
|
||||
"""
|
||||
try:
|
||||
crawler = await self._get_crawler()
|
||||
|
||||
from crawl4ai import CrawlerRunConfig, CacheMode
|
||||
|
||||
config = CrawlerRunConfig(
|
||||
word_count_threshold=word_count_threshold,
|
||||
css_selector=css_selector,
|
||||
cache_mode=CacheMode.BYPASS if bypass_cache else CacheMode.ENABLED,
|
||||
stream=stream
|
||||
)
|
||||
|
||||
results = []
|
||||
|
||||
if stream:
|
||||
async for result in await crawler.arun_many(urls, config=config):
|
||||
if result.success:
|
||||
results.append({
|
||||
"success": True,
|
||||
"url": result.url,
|
||||
"markdown": result.markdown.raw_markdown if hasattr(result.markdown, 'raw_markdown') else str(result.markdown),
|
||||
"html": result.html
|
||||
})
|
||||
else:
|
||||
results.append({
|
||||
"success": False,
|
||||
"url": result.url,
|
||||
"error": result.error_message
|
||||
})
|
||||
else:
|
||||
raw_results = await crawler.arun_many(urls, config=config)
|
||||
for result in raw_results:
|
||||
if result.success:
|
||||
results.append({
|
||||
"success": True,
|
||||
"url": result.url,
|
||||
"markdown": result.markdown.raw_markdown if hasattr(result.markdown, 'raw_markdown') else str(result.markdown),
|
||||
"html": result.html
|
||||
})
|
||||
else:
|
||||
results.append({
|
||||
"success": False,
|
||||
"url": result.url,
|
||||
"error": result.error_message
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Crawl4AI crawl_many error: {e}")
|
||||
return [{"error": f"Crawl4AI crawl_many error: {str(e)}", "success": False}]
|
||||
|
||||
async def extract_css(
|
||||
self,
|
||||
url: str,
|
||||
schema: Dict[str, Any],
|
||||
js_code: Optional[Union[str, List[str]]] = None,
|
||||
wait_for: Optional[str] = None,
|
||||
bypass_cache: bool = True
|
||||
) -> Dict[str, Any]:
|
||||
"""Extract structured data using CSS selectors.
|
||||
|
||||
Args:
|
||||
url: The URL to crawl (or "raw://<html>" for raw HTML)
|
||||
schema: Extraction schema with baseSelector and fields
|
||||
js_code: JavaScript to execute before extraction
|
||||
wait_for: Wait condition before extraction
|
||||
bypass_cache: Skip cache
|
||||
|
||||
Returns:
|
||||
Dict with 'data' containing extracted items
|
||||
|
||||
Example schema:
|
||||
{
|
||||
"name": "Products",
|
||||
"baseSelector": "div.product",
|
||||
"fields": [
|
||||
{"name": "title", "selector": "h2", "type": "text"},
|
||||
{"name": "price", "selector": ".price", "type": "text"},
|
||||
{"name": "link", "selector": "a", "type": "attribute", "attribute": "href"}
|
||||
]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
crawler = await self._get_crawler()
|
||||
|
||||
from crawl4ai import CrawlerRunConfig, CacheMode
|
||||
from crawl4ai.extraction_strategy import JsonCssExtractionStrategy
|
||||
|
||||
extraction_strategy = JsonCssExtractionStrategy(schema, verbose=self.verbose)
|
||||
|
||||
config = CrawlerRunConfig(
|
||||
extraction_strategy=extraction_strategy,
|
||||
js_code=js_code,
|
||||
wait_for=wait_for,
|
||||
cache_mode=CacheMode.BYPASS if bypass_cache else CacheMode.ENABLED
|
||||
)
|
||||
|
||||
result = await crawler.arun(url=url, config=config)
|
||||
|
||||
if not result.success:
|
||||
return {"error": f"Extraction failed: {result.error_message}", "success": False}
|
||||
|
||||
import json
|
||||
try:
|
||||
data = json.loads(result.extracted_content) if result.extracted_content else []
|
||||
except json.JSONDecodeError:
|
||||
data = result.extracted_content
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"url": result.url,
|
||||
"data": data,
|
||||
"count": len(data) if isinstance(data, list) else 1
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Crawl4AI extract_css error: {e}")
|
||||
return {"error": f"Crawl4AI extract_css error: {str(e)}", "success": False}
|
||||
|
||||
async def extract_llm(
|
||||
self,
|
||||
url: str,
|
||||
instruction: str,
|
||||
schema: Optional[Dict[str, Any]] = None,
|
||||
provider: str = "openai/gpt-4o-mini",
|
||||
api_token: Optional[str] = None,
|
||||
js_code: Optional[Union[str, List[str]]] = None,
|
||||
wait_for: Optional[str] = None,
|
||||
bypass_cache: bool = True
|
||||
) -> Dict[str, Any]:
|
||||
"""Extract structured data using LLM.
|
||||
|
||||
Args:
|
||||
url: The URL to crawl
|
||||
instruction: Instructions for the LLM on what to extract
|
||||
schema: Optional Pydantic-style JSON schema for structured output
|
||||
provider: LLM provider string (e.g., "openai/gpt-4o-mini", "ollama/llama3.3")
|
||||
api_token: API token for the provider (uses env var if not provided)
|
||||
js_code: JavaScript to execute before extraction
|
||||
wait_for: Wait condition before extraction
|
||||
bypass_cache: Skip cache
|
||||
|
||||
Returns:
|
||||
Dict with 'data' containing extracted content
|
||||
"""
|
||||
try:
|
||||
crawler = await self._get_crawler()
|
||||
|
||||
import os
|
||||
from crawl4ai import CrawlerRunConfig, CacheMode, LLMConfig
|
||||
from crawl4ai.extraction_strategy import LLMExtractionStrategy
|
||||
|
||||
# Get API token from env if not provided
|
||||
if api_token is None and "openai" in provider.lower():
|
||||
api_token = os.environ.get("OPENAI_API_KEY")
|
||||
|
||||
llm_config = LLMConfig(provider=provider, api_token=api_token)
|
||||
|
||||
extraction_strategy = LLMExtractionStrategy(
|
||||
llm_config=llm_config,
|
||||
schema=schema,
|
||||
extraction_type="schema" if schema else "block",
|
||||
instruction=instruction,
|
||||
verbose=self.verbose
|
||||
)
|
||||
|
||||
config = CrawlerRunConfig(
|
||||
extraction_strategy=extraction_strategy,
|
||||
js_code=js_code,
|
||||
wait_for=wait_for,
|
||||
cache_mode=CacheMode.BYPASS if bypass_cache else CacheMode.ENABLED
|
||||
)
|
||||
|
||||
result = await crawler.arun(url=url, config=config)
|
||||
|
||||
if not result.success:
|
||||
return {"error": f"LLM extraction failed: {result.error_message}", "success": False}
|
||||
|
||||
import json
|
||||
try:
|
||||
data = json.loads(result.extracted_content) if result.extracted_content else None
|
||||
except json.JSONDecodeError:
|
||||
data = result.extracted_content
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"url": result.url,
|
||||
"data": data
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Crawl4AI extract_llm error: {e}")
|
||||
return {"error": f"Crawl4AI extract_llm error: {str(e)}", "success": False}
|
||||
|
||||
|
||||
# Standalone async functions for easy access
|
||||
|
||||
async def crawl4ai(
|
||||
url: str,
|
||||
css_selector: Optional[str] = None,
|
||||
js_code: Optional[str] = None,
|
||||
wait_for: Optional[str] = None,
|
||||
screenshot: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Crawl a URL and return markdown content.
|
||||
|
||||
Args:
|
||||
url: The URL to crawl
|
||||
css_selector: CSS selector to focus on specific content
|
||||
js_code: JavaScript to execute before crawling
|
||||
wait_for: Wait condition - "css:selector" or "js:() => boolean"
|
||||
screenshot: Capture screenshot
|
||||
|
||||
Returns:
|
||||
Dict with 'markdown', 'html', 'links', 'media', etc.
|
||||
|
||||
Example:
|
||||
result = await crawl4ai("https://example.com")
|
||||
print(result["markdown"])
|
||||
"""
|
||||
is_available, error = _check_crawl4ai_available()
|
||||
if not is_available:
|
||||
logging.error(error)
|
||||
return {"error": error, "success": False}
|
||||
|
||||
tools = Crawl4AITools()
|
||||
try:
|
||||
result = await tools.crawl(
|
||||
url=url,
|
||||
css_selector=css_selector,
|
||||
js_code=js_code,
|
||||
wait_for=wait_for,
|
||||
screenshot=screenshot
|
||||
)
|
||||
return result
|
||||
finally:
|
||||
await tools.close()
|
||||
|
||||
|
||||
async def crawl4ai_many(
|
||||
urls: List[str],
|
||||
css_selector: Optional[str] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Crawl multiple URLs concurrently.
|
||||
|
||||
Args:
|
||||
urls: List of URLs to crawl
|
||||
css_selector: CSS selector to focus on specific content
|
||||
|
||||
Returns:
|
||||
List of crawl results
|
||||
|
||||
Example:
|
||||
results = await crawl4ai_many(["https://example.com", "https://example.org"])
|
||||
"""
|
||||
is_available, error = _check_crawl4ai_available()
|
||||
if not is_available:
|
||||
logging.error(error)
|
||||
return [{"error": error, "success": False}]
|
||||
|
||||
tools = Crawl4AITools()
|
||||
try:
|
||||
return await tools.crawl_many(urls=urls, css_selector=css_selector)
|
||||
finally:
|
||||
await tools.close()
|
||||
|
||||
|
||||
async def crawl4ai_extract(
|
||||
url: str,
|
||||
schema: Dict[str, Any],
|
||||
js_code: Optional[str] = None,
|
||||
wait_for: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Extract structured data using CSS selectors.
|
||||
|
||||
Args:
|
||||
url: The URL to crawl
|
||||
schema: Extraction schema with baseSelector and fields
|
||||
js_code: JavaScript to execute before extraction
|
||||
wait_for: Wait condition before extraction
|
||||
|
||||
Returns:
|
||||
Dict with 'data' containing extracted items
|
||||
|
||||
Example:
|
||||
schema = {
|
||||
"name": "Products",
|
||||
"baseSelector": "div.product",
|
||||
"fields": [
|
||||
{"name": "title", "selector": "h2", "type": "text"},
|
||||
{"name": "price", "selector": ".price", "type": "text"}
|
||||
]
|
||||
}
|
||||
result = await crawl4ai_extract("https://example.com/products", schema)
|
||||
"""
|
||||
is_available, error = _check_crawl4ai_available()
|
||||
if not is_available:
|
||||
logging.error(error)
|
||||
return {"error": error, "success": False}
|
||||
|
||||
tools = Crawl4AITools()
|
||||
try:
|
||||
return await tools.extract_css(
|
||||
url=url,
|
||||
schema=schema,
|
||||
js_code=js_code,
|
||||
wait_for=wait_for
|
||||
)
|
||||
finally:
|
||||
await tools.close()
|
||||
|
||||
|
||||
async def crawl4ai_llm_extract(
|
||||
url: str,
|
||||
instruction: str,
|
||||
schema: Optional[Dict[str, Any]] = None,
|
||||
provider: str = "openai/gpt-4o-mini"
|
||||
) -> Dict[str, Any]:
|
||||
"""Extract data using LLM.
|
||||
|
||||
Args:
|
||||
url: The URL to crawl
|
||||
instruction: Instructions for the LLM
|
||||
schema: Optional JSON schema for structured output
|
||||
provider: LLM provider (default "openai/gpt-4o-mini")
|
||||
|
||||
Returns:
|
||||
Dict with 'data' containing extracted content
|
||||
|
||||
Example:
|
||||
result = await crawl4ai_llm_extract(
|
||||
"https://example.com/about",
|
||||
instruction="Extract company name, founding year, and description"
|
||||
)
|
||||
"""
|
||||
is_available, error = _check_crawl4ai_available()
|
||||
if not is_available:
|
||||
logging.error(error)
|
||||
return {"error": error, "success": False}
|
||||
|
||||
tools = Crawl4AITools()
|
||||
try:
|
||||
return await tools.extract_llm(
|
||||
url=url,
|
||||
instruction=instruction,
|
||||
schema=schema,
|
||||
provider=provider
|
||||
)
|
||||
finally:
|
||||
await tools.close()
|
||||
|
||||
|
||||
# Synchronous wrappers for convenience
|
||||
|
||||
def crawl4ai_sync(
|
||||
url: str,
|
||||
css_selector: Optional[str] = None,
|
||||
js_code: Optional[str] = None,
|
||||
wait_for: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Synchronous version of crawl4ai.
|
||||
|
||||
Args:
|
||||
url: The URL to crawl
|
||||
css_selector: CSS selector to focus on specific content
|
||||
js_code: JavaScript to execute
|
||||
wait_for: Wait condition
|
||||
|
||||
Returns:
|
||||
Dict with crawl results
|
||||
"""
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
crawl4ai(url, css_selector, js_code, wait_for)
|
||||
)
|
||||
|
||||
|
||||
def crawl4ai_extract_sync(
|
||||
url: str,
|
||||
schema: Dict[str, Any],
|
||||
js_code: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Synchronous version of crawl4ai_extract.
|
||||
|
||||
Args:
|
||||
url: The URL to crawl
|
||||
schema: Extraction schema
|
||||
js_code: JavaScript to execute
|
||||
|
||||
Returns:
|
||||
Dict with extracted data
|
||||
"""
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
crawl4ai_extract(url, schema, js_code)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example usage
|
||||
async def main():
|
||||
print("=" * 60)
|
||||
print("Crawl4AI Tools - Example Usage")
|
||||
print("=" * 60)
|
||||
|
||||
is_available, error = _check_crawl4ai_available()
|
||||
if not is_available:
|
||||
print(f"\nCrawl4AI is not available: {error}")
|
||||
print("\nTo use Crawl4AI tools:")
|
||||
print("1. Install the package: pip install crawl4ai")
|
||||
print("2. Run setup: crawl4ai-setup")
|
||||
else:
|
||||
print("Crawl4AI is available!")
|
||||
|
||||
# Example crawl
|
||||
print("\n1. Basic Crawl")
|
||||
print("-" * 30)
|
||||
result = await crawl4ai("https://example.com")
|
||||
if result.get("success"):
|
||||
print(f"URL: {result['url']}")
|
||||
print(f"Markdown length: {len(result.get('markdown', ''))}")
|
||||
print(f"Links found: {len(result.get('links', {}).get('internal', []))}")
|
||||
else:
|
||||
print(f"Error: {result.get('error')}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Demonstration Complete")
|
||||
print("=" * 60)
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,319 @@
|
||||
"""
|
||||
Mentions Parser for PraisonAI Agents.
|
||||
|
||||
Provides @mention syntax support similar to Cursor IDE:
|
||||
- @file:path/to/file.py - Include file content
|
||||
- @web:query - Search the web
|
||||
- @doc:name - Include doc from .praison/docs/
|
||||
- @rule:name - Include specific rule
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.tools.mentions import MentionsParser
|
||||
|
||||
parser = MentionsParser()
|
||||
context, cleaned_prompt = parser.process("@file:main.py explain this code")
|
||||
"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MentionsParser:
|
||||
"""
|
||||
Parse and process @mentions in prompts.
|
||||
|
||||
Supports:
|
||||
- @file:path - Include file content
|
||||
- @web:query - Web search (requires web search tool)
|
||||
- @doc:name - Include doc from .praison/docs/
|
||||
- @rule:name - Include specific rule
|
||||
- @url:https://... - Fetch URL content
|
||||
"""
|
||||
|
||||
# Patterns for different mention types
|
||||
PATTERNS = {
|
||||
"file": re.compile(r'@file:([^\s]+)'),
|
||||
"web": re.compile(r'@web:([^\s]+(?:\s+[^\s@]+)*)'),
|
||||
"doc": re.compile(r'@doc:([^\s]+)'),
|
||||
"rule": re.compile(r'@rule:([^\s]+)'),
|
||||
"url": re.compile(r'@url:(https?://[^\s]+)'),
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace_path: Optional[str] = None,
|
||||
verbose: int = 0
|
||||
):
|
||||
"""
|
||||
Initialize MentionsParser.
|
||||
|
||||
Args:
|
||||
workspace_path: Path to workspace/project root
|
||||
verbose: Verbosity level
|
||||
"""
|
||||
self.workspace_path = Path(workspace_path) if workspace_path else Path.cwd()
|
||||
self.verbose = verbose
|
||||
|
||||
# Lazy-loaded managers
|
||||
self._docs_manager = None
|
||||
self._rules_manager = None
|
||||
|
||||
def _log(self, msg: str, level: int = logging.INFO):
|
||||
"""Log message if verbose."""
|
||||
if self.verbose >= 1:
|
||||
logger.log(level, msg)
|
||||
|
||||
def _get_docs_manager(self):
|
||||
"""Lazy load DocsManager."""
|
||||
if self._docs_manager is None:
|
||||
try:
|
||||
from praisonaiagents.memory.docs_manager import DocsManager
|
||||
self._docs_manager = DocsManager(workspace_path=str(self.workspace_path))
|
||||
except ImportError:
|
||||
self._log("DocsManager not available", logging.WARNING)
|
||||
return self._docs_manager
|
||||
|
||||
def _get_rules_manager(self):
|
||||
"""Lazy load RulesManager."""
|
||||
if self._rules_manager is None:
|
||||
try:
|
||||
from praisonaiagents.memory.rules_manager import RulesManager
|
||||
self._rules_manager = RulesManager(workspace_path=str(self.workspace_path))
|
||||
except ImportError:
|
||||
self._log("RulesManager not available", logging.WARNING)
|
||||
return self._rules_manager
|
||||
|
||||
def process(self, prompt: str) -> Tuple[str, str]:
|
||||
"""
|
||||
Process a prompt and extract @mentions.
|
||||
|
||||
Args:
|
||||
prompt: The input prompt with @mentions
|
||||
|
||||
Returns:
|
||||
Tuple of (context_string, cleaned_prompt)
|
||||
"""
|
||||
context_parts = []
|
||||
cleaned_prompt = prompt
|
||||
|
||||
# Process each mention type
|
||||
for mention_type, pattern in self.PATTERNS.items():
|
||||
matches = pattern.findall(prompt)
|
||||
for match in matches:
|
||||
context = self._process_mention(mention_type, match)
|
||||
if context:
|
||||
context_parts.append(context)
|
||||
# Remove the mention from the prompt
|
||||
cleaned_prompt = pattern.sub('', cleaned_prompt, count=1)
|
||||
|
||||
# Clean up extra whitespace
|
||||
cleaned_prompt = ' '.join(cleaned_prompt.split())
|
||||
|
||||
# Build context string
|
||||
context_string = ""
|
||||
if context_parts:
|
||||
context_string = "\n\n".join(context_parts) + "\n\n"
|
||||
|
||||
return context_string, cleaned_prompt
|
||||
|
||||
def _process_mention(self, mention_type: str, value: str) -> Optional[str]:
|
||||
"""
|
||||
Process a single mention.
|
||||
|
||||
Args:
|
||||
mention_type: Type of mention (file, web, doc, rule, url)
|
||||
value: The mention value
|
||||
|
||||
Returns:
|
||||
Context string or None
|
||||
"""
|
||||
if mention_type == "file":
|
||||
return self._process_file_mention(value)
|
||||
elif mention_type == "web":
|
||||
return self._process_web_mention(value)
|
||||
elif mention_type == "doc":
|
||||
return self._process_doc_mention(value)
|
||||
elif mention_type == "rule":
|
||||
return self._process_rule_mention(value)
|
||||
elif mention_type == "url":
|
||||
return self._process_url_mention(value)
|
||||
return None
|
||||
|
||||
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 not full_path.exists():
|
||||
self._log(f"File not found: {file_path}", logging.WARNING)
|
||||
return f"# File: {file_path}\n[File not found]"
|
||||
|
||||
content = full_path.read_text(encoding="utf-8")
|
||||
|
||||
# Limit content size
|
||||
if len(content) > 50000:
|
||||
content = content[:50000] + "\n... (truncated)"
|
||||
|
||||
# Detect language for code fence
|
||||
ext = full_path.suffix.lower()
|
||||
lang_map = {
|
||||
".py": "python",
|
||||
".js": "javascript",
|
||||
".ts": "typescript",
|
||||
".jsx": "jsx",
|
||||
".tsx": "tsx",
|
||||
".java": "java",
|
||||
".cpp": "cpp",
|
||||
".c": "c",
|
||||
".go": "go",
|
||||
".rs": "rust",
|
||||
".rb": "ruby",
|
||||
".php": "php",
|
||||
".swift": "swift",
|
||||
".kt": "kotlin",
|
||||
".md": "markdown",
|
||||
".json": "json",
|
||||
".yaml": "yaml",
|
||||
".yml": "yaml",
|
||||
".xml": "xml",
|
||||
".html": "html",
|
||||
".css": "css",
|
||||
".sql": "sql",
|
||||
".sh": "bash",
|
||||
}
|
||||
lang = lang_map.get(ext, "")
|
||||
|
||||
return f"# File: {file_path}\n```{lang}\n{content}\n```"
|
||||
|
||||
except Exception as e:
|
||||
self._log(f"Error reading file {file_path}: {e}", logging.ERROR)
|
||||
return f"# File: {file_path}\n[Error reading file: {e}]"
|
||||
|
||||
def _process_web_mention(self, query: str) -> Optional[str]:
|
||||
"""Process @web:query mention."""
|
||||
try:
|
||||
# Try to use web search
|
||||
from praisonaiagents.tools import duckduckgo_search
|
||||
|
||||
results = duckduckgo_search(query, max_results=3)
|
||||
if results:
|
||||
return f"# Web Search: {query}\n{results}"
|
||||
else:
|
||||
return f"# Web Search: {query}\n[No results found]"
|
||||
|
||||
except ImportError:
|
||||
self._log("Web search not available", logging.WARNING)
|
||||
return f"# Web Search: {query}\n[Web search not available. Install with: pip install duckduckgo-search]"
|
||||
except Exception as e:
|
||||
self._log(f"Error in web search: {e}", logging.ERROR)
|
||||
return f"# Web Search: {query}\n[Error: {e}]"
|
||||
|
||||
def _process_doc_mention(self, doc_name: str) -> Optional[str]:
|
||||
"""Process @doc:name mention."""
|
||||
docs = self._get_docs_manager()
|
||||
if not docs:
|
||||
return f"# Doc: {doc_name}\n[DocsManager not available]"
|
||||
|
||||
doc = docs.get_doc(doc_name)
|
||||
if doc:
|
||||
return f"# Doc: {doc.name}\n{doc.content}"
|
||||
else:
|
||||
return f"# Doc: {doc_name}\n[Doc not found]"
|
||||
|
||||
def _process_rule_mention(self, rule_name: str) -> Optional[str]:
|
||||
"""Process @rule:name mention."""
|
||||
rules = self._get_rules_manager()
|
||||
if not rules:
|
||||
return f"# Rule: {rule_name}\n[RulesManager not available]"
|
||||
|
||||
rule = rules.get_rule(rule_name)
|
||||
if rule:
|
||||
return f"# Rule: {rule.name}\n{rule.content}"
|
||||
else:
|
||||
return f"# Rule: {rule_name}\n[Rule not found]"
|
||||
|
||||
def _process_url_mention(self, url: str) -> Optional[str]:
|
||||
"""Process @url:https://... mention."""
|
||||
try:
|
||||
import urllib.request
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={'User-Agent': 'Mozilla/5.0 (compatible; PraisonAI/1.0)'}
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(req, timeout=10) as response:
|
||||
content = response.read().decode('utf-8', errors='ignore')
|
||||
|
||||
# Limit content size
|
||||
if len(content) > 50000:
|
||||
content = content[:50000] + "\n... (truncated)"
|
||||
|
||||
# Try to extract text from HTML
|
||||
if '<html' in content.lower():
|
||||
# Simple HTML text extraction
|
||||
import re
|
||||
# Remove scripts and styles
|
||||
content = re.sub(r'<script[^>]*>.*?</script>', '', content, flags=re.DOTALL | re.IGNORECASE)
|
||||
content = re.sub(r'<style[^>]*>.*?</style>', '', content, flags=re.DOTALL | re.IGNORECASE)
|
||||
# Remove tags
|
||||
content = re.sub(r'<[^>]+>', ' ', content)
|
||||
# Clean whitespace
|
||||
content = ' '.join(content.split())
|
||||
|
||||
return f"# URL: {url}\n{content[:10000]}"
|
||||
|
||||
except Exception as e:
|
||||
self._log(f"Error fetching URL {url}: {e}", logging.ERROR)
|
||||
return f"# URL: {url}\n[Error fetching URL: {e}]"
|
||||
|
||||
def extract_mentions(self, prompt: str) -> Dict[str, List[str]]:
|
||||
"""
|
||||
Extract all mentions from a prompt without processing them.
|
||||
|
||||
Args:
|
||||
prompt: The input prompt
|
||||
|
||||
Returns:
|
||||
Dict mapping mention types to lists of values
|
||||
"""
|
||||
mentions = {}
|
||||
for mention_type, pattern in self.PATTERNS.items():
|
||||
matches = pattern.findall(prompt)
|
||||
if matches:
|
||||
mentions[mention_type] = matches
|
||||
return mentions
|
||||
|
||||
def has_mentions(self, prompt: str) -> bool:
|
||||
"""Check if a prompt contains any @mentions."""
|
||||
for pattern in self.PATTERNS.values():
|
||||
if pattern.search(prompt):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def process_mentions(prompt: str, workspace_path: Optional[str] = None) -> Tuple[str, str]:
|
||||
"""
|
||||
Convenience function to process mentions in a prompt.
|
||||
|
||||
Args:
|
||||
prompt: The input prompt with @mentions
|
||||
workspace_path: Optional workspace path
|
||||
|
||||
Returns:
|
||||
Tuple of (context_string, cleaned_prompt)
|
||||
"""
|
||||
parser = MentionsParser(workspace_path=workspace_path)
|
||||
return parser.process(prompt)
|
||||
|
||||
|
||||
# Export
|
||||
__all__ = ["MentionsParser", "process_mentions"]
|
||||
@@ -0,0 +1,265 @@
|
||||
"""
|
||||
Rules Tools for PraisonAI Agents.
|
||||
|
||||
Provides tools for agents to create, read, and manage rules dynamically.
|
||||
This enables Agent-Requested rules similar to Cursor's agent-requested rules feature.
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.tools.rules_tools import (
|
||||
create_rule_tool,
|
||||
list_rules_tool,
|
||||
get_rule_tool,
|
||||
delete_rule_tool
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
tools=[create_rule_tool, list_rules_tool, get_rule_tool]
|
||||
)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Singleton RulesManager instance
|
||||
_rules_manager = None
|
||||
|
||||
|
||||
def _get_rules_manager():
|
||||
"""Get or create the RulesManager singleton."""
|
||||
global _rules_manager
|
||||
if _rules_manager is None:
|
||||
from praisonaiagents.memory.rules_manager import RulesManager
|
||||
_rules_manager = RulesManager()
|
||||
return _rules_manager
|
||||
|
||||
|
||||
def create_rule_tool(
|
||||
name: str,
|
||||
content: str,
|
||||
description: str = "",
|
||||
globs: Optional[str] = None,
|
||||
activation: str = "always",
|
||||
priority: int = 0,
|
||||
scope: str = "workspace"
|
||||
) -> str:
|
||||
"""
|
||||
Create a new rule for the AI agent to follow.
|
||||
|
||||
This tool allows the agent to create persistent rules that will be
|
||||
applied to future conversations and tasks.
|
||||
|
||||
Args:
|
||||
name: Rule name (used as filename, no spaces or special chars)
|
||||
content: The rule content in markdown format
|
||||
description: Short description of what the rule does
|
||||
globs: Comma-separated glob patterns for file matching (e.g., "**/*.py,**/*.js")
|
||||
activation: When to apply the rule:
|
||||
- "always": Always apply this rule
|
||||
- "glob": Apply when working with files matching globs
|
||||
- "manual": Only apply when explicitly mentioned
|
||||
- "ai_decision": Let AI decide when to apply
|
||||
priority: Priority (higher = applied first, default 0)
|
||||
scope: Where to save ("workspace" or "global")
|
||||
|
||||
Returns:
|
||||
Success message with rule details
|
||||
|
||||
Example:
|
||||
create_rule_tool(
|
||||
name="python-style",
|
||||
content="- Use type hints for all functions\\n- Follow PEP 8",
|
||||
description="Python coding standards",
|
||||
globs="**/*.py",
|
||||
activation="glob"
|
||||
)
|
||||
"""
|
||||
try:
|
||||
manager = _get_rules_manager()
|
||||
|
||||
# Parse globs
|
||||
glob_list = None
|
||||
if globs:
|
||||
glob_list = [g.strip() for g in globs.split(",") if g.strip()]
|
||||
|
||||
# Validate activation
|
||||
valid_activations = ["always", "glob", "manual", "ai_decision"]
|
||||
if activation not in valid_activations:
|
||||
return f"Error: activation must be one of {valid_activations}"
|
||||
|
||||
# Validate scope
|
||||
if scope not in ["workspace", "global"]:
|
||||
return "Error: scope must be 'workspace' or 'global'"
|
||||
|
||||
# Create the rule
|
||||
rule = manager.create_rule(
|
||||
name=name,
|
||||
content=content,
|
||||
description=description,
|
||||
globs=glob_list,
|
||||
activation=activation,
|
||||
priority=priority,
|
||||
scope=scope
|
||||
)
|
||||
|
||||
return f"""Successfully created rule '{name}'
|
||||
Location: {rule.file_path}
|
||||
Activation: {activation}
|
||||
Priority: {priority}
|
||||
Description: {description or 'None'}
|
||||
Globs: {glob_list or 'None'}
|
||||
|
||||
The rule will be applied according to its activation mode."""
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating rule: {e}")
|
||||
return f"Error creating rule: {str(e)}"
|
||||
|
||||
|
||||
def list_rules_tool() -> str:
|
||||
"""
|
||||
List all available rules.
|
||||
|
||||
Returns a formatted list of all rules with their metadata.
|
||||
|
||||
Returns:
|
||||
Formatted string with rule information
|
||||
"""
|
||||
try:
|
||||
manager = _get_rules_manager()
|
||||
rules = manager.get_all_rules()
|
||||
|
||||
if not rules:
|
||||
return "No rules found. Use create_rule_tool to create new rules."
|
||||
|
||||
lines = ["# Available Rules\n"]
|
||||
|
||||
for rule in rules:
|
||||
scope = "global" if "global:" in (rule.file_path or "") else "workspace"
|
||||
lines.append(f"## {rule.name}")
|
||||
lines.append(f"- **Activation**: {rule.activation}")
|
||||
lines.append(f"- **Priority**: {rule.priority}")
|
||||
if rule.description:
|
||||
lines.append(f"- **Description**: {rule.description}")
|
||||
if rule.globs:
|
||||
lines.append(f"- **Globs**: {', '.join(rule.globs)}")
|
||||
lines.append(f"- **Scope**: {scope}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing rules: {e}")
|
||||
return f"Error listing rules: {str(e)}"
|
||||
|
||||
|
||||
def get_rule_tool(name: str) -> str:
|
||||
"""
|
||||
Get the content of a specific rule.
|
||||
|
||||
Args:
|
||||
name: The name of the rule to retrieve
|
||||
|
||||
Returns:
|
||||
The rule content and metadata
|
||||
"""
|
||||
try:
|
||||
manager = _get_rules_manager()
|
||||
rule = manager.get_rule(name)
|
||||
|
||||
if not rule:
|
||||
return f"Rule '{name}' not found. Use list_rules_tool to see available rules."
|
||||
|
||||
return f"""# Rule: {rule.name}
|
||||
|
||||
**Description**: {rule.description or 'None'}
|
||||
**Activation**: {rule.activation}
|
||||
**Priority**: {rule.priority}
|
||||
**Globs**: {', '.join(rule.globs) if rule.globs else 'None'}
|
||||
**File**: {rule.file_path}
|
||||
|
||||
## Content:
|
||||
{rule.content}
|
||||
"""
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting rule: {e}")
|
||||
return f"Error getting rule: {str(e)}"
|
||||
|
||||
|
||||
def delete_rule_tool(name: str, scope: Optional[str] = None) -> str:
|
||||
"""
|
||||
Delete a rule.
|
||||
|
||||
Args:
|
||||
name: The name of the rule to delete
|
||||
scope: Optional scope to delete from ("workspace" or "global")
|
||||
|
||||
Returns:
|
||||
Success or error message
|
||||
"""
|
||||
try:
|
||||
manager = _get_rules_manager()
|
||||
|
||||
if scope and scope not in ["workspace", "global"]:
|
||||
return "Error: scope must be 'workspace' or 'global'"
|
||||
|
||||
success = manager.delete_rule(name, scope)
|
||||
|
||||
if success:
|
||||
return f"Successfully deleted rule '{name}'"
|
||||
else:
|
||||
return f"Rule '{name}' not found or could not be deleted"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting rule: {e}")
|
||||
return f"Error deleting rule: {str(e)}"
|
||||
|
||||
|
||||
def get_active_rules_tool(file_path: Optional[str] = None) -> str:
|
||||
"""
|
||||
Get all rules that are currently active for a given file or context.
|
||||
|
||||
Args:
|
||||
file_path: Optional file path to check which rules apply
|
||||
|
||||
Returns:
|
||||
Formatted string with active rules
|
||||
"""
|
||||
try:
|
||||
manager = _get_rules_manager()
|
||||
|
||||
if file_path:
|
||||
rules = manager.get_rules_for_file(file_path)
|
||||
else:
|
||||
rules = manager.get_active_rules()
|
||||
|
||||
if not rules:
|
||||
return "No active rules for this context."
|
||||
|
||||
lines = ["# Active Rules\n"]
|
||||
|
||||
for rule in rules:
|
||||
lines.append(f"## {rule.name}")
|
||||
if rule.description:
|
||||
lines.append(f"*{rule.description}*\n")
|
||||
lines.append(rule.content)
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting active rules: {e}")
|
||||
return f"Error getting active rules: {str(e)}"
|
||||
|
||||
|
||||
# Export all tools
|
||||
__all__ = [
|
||||
"create_rule_tool",
|
||||
"list_rules_tool",
|
||||
"get_rule_tool",
|
||||
"delete_rule_tool",
|
||||
"get_active_rules_tool"
|
||||
]
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "0.0.185"
|
||||
version = "0.0.196"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
requires-python = ">=3.10"
|
||||
authors = [
|
||||
@@ -22,7 +22,8 @@ dependencies = [
|
||||
mcp = [
|
||||
"mcp>=1.6.0",
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn>=0.34.0"
|
||||
"uvicorn>=0.34.0",
|
||||
"websockets>=12.0"
|
||||
]
|
||||
|
||||
memory = [
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,252 @@
|
||||
"""
|
||||
Tests for FastContext high-level API.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_workspace():
|
||||
"""Create a temporary workspace with test files."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create directory structure
|
||||
src_dir = Path(tmpdir) / "src"
|
||||
src_dir.mkdir()
|
||||
|
||||
(src_dir / "main.py").write_text("""
|
||||
def main():
|
||||
print("Hello, World!")
|
||||
authenticate_user()
|
||||
|
||||
def authenticate_user():
|
||||
# Authentication logic here
|
||||
pass
|
||||
|
||||
class UserService:
|
||||
def login(self, username, password):
|
||||
return True
|
||||
""")
|
||||
|
||||
(src_dir / "utils.py").write_text("""
|
||||
import os
|
||||
|
||||
def get_config():
|
||||
return {"debug": True}
|
||||
|
||||
def validate_input(data):
|
||||
if not data:
|
||||
raise ValueError("Empty data")
|
||||
return True
|
||||
""")
|
||||
|
||||
auth_dir = src_dir / "auth"
|
||||
auth_dir.mkdir()
|
||||
|
||||
(auth_dir / "handler.py").write_text("""
|
||||
from typing import Optional
|
||||
|
||||
class AuthHandler:
|
||||
def __init__(self):
|
||||
self.token = None
|
||||
|
||||
def authenticate(self, credentials: dict) -> bool:
|
||||
return True
|
||||
|
||||
def logout(self):
|
||||
self.token = None
|
||||
""")
|
||||
|
||||
yield tmpdir
|
||||
|
||||
|
||||
class TestFastContextInit:
|
||||
"""Tests for FastContext initialization."""
|
||||
|
||||
def test_default_initialization(self, temp_workspace):
|
||||
"""Test default initialization."""
|
||||
from praisonaiagents.context.fast import FastContext
|
||||
|
||||
fc = FastContext(workspace_path=temp_workspace)
|
||||
|
||||
assert fc.workspace_path == temp_workspace
|
||||
assert fc.max_turns == 4
|
||||
assert fc.max_parallel == 8
|
||||
assert fc.model == "gpt-4o-mini"
|
||||
assert fc.cache_enabled is True
|
||||
|
||||
def test_custom_initialization(self, temp_workspace):
|
||||
"""Test custom initialization."""
|
||||
from praisonaiagents.context.fast import FastContext
|
||||
|
||||
fc = FastContext(
|
||||
workspace_path=temp_workspace,
|
||||
max_turns=3,
|
||||
max_parallel=4,
|
||||
model="claude-3-haiku-20240307",
|
||||
cache_enabled=False
|
||||
)
|
||||
|
||||
assert fc.max_turns == 3
|
||||
assert fc.max_parallel == 4
|
||||
assert fc.model == "claude-3-haiku-20240307"
|
||||
assert fc.cache_enabled is False
|
||||
|
||||
def test_context_manager(self, temp_workspace):
|
||||
"""Test context manager usage."""
|
||||
from praisonaiagents.context.fast import FastContext
|
||||
|
||||
with FastContext(workspace_path=temp_workspace) as fc:
|
||||
result = fc.search("def")
|
||||
assert result.total_files > 0
|
||||
|
||||
|
||||
class TestFastContextSearch:
|
||||
"""Tests for FastContext search functionality."""
|
||||
|
||||
def test_search_basic(self, temp_workspace):
|
||||
"""Test basic search."""
|
||||
from praisonaiagents.context.fast import FastContext
|
||||
|
||||
fc = FastContext(workspace_path=temp_workspace)
|
||||
result = fc.search("authenticate")
|
||||
|
||||
assert result.total_files > 0
|
||||
file_paths = [f.path for f in result.files]
|
||||
assert any("main.py" in p for p in file_paths)
|
||||
|
||||
def test_search_with_patterns(self, temp_workspace):
|
||||
"""Test search with include patterns."""
|
||||
from praisonaiagents.context.fast import FastContext
|
||||
|
||||
fc = FastContext(workspace_path=temp_workspace)
|
||||
result = fc.search("def", include_patterns=["**/auth/*.py"])
|
||||
|
||||
for f in result.files:
|
||||
assert "auth" in f.path
|
||||
|
||||
def test_search_no_results(self, temp_workspace):
|
||||
"""Test search with no matches."""
|
||||
from praisonaiagents.context.fast import FastContext
|
||||
|
||||
fc = FastContext(workspace_path=temp_workspace)
|
||||
result = fc.search("nonexistent_xyz_123")
|
||||
|
||||
assert result.total_files == 0
|
||||
|
||||
|
||||
class TestFastContextCache:
|
||||
"""Tests for FastContext caching."""
|
||||
|
||||
def test_cache_hit(self, temp_workspace):
|
||||
"""Test cache hit on repeated query."""
|
||||
from praisonaiagents.context.fast import FastContext
|
||||
|
||||
fc = FastContext(workspace_path=temp_workspace, cache_enabled=True)
|
||||
|
||||
# First search
|
||||
result1 = fc.search("authenticate")
|
||||
assert result1.from_cache is False
|
||||
|
||||
# Second search (should hit cache)
|
||||
result2 = fc.search("authenticate")
|
||||
assert result2.from_cache is True
|
||||
|
||||
def test_cache_disabled(self, temp_workspace):
|
||||
"""Test cache disabled."""
|
||||
from praisonaiagents.context.fast import FastContext
|
||||
|
||||
fc = FastContext(workspace_path=temp_workspace, cache_enabled=False)
|
||||
|
||||
result1 = fc.search("authenticate")
|
||||
result2 = fc.search("authenticate")
|
||||
|
||||
assert result1.from_cache is False
|
||||
assert result2.from_cache is False
|
||||
|
||||
def test_clear_cache(self, temp_workspace):
|
||||
"""Test clearing cache."""
|
||||
from praisonaiagents.context.fast import FastContext
|
||||
|
||||
fc = FastContext(workspace_path=temp_workspace, cache_enabled=True)
|
||||
|
||||
fc.search("authenticate")
|
||||
fc.clear_cache()
|
||||
|
||||
result = fc.search("authenticate")
|
||||
assert result.from_cache is False
|
||||
|
||||
|
||||
class TestFastContextFiles:
|
||||
"""Tests for FastContext file operations."""
|
||||
|
||||
def test_search_files(self, temp_workspace):
|
||||
"""Test searching for files."""
|
||||
from praisonaiagents.context.fast import FastContext
|
||||
|
||||
fc = FastContext(workspace_path=temp_workspace)
|
||||
result = fc.search_files("**/*.py")
|
||||
|
||||
assert result.total_files >= 3
|
||||
|
||||
def test_read_context(self, temp_workspace):
|
||||
"""Test reading file context."""
|
||||
from praisonaiagents.context.fast import FastContext
|
||||
|
||||
fc = FastContext(workspace_path=temp_workspace)
|
||||
content = fc.read_context("src/main.py")
|
||||
|
||||
assert content is not None
|
||||
assert "def main():" in content
|
||||
|
||||
def test_read_context_with_lines(self, temp_workspace):
|
||||
"""Test reading specific lines."""
|
||||
from praisonaiagents.context.fast import FastContext
|
||||
|
||||
fc = FastContext(workspace_path=temp_workspace)
|
||||
content = fc.read_context("src/main.py", start_line=1, end_line=5, context_lines=0)
|
||||
|
||||
assert content is not None
|
||||
lines = content.split("\n")
|
||||
assert len(lines) <= 5
|
||||
|
||||
|
||||
class TestFastContextAgentIntegration:
|
||||
"""Tests for FastContext agent integration."""
|
||||
|
||||
def test_get_context_for_agent(self, temp_workspace):
|
||||
"""Test getting formatted context for agent."""
|
||||
from praisonaiagents.context.fast import FastContext
|
||||
|
||||
fc = FastContext(workspace_path=temp_workspace)
|
||||
context = fc.get_context_for_agent("authenticate")
|
||||
|
||||
assert "Relevant Code Context" in context
|
||||
assert "authenticate" in context.lower()
|
||||
|
||||
def test_get_context_for_agent_no_results(self, temp_workspace):
|
||||
"""Test context for agent with no results."""
|
||||
from praisonaiagents.context.fast import FastContext
|
||||
|
||||
fc = FastContext(workspace_path=temp_workspace)
|
||||
context = fc.get_context_for_agent("nonexistent_xyz_123")
|
||||
|
||||
assert "No relevant code found" in context
|
||||
|
||||
|
||||
class TestFastSearchFunction:
|
||||
"""Tests for fast_search convenience function."""
|
||||
|
||||
def test_fast_search(self, temp_workspace):
|
||||
"""Test fast_search function."""
|
||||
from praisonaiagents.context.fast.fast_context import fast_search
|
||||
|
||||
result = fast_search("authenticate", workspace_path=temp_workspace)
|
||||
|
||||
assert result.total_files > 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
Tests for FastContextAgent class.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_workspace():
|
||||
"""Create a temporary workspace with test files."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create directory structure
|
||||
src_dir = Path(tmpdir) / "src"
|
||||
src_dir.mkdir()
|
||||
|
||||
(src_dir / "main.py").write_text("""
|
||||
def main():
|
||||
print("Hello, World!")
|
||||
authenticate_user()
|
||||
|
||||
def authenticate_user():
|
||||
# Authentication logic here
|
||||
pass
|
||||
|
||||
class UserService:
|
||||
def login(self, username, password):
|
||||
return True
|
||||
""")
|
||||
|
||||
(src_dir / "utils.py").write_text("""
|
||||
import os
|
||||
|
||||
def get_config():
|
||||
return {"debug": True}
|
||||
|
||||
def validate_input(data):
|
||||
if not data:
|
||||
raise ValueError("Empty data")
|
||||
return True
|
||||
""")
|
||||
|
||||
auth_dir = src_dir / "auth"
|
||||
auth_dir.mkdir()
|
||||
|
||||
(auth_dir / "handler.py").write_text("""
|
||||
from typing import Optional
|
||||
|
||||
class AuthHandler:
|
||||
def __init__(self):
|
||||
self.token = None
|
||||
|
||||
def authenticate(self, credentials: dict) -> bool:
|
||||
return True
|
||||
|
||||
def logout(self):
|
||||
self.token = None
|
||||
""")
|
||||
|
||||
yield tmpdir
|
||||
|
||||
|
||||
class TestFastContextAgentInit:
|
||||
"""Tests for FastContextAgent initialization."""
|
||||
|
||||
def test_default_initialization(self, temp_workspace):
|
||||
"""Test default initialization."""
|
||||
from praisonaiagents.context.fast.fast_context_agent import FastContextAgent
|
||||
|
||||
agent = FastContextAgent(workspace_path=temp_workspace)
|
||||
|
||||
assert agent.workspace_path == temp_workspace
|
||||
assert agent.max_turns == 4
|
||||
assert agent.max_parallel == 8
|
||||
assert agent.model == "gpt-4o-mini"
|
||||
|
||||
def test_custom_initialization(self, temp_workspace):
|
||||
"""Test custom initialization."""
|
||||
from praisonaiagents.context.fast.fast_context_agent import FastContextAgent
|
||||
|
||||
agent = FastContextAgent(
|
||||
workspace_path=temp_workspace,
|
||||
max_turns=3,
|
||||
max_parallel=4,
|
||||
model="claude-3-haiku-20240307"
|
||||
)
|
||||
|
||||
assert agent.max_turns == 3
|
||||
assert agent.max_parallel == 4
|
||||
assert agent.model == "claude-3-haiku-20240307"
|
||||
|
||||
|
||||
class TestFastContextAgentSearch:
|
||||
"""Tests for FastContextAgent search functionality."""
|
||||
|
||||
def test_search_returns_result(self, temp_workspace):
|
||||
"""Test that search returns FastContextResult."""
|
||||
from praisonaiagents.context.fast.fast_context_agent import FastContextAgent
|
||||
from praisonaiagents.context.fast.result import FastContextResult
|
||||
|
||||
agent = FastContextAgent(workspace_path=temp_workspace)
|
||||
|
||||
# Use simple search without LLM
|
||||
result = agent.search_simple("authenticate")
|
||||
|
||||
assert isinstance(result, FastContextResult)
|
||||
assert result.total_files > 0
|
||||
|
||||
def test_search_finds_relevant_files(self, temp_workspace):
|
||||
"""Test that search finds relevant files."""
|
||||
from praisonaiagents.context.fast.fast_context_agent import FastContextAgent
|
||||
|
||||
agent = FastContextAgent(workspace_path=temp_workspace)
|
||||
|
||||
result = agent.search_simple("authenticate")
|
||||
|
||||
# Should find files containing "authenticate"
|
||||
file_paths = [f.path for f in result.files]
|
||||
assert any("main.py" in p for p in file_paths)
|
||||
assert any("handler.py" in p for p in file_paths)
|
||||
|
||||
def test_search_with_file_pattern(self, temp_workspace):
|
||||
"""Test search with file pattern filter."""
|
||||
from praisonaiagents.context.fast.fast_context_agent import FastContextAgent
|
||||
|
||||
agent = FastContextAgent(workspace_path=temp_workspace)
|
||||
|
||||
result = agent.search_simple("def", include_patterns=["**/auth/*.py"])
|
||||
|
||||
# Should only find in auth directory
|
||||
for f in result.files:
|
||||
assert "auth" in f.path
|
||||
|
||||
def test_search_records_timing(self, temp_workspace):
|
||||
"""Test that search records timing information."""
|
||||
from praisonaiagents.context.fast.fast_context_agent import FastContextAgent
|
||||
|
||||
agent = FastContextAgent(workspace_path=temp_workspace)
|
||||
|
||||
result = agent.search_simple("def")
|
||||
|
||||
# search_time_ms >= 0 is valid (can be 0 for very fast searches)
|
||||
assert result.search_time_ms >= 0
|
||||
assert result.turns_used == 1
|
||||
assert result.total_tool_calls == 1
|
||||
|
||||
def test_search_no_results(self, temp_workspace):
|
||||
"""Test search with no matches."""
|
||||
from praisonaiagents.context.fast.fast_context_agent import FastContextAgent
|
||||
|
||||
agent = FastContextAgent(workspace_path=temp_workspace)
|
||||
|
||||
result = agent.search_simple("nonexistent_pattern_xyz123")
|
||||
|
||||
assert result.total_files == 0
|
||||
|
||||
|
||||
class TestFastContextAgentTools:
|
||||
"""Tests for FastContextAgent tool definitions."""
|
||||
|
||||
def test_get_tools(self, temp_workspace):
|
||||
"""Test getting tool definitions."""
|
||||
from praisonaiagents.context.fast.fast_context_agent import FastContextAgent
|
||||
|
||||
agent = FastContextAgent(workspace_path=temp_workspace)
|
||||
tools = agent.get_tools()
|
||||
|
||||
assert len(tools) == 4
|
||||
tool_names = [t["name"] for t in tools]
|
||||
assert "grep_search" in tool_names
|
||||
assert "glob_search" in tool_names
|
||||
assert "read_file" in tool_names
|
||||
assert "list_directory" in tool_names
|
||||
|
||||
def test_execute_tool(self, temp_workspace):
|
||||
"""Test executing a tool directly."""
|
||||
from praisonaiagents.context.fast.fast_context_agent import FastContextAgent
|
||||
|
||||
agent = FastContextAgent(workspace_path=temp_workspace)
|
||||
|
||||
result = agent.execute_tool("glob_search", pattern="**/*.py")
|
||||
|
||||
assert len(result) >= 3 # At least 3 .py files
|
||||
|
||||
|
||||
class TestFastContextAgentLLMSearch:
|
||||
"""Tests for FastContextAgent LLM-powered search."""
|
||||
|
||||
def test_search_falls_back_to_simple(self, temp_workspace):
|
||||
"""Test that search falls back to simple when LLM not available."""
|
||||
from praisonaiagents.context.fast.fast_context_agent import FastContextAgent
|
||||
|
||||
agent = FastContextAgent(workspace_path=temp_workspace)
|
||||
|
||||
# search() should fall back to search_simple when LLM is not available
|
||||
result = agent.search("find authentication code")
|
||||
|
||||
# Should still return results (from simple search fallback)
|
||||
assert result is not None
|
||||
assert result.query == "find authentication code"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,269 @@
|
||||
"""
|
||||
Tests for Fast Context result classes.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import time
|
||||
|
||||
|
||||
class TestLineRange:
|
||||
"""Tests for LineRange dataclass."""
|
||||
|
||||
def test_line_range_creation(self):
|
||||
"""Test basic LineRange creation."""
|
||||
from praisonaiagents.context.fast.result import LineRange
|
||||
|
||||
lr = LineRange(start=10, end=20)
|
||||
assert lr.start == 10
|
||||
assert lr.end == 20
|
||||
assert lr.content is None
|
||||
assert lr.relevance_score == 1.0
|
||||
|
||||
def test_line_range_with_content(self):
|
||||
"""Test LineRange with content."""
|
||||
from praisonaiagents.context.fast.result import LineRange
|
||||
|
||||
lr = LineRange(start=1, end=3, content="line1\nline2\nline3")
|
||||
assert lr.content == "line1\nline2\nline3"
|
||||
|
||||
def test_line_range_invalid_start(self):
|
||||
"""Test LineRange corrects invalid start."""
|
||||
from praisonaiagents.context.fast.result import LineRange
|
||||
|
||||
lr = LineRange(start=0, end=5)
|
||||
assert lr.start == 1 # Corrected to 1
|
||||
|
||||
def test_line_range_invalid_end(self):
|
||||
"""Test LineRange corrects invalid end."""
|
||||
from praisonaiagents.context.fast.result import LineRange
|
||||
|
||||
lr = LineRange(start=10, end=5)
|
||||
assert lr.end == 10 # Corrected to start
|
||||
|
||||
def test_line_count(self):
|
||||
"""Test line_count property."""
|
||||
from praisonaiagents.context.fast.result import LineRange
|
||||
|
||||
lr = LineRange(start=10, end=20)
|
||||
assert lr.line_count == 11
|
||||
|
||||
def test_overlaps_true(self):
|
||||
"""Test overlaps returns True for overlapping ranges."""
|
||||
from praisonaiagents.context.fast.result import LineRange
|
||||
|
||||
lr1 = LineRange(start=10, end=20)
|
||||
lr2 = LineRange(start=15, end=25)
|
||||
assert lr1.overlaps(lr2) is True
|
||||
assert lr2.overlaps(lr1) is True
|
||||
|
||||
def test_overlaps_false(self):
|
||||
"""Test overlaps returns False for non-overlapping ranges."""
|
||||
from praisonaiagents.context.fast.result import LineRange
|
||||
|
||||
lr1 = LineRange(start=10, end=20)
|
||||
lr2 = LineRange(start=25, end=30)
|
||||
assert lr1.overlaps(lr2) is False
|
||||
|
||||
def test_overlaps_adjacent(self):
|
||||
"""Test overlaps for adjacent ranges."""
|
||||
from praisonaiagents.context.fast.result import LineRange
|
||||
|
||||
lr1 = LineRange(start=10, end=20)
|
||||
lr2 = LineRange(start=21, end=30)
|
||||
assert lr1.overlaps(lr2) is False
|
||||
|
||||
def test_merge(self):
|
||||
"""Test merging overlapping ranges."""
|
||||
from praisonaiagents.context.fast.result import LineRange
|
||||
|
||||
lr1 = LineRange(start=10, end=20, relevance_score=0.8)
|
||||
lr2 = LineRange(start=15, end=25, relevance_score=0.9)
|
||||
merged = lr1.merge(lr2)
|
||||
|
||||
assert merged.start == 10
|
||||
assert merged.end == 25
|
||||
assert merged.relevance_score == 0.9 # Max of both
|
||||
|
||||
def test_merge_non_overlapping_raises(self):
|
||||
"""Test merge raises for non-overlapping ranges."""
|
||||
from praisonaiagents.context.fast.result import LineRange
|
||||
|
||||
lr1 = LineRange(start=10, end=20)
|
||||
lr2 = LineRange(start=25, end=30)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
lr1.merge(lr2)
|
||||
|
||||
|
||||
class TestFileMatch:
|
||||
"""Tests for FileMatch dataclass."""
|
||||
|
||||
def test_file_match_creation(self):
|
||||
"""Test basic FileMatch creation."""
|
||||
from praisonaiagents.context.fast.result import FileMatch
|
||||
|
||||
fm = FileMatch(path="/path/to/file.py")
|
||||
assert fm.path == "/path/to/file.py"
|
||||
assert fm.line_ranges == []
|
||||
assert fm.relevance_score == 1.0
|
||||
assert fm.match_count == 0
|
||||
|
||||
def test_add_line_range(self):
|
||||
"""Test adding line ranges."""
|
||||
from praisonaiagents.context.fast.result import FileMatch, LineRange
|
||||
|
||||
fm = FileMatch(path="/path/to/file.py")
|
||||
fm.add_line_range(LineRange(start=10, end=20))
|
||||
|
||||
assert len(fm.line_ranges) == 1
|
||||
assert fm.line_ranges[0].start == 10
|
||||
|
||||
def test_add_overlapping_line_ranges(self):
|
||||
"""Test adding overlapping line ranges merges them."""
|
||||
from praisonaiagents.context.fast.result import FileMatch, LineRange
|
||||
|
||||
fm = FileMatch(path="/path/to/file.py")
|
||||
fm.add_line_range(LineRange(start=10, end=20))
|
||||
fm.add_line_range(LineRange(start=15, end=25))
|
||||
|
||||
assert len(fm.line_ranges) == 1
|
||||
assert fm.line_ranges[0].start == 10
|
||||
assert fm.line_ranges[0].end == 25
|
||||
|
||||
def test_add_non_overlapping_line_ranges(self):
|
||||
"""Test adding non-overlapping line ranges keeps them separate."""
|
||||
from praisonaiagents.context.fast.result import FileMatch, LineRange
|
||||
|
||||
fm = FileMatch(path="/path/to/file.py")
|
||||
fm.add_line_range(LineRange(start=10, end=20))
|
||||
fm.add_line_range(LineRange(start=30, end=40))
|
||||
|
||||
assert len(fm.line_ranges) == 2
|
||||
|
||||
def test_total_lines(self):
|
||||
"""Test total_lines property."""
|
||||
from praisonaiagents.context.fast.result import FileMatch, LineRange
|
||||
|
||||
fm = FileMatch(path="/path/to/file.py")
|
||||
fm.add_line_range(LineRange(start=10, end=20)) # 11 lines
|
||||
fm.add_line_range(LineRange(start=30, end=35)) # 6 lines
|
||||
|
||||
assert fm.total_lines == 17
|
||||
|
||||
|
||||
class TestFastContextResult:
|
||||
"""Tests for FastContextResult dataclass."""
|
||||
|
||||
def test_result_creation(self):
|
||||
"""Test basic result creation."""
|
||||
from praisonaiagents.context.fast.result import FastContextResult
|
||||
|
||||
result = FastContextResult(query="find authentication")
|
||||
assert result.query == "find authentication"
|
||||
assert result.files == []
|
||||
assert result.search_time_ms == 0
|
||||
|
||||
def test_timer(self):
|
||||
"""Test timer functionality."""
|
||||
from praisonaiagents.context.fast.result import FastContextResult
|
||||
|
||||
result = FastContextResult()
|
||||
result.start_timer()
|
||||
time.sleep(0.1) # 100ms
|
||||
result.stop_timer()
|
||||
|
||||
assert result.search_time_ms >= 100
|
||||
assert result.search_time_ms < 200 # Should be close to 100ms
|
||||
|
||||
def test_add_file(self):
|
||||
"""Test adding files."""
|
||||
from praisonaiagents.context.fast.result import FastContextResult, FileMatch
|
||||
|
||||
result = FastContextResult()
|
||||
result.add_file(FileMatch(path="/path/to/file.py"))
|
||||
|
||||
assert result.total_files == 1
|
||||
|
||||
def test_add_duplicate_file_merges(self):
|
||||
"""Test adding duplicate file path merges them."""
|
||||
from praisonaiagents.context.fast.result import (
|
||||
FastContextResult, FileMatch, LineRange
|
||||
)
|
||||
|
||||
result = FastContextResult()
|
||||
|
||||
fm1 = FileMatch(path="/path/to/file.py", relevance_score=0.8)
|
||||
fm1.add_line_range(LineRange(start=10, end=20))
|
||||
result.add_file(fm1)
|
||||
|
||||
fm2 = FileMatch(path="/path/to/file.py", relevance_score=0.9)
|
||||
fm2.add_line_range(LineRange(start=30, end=40))
|
||||
result.add_file(fm2)
|
||||
|
||||
assert result.total_files == 1
|
||||
assert len(result.files[0].line_ranges) == 2
|
||||
assert result.files[0].relevance_score == 0.9
|
||||
|
||||
def test_sort_by_relevance(self):
|
||||
"""Test sorting files by relevance."""
|
||||
from praisonaiagents.context.fast.result import FastContextResult, FileMatch
|
||||
|
||||
result = FastContextResult()
|
||||
result.add_file(FileMatch(path="/low.py", relevance_score=0.3))
|
||||
result.add_file(FileMatch(path="/high.py", relevance_score=0.9))
|
||||
result.add_file(FileMatch(path="/mid.py", relevance_score=0.6))
|
||||
|
||||
result.sort_by_relevance()
|
||||
|
||||
assert result.files[0].path == "/high.py"
|
||||
assert result.files[1].path == "/mid.py"
|
||||
assert result.files[2].path == "/low.py"
|
||||
|
||||
def test_to_context_string(self):
|
||||
"""Test converting result to context string."""
|
||||
from praisonaiagents.context.fast.result import (
|
||||
FastContextResult, FileMatch, LineRange
|
||||
)
|
||||
|
||||
result = FastContextResult()
|
||||
fm = FileMatch(path="/path/to/file.py")
|
||||
fm.add_line_range(LineRange(start=10, end=20))
|
||||
result.add_file(fm)
|
||||
|
||||
context = result.to_context_string()
|
||||
|
||||
assert "1 relevant file" in context
|
||||
assert "/path/to/file.py" in context
|
||||
assert "Lines 10-20" in context
|
||||
|
||||
def test_to_context_string_empty(self):
|
||||
"""Test context string for empty result."""
|
||||
from praisonaiagents.context.fast.result import FastContextResult
|
||||
|
||||
result = FastContextResult()
|
||||
context = result.to_context_string()
|
||||
|
||||
assert "No relevant files found" in context
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test converting result to dictionary."""
|
||||
from praisonaiagents.context.fast.result import (
|
||||
FastContextResult, FileMatch, LineRange
|
||||
)
|
||||
|
||||
result = FastContextResult(query="test", search_time_ms=100, turns_used=2)
|
||||
fm = FileMatch(path="/file.py", relevance_score=0.8)
|
||||
fm.add_line_range(LineRange(start=10, end=20))
|
||||
result.add_file(fm)
|
||||
|
||||
d = result.to_dict()
|
||||
|
||||
assert d["query"] == "test"
|
||||
assert d["search_time_ms"] == 100
|
||||
assert d["turns_used"] == 2
|
||||
assert len(d["files"]) == 1
|
||||
assert d["files"][0]["path"] == "/file.py"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,261 @@
|
||||
"""
|
||||
Tests for File Indexer.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_workspace():
|
||||
"""Create a temporary workspace with test files."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create directory structure
|
||||
src_dir = Path(tmpdir) / "src"
|
||||
src_dir.mkdir()
|
||||
|
||||
(src_dir / "main.py").write_text("def main(): pass")
|
||||
(src_dir / "utils.py").write_text("def util(): pass")
|
||||
(src_dir / "config.json").write_text('{"key": "value"}')
|
||||
|
||||
auth_dir = src_dir / "auth"
|
||||
auth_dir.mkdir()
|
||||
(auth_dir / "handler.py").write_text("class Handler: pass")
|
||||
|
||||
# Create .gitignore
|
||||
(Path(tmpdir) / ".gitignore").write_text("__pycache__/\n*.pyc\n")
|
||||
|
||||
# Create ignored directory
|
||||
cache_dir = Path(tmpdir) / "__pycache__"
|
||||
cache_dir.mkdir()
|
||||
(cache_dir / "cached.pyc").write_text("cached")
|
||||
|
||||
yield tmpdir
|
||||
|
||||
|
||||
class TestFileIndexerInit:
|
||||
"""Tests for FileIndexer initialization."""
|
||||
|
||||
def test_default_initialization(self, temp_workspace):
|
||||
"""Test default initialization."""
|
||||
from praisonaiagents.context.fast.indexer import FileIndexer
|
||||
|
||||
indexer = FileIndexer(workspace_path=temp_workspace)
|
||||
|
||||
assert indexer.workspace_path == temp_workspace
|
||||
assert indexer.respect_gitignore is True
|
||||
assert indexer.total_files == 0
|
||||
|
||||
def test_custom_extensions(self, temp_workspace):
|
||||
"""Test custom extensions."""
|
||||
from praisonaiagents.context.fast.indexer import FileIndexer
|
||||
|
||||
indexer = FileIndexer(
|
||||
workspace_path=temp_workspace,
|
||||
extensions={".py"}
|
||||
)
|
||||
|
||||
assert indexer.extensions == {".py"}
|
||||
|
||||
|
||||
class TestFileIndexerIndex:
|
||||
"""Tests for FileIndexer indexing."""
|
||||
|
||||
def test_index_files(self, temp_workspace):
|
||||
"""Test indexing files."""
|
||||
from praisonaiagents.context.fast.indexer import FileIndexer
|
||||
|
||||
indexer = FileIndexer(workspace_path=temp_workspace)
|
||||
count = indexer.index()
|
||||
|
||||
assert count >= 3 # At least main.py, utils.py, handler.py
|
||||
assert indexer.total_files >= 3
|
||||
|
||||
def test_index_respects_gitignore(self, temp_workspace):
|
||||
"""Test that indexing respects .gitignore."""
|
||||
from praisonaiagents.context.fast.indexer import FileIndexer
|
||||
|
||||
indexer = FileIndexer(workspace_path=temp_workspace, respect_gitignore=True)
|
||||
indexer.index()
|
||||
|
||||
# Should not include __pycache__ files
|
||||
for path in indexer.files:
|
||||
assert "__pycache__" not in path
|
||||
assert not path.endswith(".pyc")
|
||||
|
||||
def test_index_by_extension(self, temp_workspace):
|
||||
"""Test indexing organizes by extension."""
|
||||
from praisonaiagents.context.fast.indexer import FileIndexer
|
||||
|
||||
indexer = FileIndexer(workspace_path=temp_workspace)
|
||||
indexer.index()
|
||||
|
||||
assert ".py" in indexer.by_extension
|
||||
assert len(indexer.by_extension[".py"]) >= 3
|
||||
|
||||
def test_index_with_extension_filter(self, temp_workspace):
|
||||
"""Test indexing with extension filter."""
|
||||
from praisonaiagents.context.fast.indexer import FileIndexer
|
||||
|
||||
indexer = FileIndexer(workspace_path=temp_workspace, extensions={".py"})
|
||||
indexer.index()
|
||||
|
||||
# Should only have .py files
|
||||
for path in indexer.files:
|
||||
assert path.endswith(".py")
|
||||
|
||||
|
||||
class TestFileIndexerFind:
|
||||
"""Tests for FileIndexer find methods."""
|
||||
|
||||
def test_find_by_pattern(self, temp_workspace):
|
||||
"""Test finding files by pattern."""
|
||||
from praisonaiagents.context.fast.indexer import FileIndexer
|
||||
|
||||
indexer = FileIndexer(workspace_path=temp_workspace)
|
||||
indexer.index()
|
||||
|
||||
results = indexer.find_by_pattern("**/*.py")
|
||||
|
||||
assert len(results) >= 3
|
||||
for info in results:
|
||||
assert info.path.endswith(".py")
|
||||
|
||||
def test_find_by_extension(self, temp_workspace):
|
||||
"""Test finding files by extension."""
|
||||
from praisonaiagents.context.fast.indexer import FileIndexer
|
||||
|
||||
indexer = FileIndexer(workspace_path=temp_workspace)
|
||||
indexer.index()
|
||||
|
||||
results = indexer.find_by_extension(".py")
|
||||
|
||||
assert len(results) >= 3
|
||||
|
||||
def test_find_by_extension_without_dot(self, temp_workspace):
|
||||
"""Test finding files by extension without dot."""
|
||||
from praisonaiagents.context.fast.indexer import FileIndexer
|
||||
|
||||
indexer = FileIndexer(workspace_path=temp_workspace)
|
||||
indexer.index()
|
||||
|
||||
results = indexer.find_by_extension("py")
|
||||
|
||||
assert len(results) >= 3
|
||||
|
||||
def test_find_by_name_exact(self, temp_workspace):
|
||||
"""Test finding files by exact name."""
|
||||
from praisonaiagents.context.fast.indexer import FileIndexer
|
||||
|
||||
indexer = FileIndexer(workspace_path=temp_workspace)
|
||||
indexer.index()
|
||||
|
||||
results = indexer.find_by_name("main.py", exact=True)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].path.endswith("main.py")
|
||||
|
||||
def test_find_by_name_partial(self, temp_workspace):
|
||||
"""Test finding files by partial name."""
|
||||
from praisonaiagents.context.fast.indexer import FileIndexer
|
||||
|
||||
indexer = FileIndexer(workspace_path=temp_workspace)
|
||||
indexer.index()
|
||||
|
||||
results = indexer.find_by_name("handler")
|
||||
|
||||
assert len(results) >= 1
|
||||
assert any("handler" in r.path for r in results)
|
||||
|
||||
def test_get_file(self, temp_workspace):
|
||||
"""Test getting file by path."""
|
||||
from praisonaiagents.context.fast.indexer import FileIndexer
|
||||
|
||||
indexer = FileIndexer(workspace_path=temp_workspace)
|
||||
indexer.index()
|
||||
|
||||
# Find a file first
|
||||
results = indexer.find_by_name("main.py", exact=True)
|
||||
assert len(results) > 0
|
||||
|
||||
# Get by path
|
||||
file_info = indexer.get_file(results[0].path)
|
||||
assert file_info is not None
|
||||
assert file_info.path == results[0].path
|
||||
|
||||
|
||||
class TestFileIndexerStats:
|
||||
"""Tests for FileIndexer statistics."""
|
||||
|
||||
def test_get_stats(self, temp_workspace):
|
||||
"""Test getting index statistics."""
|
||||
from praisonaiagents.context.fast.indexer import FileIndexer
|
||||
|
||||
indexer = FileIndexer(workspace_path=temp_workspace)
|
||||
indexer.index()
|
||||
|
||||
stats = indexer.get_stats()
|
||||
|
||||
assert stats["total_files"] >= 3
|
||||
assert stats["total_size_bytes"] > 0
|
||||
assert ".py" in stats["extensions"]
|
||||
|
||||
def test_is_stale(self, temp_workspace):
|
||||
"""Test stale check."""
|
||||
from praisonaiagents.context.fast.indexer import FileIndexer
|
||||
|
||||
indexer = FileIndexer(workspace_path=temp_workspace)
|
||||
|
||||
# Before indexing, should be stale
|
||||
assert indexer.is_stale() is True
|
||||
|
||||
indexer.index()
|
||||
|
||||
# After indexing, should not be stale
|
||||
assert indexer.is_stale(max_age=60.0) is False
|
||||
|
||||
|
||||
class TestFileIndexerPersistence:
|
||||
"""Tests for FileIndexer save/load."""
|
||||
|
||||
def test_save_and_load(self, temp_workspace):
|
||||
"""Test saving and loading index."""
|
||||
from praisonaiagents.context.fast.indexer import FileIndexer
|
||||
|
||||
indexer = FileIndexer(workspace_path=temp_workspace)
|
||||
indexer.index()
|
||||
|
||||
# Save index
|
||||
index_path = os.path.join(temp_workspace, ".fast_context_index.json")
|
||||
indexer.save_index(index_path)
|
||||
|
||||
assert os.path.exists(index_path)
|
||||
|
||||
# Load into new indexer
|
||||
indexer2 = FileIndexer(workspace_path=temp_workspace)
|
||||
loaded = indexer2.load_index(index_path)
|
||||
|
||||
assert loaded is True
|
||||
assert indexer2.total_files == indexer.total_files
|
||||
|
||||
def test_load_wrong_workspace(self, temp_workspace):
|
||||
"""Test loading index from wrong workspace."""
|
||||
from praisonaiagents.context.fast.indexer import FileIndexer
|
||||
|
||||
indexer = FileIndexer(workspace_path=temp_workspace)
|
||||
indexer.index()
|
||||
|
||||
index_path = os.path.join(temp_workspace, ".fast_context_index.json")
|
||||
indexer.save_index(index_path)
|
||||
|
||||
# Try to load with different workspace
|
||||
indexer2 = FileIndexer(workspace_path="/different/path")
|
||||
loaded = indexer2.load_index(index_path)
|
||||
|
||||
assert loaded is False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
Tests for Fast Context parallel executor.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
import time
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_workspace():
|
||||
"""Create a temporary workspace with test files."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create some test files
|
||||
for i in range(5):
|
||||
(Path(tmpdir) / f"file{i}.py").write_text(f"# File {i}\ndef func{i}(): pass")
|
||||
yield tmpdir
|
||||
|
||||
|
||||
class TestParallelExecutor:
|
||||
"""Tests for ParallelExecutor class."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_single_task(self, temp_workspace):
|
||||
"""Test executing a single task."""
|
||||
from praisonaiagents.context.fast.parallel_executor import ParallelExecutor
|
||||
from praisonaiagents.context.fast.search_tools import glob_search
|
||||
|
||||
executor = ParallelExecutor(max_parallel=8)
|
||||
|
||||
tasks = [
|
||||
{"tool": "glob_search", "args": {"search_path": temp_workspace, "pattern": "*.py"}}
|
||||
]
|
||||
|
||||
results = await executor.execute(tasks)
|
||||
|
||||
assert len(results) == 1
|
||||
assert len(results[0]) == 5 # 5 .py files
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_multiple_tasks_parallel(self, temp_workspace):
|
||||
"""Test executing multiple tasks in parallel."""
|
||||
from praisonaiagents.context.fast.parallel_executor import ParallelExecutor
|
||||
|
||||
executor = ParallelExecutor(max_parallel=8)
|
||||
|
||||
tasks = [
|
||||
{"tool": "glob_search", "args": {"search_path": temp_workspace, "pattern": "*.py"}},
|
||||
{"tool": "grep_search", "args": {"search_path": temp_workspace, "pattern": "def"}},
|
||||
{"tool": "list_directory", "args": {"dir_path": temp_workspace}},
|
||||
]
|
||||
|
||||
start = time.perf_counter()
|
||||
results = await executor.execute(tasks)
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
assert len(results) == 3
|
||||
# All tasks should complete (parallel execution)
|
||||
assert all(r is not None for r in results)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_with_timeout(self, temp_workspace):
|
||||
"""Test execution with timeout."""
|
||||
from praisonaiagents.context.fast.parallel_executor import ParallelExecutor
|
||||
|
||||
executor = ParallelExecutor(max_parallel=8, timeout=0.001) # Very short timeout
|
||||
|
||||
# This should timeout for slow operations
|
||||
tasks = [
|
||||
{"tool": "glob_search", "args": {"search_path": temp_workspace, "pattern": "**/*.py"}}
|
||||
]
|
||||
|
||||
results = await executor.execute(tasks)
|
||||
|
||||
# Should return results (either success or timeout error)
|
||||
assert len(results) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_respects_max_parallel(self, temp_workspace):
|
||||
"""Test that max_parallel limit is respected."""
|
||||
from praisonaiagents.context.fast.parallel_executor import ParallelExecutor
|
||||
|
||||
executor = ParallelExecutor(max_parallel=2)
|
||||
|
||||
# Create more tasks than max_parallel
|
||||
tasks = [
|
||||
{"tool": "glob_search", "args": {"search_path": temp_workspace, "pattern": "*.py"}}
|
||||
for _ in range(5)
|
||||
]
|
||||
|
||||
results = await executor.execute(tasks)
|
||||
|
||||
assert len(results) == 5
|
||||
# All should complete successfully
|
||||
assert all(len(r) == 5 for r in results)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_handles_errors(self, temp_workspace):
|
||||
"""Test that errors in one task don't affect others."""
|
||||
from praisonaiagents.context.fast.parallel_executor import ParallelExecutor
|
||||
|
||||
executor = ParallelExecutor(max_parallel=8)
|
||||
|
||||
tasks = [
|
||||
{"tool": "glob_search", "args": {"search_path": temp_workspace, "pattern": "*.py"}},
|
||||
{"tool": "read_file", "args": {"filepath": "/nonexistent/file.py"}}, # Will fail
|
||||
{"tool": "list_directory", "args": {"dir_path": temp_workspace}},
|
||||
]
|
||||
|
||||
results = await executor.execute(tasks)
|
||||
|
||||
assert len(results) == 3
|
||||
# First and third should succeed
|
||||
assert len(results[0]) == 5
|
||||
assert results[1]["success"] is False # Error result
|
||||
assert results[2]["success"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_unknown_tool(self, temp_workspace):
|
||||
"""Test handling of unknown tool."""
|
||||
from praisonaiagents.context.fast.parallel_executor import ParallelExecutor
|
||||
|
||||
executor = ParallelExecutor(max_parallel=8)
|
||||
|
||||
tasks = [
|
||||
{"tool": "unknown_tool", "args": {}}
|
||||
]
|
||||
|
||||
results = await executor.execute(tasks)
|
||||
|
||||
assert len(results) == 1
|
||||
assert "error" in results[0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_empty_tasks(self):
|
||||
"""Test executing empty task list."""
|
||||
from praisonaiagents.context.fast.parallel_executor import ParallelExecutor
|
||||
|
||||
executor = ParallelExecutor(max_parallel=8)
|
||||
|
||||
results = await executor.execute([])
|
||||
|
||||
assert results == []
|
||||
|
||||
def test_execute_sync(self, temp_workspace):
|
||||
"""Test synchronous execution wrapper."""
|
||||
from praisonaiagents.context.fast.parallel_executor import ParallelExecutor
|
||||
|
||||
executor = ParallelExecutor(max_parallel=8)
|
||||
|
||||
tasks = [
|
||||
{"tool": "glob_search", "args": {"search_path": temp_workspace, "pattern": "*.py"}}
|
||||
]
|
||||
|
||||
results = executor.execute_sync(tasks)
|
||||
|
||||
assert len(results) == 1
|
||||
assert len(results[0]) == 5
|
||||
|
||||
|
||||
class TestToolCallBatch:
|
||||
"""Tests for ToolCallBatch class."""
|
||||
|
||||
def test_batch_creation(self):
|
||||
"""Test creating a tool call batch."""
|
||||
from praisonaiagents.context.fast.parallel_executor import ToolCallBatch
|
||||
|
||||
batch = ToolCallBatch()
|
||||
batch.add("grep_search", search_path="/path", pattern="test")
|
||||
batch.add("glob_search", search_path="/path", pattern="*.py")
|
||||
|
||||
assert len(batch.tasks) == 2
|
||||
|
||||
def test_batch_max_size(self):
|
||||
"""Test batch respects max size."""
|
||||
from praisonaiagents.context.fast.parallel_executor import ToolCallBatch
|
||||
|
||||
batch = ToolCallBatch(max_size=2)
|
||||
batch.add("grep_search", search_path="/path", pattern="test1")
|
||||
batch.add("grep_search", search_path="/path", pattern="test2")
|
||||
|
||||
# Should not add beyond max_size
|
||||
added = batch.add("grep_search", search_path="/path", pattern="test3")
|
||||
|
||||
assert added is False
|
||||
assert len(batch.tasks) == 2
|
||||
|
||||
def test_batch_is_full(self):
|
||||
"""Test is_full property."""
|
||||
from praisonaiagents.context.fast.parallel_executor import ToolCallBatch
|
||||
|
||||
batch = ToolCallBatch(max_size=2)
|
||||
assert batch.is_full is False
|
||||
|
||||
batch.add("grep_search", search_path="/path", pattern="test1")
|
||||
assert batch.is_full is False
|
||||
|
||||
batch.add("grep_search", search_path="/path", pattern="test2")
|
||||
assert batch.is_full is True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,336 @@
|
||||
"""
|
||||
Tests for Fast Context search tools.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_workspace():
|
||||
"""Create a temporary workspace with test files."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create directory structure
|
||||
src_dir = Path(tmpdir) / "src"
|
||||
src_dir.mkdir()
|
||||
|
||||
# Create Python files
|
||||
(src_dir / "main.py").write_text("""
|
||||
def main():
|
||||
print("Hello, World!")
|
||||
authenticate_user()
|
||||
|
||||
def authenticate_user():
|
||||
# Authentication logic here
|
||||
pass
|
||||
|
||||
class UserService:
|
||||
def login(self, username, password):
|
||||
return True
|
||||
""")
|
||||
|
||||
(src_dir / "utils.py").write_text("""
|
||||
import os
|
||||
|
||||
def get_config():
|
||||
return {"debug": True}
|
||||
|
||||
def validate_input(data):
|
||||
if not data:
|
||||
raise ValueError("Empty data")
|
||||
return True
|
||||
""")
|
||||
|
||||
# Create subdirectory
|
||||
auth_dir = src_dir / "auth"
|
||||
auth_dir.mkdir()
|
||||
|
||||
(auth_dir / "handler.py").write_text("""
|
||||
from typing import Optional
|
||||
|
||||
class AuthHandler:
|
||||
def __init__(self):
|
||||
self.token = None
|
||||
|
||||
def authenticate(self, credentials: dict) -> bool:
|
||||
# Authenticate user
|
||||
return True
|
||||
|
||||
def logout(self):
|
||||
self.token = None
|
||||
""")
|
||||
|
||||
# Create a .gitignore
|
||||
(Path(tmpdir) / ".gitignore").write_text("""
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
""")
|
||||
|
||||
# Create ignored directory
|
||||
cache_dir = Path(tmpdir) / "__pycache__"
|
||||
cache_dir.mkdir()
|
||||
(cache_dir / "cached.pyc").write_text("cached content")
|
||||
|
||||
yield tmpdir
|
||||
|
||||
|
||||
class TestGrepSearch:
|
||||
"""Tests for grep_search function."""
|
||||
|
||||
def test_grep_simple_pattern(self, temp_workspace):
|
||||
"""Test grep with simple pattern."""
|
||||
from praisonaiagents.context.fast.search_tools import grep_search
|
||||
|
||||
results = grep_search(temp_workspace, "authenticate")
|
||||
|
||||
assert len(results) > 0
|
||||
# Should find in main.py and handler.py
|
||||
paths = [r["path"] for r in results]
|
||||
assert any("main.py" in p for p in paths)
|
||||
assert any("handler.py" in p for p in paths)
|
||||
|
||||
def test_grep_regex_pattern(self, temp_workspace):
|
||||
"""Test grep with regex pattern."""
|
||||
from praisonaiagents.context.fast.search_tools import grep_search
|
||||
|
||||
results = grep_search(temp_workspace, r"def \w+\(", is_regex=True)
|
||||
|
||||
assert len(results) > 0
|
||||
# Should find function definitions
|
||||
|
||||
def test_grep_case_insensitive(self, temp_workspace):
|
||||
"""Test grep case insensitive search."""
|
||||
from praisonaiagents.context.fast.search_tools import grep_search
|
||||
|
||||
results = grep_search(temp_workspace, "AUTHENTICATE", case_sensitive=False)
|
||||
|
||||
assert len(results) > 0
|
||||
|
||||
def test_grep_case_sensitive(self, temp_workspace):
|
||||
"""Test grep case sensitive search."""
|
||||
from praisonaiagents.context.fast.search_tools import grep_search
|
||||
|
||||
results = grep_search(temp_workspace, "AUTHENTICATE", case_sensitive=True)
|
||||
|
||||
assert len(results) == 0 # No uppercase AUTHENTICATE exists
|
||||
|
||||
def test_grep_with_file_pattern(self, temp_workspace):
|
||||
"""Test grep with file pattern filter."""
|
||||
from praisonaiagents.context.fast.search_tools import grep_search
|
||||
|
||||
results = grep_search(temp_workspace, "def", include_patterns=["*.py"])
|
||||
|
||||
assert len(results) > 0
|
||||
# All results should be .py files
|
||||
for r in results:
|
||||
assert r["path"].endswith(".py")
|
||||
|
||||
def test_grep_with_exclude_pattern(self, temp_workspace):
|
||||
"""Test grep with exclude pattern."""
|
||||
from praisonaiagents.context.fast.search_tools import grep_search
|
||||
|
||||
results = grep_search(temp_workspace, "def", exclude_patterns=["**/auth/*"])
|
||||
|
||||
# Should not include auth directory files
|
||||
for r in results:
|
||||
assert "auth" not in r["path"]
|
||||
|
||||
def test_grep_max_results(self, temp_workspace):
|
||||
"""Test grep with max results limit."""
|
||||
from praisonaiagents.context.fast.search_tools import grep_search
|
||||
|
||||
results = grep_search(temp_workspace, "def", max_results=2)
|
||||
|
||||
assert len(results) <= 2
|
||||
|
||||
def test_grep_no_matches(self, temp_workspace):
|
||||
"""Test grep with no matches."""
|
||||
from praisonaiagents.context.fast.search_tools import grep_search
|
||||
|
||||
results = grep_search(temp_workspace, "nonexistent_pattern_xyz")
|
||||
|
||||
assert len(results) == 0
|
||||
|
||||
def test_grep_respects_gitignore(self, temp_workspace):
|
||||
"""Test grep respects .gitignore patterns."""
|
||||
from praisonaiagents.context.fast.search_tools import grep_search
|
||||
|
||||
results = grep_search(temp_workspace, "cached", respect_gitignore=True)
|
||||
|
||||
# Should not find content in __pycache__
|
||||
for r in results:
|
||||
assert "__pycache__" not in r["path"]
|
||||
|
||||
|
||||
class TestGlobSearch:
|
||||
"""Tests for glob_search function."""
|
||||
|
||||
def test_glob_all_python_files(self, temp_workspace):
|
||||
"""Test glob to find all Python files."""
|
||||
from praisonaiagents.context.fast.search_tools import glob_search
|
||||
|
||||
results = glob_search(temp_workspace, "**/*.py")
|
||||
|
||||
assert len(results) >= 3 # main.py, utils.py, handler.py
|
||||
for r in results:
|
||||
assert r["path"].endswith(".py")
|
||||
|
||||
def test_glob_specific_directory(self, temp_workspace):
|
||||
"""Test glob in specific directory."""
|
||||
from praisonaiagents.context.fast.search_tools import glob_search
|
||||
|
||||
results = glob_search(temp_workspace, "src/auth/*.py")
|
||||
|
||||
assert len(results) == 1
|
||||
assert "handler.py" in results[0]["path"]
|
||||
|
||||
def test_glob_by_name(self, temp_workspace):
|
||||
"""Test glob by filename."""
|
||||
from praisonaiagents.context.fast.search_tools import glob_search
|
||||
|
||||
results = glob_search(temp_workspace, "**/main.py")
|
||||
|
||||
assert len(results) == 1
|
||||
assert "main.py" in results[0]["path"]
|
||||
|
||||
def test_glob_no_matches(self, temp_workspace):
|
||||
"""Test glob with no matches."""
|
||||
from praisonaiagents.context.fast.search_tools import glob_search
|
||||
|
||||
results = glob_search(temp_workspace, "**/*.java")
|
||||
|
||||
assert len(results) == 0
|
||||
|
||||
def test_glob_max_results(self, temp_workspace):
|
||||
"""Test glob with max results."""
|
||||
from praisonaiagents.context.fast.search_tools import glob_search
|
||||
|
||||
results = glob_search(temp_workspace, "**/*.py", max_results=2)
|
||||
|
||||
assert len(results) <= 2
|
||||
|
||||
def test_glob_respects_gitignore(self, temp_workspace):
|
||||
"""Test glob respects .gitignore."""
|
||||
from praisonaiagents.context.fast.search_tools import glob_search
|
||||
|
||||
results = glob_search(temp_workspace, "**/*.pyc", respect_gitignore=True)
|
||||
|
||||
assert len(results) == 0 # .pyc is in .gitignore
|
||||
|
||||
|
||||
class TestReadFile:
|
||||
"""Tests for read_file function."""
|
||||
|
||||
def test_read_entire_file(self, temp_workspace):
|
||||
"""Test reading entire file."""
|
||||
from praisonaiagents.context.fast.search_tools import read_file
|
||||
|
||||
result = read_file(os.path.join(temp_workspace, "src", "main.py"))
|
||||
|
||||
assert result["success"] is True
|
||||
assert "def main():" in result["content"]
|
||||
assert result["total_lines"] > 0
|
||||
|
||||
def test_read_file_with_line_range(self, temp_workspace):
|
||||
"""Test reading specific line range."""
|
||||
from praisonaiagents.context.fast.search_tools import read_file
|
||||
|
||||
result = read_file(
|
||||
os.path.join(temp_workspace, "src", "main.py"),
|
||||
start_line=1,
|
||||
end_line=3
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["start_line"] == 1
|
||||
assert result["end_line"] == 3
|
||||
lines = result["content"].split("\n")
|
||||
assert len(lines) <= 3
|
||||
|
||||
def test_read_nonexistent_file(self, temp_workspace):
|
||||
"""Test reading nonexistent file."""
|
||||
from praisonaiagents.context.fast.search_tools import read_file
|
||||
|
||||
result = read_file(os.path.join(temp_workspace, "nonexistent.py"))
|
||||
|
||||
assert result["success"] is False
|
||||
assert "error" in result
|
||||
|
||||
def test_read_file_with_context(self, temp_workspace):
|
||||
"""Test reading with context lines."""
|
||||
from praisonaiagents.context.fast.search_tools import read_file
|
||||
|
||||
result = read_file(
|
||||
os.path.join(temp_workspace, "src", "main.py"),
|
||||
start_line=5,
|
||||
end_line=5,
|
||||
context_lines=2
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
# Should include 2 lines before and after line 5
|
||||
|
||||
|
||||
class TestListDirectory:
|
||||
"""Tests for list_directory function."""
|
||||
|
||||
def test_list_directory(self, temp_workspace):
|
||||
"""Test listing directory contents."""
|
||||
from praisonaiagents.context.fast.search_tools import list_directory
|
||||
|
||||
result = list_directory(temp_workspace)
|
||||
|
||||
assert result["success"] is True
|
||||
assert len(result["entries"]) > 0
|
||||
|
||||
# Should contain src directory
|
||||
names = [e["name"] for e in result["entries"]]
|
||||
assert "src" in names
|
||||
|
||||
def test_list_directory_recursive(self, temp_workspace):
|
||||
"""Test listing directory recursively."""
|
||||
from praisonaiagents.context.fast.search_tools import list_directory
|
||||
|
||||
result = list_directory(temp_workspace, recursive=True)
|
||||
|
||||
assert result["success"] is True
|
||||
# Should find files in subdirectories
|
||||
paths = [e["path"] for e in result["entries"]]
|
||||
assert any("main.py" in p for p in paths)
|
||||
assert any("handler.py" in p for p in paths)
|
||||
|
||||
def test_list_directory_max_depth(self, temp_workspace):
|
||||
"""Test listing with max depth."""
|
||||
from praisonaiagents.context.fast.search_tools import list_directory
|
||||
|
||||
result = list_directory(temp_workspace, recursive=True, max_depth=1)
|
||||
|
||||
assert result["success"] is True
|
||||
# Should not find files in auth subdirectory (depth 2)
|
||||
paths = [e["path"] for e in result["entries"]]
|
||||
assert not any("handler.py" in p for p in paths)
|
||||
|
||||
def test_list_nonexistent_directory(self, temp_workspace):
|
||||
"""Test listing nonexistent directory."""
|
||||
from praisonaiagents.context.fast.search_tools import list_directory
|
||||
|
||||
result = list_directory(os.path.join(temp_workspace, "nonexistent"))
|
||||
|
||||
assert result["success"] is False
|
||||
|
||||
def test_list_directory_respects_gitignore(self, temp_workspace):
|
||||
"""Test listing respects .gitignore."""
|
||||
from praisonaiagents.context.fast.search_tools import list_directory
|
||||
|
||||
result = list_directory(temp_workspace, recursive=True, respect_gitignore=True)
|
||||
|
||||
# Should not include __pycache__
|
||||
paths = [e["path"] for e in result["entries"]]
|
||||
assert not any("__pycache__" in p for p in paths)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Tests for MCP Backward Compatibility - TDD approach.
|
||||
|
||||
These tests ensure backward compatibility with older MCP servers
|
||||
and the deprecated HTTP+SSE transport from protocol version 2024-11-05.
|
||||
|
||||
Backward compatibility features:
|
||||
- Legacy SSE endpoint support (/sse suffix)
|
||||
- Fallback detection for older servers
|
||||
- Automatic transport negotiation
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestLegacySSESupport:
|
||||
"""Test legacy SSE transport support."""
|
||||
|
||||
def test_sse_url_detection(self):
|
||||
"""Test that /sse URLs are detected as legacy SSE."""
|
||||
from praisonaiagents.mcp.mcp_transport import get_transport_type
|
||||
|
||||
assert get_transport_type("http://localhost:8080/sse") == "sse"
|
||||
assert get_transport_type("https://example.com/api/sse") == "sse"
|
||||
|
||||
def test_non_sse_http_uses_streamable(self):
|
||||
"""Test that non-/sse HTTP URLs use Streamable HTTP."""
|
||||
from praisonaiagents.mcp.mcp_transport import get_transport_type
|
||||
|
||||
assert get_transport_type("http://localhost:8080/mcp") == "http_stream"
|
||||
assert get_transport_type("https://example.com/api") == "http_stream"
|
||||
|
||||
|
||||
class TestTransportNegotiation:
|
||||
"""Test automatic transport negotiation."""
|
||||
|
||||
def test_detect_streamable_http_support(self):
|
||||
"""Test detection of Streamable HTTP support."""
|
||||
from praisonaiagents.mcp.mcp_compat import detect_transport_support
|
||||
|
||||
# Successful POST to MCP endpoint indicates Streamable HTTP
|
||||
result = detect_transport_support(
|
||||
response_status=200,
|
||||
content_type="application/json"
|
||||
)
|
||||
assert result == "http_stream"
|
||||
|
||||
def test_detect_legacy_sse_fallback(self):
|
||||
"""Test fallback to legacy SSE on specific error codes."""
|
||||
from praisonaiagents.mcp.mcp_compat import detect_transport_support
|
||||
|
||||
# 400, 404, 405 indicate need for legacy SSE
|
||||
assert detect_transport_support(response_status=400) == "sse"
|
||||
assert detect_transport_support(response_status=404) == "sse"
|
||||
assert detect_transport_support(response_status=405) == "sse"
|
||||
|
||||
def test_detect_sse_from_event_stream(self):
|
||||
"""Test detection of SSE from content type."""
|
||||
from praisonaiagents.mcp.mcp_compat import detect_transport_support
|
||||
|
||||
result = detect_transport_support(
|
||||
response_status=200,
|
||||
content_type="text/event-stream"
|
||||
)
|
||||
# SSE response indicates legacy transport
|
||||
assert result in ["sse", "http_stream"]
|
||||
|
||||
|
||||
class TestProtocolVersionCompatibility:
|
||||
"""Test protocol version compatibility."""
|
||||
|
||||
def test_supports_2024_11_05(self):
|
||||
"""Test support for protocol version 2024-11-05."""
|
||||
from praisonaiagents.mcp.mcp_session import is_valid_protocol_version
|
||||
|
||||
assert is_valid_protocol_version("2024-11-05") is True
|
||||
|
||||
def test_supports_2025_03_26(self):
|
||||
"""Test support for protocol version 2025-03-26."""
|
||||
from praisonaiagents.mcp.mcp_session import is_valid_protocol_version
|
||||
|
||||
assert is_valid_protocol_version("2025-03-26") is True
|
||||
|
||||
def test_supports_2025_11_25(self):
|
||||
"""Test support for protocol version 2025-11-25."""
|
||||
from praisonaiagents.mcp.mcp_session import is_valid_protocol_version
|
||||
|
||||
assert is_valid_protocol_version("2025-11-25") is True
|
||||
|
||||
|
||||
class TestMCPClassBackwardCompat:
|
||||
"""Test MCP class backward compatibility."""
|
||||
|
||||
def test_mcp_accepts_sse_url(self):
|
||||
"""Test MCP class accepts /sse URLs."""
|
||||
# This is a design test - actual connection would require server
|
||||
from praisonaiagents.mcp.mcp_transport import get_transport_type
|
||||
|
||||
# Verify URL would be routed to SSE transport
|
||||
assert get_transport_type("http://localhost:8080/sse") == "sse"
|
||||
|
||||
def test_mcp_accepts_command_string(self):
|
||||
"""Test MCP class accepts command strings (stdio)."""
|
||||
from praisonaiagents.mcp.mcp_transport import get_transport_type
|
||||
|
||||
assert get_transport_type("python server.py") == "stdio"
|
||||
assert get_transport_type("npx @mcp/server") == "stdio"
|
||||
|
||||
def test_mcp_accepts_http_url(self):
|
||||
"""Test MCP class accepts HTTP URLs."""
|
||||
from praisonaiagents.mcp.mcp_transport import get_transport_type
|
||||
|
||||
assert get_transport_type("http://localhost:8080/mcp") == "http_stream"
|
||||
|
||||
def test_mcp_accepts_websocket_url(self):
|
||||
"""Test MCP class accepts WebSocket URLs."""
|
||||
from praisonaiagents.mcp.mcp_transport import get_transport_type
|
||||
|
||||
assert get_transport_type("ws://localhost:8080/mcp") == "websocket"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,323 @@
|
||||
"""
|
||||
Tests for MCP HTTP Stream Transport features.
|
||||
|
||||
Tests the HTTPStreamTransport implementation including:
|
||||
- Protocol version header (Mcp-Protocol-Version)
|
||||
- Session management (Mcp-Session-Id)
|
||||
- Resumability (Last-Event-ID, retry delay)
|
||||
- Session termination (HTTP DELETE)
|
||||
- SSE event parsing
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestHTTPStreamTransportInit:
|
||||
"""Test HTTPStreamTransport initialization."""
|
||||
|
||||
def test_transport_has_protocol_version_header(self):
|
||||
"""Test that transport includes Mcp-Protocol-Version header."""
|
||||
from praisonaiagents.mcp.mcp_http_stream import HTTPStreamTransport
|
||||
|
||||
transport = HTTPStreamTransport('https://api.example.com/mcp')
|
||||
|
||||
assert 'Mcp-Protocol-Version' in transport.headers
|
||||
assert transport.headers['Mcp-Protocol-Version'] == '2025-03-26'
|
||||
|
||||
def test_transport_default_protocol_version(self):
|
||||
"""Test default protocol version for backward compatibility."""
|
||||
from praisonaiagents.mcp.mcp_http_stream import HTTPStreamTransport
|
||||
|
||||
assert HTTPStreamTransport.DEFAULT_PROTOCOL_VERSION == '2025-03-26'
|
||||
|
||||
def test_transport_custom_protocol_version(self):
|
||||
"""Test custom protocol version via options."""
|
||||
from praisonaiagents.mcp.mcp_http_stream import HTTPStreamTransport
|
||||
|
||||
transport = HTTPStreamTransport(
|
||||
'https://api.example.com/mcp',
|
||||
options={'protocol_version': '2025-11-25'}
|
||||
)
|
||||
|
||||
assert transport.headers['Mcp-Protocol-Version'] == '2025-11-25'
|
||||
|
||||
def test_transport_has_accept_header(self):
|
||||
"""Test that transport includes correct Accept header."""
|
||||
from praisonaiagents.mcp.mcp_http_stream import HTTPStreamTransport
|
||||
|
||||
transport = HTTPStreamTransport('https://api.example.com/mcp')
|
||||
|
||||
assert 'Accept' in transport.headers
|
||||
assert 'application/json' in transport.headers['Accept']
|
||||
assert 'text/event-stream' in transport.headers['Accept']
|
||||
|
||||
def test_transport_has_session_id_when_provided(self):
|
||||
"""Test that transport includes session ID when provided."""
|
||||
from praisonaiagents.mcp.mcp_http_stream import HTTPStreamTransport
|
||||
|
||||
transport = HTTPStreamTransport(
|
||||
'https://api.example.com/mcp',
|
||||
session_id='test-session-123'
|
||||
)
|
||||
|
||||
assert 'Mcp-Session-Id' in transport.headers
|
||||
assert transport.headers['Mcp-Session-Id'] == 'test-session-123'
|
||||
|
||||
def test_transport_no_session_id_when_not_provided(self):
|
||||
"""Test that transport doesn't include session ID when not provided."""
|
||||
from praisonaiagents.mcp.mcp_http_stream import HTTPStreamTransport
|
||||
|
||||
transport = HTTPStreamTransport('https://api.example.com/mcp')
|
||||
|
||||
assert 'Mcp-Session-Id' not in transport.headers
|
||||
|
||||
|
||||
class TestHTTPStreamTransportResumability:
|
||||
"""Test HTTPStreamTransport resumability features."""
|
||||
|
||||
def test_transport_has_last_event_id_tracking(self):
|
||||
"""Test that transport tracks last event ID."""
|
||||
from praisonaiagents.mcp.mcp_http_stream import HTTPStreamTransport
|
||||
|
||||
transport = HTTPStreamTransport('https://api.example.com/mcp')
|
||||
|
||||
assert hasattr(transport, 'last_event_id')
|
||||
assert transport.last_event_id is None
|
||||
|
||||
def test_transport_has_retry_delay_tracking(self):
|
||||
"""Test that transport tracks retry delay."""
|
||||
from praisonaiagents.mcp.mcp_http_stream import HTTPStreamTransport
|
||||
|
||||
transport = HTTPStreamTransport('https://api.example.com/mcp')
|
||||
|
||||
assert hasattr(transport, '_retry_delay_ms')
|
||||
assert transport._retry_delay_ms == 3000 # Default 3 seconds
|
||||
|
||||
def test_transport_updates_last_event_id(self):
|
||||
"""Test that last_event_id can be updated."""
|
||||
from praisonaiagents.mcp.mcp_http_stream import HTTPStreamTransport
|
||||
|
||||
transport = HTTPStreamTransport('https://api.example.com/mcp')
|
||||
transport.last_event_id = 'event-123'
|
||||
|
||||
assert transport.last_event_id == 'event-123'
|
||||
|
||||
def test_transport_updates_retry_delay(self):
|
||||
"""Test that retry delay can be updated."""
|
||||
from praisonaiagents.mcp.mcp_http_stream import HTTPStreamTransport
|
||||
|
||||
transport = HTTPStreamTransport('https://api.example.com/mcp')
|
||||
transport._retry_delay_ms = 5000
|
||||
|
||||
assert transport._retry_delay_ms == 5000
|
||||
|
||||
|
||||
class TestHTTPStreamTransportSessionTermination:
|
||||
"""Test HTTPStreamTransport session termination."""
|
||||
|
||||
def test_transport_has_terminate_session_method(self):
|
||||
"""Test that transport has terminate_session method."""
|
||||
from praisonaiagents.mcp.mcp_http_stream import HTTPStreamTransport
|
||||
|
||||
transport = HTTPStreamTransport('https://api.example.com/mcp')
|
||||
|
||||
assert hasattr(transport, 'terminate_session')
|
||||
assert callable(transport.terminate_session)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminate_session_returns_false_without_session(self):
|
||||
"""Test that terminate_session returns False without session."""
|
||||
from praisonaiagents.mcp.mcp_http_stream import HTTPStreamTransport
|
||||
|
||||
transport = HTTPStreamTransport('https://api.example.com/mcp')
|
||||
|
||||
# No session, should return False
|
||||
result = await transport.terminate_session()
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminate_session_returns_false_without_client_session(self):
|
||||
"""Test that terminate_session returns False without aiohttp session."""
|
||||
from praisonaiagents.mcp.mcp_http_stream import HTTPStreamTransport
|
||||
|
||||
transport = HTTPStreamTransport(
|
||||
'https://api.example.com/mcp',
|
||||
session_id='test-session'
|
||||
)
|
||||
# _session is None (not initialized)
|
||||
|
||||
result = await transport.terminate_session()
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestHTTPStreamTransportAutoDetection:
|
||||
"""Test transport auto-detection in MCP class."""
|
||||
|
||||
def test_http_url_uses_http_stream_transport(self):
|
||||
"""Test that HTTP URLs use HTTPStreamTransport."""
|
||||
from praisonaiagents.mcp.mcp_transport import get_transport_type
|
||||
|
||||
assert get_transport_type('http://localhost:8080/mcp') == 'http_stream'
|
||||
assert get_transport_type('https://api.example.com/mcp') == 'http_stream'
|
||||
|
||||
def test_sse_url_uses_sse_transport(self):
|
||||
"""Test that SSE URLs use SSE transport."""
|
||||
from praisonaiagents.mcp.mcp_transport import get_transport_type
|
||||
|
||||
assert get_transport_type('http://localhost:8080/sse') == 'sse'
|
||||
assert get_transport_type('https://api.example.com/sse') == 'sse'
|
||||
|
||||
def test_websocket_url_uses_websocket_transport(self):
|
||||
"""Test that WebSocket URLs use WebSocket transport."""
|
||||
from praisonaiagents.mcp.mcp_transport import get_transport_type
|
||||
|
||||
assert get_transport_type('ws://localhost:8080/mcp') == 'websocket'
|
||||
assert get_transport_type('wss://api.example.com/mcp') == 'websocket'
|
||||
|
||||
def test_command_string_uses_stdio_transport(self):
|
||||
"""Test that command strings use stdio transport."""
|
||||
from praisonaiagents.mcp.mcp_transport import get_transport_type
|
||||
|
||||
assert get_transport_type('npx @modelcontextprotocol/server-memory') == 'stdio'
|
||||
assert get_transport_type('python3 server.py') == 'stdio'
|
||||
|
||||
|
||||
class TestSSEEventParsing:
|
||||
"""Test SSE event parsing for resumability."""
|
||||
|
||||
def test_parse_sse_event_with_id(self):
|
||||
"""Test parsing SSE event with id field."""
|
||||
from praisonaiagents.mcp.mcp_session import parse_sse_event
|
||||
|
||||
event_text = "id: event-123\ndata: {\"test\": true}"
|
||||
result = parse_sse_event(event_text)
|
||||
|
||||
assert result['id'] == 'event-123'
|
||||
assert result['data'] == '{"test": true}'
|
||||
|
||||
def test_parse_sse_event_with_retry(self):
|
||||
"""Test parsing SSE event with retry field."""
|
||||
from praisonaiagents.mcp.mcp_session import parse_sse_event
|
||||
|
||||
event_text = "retry: 5000\ndata: {\"test\": true}"
|
||||
result = parse_sse_event(event_text)
|
||||
|
||||
assert result['retry'] == 5000
|
||||
assert result['data'] == '{"test": true}'
|
||||
|
||||
def test_parse_sse_event_with_all_fields(self):
|
||||
"""Test parsing SSE event with all fields."""
|
||||
from praisonaiagents.mcp.mcp_session import parse_sse_event
|
||||
|
||||
event_text = "id: event-456\nretry: 3000\ndata: {\"jsonrpc\": \"2.0\"}"
|
||||
result = parse_sse_event(event_text)
|
||||
|
||||
assert result['id'] == 'event-456'
|
||||
assert result['retry'] == 3000
|
||||
assert result['data'] == '{"jsonrpc": "2.0"}'
|
||||
|
||||
def test_parse_sse_event_multiline_data(self):
|
||||
"""Test parsing SSE event with multiline data."""
|
||||
from praisonaiagents.mcp.mcp_session import parse_sse_event
|
||||
|
||||
event_text = "data: line1\ndata: line2"
|
||||
result = parse_sse_event(event_text)
|
||||
|
||||
assert result['data'] == 'line1\nline2'
|
||||
|
||||
|
||||
class TestResumabilityManager:
|
||||
"""Test ResumabilityManager for SSE stream recovery."""
|
||||
|
||||
def test_manager_tracks_event_ids(self):
|
||||
"""Test that manager tracks event IDs per stream."""
|
||||
from praisonaiagents.mcp.mcp_session import ResumabilityManager
|
||||
|
||||
manager = ResumabilityManager()
|
||||
manager.set_last_event_id('stream1', 'event-100')
|
||||
manager.set_last_event_id('stream2', 'event-200')
|
||||
|
||||
assert manager.get_last_event_id('stream1') == 'event-100'
|
||||
assert manager.get_last_event_id('stream2') == 'event-200'
|
||||
|
||||
def test_manager_returns_none_for_unknown_stream(self):
|
||||
"""Test that manager returns None for unknown stream."""
|
||||
from praisonaiagents.mcp.mcp_session import ResumabilityManager
|
||||
|
||||
manager = ResumabilityManager()
|
||||
|
||||
assert manager.get_last_event_id('unknown') is None
|
||||
|
||||
def test_manager_generates_resume_headers(self):
|
||||
"""Test that manager generates Last-Event-ID header."""
|
||||
from praisonaiagents.mcp.mcp_session import ResumabilityManager
|
||||
|
||||
manager = ResumabilityManager()
|
||||
manager.set_last_event_id('stream1', 'event-123')
|
||||
|
||||
headers = manager.get_resume_headers('stream1')
|
||||
|
||||
assert 'Last-Event-ID' in headers
|
||||
assert headers['Last-Event-ID'] == 'event-123'
|
||||
|
||||
def test_manager_tracks_retry_delay(self):
|
||||
"""Test that manager tracks retry delay."""
|
||||
from praisonaiagents.mcp.mcp_session import ResumabilityManager
|
||||
|
||||
manager = ResumabilityManager()
|
||||
manager.set_retry_delay(5000)
|
||||
|
||||
assert manager.get_retry_delay() == 5000
|
||||
|
||||
def test_manager_default_retry_delay(self):
|
||||
"""Test default retry delay."""
|
||||
from praisonaiagents.mcp.mcp_session import ResumabilityManager
|
||||
|
||||
manager = ResumabilityManager()
|
||||
|
||||
# Default should be 3000ms
|
||||
assert manager.get_retry_delay() == 3000
|
||||
|
||||
|
||||
class TestEventStore:
|
||||
"""Test EventStore for message replay."""
|
||||
|
||||
def test_store_events(self):
|
||||
"""Test storing events."""
|
||||
from praisonaiagents.mcp.mcp_session import EventStore
|
||||
|
||||
store = EventStore(max_events=100)
|
||||
store.store('stream1', 'event-1', {'data': 'test1'})
|
||||
store.store('stream1', 'event-2', {'data': 'test2'})
|
||||
|
||||
events = store.get_all_events('stream1')
|
||||
assert len(events) == 2
|
||||
|
||||
def test_get_events_after_id(self):
|
||||
"""Test getting events after specific ID."""
|
||||
from praisonaiagents.mcp.mcp_session import EventStore
|
||||
|
||||
store = EventStore(max_events=100)
|
||||
store.store('stream1', 'event-1', {'data': 'test1'})
|
||||
store.store('stream1', 'event-2', {'data': 'test2'})
|
||||
store.store('stream1', 'event-3', {'data': 'test3'})
|
||||
|
||||
events = store.get_events_after('stream1', 'event-1')
|
||||
assert len(events) == 2
|
||||
assert events[0]['data'] == 'test2'
|
||||
assert events[1]['data'] == 'test3'
|
||||
|
||||
def test_store_respects_max_events(self):
|
||||
"""Test that store respects max_events limit."""
|
||||
from praisonaiagents.mcp.mcp_session import EventStore
|
||||
|
||||
store = EventStore(max_events=3)
|
||||
for i in range(5):
|
||||
store.store('stream1', f'event-{i}', {'data': f'test{i}'})
|
||||
|
||||
events = store.get_all_events('stream1')
|
||||
assert len(events) == 3
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Tests for MCP Resumability - TDD approach.
|
||||
|
||||
These tests define the expected behavior of SSE stream resumability
|
||||
per MCP Protocol Revision 2025-11-25.
|
||||
|
||||
Resumability features:
|
||||
- SSE event ID tracking
|
||||
- Last-Event-ID header support
|
||||
- Message replay on reconnection
|
||||
- Retry field handling
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestResumabilityManager:
|
||||
"""Test ResumabilityManager class."""
|
||||
|
||||
def test_manager_creation(self):
|
||||
"""Test ResumabilityManager can be created."""
|
||||
from praisonaiagents.mcp.mcp_session import ResumabilityManager
|
||||
|
||||
manager = ResumabilityManager()
|
||||
assert manager.get_retry_delay() == 3000 # Default 3 seconds
|
||||
|
||||
def test_set_and_get_last_event_id(self):
|
||||
"""Test setting and getting last event ID for a stream."""
|
||||
from praisonaiagents.mcp.mcp_session import ResumabilityManager
|
||||
|
||||
manager = ResumabilityManager()
|
||||
|
||||
# Initially no event ID
|
||||
assert manager.get_last_event_id("stream-1") is None
|
||||
|
||||
# Set event ID
|
||||
manager.set_last_event_id("stream-1", "event-123")
|
||||
assert manager.get_last_event_id("stream-1") == "event-123"
|
||||
|
||||
def test_multiple_streams(self):
|
||||
"""Test tracking multiple streams independently."""
|
||||
from praisonaiagents.mcp.mcp_session import ResumabilityManager
|
||||
|
||||
manager = ResumabilityManager()
|
||||
|
||||
manager.set_last_event_id("stream-1", "event-a")
|
||||
manager.set_last_event_id("stream-2", "event-b")
|
||||
|
||||
assert manager.get_last_event_id("stream-1") == "event-a"
|
||||
assert manager.get_last_event_id("stream-2") == "event-b"
|
||||
|
||||
def test_update_event_id(self):
|
||||
"""Test updating event ID for a stream."""
|
||||
from praisonaiagents.mcp.mcp_session import ResumabilityManager
|
||||
|
||||
manager = ResumabilityManager()
|
||||
|
||||
manager.set_last_event_id("stream-1", "event-1")
|
||||
manager.set_last_event_id("stream-1", "event-2")
|
||||
|
||||
assert manager.get_last_event_id("stream-1") == "event-2"
|
||||
|
||||
def test_set_retry_delay(self):
|
||||
"""Test setting retry delay from SSE retry field."""
|
||||
from praisonaiagents.mcp.mcp_session import ResumabilityManager
|
||||
|
||||
manager = ResumabilityManager()
|
||||
|
||||
manager.set_retry_delay(5000)
|
||||
assert manager.get_retry_delay() == 5000
|
||||
|
||||
def test_get_resume_headers_with_event_id(self):
|
||||
"""Test getting resume headers when event ID is available."""
|
||||
from praisonaiagents.mcp.mcp_session import ResumabilityManager
|
||||
|
||||
manager = ResumabilityManager()
|
||||
manager.set_last_event_id("stream-1", "event-123")
|
||||
|
||||
headers = manager.get_resume_headers("stream-1")
|
||||
|
||||
assert headers == {'Last-Event-ID': 'event-123'}
|
||||
|
||||
def test_get_resume_headers_without_event_id(self):
|
||||
"""Test getting resume headers when no event ID is stored."""
|
||||
from praisonaiagents.mcp.mcp_session import ResumabilityManager
|
||||
|
||||
manager = ResumabilityManager()
|
||||
|
||||
headers = manager.get_resume_headers("stream-1")
|
||||
|
||||
assert headers == {}
|
||||
|
||||
def test_clear_stream(self):
|
||||
"""Test clearing tracking for a specific stream."""
|
||||
from praisonaiagents.mcp.mcp_session import ResumabilityManager
|
||||
|
||||
manager = ResumabilityManager()
|
||||
manager.set_last_event_id("stream-1", "event-a")
|
||||
manager.set_last_event_id("stream-2", "event-b")
|
||||
|
||||
manager.clear_stream("stream-1")
|
||||
|
||||
assert manager.get_last_event_id("stream-1") is None
|
||||
assert manager.get_last_event_id("stream-2") == "event-b"
|
||||
|
||||
def test_clear_all(self):
|
||||
"""Test clearing all stream tracking."""
|
||||
from praisonaiagents.mcp.mcp_session import ResumabilityManager
|
||||
|
||||
manager = ResumabilityManager()
|
||||
manager.set_last_event_id("stream-1", "event-a")
|
||||
manager.set_last_event_id("stream-2", "event-b")
|
||||
|
||||
manager.clear_all()
|
||||
|
||||
assert manager.get_last_event_id("stream-1") is None
|
||||
assert manager.get_last_event_id("stream-2") is None
|
||||
|
||||
|
||||
class TestSSEEventParsing:
|
||||
"""Test SSE event parsing utilities."""
|
||||
|
||||
def test_parse_sse_event_with_id(self):
|
||||
"""Test parsing SSE event with ID field."""
|
||||
from praisonaiagents.mcp.mcp_session import parse_sse_event
|
||||
|
||||
event_text = "id: event-123\ndata: {\"test\": 1}\n\n"
|
||||
event = parse_sse_event(event_text)
|
||||
|
||||
assert event['id'] == 'event-123'
|
||||
assert event['data'] == '{"test": 1}'
|
||||
|
||||
def test_parse_sse_event_with_retry(self):
|
||||
"""Test parsing SSE event with retry field."""
|
||||
from praisonaiagents.mcp.mcp_session import parse_sse_event
|
||||
|
||||
event_text = "retry: 5000\ndata: {}\n\n"
|
||||
event = parse_sse_event(event_text)
|
||||
|
||||
assert event['retry'] == 5000
|
||||
|
||||
def test_parse_sse_event_data_only(self):
|
||||
"""Test parsing SSE event with only data."""
|
||||
from praisonaiagents.mcp.mcp_session import parse_sse_event
|
||||
|
||||
event_text = "data: {\"message\": \"hello\"}\n\n"
|
||||
event = parse_sse_event(event_text)
|
||||
|
||||
assert event['data'] == '{"message": "hello"}'
|
||||
assert event.get('id') is None
|
||||
|
||||
def test_parse_sse_event_multiline_data(self):
|
||||
"""Test parsing SSE event with multiline data."""
|
||||
from praisonaiagents.mcp.mcp_session import parse_sse_event
|
||||
|
||||
event_text = "data: line1\ndata: line2\n\n"
|
||||
event = parse_sse_event(event_text)
|
||||
|
||||
assert event['data'] == 'line1\nline2'
|
||||
|
||||
def test_parse_sse_event_empty_data(self):
|
||||
"""Test parsing SSE event with empty data (for priming)."""
|
||||
from praisonaiagents.mcp.mcp_session import parse_sse_event
|
||||
|
||||
event_text = "id: prime-123\ndata: \n\n"
|
||||
event = parse_sse_event(event_text)
|
||||
|
||||
assert event['id'] == 'prime-123'
|
||||
assert event['data'] == ''
|
||||
|
||||
|
||||
class TestEventStore:
|
||||
"""Test optional event store for message redelivery."""
|
||||
|
||||
def test_event_store_creation(self):
|
||||
"""Test EventStore can be created."""
|
||||
from praisonaiagents.mcp.mcp_session import EventStore
|
||||
|
||||
store = EventStore(max_events=100)
|
||||
assert store.max_events == 100
|
||||
|
||||
def test_store_and_retrieve_event(self):
|
||||
"""Test storing and retrieving events."""
|
||||
from praisonaiagents.mcp.mcp_session import EventStore
|
||||
|
||||
store = EventStore()
|
||||
|
||||
store.store("stream-1", "event-1", {"data": "test1"})
|
||||
store.store("stream-1", "event-2", {"data": "test2"})
|
||||
|
||||
events = store.get_events_after("stream-1", "event-1")
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0] == {"data": "test2"}
|
||||
|
||||
def test_store_respects_max_events(self):
|
||||
"""Test that store respects max_events limit."""
|
||||
from praisonaiagents.mcp.mcp_session import EventStore
|
||||
|
||||
store = EventStore(max_events=3)
|
||||
|
||||
for i in range(5):
|
||||
store.store("stream-1", f"event-{i}", {"data": i})
|
||||
|
||||
# Should only have last 3 events
|
||||
all_events = store.get_all_events("stream-1")
|
||||
assert len(all_events) <= 3
|
||||
|
||||
def test_store_per_stream_isolation(self):
|
||||
"""Test that events are isolated per stream."""
|
||||
from praisonaiagents.mcp.mcp_session import EventStore
|
||||
|
||||
store = EventStore()
|
||||
|
||||
store.store("stream-1", "event-a", {"data": "a"})
|
||||
store.store("stream-2", "event-b", {"data": "b"})
|
||||
|
||||
events_1 = store.get_all_events("stream-1")
|
||||
events_2 = store.get_all_events("stream-2")
|
||||
|
||||
assert len(events_1) == 1
|
||||
assert len(events_2) == 1
|
||||
assert events_1[0] == {"data": "a"}
|
||||
assert events_2[0] == {"data": "b"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Tests for MCP Security Features - TDD approach.
|
||||
|
||||
These tests define the expected behavior of security features
|
||||
per MCP Protocol Revision 2025-11-25.
|
||||
|
||||
Security features:
|
||||
- Origin header validation (DNS rebinding prevention)
|
||||
- Localhost binding recommendation
|
||||
- Authentication header support
|
||||
- Secure session ID handling
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestOriginValidation:
|
||||
"""Test Origin header validation for DNS rebinding prevention."""
|
||||
|
||||
def test_valid_localhost_origins(self):
|
||||
"""Test that localhost origins are accepted."""
|
||||
from praisonaiagents.mcp.mcp_security import is_valid_origin
|
||||
|
||||
assert is_valid_origin("http://localhost:8080", allowed_origins=["localhost"]) is True
|
||||
assert is_valid_origin("http://127.0.0.1:3000", allowed_origins=["127.0.0.1"]) is True
|
||||
assert is_valid_origin("http://localhost", allowed_origins=["localhost"]) is True
|
||||
|
||||
def test_valid_custom_origins(self):
|
||||
"""Test that configured custom origins are accepted."""
|
||||
from praisonaiagents.mcp.mcp_security import is_valid_origin
|
||||
|
||||
allowed = ["example.com", "api.example.com"]
|
||||
|
||||
assert is_valid_origin("https://example.com", allowed_origins=allowed) is True
|
||||
assert is_valid_origin("https://api.example.com/path", allowed_origins=allowed) is True
|
||||
|
||||
def test_invalid_origins_rejected(self):
|
||||
"""Test that invalid origins are rejected."""
|
||||
from praisonaiagents.mcp.mcp_security import is_valid_origin
|
||||
|
||||
allowed = ["localhost", "example.com"]
|
||||
|
||||
assert is_valid_origin("https://evil.com", allowed_origins=allowed) is False
|
||||
assert is_valid_origin("https://example.com.evil.com", allowed_origins=allowed) is False
|
||||
|
||||
def test_missing_origin_handling(self):
|
||||
"""Test handling of missing Origin header."""
|
||||
from praisonaiagents.mcp.mcp_security import is_valid_origin
|
||||
|
||||
# Missing origin should be rejected by default
|
||||
assert is_valid_origin(None, allowed_origins=["localhost"]) is False
|
||||
assert is_valid_origin("", allowed_origins=["localhost"]) is False
|
||||
|
||||
def test_allow_missing_origin_option(self):
|
||||
"""Test option to allow missing Origin header."""
|
||||
from praisonaiagents.mcp.mcp_security import is_valid_origin
|
||||
|
||||
# Can optionally allow missing origin for non-browser clients
|
||||
assert is_valid_origin(None, allowed_origins=["localhost"], allow_missing=True) is True
|
||||
|
||||
|
||||
class TestLocalhostBinding:
|
||||
"""Test localhost binding recommendations."""
|
||||
|
||||
def test_is_localhost_address(self):
|
||||
"""Test detection of localhost addresses."""
|
||||
from praisonaiagents.mcp.mcp_security import is_localhost_address
|
||||
|
||||
assert is_localhost_address("127.0.0.1") is True
|
||||
assert is_localhost_address("localhost") is True
|
||||
assert is_localhost_address("::1") is True # IPv6 localhost
|
||||
assert is_localhost_address("0.0.0.0") is False # All interfaces
|
||||
assert is_localhost_address("192.168.1.1") is False
|
||||
|
||||
def test_should_bind_localhost_only(self):
|
||||
"""Test recommendation for localhost-only binding."""
|
||||
from praisonaiagents.mcp.mcp_security import should_bind_localhost_only
|
||||
|
||||
# Local servers should bind to localhost only
|
||||
assert should_bind_localhost_only(is_local=True) is True
|
||||
|
||||
# Remote/production servers may bind to all interfaces
|
||||
assert should_bind_localhost_only(is_local=False) is False
|
||||
|
||||
|
||||
class TestAuthenticationHeaders:
|
||||
"""Test authentication header support."""
|
||||
|
||||
def test_create_bearer_auth_header(self):
|
||||
"""Test creating Bearer authentication header."""
|
||||
from praisonaiagents.mcp.mcp_security import create_auth_header
|
||||
|
||||
header = create_auth_header("my-secret-token", auth_type="bearer")
|
||||
|
||||
assert header == {"Authorization": "Bearer my-secret-token"}
|
||||
|
||||
def test_create_basic_auth_header(self):
|
||||
"""Test creating Basic authentication header."""
|
||||
from praisonaiagents.mcp.mcp_security import create_auth_header
|
||||
import base64
|
||||
|
||||
header = create_auth_header("user:pass", auth_type="basic")
|
||||
|
||||
expected = base64.b64encode(b"user:pass").decode()
|
||||
assert header == {"Authorization": f"Basic {expected}"}
|
||||
|
||||
def test_create_custom_auth_header(self):
|
||||
"""Test creating custom authentication header."""
|
||||
from praisonaiagents.mcp.mcp_security import create_auth_header
|
||||
|
||||
header = create_auth_header("custom-value", auth_type="custom", header_name="X-API-Key")
|
||||
|
||||
assert header == {"X-API-Key": "custom-value"}
|
||||
|
||||
def test_validate_auth_header(self):
|
||||
"""Test validating authentication header presence."""
|
||||
from praisonaiagents.mcp.mcp_security import validate_auth_header
|
||||
|
||||
headers_with_auth = {"Authorization": "Bearer token"}
|
||||
headers_without_auth = {"Content-Type": "application/json"}
|
||||
|
||||
assert validate_auth_header(headers_with_auth) is True
|
||||
assert validate_auth_header(headers_without_auth) is False
|
||||
|
||||
|
||||
class TestSecureSessionHandling:
|
||||
"""Test secure session ID handling."""
|
||||
|
||||
def test_generate_secure_session_id(self):
|
||||
"""Test generating cryptographically secure session ID."""
|
||||
from praisonaiagents.mcp.mcp_security import generate_secure_session_id
|
||||
|
||||
session_id = generate_secure_session_id()
|
||||
|
||||
# Should be non-empty
|
||||
assert len(session_id) > 0
|
||||
|
||||
# Should be unique
|
||||
session_id2 = generate_secure_session_id()
|
||||
assert session_id != session_id2
|
||||
|
||||
# Should contain only valid characters (visible ASCII)
|
||||
for char in session_id:
|
||||
assert 0x21 <= ord(char) <= 0x7E
|
||||
|
||||
def test_session_id_length(self):
|
||||
"""Test session ID has sufficient length for security."""
|
||||
from praisonaiagents.mcp.mcp_security import generate_secure_session_id
|
||||
|
||||
session_id = generate_secure_session_id()
|
||||
|
||||
# Should be at least 32 characters for security
|
||||
assert len(session_id) >= 32
|
||||
|
||||
def test_session_id_entropy(self):
|
||||
"""Test session ID has sufficient entropy."""
|
||||
from praisonaiagents.mcp.mcp_security import generate_secure_session_id
|
||||
|
||||
# Generate multiple IDs and check they're all different
|
||||
ids = [generate_secure_session_id() for _ in range(100)]
|
||||
unique_ids = set(ids)
|
||||
|
||||
assert len(unique_ids) == 100 # All should be unique
|
||||
|
||||
|
||||
class TestDNSRebindingPrevention:
|
||||
"""Test DNS rebinding attack prevention."""
|
||||
|
||||
def test_extract_origin_host(self):
|
||||
"""Test extracting host from Origin header."""
|
||||
from praisonaiagents.mcp.mcp_security import extract_origin_host
|
||||
|
||||
assert extract_origin_host("http://localhost:8080") == "localhost"
|
||||
assert extract_origin_host("https://example.com:443/path") == "example.com"
|
||||
assert extract_origin_host("http://127.0.0.1") == "127.0.0.1"
|
||||
|
||||
def test_detect_dns_rebinding_attempt(self):
|
||||
"""Test detection of potential DNS rebinding attempts."""
|
||||
from praisonaiagents.mcp.mcp_security import is_potential_dns_rebinding
|
||||
|
||||
# External origin trying to access localhost server
|
||||
assert is_potential_dns_rebinding(
|
||||
origin="https://evil.com",
|
||||
server_host="localhost"
|
||||
) is True
|
||||
|
||||
# Same origin is safe
|
||||
assert is_potential_dns_rebinding(
|
||||
origin="http://localhost:8080",
|
||||
server_host="localhost"
|
||||
) is False
|
||||
|
||||
|
||||
class TestSecurityConfig:
|
||||
"""Test security configuration."""
|
||||
|
||||
def test_security_config_defaults(self):
|
||||
"""Test default security configuration."""
|
||||
from praisonaiagents.mcp.mcp_security import SecurityConfig
|
||||
|
||||
config = SecurityConfig()
|
||||
|
||||
# Secure defaults
|
||||
assert config.validate_origin is True
|
||||
assert config.allow_missing_origin is False
|
||||
assert "localhost" in config.allowed_origins
|
||||
assert "127.0.0.1" in config.allowed_origins
|
||||
|
||||
def test_security_config_custom(self):
|
||||
"""Test custom security configuration."""
|
||||
from praisonaiagents.mcp.mcp_security import SecurityConfig
|
||||
|
||||
config = SecurityConfig(
|
||||
allowed_origins=["example.com"],
|
||||
allow_missing_origin=True,
|
||||
require_auth=True
|
||||
)
|
||||
|
||||
assert "example.com" in config.allowed_origins
|
||||
assert config.allow_missing_origin is True
|
||||
assert config.require_auth is True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Tests for MCP server wrapper - TDD approach.
|
||||
|
||||
These tests define the expected behavior of ToolsMCPServer class.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestToolsMCPServerInit:
|
||||
"""Test ToolsMCPServer initialization."""
|
||||
|
||||
def test_init_with_name(self):
|
||||
"""Test server initializes with custom name."""
|
||||
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
|
||||
|
||||
server = ToolsMCPServer(name="my-tools")
|
||||
assert server.name == "my-tools"
|
||||
|
||||
def test_init_with_default_name(self):
|
||||
"""Test server initializes with default name."""
|
||||
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
|
||||
|
||||
server = ToolsMCPServer()
|
||||
assert server.name == "praisonai-tools"
|
||||
|
||||
def test_init_with_tools_list(self):
|
||||
"""Test server initializes with list of tools."""
|
||||
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
|
||||
|
||||
def my_tool(x: str) -> str:
|
||||
"""A test tool."""
|
||||
return x
|
||||
|
||||
server = ToolsMCPServer(tools=[my_tool])
|
||||
assert len(server.tools) == 1
|
||||
|
||||
def test_init_empty_tools(self):
|
||||
"""Test server initializes with empty tools list."""
|
||||
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
|
||||
|
||||
server = ToolsMCPServer()
|
||||
assert server.tools == []
|
||||
|
||||
|
||||
class TestToolsMCPServerRegister:
|
||||
"""Test tool registration."""
|
||||
|
||||
def test_register_single_tool(self):
|
||||
"""Test registering a single tool."""
|
||||
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
|
||||
|
||||
server = ToolsMCPServer()
|
||||
|
||||
def search(query: str) -> str:
|
||||
"""Search for information."""
|
||||
return f"Results for {query}"
|
||||
|
||||
server.register_tool(search)
|
||||
|
||||
assert len(server.tools) == 1
|
||||
assert server.tools[0].__name__ == "search"
|
||||
|
||||
def test_register_multiple_tools(self):
|
||||
"""Test registering multiple tools."""
|
||||
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
|
||||
|
||||
server = ToolsMCPServer()
|
||||
|
||||
def tool1(x: str) -> str:
|
||||
"""Tool 1."""
|
||||
return x
|
||||
|
||||
def tool2(y: int) -> int:
|
||||
"""Tool 2."""
|
||||
return y
|
||||
|
||||
server.register_tool(tool1)
|
||||
server.register_tool(tool2)
|
||||
|
||||
assert len(server.tools) == 2
|
||||
|
||||
def test_register_tools_batch(self):
|
||||
"""Test registering multiple tools at once."""
|
||||
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
|
||||
|
||||
server = ToolsMCPServer()
|
||||
|
||||
def tool1(x: str) -> str:
|
||||
"""Tool 1."""
|
||||
return x
|
||||
|
||||
def tool2(y: int) -> int:
|
||||
"""Tool 2."""
|
||||
return y
|
||||
|
||||
server.register_tools([tool1, tool2])
|
||||
|
||||
assert len(server.tools) == 2
|
||||
|
||||
def test_register_async_tool(self):
|
||||
"""Test registering an async tool."""
|
||||
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
|
||||
|
||||
server = ToolsMCPServer()
|
||||
|
||||
async def async_search(query: str) -> str:
|
||||
"""Async search."""
|
||||
return f"Results for {query}"
|
||||
|
||||
server.register_tool(async_search)
|
||||
|
||||
assert len(server.tools) == 1
|
||||
|
||||
|
||||
class TestToolsMCPServerSchemas:
|
||||
"""Test schema generation for registered tools."""
|
||||
|
||||
def test_get_tool_schemas(self):
|
||||
"""Test getting schemas for all registered tools."""
|
||||
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
|
||||
|
||||
server = ToolsMCPServer()
|
||||
|
||||
def search(query: str, max_results: int = 10) -> str:
|
||||
"""Search the web."""
|
||||
return ""
|
||||
|
||||
server.register_tool(search)
|
||||
schemas = server.get_tool_schemas()
|
||||
|
||||
assert len(schemas) == 1
|
||||
assert schemas[0]["name"] == "search"
|
||||
assert schemas[0]["description"] == "Search the web."
|
||||
assert "query" in schemas[0]["inputSchema"]["properties"]
|
||||
|
||||
def test_get_tool_names(self):
|
||||
"""Test getting list of tool names."""
|
||||
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
|
||||
|
||||
server = ToolsMCPServer()
|
||||
|
||||
def tool1(x: str) -> str:
|
||||
"""Tool 1."""
|
||||
return x
|
||||
|
||||
def tool2(y: int) -> int:
|
||||
"""Tool 2."""
|
||||
return y
|
||||
|
||||
server.register_tools([tool1, tool2])
|
||||
names = server.get_tool_names()
|
||||
|
||||
assert set(names) == {"tool1", "tool2"}
|
||||
|
||||
|
||||
class TestToolsMCPServerExecution:
|
||||
"""Test tool execution through MCP server."""
|
||||
|
||||
def test_execute_sync_tool(self):
|
||||
"""Test executing a synchronous tool."""
|
||||
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
|
||||
|
||||
server = ToolsMCPServer()
|
||||
|
||||
def add(a: int, b: int) -> int:
|
||||
"""Add two numbers."""
|
||||
return a + b
|
||||
|
||||
server.register_tool(add)
|
||||
result = server.execute_tool("add", {"a": 5, "b": 3})
|
||||
|
||||
assert result == 8
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_async_tool(self):
|
||||
"""Test executing an async tool."""
|
||||
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
|
||||
|
||||
server = ToolsMCPServer()
|
||||
|
||||
async def async_add(a: int, b: int) -> int:
|
||||
"""Add two numbers asynchronously."""
|
||||
return a + b
|
||||
|
||||
server.register_tool(async_add)
|
||||
result = await server.execute_tool_async("async_add", {"a": 5, "b": 3})
|
||||
|
||||
assert result == 8
|
||||
|
||||
def test_execute_nonexistent_tool(self):
|
||||
"""Test executing a tool that doesn't exist."""
|
||||
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
|
||||
|
||||
server = ToolsMCPServer()
|
||||
|
||||
with pytest.raises(ValueError, match="Tool 'nonexistent' not found"):
|
||||
server.execute_tool("nonexistent", {})
|
||||
|
||||
|
||||
class TestToolsMCPServerFastMCP:
|
||||
"""Test FastMCP integration."""
|
||||
|
||||
def test_get_fastmcp_instance(self):
|
||||
"""Test getting FastMCP instance with registered tools."""
|
||||
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
|
||||
|
||||
server = ToolsMCPServer(name="test-server")
|
||||
|
||||
def my_tool(x: str) -> str:
|
||||
"""A test tool."""
|
||||
return x
|
||||
|
||||
server.register_tool(my_tool)
|
||||
|
||||
# Get FastMCP instance
|
||||
mcp = server.get_fastmcp()
|
||||
|
||||
assert mcp is not None
|
||||
assert mcp.name == "test-server"
|
||||
|
||||
def test_fastmcp_has_registered_tools(self):
|
||||
"""Test that FastMCP instance has all registered tools."""
|
||||
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
|
||||
|
||||
server = ToolsMCPServer()
|
||||
|
||||
def search(query: str) -> str:
|
||||
"""Search tool."""
|
||||
return query
|
||||
|
||||
def calculate(x: int, y: int) -> int:
|
||||
"""Calculate tool."""
|
||||
return x + y
|
||||
|
||||
server.register_tools([search, calculate])
|
||||
mcp = server.get_fastmcp()
|
||||
|
||||
# FastMCP should have both tools registered
|
||||
# We can check by looking at the internal tool registry
|
||||
assert mcp is not None
|
||||
|
||||
|
||||
class TestLaunchToolsMCPServer:
|
||||
"""Test the convenience function for launching MCP server."""
|
||||
|
||||
def test_launch_function_exists(self):
|
||||
"""Test that launch_tools_mcp_server function exists."""
|
||||
from praisonaiagents.mcp.mcp_server import launch_tools_mcp_server
|
||||
|
||||
assert callable(launch_tools_mcp_server)
|
||||
|
||||
def test_launch_with_tool_names(self):
|
||||
"""Test launching server with tool names from registry."""
|
||||
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
|
||||
|
||||
# This should create a server with specified tools
|
||||
# We'll mock the actual launch to avoid blocking
|
||||
with patch.object(ToolsMCPServer, 'run', return_value=None):
|
||||
# Just verify it doesn't raise
|
||||
pass # Actual launch test will be in integration tests
|
||||
|
||||
def test_launch_with_custom_tools(self):
|
||||
"""Test launching server with custom tool functions."""
|
||||
from praisonaiagents.mcp.mcp_server import launch_tools_mcp_server
|
||||
|
||||
def my_custom_tool(x: str) -> str:
|
||||
"""Custom tool."""
|
||||
return x
|
||||
|
||||
# Verify function accepts tools parameter
|
||||
# Actual launch test will be in integration tests
|
||||
assert callable(launch_tools_mcp_server)
|
||||
|
||||
|
||||
class TestToolsMCPServerTransports:
|
||||
"""Test different transport options."""
|
||||
|
||||
def test_stdio_transport_config(self):
|
||||
"""Test stdio transport configuration."""
|
||||
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
|
||||
|
||||
server = ToolsMCPServer()
|
||||
|
||||
# Server should support stdio transport
|
||||
assert hasattr(server, 'run_stdio') or hasattr(server, 'run')
|
||||
|
||||
def test_sse_transport_config(self):
|
||||
"""Test SSE transport configuration."""
|
||||
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
|
||||
|
||||
server = ToolsMCPServer()
|
||||
|
||||
# Server should support SSE transport
|
||||
assert hasattr(server, 'run_sse') or hasattr(server, 'run')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,242 @@
|
||||
"""Tests for MCP Session Management - TDD approach.
|
||||
|
||||
These tests define the expected behavior of session management for
|
||||
Streamable HTTP transport per MCP Protocol Revision 2025-11-25.
|
||||
|
||||
Session management features:
|
||||
- MCP-Session-Id header handling
|
||||
- Session persistence across requests
|
||||
- Session expiration handling (HTTP 404)
|
||||
- Session termination via HTTP DELETE
|
||||
- Session ID validation (visible ASCII 0x21-0x7E)
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestSessionIdValidation:
|
||||
"""Test session ID validation per MCP spec."""
|
||||
|
||||
def test_valid_session_id_ascii(self):
|
||||
"""Test that valid ASCII session IDs are accepted."""
|
||||
from praisonaiagents.mcp.mcp_session import is_valid_session_id
|
||||
|
||||
# Valid session IDs (visible ASCII 0x21-0x7E)
|
||||
assert is_valid_session_id("abc123") is True
|
||||
assert is_valid_session_id("session-id-12345") is True
|
||||
assert is_valid_session_id("a1b2c3d4e5f6") is True
|
||||
assert is_valid_session_id("!@#$%^&*()") is True # Special chars in range
|
||||
assert is_valid_session_id("UUID-1234-5678-90ab") is True
|
||||
|
||||
def test_invalid_session_id_with_spaces(self):
|
||||
"""Test that session IDs with spaces are rejected."""
|
||||
from praisonaiagents.mcp.mcp_session import is_valid_session_id
|
||||
|
||||
assert is_valid_session_id("session id") is False
|
||||
assert is_valid_session_id(" leading") is False
|
||||
assert is_valid_session_id("trailing ") is False
|
||||
|
||||
def test_invalid_session_id_with_control_chars(self):
|
||||
"""Test that session IDs with control characters are rejected."""
|
||||
from praisonaiagents.mcp.mcp_session import is_valid_session_id
|
||||
|
||||
assert is_valid_session_id("session\x00id") is False # NULL
|
||||
assert is_valid_session_id("session\nid") is False # Newline
|
||||
assert is_valid_session_id("session\tid") is False # Tab
|
||||
|
||||
def test_invalid_session_id_empty(self):
|
||||
"""Test that empty session IDs are rejected."""
|
||||
from praisonaiagents.mcp.mcp_session import is_valid_session_id
|
||||
|
||||
assert is_valid_session_id("") is False
|
||||
assert is_valid_session_id(None) is False
|
||||
|
||||
def test_invalid_session_id_non_ascii(self):
|
||||
"""Test that non-ASCII characters are rejected."""
|
||||
from praisonaiagents.mcp.mcp_session import is_valid_session_id
|
||||
|
||||
assert is_valid_session_id("session\x7fid") is False # DEL char
|
||||
assert is_valid_session_id("session\x80id") is False # Extended ASCII
|
||||
|
||||
|
||||
class TestSessionManager:
|
||||
"""Test SessionManager class for managing MCP sessions."""
|
||||
|
||||
def test_session_manager_creation(self):
|
||||
"""Test SessionManager can be created."""
|
||||
from praisonaiagents.mcp.mcp_session import SessionManager
|
||||
|
||||
manager = SessionManager()
|
||||
assert manager.session_id is None
|
||||
assert manager.is_active is False
|
||||
|
||||
def test_session_manager_set_session_id(self):
|
||||
"""Test setting session ID."""
|
||||
from praisonaiagents.mcp.mcp_session import SessionManager
|
||||
|
||||
manager = SessionManager()
|
||||
manager.set_session_id("test-session-123")
|
||||
|
||||
assert manager.session_id == "test-session-123"
|
||||
assert manager.is_active is True
|
||||
|
||||
def test_session_manager_rejects_invalid_id(self):
|
||||
"""Test that invalid session IDs are rejected."""
|
||||
from praisonaiagents.mcp.mcp_session import SessionManager
|
||||
|
||||
manager = SessionManager()
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid session ID"):
|
||||
manager.set_session_id("invalid session") # Contains space
|
||||
|
||||
def test_session_manager_clear_session(self):
|
||||
"""Test clearing session."""
|
||||
from praisonaiagents.mcp.mcp_session import SessionManager
|
||||
|
||||
manager = SessionManager()
|
||||
manager.set_session_id("test-session-123")
|
||||
manager.clear()
|
||||
|
||||
assert manager.session_id is None
|
||||
assert manager.is_active is False
|
||||
|
||||
def test_session_manager_get_headers(self):
|
||||
"""Test getting headers with session ID."""
|
||||
from praisonaiagents.mcp.mcp_session import SessionManager
|
||||
|
||||
manager = SessionManager()
|
||||
|
||||
# No session - no header
|
||||
headers = manager.get_headers()
|
||||
assert 'Mcp-Session-Id' not in headers
|
||||
|
||||
# With session - include header
|
||||
manager.set_session_id("test-session-123")
|
||||
headers = manager.get_headers()
|
||||
assert headers['Mcp-Session-Id'] == "test-session-123"
|
||||
|
||||
|
||||
class TestSessionFromResponse:
|
||||
"""Test extracting session ID from HTTP response headers."""
|
||||
|
||||
def test_extract_session_id_from_headers(self):
|
||||
"""Test extracting session ID from response headers."""
|
||||
from praisonaiagents.mcp.mcp_session import extract_session_id
|
||||
|
||||
headers = {'Mcp-Session-Id': 'new-session-456'}
|
||||
session_id = extract_session_id(headers)
|
||||
|
||||
assert session_id == 'new-session-456'
|
||||
|
||||
def test_extract_session_id_case_insensitive(self):
|
||||
"""Test that header name is case-insensitive."""
|
||||
from praisonaiagents.mcp.mcp_session import extract_session_id
|
||||
|
||||
# Various case combinations
|
||||
assert extract_session_id({'mcp-session-id': 'test'}) == 'test'
|
||||
assert extract_session_id({'MCP-SESSION-ID': 'test'}) == 'test'
|
||||
assert extract_session_id({'Mcp-Session-Id': 'test'}) == 'test'
|
||||
|
||||
def test_extract_session_id_missing(self):
|
||||
"""Test when session ID header is missing."""
|
||||
from praisonaiagents.mcp.mcp_session import extract_session_id
|
||||
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
session_id = extract_session_id(headers)
|
||||
|
||||
assert session_id is None
|
||||
|
||||
|
||||
class TestSessionExpiration:
|
||||
"""Test handling of session expiration (HTTP 404)."""
|
||||
|
||||
def test_is_session_expired_404(self):
|
||||
"""Test that HTTP 404 indicates session expiration."""
|
||||
from praisonaiagents.mcp.mcp_session import is_session_expired
|
||||
|
||||
assert is_session_expired(404) is True
|
||||
assert is_session_expired(200) is False
|
||||
assert is_session_expired(400) is False
|
||||
assert is_session_expired(500) is False
|
||||
|
||||
def test_session_manager_marks_expired(self):
|
||||
"""Test that session manager can mark session as expired."""
|
||||
from praisonaiagents.mcp.mcp_session import SessionManager
|
||||
|
||||
manager = SessionManager()
|
||||
manager.set_session_id("test-session")
|
||||
|
||||
manager.mark_expired()
|
||||
|
||||
assert manager.is_expired is True
|
||||
assert manager.is_active is False
|
||||
|
||||
|
||||
class TestProtocolVersion:
|
||||
"""Test MCP-Protocol-Version header handling."""
|
||||
|
||||
def test_default_protocol_version(self):
|
||||
"""Test default protocol version is 2025-03-26 for compatibility."""
|
||||
from praisonaiagents.mcp.mcp_session import SessionManager
|
||||
|
||||
manager = SessionManager()
|
||||
headers = manager.get_headers()
|
||||
|
||||
# Default version for backward compatibility
|
||||
assert headers.get('Mcp-Protocol-Version') == '2025-03-26'
|
||||
|
||||
def test_set_protocol_version(self):
|
||||
"""Test setting negotiated protocol version."""
|
||||
from praisonaiagents.mcp.mcp_session import SessionManager
|
||||
|
||||
manager = SessionManager()
|
||||
manager.set_protocol_version('2025-11-25')
|
||||
|
||||
headers = manager.get_headers()
|
||||
assert headers['Mcp-Protocol-Version'] == '2025-11-25'
|
||||
|
||||
def test_valid_protocol_versions(self):
|
||||
"""Test that valid protocol versions are accepted."""
|
||||
from praisonaiagents.mcp.mcp_session import is_valid_protocol_version
|
||||
|
||||
assert is_valid_protocol_version('2025-03-26') is True
|
||||
assert is_valid_protocol_version('2025-06-18') is True
|
||||
assert is_valid_protocol_version('2025-11-25') is True
|
||||
assert is_valid_protocol_version('2024-11-05') is True
|
||||
|
||||
def test_invalid_protocol_versions(self):
|
||||
"""Test that invalid protocol versions are rejected."""
|
||||
from praisonaiagents.mcp.mcp_session import is_valid_protocol_version
|
||||
|
||||
assert is_valid_protocol_version('invalid') is False
|
||||
assert is_valid_protocol_version('2025') is False
|
||||
assert is_valid_protocol_version('') is False
|
||||
|
||||
|
||||
class TestHTTPStreamSessionIntegration:
|
||||
"""Test session management integration with HTTPStreamTransport."""
|
||||
|
||||
def test_transport_uses_session_manager(self):
|
||||
"""Test that HTTPStreamTransport uses SessionManager."""
|
||||
# This will be implemented when we update HTTPStreamTransport
|
||||
pass
|
||||
|
||||
def test_transport_handles_session_from_init_response(self):
|
||||
"""Test that transport extracts session ID from InitializeResult."""
|
||||
pass
|
||||
|
||||
def test_transport_includes_session_in_requests(self):
|
||||
"""Test that transport includes session ID in subsequent requests."""
|
||||
pass
|
||||
|
||||
def test_transport_handles_session_expiration(self):
|
||||
"""Test that transport handles HTTP 404 session expiration."""
|
||||
pass
|
||||
|
||||
def test_transport_can_terminate_session(self):
|
||||
"""Test that transport can terminate session via HTTP DELETE."""
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Tests for MCP Transport Abstraction Layer - TDD approach.
|
||||
|
||||
These tests define the expected behavior of the transport abstraction
|
||||
layer that provides a common interface for all MCP transports.
|
||||
|
||||
Transport abstraction features:
|
||||
- BaseTransport abstract class
|
||||
- Common interface (connect, send, receive, close)
|
||||
- Transport factory for automatic selection
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from abc import ABC
|
||||
|
||||
|
||||
class TestBaseTransport:
|
||||
"""Test BaseTransport abstract class."""
|
||||
|
||||
def test_base_transport_is_abstract(self):
|
||||
"""Test that BaseTransport cannot be instantiated directly."""
|
||||
from praisonaiagents.mcp.mcp_transport import BaseTransport
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
BaseTransport()
|
||||
|
||||
def test_base_transport_defines_interface(self):
|
||||
"""Test that BaseTransport defines required interface methods."""
|
||||
from praisonaiagents.mcp.mcp_transport import BaseTransport
|
||||
import inspect
|
||||
|
||||
# Check required abstract methods exist
|
||||
assert hasattr(BaseTransport, 'connect')
|
||||
assert hasattr(BaseTransport, 'send')
|
||||
assert hasattr(BaseTransport, 'receive')
|
||||
assert hasattr(BaseTransport, 'close')
|
||||
assert hasattr(BaseTransport, 'is_connected')
|
||||
|
||||
def test_concrete_transport_must_implement_interface(self):
|
||||
"""Test that concrete transports must implement all methods."""
|
||||
from praisonaiagents.mcp.mcp_transport import BaseTransport
|
||||
|
||||
# Incomplete implementation should fail
|
||||
class IncompleteTransport(BaseTransport):
|
||||
pass
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
IncompleteTransport()
|
||||
|
||||
|
||||
class TestTransportFactory:
|
||||
"""Test transport factory for automatic selection."""
|
||||
|
||||
def test_factory_selects_stdio_for_command(self):
|
||||
"""Test factory selects stdio transport for command strings."""
|
||||
from praisonaiagents.mcp.mcp_transport import get_transport_type
|
||||
|
||||
assert get_transport_type("python /path/to/server.py") == "stdio"
|
||||
assert get_transport_type("npx @modelcontextprotocol/server") == "stdio"
|
||||
|
||||
def test_factory_selects_websocket_for_ws_url(self):
|
||||
"""Test factory selects WebSocket transport for ws:// URLs."""
|
||||
from praisonaiagents.mcp.mcp_transport import get_transport_type
|
||||
|
||||
assert get_transport_type("ws://localhost:8080/mcp") == "websocket"
|
||||
assert get_transport_type("wss://example.com/mcp") == "websocket"
|
||||
|
||||
def test_factory_selects_sse_for_sse_url(self):
|
||||
"""Test factory selects SSE transport for /sse URLs."""
|
||||
from praisonaiagents.mcp.mcp_transport import get_transport_type
|
||||
|
||||
assert get_transport_type("http://localhost:8080/sse") == "sse"
|
||||
assert get_transport_type("https://example.com/sse") == "sse"
|
||||
|
||||
def test_factory_selects_http_stream_for_http_url(self):
|
||||
"""Test factory selects HTTP Stream transport for HTTP URLs."""
|
||||
from praisonaiagents.mcp.mcp_transport import get_transport_type
|
||||
|
||||
assert get_transport_type("http://localhost:8080/mcp") == "http_stream"
|
||||
assert get_transport_type("https://example.com/mcp") == "http_stream"
|
||||
|
||||
|
||||
class TestTransportConfig:
|
||||
"""Test transport configuration."""
|
||||
|
||||
def test_transport_config_creation(self):
|
||||
"""Test TransportConfig can be created."""
|
||||
from praisonaiagents.mcp.mcp_transport import TransportConfig
|
||||
|
||||
config = TransportConfig(
|
||||
timeout=30,
|
||||
debug=True,
|
||||
retry_count=3
|
||||
)
|
||||
|
||||
assert config.timeout == 30
|
||||
assert config.debug is True
|
||||
assert config.retry_count == 3
|
||||
|
||||
def test_transport_config_defaults(self):
|
||||
"""Test TransportConfig has sensible defaults."""
|
||||
from praisonaiagents.mcp.mcp_transport import TransportConfig
|
||||
|
||||
config = TransportConfig()
|
||||
|
||||
assert config.timeout == 60 # Default timeout
|
||||
assert config.debug is False
|
||||
assert config.retry_count == 3
|
||||
|
||||
|
||||
class TestTransportRegistry:
|
||||
"""Test transport registration mechanism."""
|
||||
|
||||
def test_register_custom_transport(self):
|
||||
"""Test registering a custom transport."""
|
||||
from praisonaiagents.mcp.mcp_transport import (
|
||||
TransportRegistry, BaseTransport
|
||||
)
|
||||
|
||||
class CustomTransport(BaseTransport):
|
||||
async def connect(self): pass
|
||||
async def send(self, message): pass
|
||||
async def receive(self): pass
|
||||
async def close(self): pass
|
||||
@property
|
||||
def is_connected(self): return False
|
||||
|
||||
registry = TransportRegistry()
|
||||
registry.register("custom", CustomTransport)
|
||||
|
||||
assert "custom" in registry.list_transports()
|
||||
|
||||
def test_get_registered_transport(self):
|
||||
"""Test getting a registered transport class."""
|
||||
from praisonaiagents.mcp.mcp_transport import (
|
||||
TransportRegistry, BaseTransport
|
||||
)
|
||||
|
||||
class MyTransport(BaseTransport):
|
||||
async def connect(self): pass
|
||||
async def send(self, message): pass
|
||||
async def receive(self): pass
|
||||
async def close(self): pass
|
||||
@property
|
||||
def is_connected(self): return False
|
||||
|
||||
registry = TransportRegistry()
|
||||
registry.register("my_transport", MyTransport)
|
||||
|
||||
transport_class = registry.get("my_transport")
|
||||
assert transport_class is MyTransport
|
||||
|
||||
def test_default_transports_registered(self):
|
||||
"""Test that default transports are pre-registered."""
|
||||
from praisonaiagents.mcp.mcp_transport import get_default_registry
|
||||
|
||||
registry = get_default_registry()
|
||||
transports = registry.list_transports()
|
||||
|
||||
# Default transports should be available
|
||||
assert "stdio" in transports
|
||||
assert "sse" in transports
|
||||
assert "http_stream" in transports
|
||||
assert "websocket" in transports
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Tests for MCP utility functions - TDD approach.
|
||||
|
||||
These tests define the expected behavior of mcp_utils module.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
|
||||
class TestFunctionToMCPSchema:
|
||||
"""Test function_to_mcp_schema() converts Python functions to MCP tool schemas."""
|
||||
|
||||
def test_simple_function_with_string_param(self):
|
||||
"""Test conversion of simple function with string parameter."""
|
||||
from praisonaiagents.mcp.mcp_utils import function_to_mcp_schema
|
||||
|
||||
def search(query: str) -> str:
|
||||
"""Search the web for information."""
|
||||
return f"Results for {query}"
|
||||
|
||||
schema = function_to_mcp_schema(search)
|
||||
|
||||
assert schema["name"] == "search"
|
||||
assert schema["description"] == "Search the web for information."
|
||||
assert schema["inputSchema"]["type"] == "object"
|
||||
assert "query" in schema["inputSchema"]["properties"]
|
||||
assert schema["inputSchema"]["properties"]["query"]["type"] == "string"
|
||||
assert "query" in schema["inputSchema"]["required"]
|
||||
|
||||
def test_function_with_multiple_params(self):
|
||||
"""Test function with multiple parameters of different types."""
|
||||
from praisonaiagents.mcp.mcp_utils import function_to_mcp_schema
|
||||
|
||||
def search(query: str, max_results: int, include_images: bool) -> Dict[str, Any]:
|
||||
"""Search with options."""
|
||||
return {}
|
||||
|
||||
schema = function_to_mcp_schema(search)
|
||||
|
||||
assert schema["name"] == "search"
|
||||
props = schema["inputSchema"]["properties"]
|
||||
assert props["query"]["type"] == "string"
|
||||
assert props["max_results"]["type"] == "integer"
|
||||
assert props["include_images"]["type"] == "boolean"
|
||||
assert set(schema["inputSchema"]["required"]) == {"query", "max_results", "include_images"}
|
||||
|
||||
def test_function_with_optional_params(self):
|
||||
"""Test function with optional parameters (defaults)."""
|
||||
from praisonaiagents.mcp.mcp_utils import function_to_mcp_schema
|
||||
|
||||
def search(query: str, max_results: int = 10, include_images: bool = False) -> str:
|
||||
"""Search with optional params."""
|
||||
return ""
|
||||
|
||||
schema = function_to_mcp_schema(search)
|
||||
|
||||
# Only required params should be in required list
|
||||
assert schema["inputSchema"]["required"] == ["query"]
|
||||
# All params should still be in properties
|
||||
assert "max_results" in schema["inputSchema"]["properties"]
|
||||
assert "include_images" in schema["inputSchema"]["properties"]
|
||||
|
||||
def test_function_with_list_param(self):
|
||||
"""Test function with list parameter."""
|
||||
from praisonaiagents.mcp.mcp_utils import function_to_mcp_schema
|
||||
|
||||
def process(items: List[str]) -> str:
|
||||
"""Process a list of items."""
|
||||
return ""
|
||||
|
||||
schema = function_to_mcp_schema(process)
|
||||
|
||||
assert schema["inputSchema"]["properties"]["items"]["type"] == "array"
|
||||
assert schema["inputSchema"]["properties"]["items"]["items"]["type"] == "string"
|
||||
|
||||
def test_function_with_dict_param(self):
|
||||
"""Test function with dict parameter."""
|
||||
from praisonaiagents.mcp.mcp_utils import function_to_mcp_schema
|
||||
|
||||
def process(data: Dict[str, Any]) -> str:
|
||||
"""Process data dictionary."""
|
||||
return ""
|
||||
|
||||
schema = function_to_mcp_schema(process)
|
||||
|
||||
assert schema["inputSchema"]["properties"]["data"]["type"] == "object"
|
||||
|
||||
def test_function_with_optional_type(self):
|
||||
"""Test function with Optional type hint."""
|
||||
from praisonaiagents.mcp.mcp_utils import function_to_mcp_schema
|
||||
|
||||
def search(query: str, category: Optional[str] = None) -> str:
|
||||
"""Search with optional category."""
|
||||
return ""
|
||||
|
||||
schema = function_to_mcp_schema(search)
|
||||
|
||||
assert "category" in schema["inputSchema"]["properties"]
|
||||
assert "category" not in schema["inputSchema"]["required"]
|
||||
|
||||
def test_function_without_docstring(self):
|
||||
"""Test function without docstring uses function name as description."""
|
||||
from praisonaiagents.mcp.mcp_utils import function_to_mcp_schema
|
||||
|
||||
def my_tool(x: str) -> str:
|
||||
return x
|
||||
|
||||
schema = function_to_mcp_schema(my_tool)
|
||||
|
||||
assert schema["name"] == "my_tool"
|
||||
assert schema["description"] == "my_tool" # Falls back to name
|
||||
|
||||
def test_async_function(self):
|
||||
"""Test async function is handled correctly."""
|
||||
from praisonaiagents.mcp.mcp_utils import function_to_mcp_schema
|
||||
|
||||
async def async_search(query: str) -> str:
|
||||
"""Async search function."""
|
||||
return ""
|
||||
|
||||
schema = function_to_mcp_schema(async_search)
|
||||
|
||||
assert schema["name"] == "async_search"
|
||||
assert schema["description"] == "Async search function."
|
||||
|
||||
|
||||
class TestGetToolMetadata:
|
||||
"""Test get_tool_metadata() extracts metadata from functions."""
|
||||
|
||||
def test_extracts_name(self):
|
||||
"""Test name extraction."""
|
||||
from praisonaiagents.mcp.mcp_utils import get_tool_metadata
|
||||
|
||||
def my_tool(x: str) -> str:
|
||||
"""Tool description."""
|
||||
return x
|
||||
|
||||
metadata = get_tool_metadata(my_tool)
|
||||
assert metadata["name"] == "my_tool"
|
||||
|
||||
def test_extracts_description_from_docstring(self):
|
||||
"""Test description extraction from docstring."""
|
||||
from praisonaiagents.mcp.mcp_utils import get_tool_metadata
|
||||
|
||||
def my_tool(x: str) -> str:
|
||||
"""This is the tool description.
|
||||
|
||||
More details here.
|
||||
"""
|
||||
return x
|
||||
|
||||
metadata = get_tool_metadata(my_tool)
|
||||
assert metadata["description"] == "This is the tool description."
|
||||
|
||||
def test_custom_name_attribute(self):
|
||||
"""Test custom __mcp_name__ attribute is used."""
|
||||
from praisonaiagents.mcp.mcp_utils import get_tool_metadata
|
||||
|
||||
def my_tool(x: str) -> str:
|
||||
"""Description."""
|
||||
return x
|
||||
|
||||
my_tool.__mcp_name__ = "custom_name"
|
||||
|
||||
metadata = get_tool_metadata(my_tool)
|
||||
assert metadata["name"] == "custom_name"
|
||||
|
||||
def test_custom_description_attribute(self):
|
||||
"""Test custom __mcp_description__ attribute is used."""
|
||||
from praisonaiagents.mcp.mcp_utils import get_tool_metadata
|
||||
|
||||
def my_tool(x: str) -> str:
|
||||
"""Original docstring."""
|
||||
return x
|
||||
|
||||
my_tool.__mcp_description__ = "Custom description"
|
||||
|
||||
metadata = get_tool_metadata(my_tool)
|
||||
assert metadata["description"] == "Custom description"
|
||||
|
||||
|
||||
class TestPythonTypeToJsonSchema:
|
||||
"""Test Python type to JSON Schema conversion."""
|
||||
|
||||
def test_basic_types(self):
|
||||
"""Test basic Python types convert correctly."""
|
||||
from praisonaiagents.mcp.mcp_utils import python_type_to_json_schema
|
||||
|
||||
assert python_type_to_json_schema(str) == {"type": "string"}
|
||||
assert python_type_to_json_schema(int) == {"type": "integer"}
|
||||
assert python_type_to_json_schema(float) == {"type": "number"}
|
||||
assert python_type_to_json_schema(bool) == {"type": "boolean"}
|
||||
|
||||
def test_list_type(self):
|
||||
"""Test List type converts to array."""
|
||||
from praisonaiagents.mcp.mcp_utils import python_type_to_json_schema
|
||||
|
||||
result = python_type_to_json_schema(List[str])
|
||||
assert result["type"] == "array"
|
||||
assert result["items"]["type"] == "string"
|
||||
|
||||
def test_dict_type(self):
|
||||
"""Test Dict type converts to object."""
|
||||
from praisonaiagents.mcp.mcp_utils import python_type_to_json_schema
|
||||
|
||||
result = python_type_to_json_schema(Dict[str, Any])
|
||||
assert result["type"] == "object"
|
||||
|
||||
def test_optional_type(self):
|
||||
"""Test Optional type is handled."""
|
||||
from praisonaiagents.mcp.mcp_utils import python_type_to_json_schema
|
||||
|
||||
result = python_type_to_json_schema(Optional[str])
|
||||
assert result["type"] == "string" # Optional just means nullable
|
||||
|
||||
def test_any_type(self):
|
||||
"""Test Any type converts to empty schema."""
|
||||
from praisonaiagents.mcp.mcp_utils import python_type_to_json_schema
|
||||
|
||||
result = python_type_to_json_schema(Any)
|
||||
assert result == {} # Any type = no constraints
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Tests for MCP WebSocket Transport - TDD approach.
|
||||
|
||||
These tests define the expected behavior of the WebSocket transport module.
|
||||
WebSocket transport is an optional feature that requires the 'websockets' package.
|
||||
|
||||
Performance considerations:
|
||||
- WebSocket is lazy-loaded only when ws:// or wss:// URLs are used
|
||||
- No impact on existing stdio/SSE/HTTP transports
|
||||
- Uses asyncio for non-blocking I/O
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock
|
||||
|
||||
|
||||
# Skip all tests if websockets is not installed
|
||||
pytest.importorskip("websockets", reason="websockets package not installed")
|
||||
|
||||
|
||||
class TestWebSocketURLDetection:
|
||||
"""Test URL detection for WebSocket transport."""
|
||||
|
||||
def test_detects_ws_url(self):
|
||||
"""Test that ws:// URLs are detected as WebSocket."""
|
||||
from praisonaiagents.mcp.mcp_websocket import is_websocket_url
|
||||
|
||||
assert is_websocket_url("ws://localhost:8080") is True
|
||||
assert is_websocket_url("ws://example.com/mcp") is True
|
||||
assert is_websocket_url("ws://127.0.0.1:3000/ws") is True
|
||||
|
||||
def test_detects_wss_url(self):
|
||||
"""Test that wss:// URLs are detected as WebSocket (secure)."""
|
||||
from praisonaiagents.mcp.mcp_websocket import is_websocket_url
|
||||
|
||||
assert is_websocket_url("wss://localhost:8080") is True
|
||||
assert is_websocket_url("wss://example.com/mcp") is True
|
||||
assert is_websocket_url("wss://secure.server.com:443/ws") is True
|
||||
|
||||
def test_rejects_http_urls(self):
|
||||
"""Test that HTTP URLs are not detected as WebSocket."""
|
||||
from praisonaiagents.mcp.mcp_websocket import is_websocket_url
|
||||
|
||||
assert is_websocket_url("http://localhost:8080") is False
|
||||
assert is_websocket_url("https://example.com/mcp") is False
|
||||
assert is_websocket_url("http://localhost:8080/sse") is False
|
||||
|
||||
def test_rejects_non_url_strings(self):
|
||||
"""Test that non-URL strings are not detected as WebSocket."""
|
||||
from praisonaiagents.mcp.mcp_websocket import is_websocket_url
|
||||
|
||||
assert is_websocket_url("python") is False
|
||||
assert is_websocket_url("/path/to/script.py") is False
|
||||
assert is_websocket_url("npx @modelcontextprotocol/server") is False
|
||||
|
||||
|
||||
class TestWebSocketMCPTool:
|
||||
"""Test WebSocketMCPTool wrapper class."""
|
||||
|
||||
def test_tool_has_required_attributes(self):
|
||||
"""Test that tool wrapper has all required attributes for Agent."""
|
||||
from praisonaiagents.mcp.mcp_websocket import WebSocketMCPTool
|
||||
|
||||
mock_session = Mock()
|
||||
tool = WebSocketMCPTool(
|
||||
name="test_tool",
|
||||
description="A test tool",
|
||||
session=mock_session,
|
||||
input_schema={"type": "object", "properties": {"query": {"type": "string"}}}
|
||||
)
|
||||
|
||||
assert tool.name == "test_tool"
|
||||
assert tool.__name__ == "test_tool"
|
||||
assert tool.__qualname__ == "test_tool"
|
||||
assert tool.__doc__ == "A test tool"
|
||||
assert tool.description == "A test tool"
|
||||
assert hasattr(tool, '__signature__')
|
||||
|
||||
def test_tool_generates_correct_signature(self):
|
||||
"""Test that tool generates correct function signature from schema."""
|
||||
from praisonaiagents.mcp.mcp_websocket import WebSocketMCPTool
|
||||
import inspect
|
||||
|
||||
mock_session = Mock()
|
||||
tool = WebSocketMCPTool(
|
||||
name="search",
|
||||
description="Search tool",
|
||||
session=mock_session,
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string"},
|
||||
"max_results": {"type": "integer"}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
)
|
||||
|
||||
sig = tool.__signature__
|
||||
params = list(sig.parameters.keys())
|
||||
|
||||
assert "query" in params
|
||||
assert "max_results" in params
|
||||
assert sig.parameters["query"].default == inspect.Parameter.empty
|
||||
assert sig.parameters["max_results"].default is None # Optional
|
||||
|
||||
def test_tool_converts_to_openai_format(self):
|
||||
"""Test conversion to OpenAI tool format."""
|
||||
from praisonaiagents.mcp.mcp_websocket import WebSocketMCPTool
|
||||
|
||||
mock_session = Mock()
|
||||
tool = WebSocketMCPTool(
|
||||
name="search",
|
||||
description="Search the web",
|
||||
session=mock_session,
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}},
|
||||
"required": ["query"]
|
||||
}
|
||||
)
|
||||
|
||||
openai_tool = tool.to_openai_tool()
|
||||
|
||||
assert openai_tool["type"] == "function"
|
||||
assert openai_tool["function"]["name"] == "search"
|
||||
assert openai_tool["function"]["description"] == "Search the web"
|
||||
assert "parameters" in openai_tool["function"]
|
||||
|
||||
def test_tool_fixes_array_schemas(self):
|
||||
"""Test that array schemas without items are fixed for OpenAI compatibility."""
|
||||
from praisonaiagents.mcp.mcp_websocket import WebSocketMCPTool
|
||||
|
||||
mock_session = Mock()
|
||||
tool = WebSocketMCPTool(
|
||||
name="process",
|
||||
description="Process items",
|
||||
session=mock_session,
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {"type": "array"} # Missing 'items' attribute
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
openai_tool = tool.to_openai_tool()
|
||||
items_schema = openai_tool["function"]["parameters"]["properties"]["items"]
|
||||
|
||||
assert "items" in items_schema # Should be added
|
||||
assert items_schema["items"]["type"] == "string" # Default
|
||||
|
||||
|
||||
class TestWebSocketTransport:
|
||||
"""Test WebSocketTransport low-level implementation."""
|
||||
|
||||
def test_transport_handles_session_id(self):
|
||||
"""Test that transport handles MCP session ID."""
|
||||
from praisonaiagents.mcp.mcp_websocket import WebSocketTransport
|
||||
|
||||
transport = WebSocketTransport("ws://localhost:8080/mcp")
|
||||
|
||||
assert transport.session_id is None
|
||||
|
||||
transport.session_id = "test-session-123"
|
||||
assert transport.session_id == "test-session-123"
|
||||
|
||||
def test_transport_initialization(self):
|
||||
"""Test transport initializes with correct parameters."""
|
||||
from praisonaiagents.mcp.mcp_websocket import WebSocketTransport
|
||||
|
||||
transport = WebSocketTransport(
|
||||
"wss://example.com/mcp",
|
||||
auth_token="secret",
|
||||
max_retries=5,
|
||||
timeout=30.0
|
||||
)
|
||||
|
||||
assert transport.url == "wss://example.com/mcp"
|
||||
assert transport.auth_token == "secret"
|
||||
assert transport.max_retries == 5
|
||||
assert transport.timeout == 30.0
|
||||
|
||||
def test_transport_send_requires_connection(self):
|
||||
"""Test that send raises error when not connected."""
|
||||
from praisonaiagents.mcp.mcp_websocket import WebSocketTransport
|
||||
import asyncio
|
||||
|
||||
transport = WebSocketTransport("ws://localhost:8080/mcp")
|
||||
|
||||
with pytest.raises(RuntimeError, match="WebSocket not connected"):
|
||||
asyncio.get_event_loop().run_until_complete(transport.send({"test": 1}))
|
||||
|
||||
|
||||
class TestWebSocketReconnection:
|
||||
"""Test WebSocket reconnection with exponential backoff."""
|
||||
|
||||
def test_exponential_backoff_calculation(self):
|
||||
"""Test exponential backoff delay calculation."""
|
||||
from praisonaiagents.mcp.mcp_websocket import calculate_backoff
|
||||
|
||||
# First retry: base delay
|
||||
assert calculate_backoff(attempt=0, base_delay=1.0, max_delay=60.0) == 1.0
|
||||
|
||||
# Second retry: 2x
|
||||
assert calculate_backoff(attempt=1, base_delay=1.0, max_delay=60.0) == 2.0
|
||||
|
||||
# Third retry: 4x
|
||||
assert calculate_backoff(attempt=2, base_delay=1.0, max_delay=60.0) == 4.0
|
||||
|
||||
# Should cap at max_delay
|
||||
assert calculate_backoff(attempt=10, base_delay=1.0, max_delay=60.0) == 60.0
|
||||
|
||||
|
||||
class TestWebSocketMCPClient:
|
||||
"""Test WebSocketMCPClient high-level interface."""
|
||||
|
||||
def test_client_initialization(self):
|
||||
"""Test client can be initialized with WebSocket URL."""
|
||||
# This test will be implemented after the basic structure is in place
|
||||
pass
|
||||
|
||||
def test_client_lists_tools(self):
|
||||
"""Test client can list available tools from server."""
|
||||
pass
|
||||
|
||||
def test_client_calls_tool(self):
|
||||
"""Test client can call a tool and get result."""
|
||||
pass
|
||||
|
||||
def test_client_handles_timeout(self):
|
||||
"""Test client handles timeout correctly."""
|
||||
pass
|
||||
|
||||
def test_client_is_iterable(self):
|
||||
"""Test client tools are iterable."""
|
||||
pass
|
||||
|
||||
def test_client_converts_to_openai_tools(self):
|
||||
"""Test client can convert all tools to OpenAI format."""
|
||||
pass
|
||||
|
||||
|
||||
class TestMCPClassWebSocketIntegration:
|
||||
"""Test MCP class integration with WebSocket transport."""
|
||||
|
||||
def test_mcp_detects_ws_url(self):
|
||||
"""Test MCP class detects ws:// URL and uses WebSocket transport."""
|
||||
# Will be implemented after WebSocketMCPClient is ready
|
||||
pass
|
||||
|
||||
def test_mcp_detects_wss_url(self):
|
||||
"""Test MCP class detects wss:// URL and uses WebSocket transport."""
|
||||
pass
|
||||
|
||||
def test_mcp_websocket_tools_work_with_agent(self):
|
||||
"""Test WebSocket tools work correctly with Agent class."""
|
||||
pass
|
||||
|
||||
|
||||
class TestWebSocketKeepalive:
|
||||
"""Test WebSocket keepalive/ping-pong handling."""
|
||||
|
||||
def test_ping_interval_configurable(self):
|
||||
"""Test that ping interval is configurable."""
|
||||
from praisonaiagents.mcp.mcp_websocket import WebSocketTransport
|
||||
|
||||
transport = WebSocketTransport(
|
||||
"ws://localhost:8080/mcp",
|
||||
ping_interval=60.0,
|
||||
ping_timeout=20.0
|
||||
)
|
||||
|
||||
assert transport.ping_interval == 60.0
|
||||
assert transport.ping_timeout == 20.0
|
||||
|
||||
|
||||
class TestWebSocketPerformance:
|
||||
"""Test WebSocket transport performance characteristics."""
|
||||
|
||||
def test_lazy_import_websocket_module(self):
|
||||
"""Test that mcp_websocket module can be imported."""
|
||||
# Just verify the module imports without errors
|
||||
from praisonaiagents.mcp.mcp_websocket import (
|
||||
is_websocket_url,
|
||||
calculate_backoff,
|
||||
WebSocketTransport,
|
||||
WebSocketMCPTool
|
||||
)
|
||||
|
||||
# Verify functions exist
|
||||
assert callable(is_websocket_url)
|
||||
assert callable(calculate_backoff)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,390 @@
|
||||
"""
|
||||
Test suite for Workflow Per-Step Agent Configuration.
|
||||
|
||||
Tests the enhanced workflow system's ability to configure different agents
|
||||
for each step, including custom roles, goals, backstories, and LLM settings.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add the package to path for testing
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
from praisonaiagents.memory.workflows import (
|
||||
WorkflowStep,
|
||||
Workflow,
|
||||
WorkflowManager
|
||||
)
|
||||
|
||||
|
||||
class TestWorkflowStepAgentFields:
|
||||
"""Test WorkflowStep dataclass agent-related fields."""
|
||||
|
||||
def test_workflow_step_has_agent_config_field(self):
|
||||
"""WorkflowStep should have agent_config field."""
|
||||
step = WorkflowStep(
|
||||
name="test_step",
|
||||
action="do something",
|
||||
agent_config={
|
||||
"role": "Researcher",
|
||||
"goal": "Find information",
|
||||
"backstory": "Expert researcher"
|
||||
}
|
||||
)
|
||||
assert hasattr(step, 'agent_config')
|
||||
assert step.agent_config["role"] == "Researcher"
|
||||
|
||||
def test_workflow_step_has_tools_field(self):
|
||||
"""WorkflowStep should have tools field."""
|
||||
step = WorkflowStep(
|
||||
name="test_step",
|
||||
action="do something",
|
||||
tools=["search", "calculator"]
|
||||
)
|
||||
assert hasattr(step, 'tools')
|
||||
assert "search" in step.tools
|
||||
|
||||
def test_workflow_step_agent_config_default_none(self):
|
||||
"""agent_config should default to None."""
|
||||
step = WorkflowStep(name="test_step", action="do something")
|
||||
assert step.agent_config is None
|
||||
|
||||
def test_workflow_step_tools_default_none(self):
|
||||
"""tools should default to None."""
|
||||
step = WorkflowStep(name="test_step", action="do something")
|
||||
assert step.tools is None
|
||||
|
||||
def test_workflow_step_to_dict_includes_agent_fields(self):
|
||||
"""to_dict() should include agent-related fields."""
|
||||
step = WorkflowStep(
|
||||
name="test_step",
|
||||
action="do something",
|
||||
agent_config={"role": "Writer"},
|
||||
tools=["grammar_check"]
|
||||
)
|
||||
d = step.to_dict()
|
||||
assert "agent_config" in d
|
||||
assert "tools" in d
|
||||
|
||||
|
||||
class TestWorkflowDefaultAgentConfig:
|
||||
"""Test Workflow dataclass default agent configuration."""
|
||||
|
||||
def test_workflow_has_default_agent_config(self):
|
||||
"""Workflow should have default_agent_config field."""
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
default_agent_config={
|
||||
"role": "Assistant",
|
||||
"goal": "Help with tasks"
|
||||
}
|
||||
)
|
||||
assert hasattr(workflow, 'default_agent_config')
|
||||
assert workflow.default_agent_config["role"] == "Assistant"
|
||||
|
||||
def test_workflow_has_default_llm(self):
|
||||
"""Workflow should have default_llm field."""
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
default_llm="gpt-4o-mini"
|
||||
)
|
||||
assert hasattr(workflow, 'default_llm')
|
||||
assert workflow.default_llm == "gpt-4o-mini"
|
||||
|
||||
def test_workflow_to_dict_includes_defaults(self):
|
||||
"""to_dict() should include default agent config fields."""
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
default_agent_config={"role": "Helper"},
|
||||
default_llm="gpt-4o"
|
||||
)
|
||||
d = workflow.to_dict()
|
||||
assert "default_agent_config" in d
|
||||
assert "default_llm" in d
|
||||
|
||||
|
||||
class TestCreateStepAgent:
|
||||
"""Test _create_step_agent method."""
|
||||
|
||||
@staticmethod
|
||||
def _register_workflow(manager, workflow):
|
||||
"""Helper to register workflow and mark as loaded."""
|
||||
manager._workflows[workflow.name.lower()] = workflow
|
||||
manager._loaded = True
|
||||
|
||||
def test_create_step_agent_with_config(self):
|
||||
"""Should create agent from step's agent_config."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
step = WorkflowStep(
|
||||
name="research",
|
||||
action="Research topic",
|
||||
agent_config={
|
||||
"role": "Researcher",
|
||||
"goal": "Find comprehensive information",
|
||||
"backstory": "Expert in research"
|
||||
}
|
||||
)
|
||||
|
||||
with patch('praisonaiagents.agent.agent.Agent') as MockAgent:
|
||||
MockAgent.return_value = Mock()
|
||||
agent = manager._create_step_agent(
|
||||
step=step,
|
||||
default_llm="gpt-4o-mini",
|
||||
verbose=0
|
||||
)
|
||||
|
||||
# Verify Agent was called with config
|
||||
MockAgent.assert_called_once()
|
||||
call_kwargs = MockAgent.call_args[1]
|
||||
assert call_kwargs["role"] == "Researcher"
|
||||
assert call_kwargs["goal"] == "Find comprehensive information"
|
||||
|
||||
def test_create_step_agent_uses_default_agent(self):
|
||||
"""Should return default_agent when no agent_config."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
step = WorkflowStep(
|
||||
name="simple_step",
|
||||
action="Do something"
|
||||
)
|
||||
|
||||
default_agent = Mock()
|
||||
agent = manager._create_step_agent(
|
||||
step=step,
|
||||
default_agent=default_agent,
|
||||
default_llm="gpt-4o-mini"
|
||||
)
|
||||
|
||||
assert agent == default_agent
|
||||
|
||||
def test_create_step_agent_includes_tools(self):
|
||||
"""Should include tools in agent config."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
mock_tool = Mock()
|
||||
step = WorkflowStep(
|
||||
name="research",
|
||||
action="Research topic",
|
||||
agent_config={"role": "Researcher"},
|
||||
tools=[mock_tool]
|
||||
)
|
||||
|
||||
with patch('praisonaiagents.agent.agent.Agent') as MockAgent:
|
||||
MockAgent.return_value = Mock()
|
||||
manager._create_step_agent(
|
||||
step=step,
|
||||
default_llm="gpt-4o-mini"
|
||||
)
|
||||
|
||||
call_kwargs = MockAgent.call_args[1]
|
||||
assert "tools" in call_kwargs
|
||||
assert mock_tool in call_kwargs["tools"]
|
||||
|
||||
def test_create_step_agent_includes_memory(self):
|
||||
"""Should include memory in agent config."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
step = WorkflowStep(
|
||||
name="research",
|
||||
action="Research topic",
|
||||
agent_config={"role": "Researcher"}
|
||||
)
|
||||
|
||||
mock_memory = Mock()
|
||||
|
||||
with patch('praisonaiagents.agent.agent.Agent') as MockAgent:
|
||||
MockAgent.return_value = Mock()
|
||||
manager._create_step_agent(
|
||||
step=step,
|
||||
memory=mock_memory
|
||||
)
|
||||
|
||||
call_kwargs = MockAgent.call_args[1]
|
||||
assert "memory" in call_kwargs
|
||||
assert call_kwargs["memory"] == mock_memory
|
||||
|
||||
|
||||
class TestExecuteWithAgents:
|
||||
"""Test execute() with per-step agent configuration."""
|
||||
|
||||
@staticmethod
|
||||
def _register_workflow(manager, workflow):
|
||||
"""Helper to register workflow and mark as loaded."""
|
||||
manager._workflows[workflow.name.lower()] = workflow
|
||||
manager._loaded = True
|
||||
|
||||
def test_execute_with_default_agent(self):
|
||||
"""Execute should use default_agent when provided."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
steps=[
|
||||
WorkflowStep(name="step1", action="Action 1"),
|
||||
WorkflowStep(name="step2", action="Action 2")
|
||||
]
|
||||
)
|
||||
self._register_workflow(manager, workflow)
|
||||
|
||||
# Create mock default agent
|
||||
mock_agent = Mock()
|
||||
mock_agent.chat = Mock(return_value="Agent response")
|
||||
|
||||
result = manager.execute(
|
||||
"test_workflow",
|
||||
default_agent=mock_agent
|
||||
)
|
||||
|
||||
assert result["success"]
|
||||
assert mock_agent.chat.call_count == 2
|
||||
|
||||
def test_execute_with_step_agent_config(self):
|
||||
"""Execute should create agent from step's agent_config."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
steps=[
|
||||
WorkflowStep(
|
||||
name="research",
|
||||
action="Research AI",
|
||||
agent_config={
|
||||
"role": "Researcher",
|
||||
"goal": "Find information"
|
||||
}
|
||||
)
|
||||
]
|
||||
)
|
||||
self._register_workflow(manager, workflow)
|
||||
|
||||
with patch('praisonaiagents.agent.agent.Agent') as MockAgent:
|
||||
mock_agent_instance = Mock()
|
||||
mock_agent_instance.chat = Mock(return_value="Research results")
|
||||
MockAgent.return_value = mock_agent_instance
|
||||
|
||||
result = manager.execute(
|
||||
"test_workflow",
|
||||
default_llm="gpt-4o-mini"
|
||||
)
|
||||
|
||||
assert result["success"]
|
||||
MockAgent.assert_called_once()
|
||||
|
||||
def test_execute_with_executor_overrides_agent(self):
|
||||
"""Executor function should override agent creation."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
steps=[
|
||||
WorkflowStep(
|
||||
name="step1",
|
||||
action="Action 1",
|
||||
agent_config={"role": "Should not be used"}
|
||||
)
|
||||
]
|
||||
)
|
||||
self._register_workflow(manager, workflow)
|
||||
|
||||
executor_called = [False]
|
||||
|
||||
def custom_executor(action):
|
||||
executor_called[0] = True
|
||||
return "Custom executor result"
|
||||
|
||||
result = manager.execute(
|
||||
"test_workflow",
|
||||
executor=custom_executor
|
||||
)
|
||||
|
||||
assert result["success"]
|
||||
assert executor_called[0]
|
||||
|
||||
def test_execute_fails_without_executor_or_agent(self):
|
||||
"""Execute should fail if no executor or agent provided."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
steps=[
|
||||
WorkflowStep(name="step1", action="Action 1")
|
||||
]
|
||||
)
|
||||
self._register_workflow(manager, workflow)
|
||||
|
||||
result = manager.execute("test_workflow")
|
||||
|
||||
assert not result["success"]
|
||||
assert "No executor available" in result.get("error", "")
|
||||
|
||||
|
||||
class TestWorkflowLevelDefaults:
|
||||
"""Test workflow-level default configurations."""
|
||||
|
||||
@staticmethod
|
||||
def _register_workflow(manager, workflow):
|
||||
"""Helper to register workflow and mark as loaded."""
|
||||
manager._workflows[workflow.name.lower()] = workflow
|
||||
manager._loaded = True
|
||||
|
||||
def test_workflow_default_llm_used(self):
|
||||
"""Workflow's default_llm should be used for agent creation."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
default_llm="gpt-4o",
|
||||
steps=[
|
||||
WorkflowStep(
|
||||
name="step1",
|
||||
action="Action 1",
|
||||
agent_config={"role": "Helper"}
|
||||
)
|
||||
]
|
||||
)
|
||||
self._register_workflow(manager, workflow)
|
||||
|
||||
with patch('praisonaiagents.agent.agent.Agent') as MockAgent:
|
||||
mock_agent_instance = Mock()
|
||||
mock_agent_instance.chat = Mock(return_value="Response")
|
||||
MockAgent.return_value = mock_agent_instance
|
||||
|
||||
manager.execute("test_workflow")
|
||||
|
||||
call_kwargs = MockAgent.call_args[1]
|
||||
assert call_kwargs.get("llm") == "gpt-4o"
|
||||
|
||||
def test_execute_default_llm_overrides_workflow(self):
|
||||
"""Execute's default_llm should override workflow's default_llm."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
default_llm="gpt-3.5-turbo",
|
||||
steps=[
|
||||
WorkflowStep(
|
||||
name="step1",
|
||||
action="Action 1",
|
||||
agent_config={"role": "Helper"}
|
||||
)
|
||||
]
|
||||
)
|
||||
self._register_workflow(manager, workflow)
|
||||
|
||||
with patch('praisonaiagents.agent.agent.Agent') as MockAgent:
|
||||
mock_agent_instance = Mock()
|
||||
mock_agent_instance.chat = Mock(return_value="Response")
|
||||
MockAgent.return_value = mock_agent_instance
|
||||
|
||||
manager.execute("test_workflow", default_llm="gpt-4o-mini")
|
||||
|
||||
call_kwargs = MockAgent.call_args[1]
|
||||
assert call_kwargs.get("llm") == "gpt-4o-mini"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
Test suite for Workflow Async Support.
|
||||
|
||||
Tests the aexecute() async method for workflow execution.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
from unittest.mock import Mock, AsyncMock, patch
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add the package to path for testing
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
from praisonaiagents.memory.workflows import (
|
||||
WorkflowStep,
|
||||
Workflow,
|
||||
WorkflowManager
|
||||
)
|
||||
|
||||
|
||||
class TestAexecuteMethod:
|
||||
"""Test aexecute() async method."""
|
||||
|
||||
@staticmethod
|
||||
def _register_workflow(manager, workflow):
|
||||
"""Helper to register workflow and mark as loaded."""
|
||||
manager._workflows[workflow.name.lower()] = workflow
|
||||
manager._loaded = True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aexecute_exists(self):
|
||||
"""WorkflowManager should have aexecute method."""
|
||||
manager = WorkflowManager()
|
||||
assert hasattr(manager, 'aexecute')
|
||||
assert asyncio.iscoroutinefunction(manager.aexecute)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aexecute_with_sync_executor(self):
|
||||
"""aexecute should work with sync executor."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
steps=[
|
||||
WorkflowStep(name="step1", action="Action 1"),
|
||||
WorkflowStep(name="step2", action="Action 2")
|
||||
]
|
||||
)
|
||||
self._register_workflow(manager, workflow)
|
||||
|
||||
def sync_executor(action):
|
||||
return f"Sync result for {action}"
|
||||
|
||||
result = await manager.aexecute("test_workflow", executor=sync_executor)
|
||||
|
||||
assert result["success"]
|
||||
assert len(result["results"]) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aexecute_with_async_executor(self):
|
||||
"""aexecute should work with async executor."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
steps=[
|
||||
WorkflowStep(name="step1", action="Action 1"),
|
||||
WorkflowStep(name="step2", action="Action 2")
|
||||
]
|
||||
)
|
||||
self._register_workflow(manager, workflow)
|
||||
|
||||
async def async_executor(action):
|
||||
await asyncio.sleep(0.01) # Simulate async operation
|
||||
return f"Async result for {action}"
|
||||
|
||||
result = await manager.aexecute("test_workflow", executor=async_executor)
|
||||
|
||||
assert result["success"]
|
||||
assert len(result["results"]) == 2
|
||||
assert "Async result" in result["results"][0]["output"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aexecute_context_passing(self):
|
||||
"""aexecute should pass context between steps."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
steps=[
|
||||
WorkflowStep(name="step1", action="Generate data"),
|
||||
WorkflowStep(name="step2", action="Process {{previous_output}}")
|
||||
]
|
||||
)
|
||||
self._register_workflow(manager, workflow)
|
||||
|
||||
received_actions = []
|
||||
|
||||
async def async_executor(action):
|
||||
received_actions.append(action)
|
||||
if "Generate" in action:
|
||||
return "DATA_ABC"
|
||||
return "Processed"
|
||||
|
||||
result = await manager.aexecute("test_workflow", executor=async_executor)
|
||||
|
||||
assert result["success"]
|
||||
# Step 2 should have received context
|
||||
assert "DATA_ABC" in received_actions[1] or "previous_output" in received_actions[1]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aexecute_workflow_not_found(self):
|
||||
"""aexecute should return error for non-existent workflow."""
|
||||
manager = WorkflowManager()
|
||||
manager._loaded = True
|
||||
|
||||
result = await manager.aexecute("nonexistent_workflow")
|
||||
|
||||
assert not result["success"]
|
||||
assert "not found" in result.get("error", "")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aexecute_with_default_agent(self):
|
||||
"""aexecute should work with default_agent."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
steps=[
|
||||
WorkflowStep(name="step1", action="Action 1")
|
||||
]
|
||||
)
|
||||
self._register_workflow(manager, workflow)
|
||||
|
||||
mock_agent = Mock()
|
||||
mock_agent.chat = Mock(return_value="Agent response")
|
||||
|
||||
result = await manager.aexecute(
|
||||
"test_workflow",
|
||||
default_agent=mock_agent
|
||||
)
|
||||
|
||||
assert result["success"]
|
||||
mock_agent.chat.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aexecute_with_async_agent(self):
|
||||
"""aexecute should use achat if available on agent."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
steps=[
|
||||
WorkflowStep(
|
||||
name="step1",
|
||||
action="Action 1",
|
||||
agent_config={"role": "Helper"}
|
||||
)
|
||||
]
|
||||
)
|
||||
self._register_workflow(manager, workflow)
|
||||
|
||||
with patch('praisonaiagents.agent.agent.Agent') as MockAgent:
|
||||
mock_agent = Mock()
|
||||
mock_agent.achat = AsyncMock(return_value="Async agent response")
|
||||
MockAgent.return_value = mock_agent
|
||||
|
||||
result = await manager.aexecute(
|
||||
"test_workflow",
|
||||
default_llm="gpt-4o-mini"
|
||||
)
|
||||
|
||||
assert result["success"]
|
||||
mock_agent.achat.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aexecute_returns_variables(self):
|
||||
"""aexecute should return accumulated variables."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
variables={"initial": "value"},
|
||||
steps=[
|
||||
WorkflowStep(name="step1", action="Action 1")
|
||||
]
|
||||
)
|
||||
self._register_workflow(manager, workflow)
|
||||
|
||||
async def async_executor(action):
|
||||
return "Output"
|
||||
|
||||
result = await manager.aexecute("test_workflow", executor=async_executor)
|
||||
|
||||
assert "variables" in result
|
||||
assert "previous_output" in result["variables"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,353 @@
|
||||
"""
|
||||
Test suite for Workflow Context Passing functionality.
|
||||
|
||||
Tests the enhanced workflow system's ability to pass context between steps,
|
||||
including output accumulation, variable substitution, and selective context inclusion.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, MagicMock, patch
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add the package to path for testing
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
from praisonaiagents.memory.workflows import (
|
||||
WorkflowStep,
|
||||
Workflow,
|
||||
WorkflowManager,
|
||||
create_workflow_manager
|
||||
)
|
||||
|
||||
|
||||
class TestWorkflowStepContextFields:
|
||||
"""Test WorkflowStep dataclass context-related fields."""
|
||||
|
||||
def test_workflow_step_has_context_from_field(self):
|
||||
"""WorkflowStep should have context_from field."""
|
||||
step = WorkflowStep(
|
||||
name="test_step",
|
||||
action="do something",
|
||||
context_from=["step1", "step2"]
|
||||
)
|
||||
assert hasattr(step, 'context_from')
|
||||
assert step.context_from == ["step1", "step2"]
|
||||
|
||||
def test_workflow_step_has_retain_full_context_field(self):
|
||||
"""WorkflowStep should have retain_full_context field with default True."""
|
||||
step = WorkflowStep(name="test_step", action="do something")
|
||||
assert hasattr(step, 'retain_full_context')
|
||||
assert step.retain_full_context == True
|
||||
|
||||
def test_workflow_step_has_output_variable_field(self):
|
||||
"""WorkflowStep should have output_variable field."""
|
||||
step = WorkflowStep(
|
||||
name="test_step",
|
||||
action="do something",
|
||||
output_variable="research_result"
|
||||
)
|
||||
assert hasattr(step, 'output_variable')
|
||||
assert step.output_variable == "research_result"
|
||||
|
||||
def test_workflow_step_to_dict_includes_new_fields(self):
|
||||
"""to_dict() should include context-related fields."""
|
||||
step = WorkflowStep(
|
||||
name="test_step",
|
||||
action="do something",
|
||||
context_from=["step1"],
|
||||
retain_full_context=False,
|
||||
output_variable="result"
|
||||
)
|
||||
d = step.to_dict()
|
||||
assert "context_from" in d
|
||||
assert "retain_full_context" in d
|
||||
assert "output_variable" in d
|
||||
|
||||
|
||||
class TestContextPassing:
|
||||
"""Test context passing between workflow steps."""
|
||||
|
||||
@staticmethod
|
||||
def _register_workflow(manager, workflow):
|
||||
"""Helper to register workflow and mark as loaded."""
|
||||
manager._workflows[workflow.name.lower()] = workflow
|
||||
manager._loaded = True
|
||||
|
||||
def test_step_receives_previous_step_output(self):
|
||||
"""Step 2 should receive Step 1's output as context."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
# Create a simple workflow with 2 steps
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
steps=[
|
||||
WorkflowStep(name="step1", action="Generate data"),
|
||||
WorkflowStep(name="step2", action="Process {{previous_output}}")
|
||||
]
|
||||
)
|
||||
self._register_workflow(manager, workflow)
|
||||
|
||||
# Track what each step receives
|
||||
received_actions = []
|
||||
|
||||
def mock_executor(action):
|
||||
received_actions.append(action)
|
||||
if "Generate" in action:
|
||||
return "Generated data: ABC123"
|
||||
return f"Processed: {action}"
|
||||
|
||||
result = manager.execute("test_workflow", executor=mock_executor)
|
||||
|
||||
assert result["success"] == True
|
||||
assert len(result["results"]) == 2
|
||||
# Step 2 should have received the output from step 1
|
||||
assert "ABC123" in received_actions[1] or "previous_output" in received_actions[1]
|
||||
|
||||
def test_context_accumulates_across_steps(self):
|
||||
"""Each step should have access to all previous outputs when retain_full_context=True."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
steps=[
|
||||
WorkflowStep(name="step1", action="First action", retain_full_context=True),
|
||||
WorkflowStep(name="step2", action="Second action", retain_full_context=True),
|
||||
WorkflowStep(name="step3", action="Third with context: {{step1_output}} and {{step2_output}}", retain_full_context=True)
|
||||
]
|
||||
)
|
||||
self._register_workflow(manager, workflow)
|
||||
|
||||
outputs = ["Output1", "Output2", "Output3"]
|
||||
call_count = [0]
|
||||
|
||||
def mock_executor(action):
|
||||
idx = call_count[0]
|
||||
call_count[0] += 1
|
||||
return outputs[idx]
|
||||
|
||||
result = manager.execute("test_workflow", executor=mock_executor)
|
||||
|
||||
assert result["success"] == True
|
||||
assert len(result["results"]) == 3
|
||||
# All outputs should be stored
|
||||
assert result["results"][0]["output"] == "Output1"
|
||||
assert result["results"][1]["output"] == "Output2"
|
||||
assert result["results"][2]["output"] == "Output3"
|
||||
|
||||
def test_context_from_specific_steps(self):
|
||||
"""Step should only receive context from specified steps via context_from."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
steps=[
|
||||
WorkflowStep(name="research", action="Research topic"),
|
||||
WorkflowStep(name="analysis", action="Analyze data"),
|
||||
WorkflowStep(
|
||||
name="summary",
|
||||
action="Summarize based on: {{research_output}}",
|
||||
context_from=["research"], # Only include research, not analysis
|
||||
retain_full_context=False
|
||||
)
|
||||
]
|
||||
)
|
||||
self._register_workflow(manager, workflow)
|
||||
|
||||
received_actions = []
|
||||
|
||||
def mock_executor(action):
|
||||
received_actions.append(action)
|
||||
if "Research" in action:
|
||||
return "Research findings: XYZ"
|
||||
elif "Analyze" in action:
|
||||
return "Analysis results: 123"
|
||||
return "Summary complete"
|
||||
|
||||
result = manager.execute("test_workflow", executor=mock_executor)
|
||||
|
||||
assert result["success"] == True
|
||||
# The summary step should have research output but not analysis
|
||||
# (This tests the context_from filtering)
|
||||
|
||||
def test_output_variable_substitution(self):
|
||||
"""Output should be stored in specified variable name."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
steps=[
|
||||
WorkflowStep(
|
||||
name="step1",
|
||||
action="Generate report",
|
||||
output_variable="report_data"
|
||||
),
|
||||
WorkflowStep(
|
||||
name="step2",
|
||||
action="Use report: {{report_data}}"
|
||||
)
|
||||
]
|
||||
)
|
||||
self._register_workflow(manager, workflow)
|
||||
|
||||
def mock_executor(action):
|
||||
if "Generate" in action:
|
||||
return "Report content here"
|
||||
return f"Used: {action}"
|
||||
|
||||
result = manager.execute("test_workflow", executor=mock_executor)
|
||||
|
||||
assert result["success"] == True
|
||||
|
||||
def test_retain_full_context_false_only_last_step(self):
|
||||
"""When retain_full_context=False, only include last step's output."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
steps=[
|
||||
WorkflowStep(name="step1", action="First"),
|
||||
WorkflowStep(name="step2", action="Second"),
|
||||
WorkflowStep(
|
||||
name="step3",
|
||||
action="Third with {{previous_output}}",
|
||||
retain_full_context=False
|
||||
)
|
||||
]
|
||||
)
|
||||
self._register_workflow(manager, workflow)
|
||||
|
||||
received_actions = []
|
||||
|
||||
def mock_executor(action):
|
||||
received_actions.append(action)
|
||||
if "First" in action:
|
||||
return "First output"
|
||||
elif "Second" in action:
|
||||
return "Second output"
|
||||
return "Third output"
|
||||
|
||||
result = manager.execute("test_workflow", executor=mock_executor)
|
||||
|
||||
assert result["success"] == True
|
||||
# Step 3 should only have step 2's output, not step 1's
|
||||
|
||||
|
||||
class TestContextVariableSubstitution:
|
||||
"""Test variable substitution with step outputs."""
|
||||
|
||||
@staticmethod
|
||||
def _register_workflow(manager, workflow):
|
||||
"""Helper to register workflow and mark as loaded."""
|
||||
manager._workflows[workflow.name.lower()] = workflow
|
||||
manager._loaded = True
|
||||
|
||||
def test_previous_output_variable(self):
|
||||
"""{{previous_output}} should be replaced with last step's output."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
steps=[
|
||||
WorkflowStep(name="step1", action="Generate"),
|
||||
WorkflowStep(name="step2", action="Process: {{previous_output}}")
|
||||
]
|
||||
)
|
||||
self._register_workflow(manager, workflow)
|
||||
|
||||
received_actions = []
|
||||
|
||||
def mock_executor(action):
|
||||
received_actions.append(action)
|
||||
if "Generate" in action:
|
||||
return "DATA_123"
|
||||
return "Done"
|
||||
|
||||
result = manager.execute("test_workflow", executor=mock_executor)
|
||||
|
||||
# Check that previous_output was substituted
|
||||
assert "DATA_123" in received_actions[1] or "previous_output" in received_actions[1]
|
||||
|
||||
def test_named_step_output_variable(self):
|
||||
"""{{step_name_output}} should be replaced with that step's output."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
steps=[
|
||||
WorkflowStep(name="research", action="Research topic"),
|
||||
WorkflowStep(name="write", action="Write about {{research_output}}")
|
||||
]
|
||||
)
|
||||
self._register_workflow(manager, workflow)
|
||||
|
||||
received_actions = []
|
||||
|
||||
def mock_executor(action):
|
||||
received_actions.append(action)
|
||||
if "Research" in action:
|
||||
return "Research findings"
|
||||
return "Article written"
|
||||
|
||||
result = manager.execute("test_workflow", executor=mock_executor)
|
||||
|
||||
assert result["success"] == True
|
||||
|
||||
|
||||
class TestWorkflowExecuteResults:
|
||||
"""Test that execute() returns proper results with context info."""
|
||||
|
||||
@staticmethod
|
||||
def _register_workflow(manager, workflow):
|
||||
"""Helper to register workflow and mark as loaded."""
|
||||
manager._workflows[workflow.name.lower()] = workflow
|
||||
manager._loaded = True
|
||||
|
||||
def test_results_include_step_outputs(self):
|
||||
"""Results should include output from each step."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
steps=[
|
||||
WorkflowStep(name="step1", action="Action 1"),
|
||||
WorkflowStep(name="step2", action="Action 2")
|
||||
]
|
||||
)
|
||||
self._register_workflow(manager, workflow)
|
||||
|
||||
def mock_executor(action):
|
||||
return f"Output for {action}"
|
||||
|
||||
result = manager.execute("test_workflow", executor=mock_executor)
|
||||
|
||||
assert result["success"] == True
|
||||
assert len(result["results"]) == 2
|
||||
assert result["results"][0]["output"] is not None
|
||||
assert result["results"][1]["output"] is not None
|
||||
|
||||
def test_results_include_accumulated_context(self):
|
||||
"""Results should include accumulated context information."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
steps=[
|
||||
WorkflowStep(name="step1", action="Action 1"),
|
||||
WorkflowStep(name="step2", action="Action 2")
|
||||
]
|
||||
)
|
||||
self._register_workflow(manager, workflow)
|
||||
|
||||
def mock_executor(action):
|
||||
return f"Output for {action}"
|
||||
|
||||
result = manager.execute("test_workflow", executor=mock_executor)
|
||||
|
||||
# Result should have context or variables with accumulated outputs
|
||||
assert "results" in result
|
||||
assert result["success"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,341 @@
|
||||
"""
|
||||
Test suite for Workflow Markdown Parser.
|
||||
|
||||
Tests the enhanced markdown parser for parsing agent config, tools,
|
||||
context_from, and other new fields from workflow markdown files.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add the package to path for testing
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
from praisonaiagents.memory.workflows import (
|
||||
WorkflowStep,
|
||||
Workflow,
|
||||
WorkflowManager
|
||||
)
|
||||
|
||||
|
||||
class TestParseAgentConfig:
|
||||
"""Test parsing agent configuration from markdown."""
|
||||
|
||||
def test_parse_agent_config_block(self):
|
||||
"""Should parse agent config from ```agent block."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
body = '''
|
||||
## Step 1: Research
|
||||
Research the topic.
|
||||
|
||||
```agent
|
||||
role: Researcher
|
||||
goal: Find comprehensive information
|
||||
backstory: Expert researcher with 10 years experience
|
||||
llm: gpt-4o
|
||||
```
|
||||
|
||||
```action
|
||||
Search for information about AI
|
||||
```
|
||||
'''
|
||||
steps = manager._parse_steps(body)
|
||||
|
||||
assert len(steps) == 1
|
||||
assert steps[0].agent_config is not None
|
||||
assert steps[0].agent_config["role"] == "Researcher"
|
||||
assert steps[0].agent_config["goal"] == "Find comprehensive information"
|
||||
assert steps[0].agent_config["llm"] == "gpt-4o"
|
||||
|
||||
def test_parse_agent_config_helper(self):
|
||||
"""_parse_agent_config should parse key-value pairs."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
agent_str = '''
|
||||
role: Writer
|
||||
goal: Write engaging content
|
||||
verbose: true
|
||||
max_iter: 10
|
||||
'''
|
||||
config = manager._parse_agent_config(agent_str)
|
||||
|
||||
assert config["role"] == "Writer"
|
||||
assert config["goal"] == "Write engaging content"
|
||||
assert config["verbose"] is True
|
||||
assert config["max_iter"] == 10
|
||||
|
||||
|
||||
class TestParseToolsBlock:
|
||||
"""Test parsing tools from markdown."""
|
||||
|
||||
def test_parse_tools_block(self):
|
||||
"""Should parse tools from ```tools block."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
body = '''
|
||||
## Step 1: Search
|
||||
Search for information.
|
||||
|
||||
```tools
|
||||
tavily_search
|
||||
web_browser
|
||||
calculator
|
||||
```
|
||||
|
||||
```action
|
||||
Search for AI trends
|
||||
```
|
||||
'''
|
||||
steps = manager._parse_steps(body)
|
||||
|
||||
assert len(steps) == 1
|
||||
assert steps[0].tools is not None
|
||||
assert len(steps[0].tools) == 3
|
||||
assert "tavily_search" in steps[0].tools
|
||||
assert "web_browser" in steps[0].tools
|
||||
assert "calculator" in steps[0].tools
|
||||
|
||||
|
||||
class TestParseContextFrom:
|
||||
"""Test parsing context_from from markdown."""
|
||||
|
||||
def test_parse_context_from(self):
|
||||
"""Should parse context_from directive."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
body = '''
|
||||
## Step 1: Research
|
||||
Research the topic.
|
||||
|
||||
```action
|
||||
Research AI
|
||||
```
|
||||
|
||||
## Step 2: Analyze
|
||||
Analyze the research.
|
||||
|
||||
```action
|
||||
Analyze data
|
||||
```
|
||||
|
||||
## Step 3: Summary
|
||||
Summarize findings.
|
||||
|
||||
context_from: [Research, Analyze]
|
||||
|
||||
```action
|
||||
Write summary
|
||||
```
|
||||
'''
|
||||
steps = manager._parse_steps(body)
|
||||
|
||||
assert len(steps) == 3
|
||||
assert steps[2].context_from is not None
|
||||
assert "Research" in steps[2].context_from
|
||||
assert "Analyze" in steps[2].context_from
|
||||
|
||||
|
||||
class TestParseRetainFullContext:
|
||||
"""Test parsing retain_full_context from markdown."""
|
||||
|
||||
def test_parse_retain_full_context_false(self):
|
||||
"""Should parse retain_full_context: false."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
body = '''
|
||||
## Step 1: Process
|
||||
Process data.
|
||||
|
||||
retain_full_context: false
|
||||
|
||||
```action
|
||||
Process the data
|
||||
```
|
||||
'''
|
||||
steps = manager._parse_steps(body)
|
||||
|
||||
assert len(steps) == 1
|
||||
assert steps[0].retain_full_context is False
|
||||
|
||||
def test_parse_retain_full_context_true(self):
|
||||
"""Should parse retain_full_context: true."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
body = '''
|
||||
## Step 1: Process
|
||||
Process data.
|
||||
|
||||
retain_full_context: true
|
||||
|
||||
```action
|
||||
Process the data
|
||||
```
|
||||
'''
|
||||
steps = manager._parse_steps(body)
|
||||
|
||||
assert len(steps) == 1
|
||||
assert steps[0].retain_full_context is True
|
||||
|
||||
def test_retain_full_context_default_true(self):
|
||||
"""retain_full_context should default to True."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
body = '''
|
||||
## Step 1: Process
|
||||
Process data.
|
||||
|
||||
```action
|
||||
Process the data
|
||||
```
|
||||
'''
|
||||
steps = manager._parse_steps(body)
|
||||
|
||||
assert len(steps) == 1
|
||||
assert steps[0].retain_full_context is True
|
||||
|
||||
|
||||
class TestParseOutputVariable:
|
||||
"""Test parsing output_variable from markdown."""
|
||||
|
||||
def test_parse_output_variable(self):
|
||||
"""Should parse output_variable directive."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
body = '''
|
||||
## Step 1: Generate
|
||||
Generate report.
|
||||
|
||||
output_variable: report_data
|
||||
|
||||
```action
|
||||
Generate the report
|
||||
```
|
||||
'''
|
||||
steps = manager._parse_steps(body)
|
||||
|
||||
assert len(steps) == 1
|
||||
assert steps[0].output_variable == "report_data"
|
||||
|
||||
|
||||
class TestLoadWorkflowWithNewFields:
|
||||
"""Test loading workflow files with new fields."""
|
||||
|
||||
def test_load_workflow_with_default_llm(self):
|
||||
"""Should load workflow with default_llm from frontmatter."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
content = '''---
|
||||
name: Test Workflow
|
||||
description: A test workflow
|
||||
default_llm: gpt-4o
|
||||
planning: true
|
||||
planning_llm: gpt-4o-mini
|
||||
---
|
||||
|
||||
## Step 1: Test
|
||||
Test step.
|
||||
|
||||
```action
|
||||
Do something
|
||||
```
|
||||
'''
|
||||
# Create temp file
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
||||
f.write(content)
|
||||
temp_path = f.name
|
||||
|
||||
try:
|
||||
from pathlib import Path
|
||||
workflow = manager._load_workflow(Path(temp_path))
|
||||
|
||||
assert workflow is not None
|
||||
assert workflow.default_llm == "gpt-4o"
|
||||
assert workflow.planning is True
|
||||
assert workflow.planning_llm == "gpt-4o-mini"
|
||||
finally:
|
||||
os.unlink(temp_path)
|
||||
|
||||
|
||||
class TestCompleteWorkflowParsing:
|
||||
"""Test complete workflow parsing with all new features."""
|
||||
|
||||
def test_parse_complete_workflow(self):
|
||||
"""Should parse a complete workflow with all new features."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
body = '''
|
||||
## Step 1: Research
|
||||
Research the topic thoroughly.
|
||||
|
||||
```agent
|
||||
role: Researcher
|
||||
goal: Find comprehensive information
|
||||
backstory: Expert researcher
|
||||
```
|
||||
|
||||
```tools
|
||||
tavily_search
|
||||
web_browser
|
||||
```
|
||||
|
||||
output_variable: research_data
|
||||
|
||||
```action
|
||||
Search for information about {{topic}}
|
||||
```
|
||||
|
||||
## Step 2: Analyze
|
||||
Analyze the research findings.
|
||||
|
||||
```agent
|
||||
role: Analyst
|
||||
goal: Analyze data patterns
|
||||
```
|
||||
|
||||
context_from: [Research]
|
||||
retain_full_context: false
|
||||
|
||||
```action
|
||||
Analyze: {{research_data}}
|
||||
```
|
||||
|
||||
## Step 3: Write
|
||||
Write the final report.
|
||||
|
||||
```agent
|
||||
role: Writer
|
||||
goal: Write engaging content
|
||||
```
|
||||
|
||||
```action
|
||||
Write report based on analysis
|
||||
```
|
||||
'''
|
||||
steps = manager._parse_steps(body)
|
||||
|
||||
assert len(steps) == 3
|
||||
|
||||
# Step 1
|
||||
assert steps[0].name == "Research"
|
||||
assert steps[0].agent_config is not None
|
||||
assert steps[0].agent_config["role"] == "Researcher"
|
||||
assert steps[0].tools is not None
|
||||
assert "tavily_search" in steps[0].tools
|
||||
assert steps[0].output_variable == "research_data"
|
||||
|
||||
# Step 2
|
||||
assert steps[1].name == "Analyze"
|
||||
assert steps[1].agent_config["role"] == "Analyst"
|
||||
assert steps[1].context_from == ["Research"]
|
||||
assert steps[1].retain_full_context is False
|
||||
|
||||
# Step 3
|
||||
assert steps[2].name == "Write"
|
||||
assert steps[2].agent_config["role"] == "Writer"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,359 @@
|
||||
"""
|
||||
Test suite for Workflow Planning Mode Integration.
|
||||
|
||||
Tests the enhanced workflow system's ability to integrate with planning mode,
|
||||
including workflow-level planning settings and planning LLM configuration.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add the package to path for testing
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
from praisonaiagents.memory.workflows import (
|
||||
WorkflowStep,
|
||||
Workflow,
|
||||
WorkflowManager
|
||||
)
|
||||
|
||||
|
||||
class TestWorkflowPlanningFields:
|
||||
"""Test Workflow dataclass planning-related fields."""
|
||||
|
||||
def test_workflow_has_planning_field(self):
|
||||
"""Workflow should have planning field."""
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
planning=True
|
||||
)
|
||||
assert hasattr(workflow, 'planning')
|
||||
assert workflow.planning is True
|
||||
|
||||
def test_workflow_planning_default_false(self):
|
||||
"""Workflow planning should default to False."""
|
||||
workflow = Workflow(name="test_workflow")
|
||||
assert workflow.planning is False
|
||||
|
||||
def test_workflow_has_planning_llm_field(self):
|
||||
"""Workflow should have planning_llm field."""
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
planning_llm="gpt-4o"
|
||||
)
|
||||
assert hasattr(workflow, 'planning_llm')
|
||||
assert workflow.planning_llm == "gpt-4o"
|
||||
|
||||
def test_workflow_planning_llm_default_none(self):
|
||||
"""Workflow planning_llm should default to None."""
|
||||
workflow = Workflow(name="test_workflow")
|
||||
assert workflow.planning_llm is None
|
||||
|
||||
def test_workflow_to_dict_includes_planning_fields(self):
|
||||
"""to_dict() should include planning-related fields."""
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
planning=True,
|
||||
planning_llm="gpt-4o"
|
||||
)
|
||||
d = workflow.to_dict()
|
||||
assert "planning" in d
|
||||
assert "planning_llm" in d
|
||||
assert d["planning"] is True
|
||||
assert d["planning_llm"] == "gpt-4o"
|
||||
|
||||
|
||||
class TestWorkflowMemoryFields:
|
||||
"""Test Workflow dataclass memory-related fields."""
|
||||
|
||||
def test_workflow_has_memory_config_field(self):
|
||||
"""Workflow should have memory_config field."""
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
memory_config={
|
||||
"provider": "rag",
|
||||
"use_embedding": True
|
||||
}
|
||||
)
|
||||
assert hasattr(workflow, 'memory_config')
|
||||
assert workflow.memory_config["provider"] == "rag"
|
||||
|
||||
def test_workflow_memory_config_default_none(self):
|
||||
"""Workflow memory_config should default to None."""
|
||||
workflow = Workflow(name="test_workflow")
|
||||
assert workflow.memory_config is None
|
||||
|
||||
def test_workflow_to_dict_includes_memory_config(self):
|
||||
"""to_dict() should include memory_config field."""
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
memory_config={"provider": "sqlite"}
|
||||
)
|
||||
d = workflow.to_dict()
|
||||
assert "memory_config" in d
|
||||
|
||||
|
||||
class TestExecutePlanningParameter:
|
||||
"""Test execute() planning parameter."""
|
||||
|
||||
@staticmethod
|
||||
def _register_workflow(manager, workflow):
|
||||
"""Helper to register workflow and mark as loaded."""
|
||||
manager._workflows[workflow.name.lower()] = workflow
|
||||
manager._loaded = True
|
||||
|
||||
def test_execute_accepts_planning_parameter(self):
|
||||
"""Execute should accept planning parameter."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
steps=[
|
||||
WorkflowStep(name="step1", action="Action 1")
|
||||
]
|
||||
)
|
||||
self._register_workflow(manager, workflow)
|
||||
|
||||
def mock_executor(action):
|
||||
return "Result"
|
||||
|
||||
# Should not raise error
|
||||
result = manager.execute(
|
||||
"test_workflow",
|
||||
executor=mock_executor,
|
||||
planning=True
|
||||
)
|
||||
|
||||
assert result["success"]
|
||||
|
||||
def test_workflow_planning_used_when_not_overridden(self):
|
||||
"""Workflow's planning setting should be used when not overridden."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
planning=True, # Workflow-level planning enabled
|
||||
steps=[
|
||||
WorkflowStep(name="step1", action="Action 1")
|
||||
]
|
||||
)
|
||||
self._register_workflow(manager, workflow)
|
||||
|
||||
def mock_executor(action):
|
||||
return "Result"
|
||||
|
||||
# Execute without planning parameter - should use workflow's setting
|
||||
result = manager.execute(
|
||||
"test_workflow",
|
||||
executor=mock_executor
|
||||
)
|
||||
|
||||
assert result["success"]
|
||||
|
||||
|
||||
class TestExecuteMemoryParameter:
|
||||
"""Test execute() memory parameter."""
|
||||
|
||||
@staticmethod
|
||||
def _register_workflow(manager, workflow):
|
||||
"""Helper to register workflow and mark as loaded."""
|
||||
manager._workflows[workflow.name.lower()] = workflow
|
||||
manager._loaded = True
|
||||
|
||||
def test_execute_accepts_memory_parameter(self):
|
||||
"""Execute should accept memory parameter."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
steps=[
|
||||
WorkflowStep(name="step1", action="Action 1")
|
||||
]
|
||||
)
|
||||
self._register_workflow(manager, workflow)
|
||||
|
||||
mock_memory = Mock()
|
||||
|
||||
def mock_executor(action):
|
||||
return "Result"
|
||||
|
||||
# Should not raise error
|
||||
result = manager.execute(
|
||||
"test_workflow",
|
||||
executor=mock_executor,
|
||||
memory=mock_memory
|
||||
)
|
||||
|
||||
assert result["success"]
|
||||
|
||||
def test_memory_passed_to_step_agent(self):
|
||||
"""Memory should be passed to created step agents."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
steps=[
|
||||
WorkflowStep(
|
||||
name="step1",
|
||||
action="Action 1",
|
||||
agent_config={"role": "Helper"}
|
||||
)
|
||||
]
|
||||
)
|
||||
self._register_workflow(manager, workflow)
|
||||
|
||||
mock_memory = Mock()
|
||||
|
||||
with patch('praisonaiagents.agent.agent.Agent') as MockAgent:
|
||||
mock_agent = Mock()
|
||||
mock_agent.chat = Mock(return_value="Response")
|
||||
MockAgent.return_value = mock_agent
|
||||
|
||||
manager.execute(
|
||||
"test_workflow",
|
||||
memory=mock_memory,
|
||||
default_llm="gpt-4o-mini"
|
||||
)
|
||||
|
||||
call_kwargs = MockAgent.call_args[1]
|
||||
assert "memory" in call_kwargs
|
||||
assert call_kwargs["memory"] == mock_memory
|
||||
|
||||
|
||||
class TestExecuteStreamParameter:
|
||||
"""Test execute() stream parameter."""
|
||||
|
||||
@staticmethod
|
||||
def _register_workflow(manager, workflow):
|
||||
"""Helper to register workflow and mark as loaded."""
|
||||
manager._workflows[workflow.name.lower()] = workflow
|
||||
manager._loaded = True
|
||||
|
||||
def test_execute_accepts_stream_parameter(self):
|
||||
"""Execute should accept stream parameter."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
steps=[
|
||||
WorkflowStep(name="step1", action="Action 1")
|
||||
]
|
||||
)
|
||||
self._register_workflow(manager, workflow)
|
||||
|
||||
def mock_executor(action):
|
||||
return "Result"
|
||||
|
||||
# Should not raise error
|
||||
result = manager.execute(
|
||||
"test_workflow",
|
||||
executor=mock_executor,
|
||||
stream=True
|
||||
)
|
||||
|
||||
assert result["success"]
|
||||
|
||||
|
||||
class TestExecuteVerboseParameter:
|
||||
"""Test execute() verbose parameter."""
|
||||
|
||||
@staticmethod
|
||||
def _register_workflow(manager, workflow):
|
||||
"""Helper to register workflow and mark as loaded."""
|
||||
manager._workflows[workflow.name.lower()] = workflow
|
||||
manager._loaded = True
|
||||
|
||||
def test_execute_accepts_verbose_parameter(self):
|
||||
"""Execute should accept verbose parameter."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
steps=[
|
||||
WorkflowStep(name="step1", action="Action 1")
|
||||
]
|
||||
)
|
||||
self._register_workflow(manager, workflow)
|
||||
|
||||
def mock_executor(action):
|
||||
return "Result"
|
||||
|
||||
# Should not raise error
|
||||
result = manager.execute(
|
||||
"test_workflow",
|
||||
executor=mock_executor,
|
||||
verbose=2
|
||||
)
|
||||
|
||||
assert result["success"]
|
||||
|
||||
def test_verbose_passed_to_step_agent(self):
|
||||
"""Verbose level should be passed to created step agents."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
steps=[
|
||||
WorkflowStep(
|
||||
name="step1",
|
||||
action="Action 1",
|
||||
agent_config={"role": "Helper"}
|
||||
)
|
||||
]
|
||||
)
|
||||
self._register_workflow(manager, workflow)
|
||||
|
||||
with patch('praisonaiagents.agent.agent.Agent') as MockAgent:
|
||||
mock_agent = Mock()
|
||||
mock_agent.chat = Mock(return_value="Response")
|
||||
MockAgent.return_value = mock_agent
|
||||
|
||||
manager.execute(
|
||||
"test_workflow",
|
||||
verbose=3,
|
||||
default_llm="gpt-4o-mini"
|
||||
)
|
||||
|
||||
call_kwargs = MockAgent.call_args[1]
|
||||
assert "verbose" in call_kwargs
|
||||
assert call_kwargs["verbose"] == 3
|
||||
|
||||
|
||||
class TestExecuteResultsWithVariables:
|
||||
"""Test that execute() returns variables in results."""
|
||||
|
||||
@staticmethod
|
||||
def _register_workflow(manager, workflow):
|
||||
"""Helper to register workflow and mark as loaded."""
|
||||
manager._workflows[workflow.name.lower()] = workflow
|
||||
manager._loaded = True
|
||||
|
||||
def test_results_include_variables(self):
|
||||
"""Results should include accumulated variables."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
variables={"initial": "value"},
|
||||
steps=[
|
||||
WorkflowStep(name="step1", action="Action 1"),
|
||||
WorkflowStep(name="step2", action="Action 2")
|
||||
]
|
||||
)
|
||||
self._register_workflow(manager, workflow)
|
||||
|
||||
def mock_executor(action):
|
||||
return f"Output for {action}"
|
||||
|
||||
result = manager.execute("test_workflow", executor=mock_executor)
|
||||
|
||||
assert "variables" in result
|
||||
assert "initial" in result["variables"]
|
||||
# Should also have step outputs as variables
|
||||
assert "previous_output" in result["variables"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,306 @@
|
||||
"""
|
||||
Test suite for Workflow Per-Step Tools Support.
|
||||
|
||||
Tests the enhanced workflow system's ability to configure different tools
|
||||
for each step, including tool execution and tool results in context.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add the package to path for testing
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
from praisonaiagents.memory.workflows import (
|
||||
WorkflowStep,
|
||||
Workflow,
|
||||
WorkflowManager
|
||||
)
|
||||
|
||||
|
||||
class TestWorkflowStepToolsField:
|
||||
"""Test WorkflowStep tools field."""
|
||||
|
||||
def test_workflow_step_tools_accepts_list(self):
|
||||
"""WorkflowStep should accept a list of tools."""
|
||||
mock_tool1 = Mock()
|
||||
mock_tool2 = Mock()
|
||||
|
||||
step = WorkflowStep(
|
||||
name="research",
|
||||
action="Research topic",
|
||||
tools=[mock_tool1, mock_tool2]
|
||||
)
|
||||
|
||||
assert step.tools is not None
|
||||
assert len(step.tools) == 2
|
||||
assert mock_tool1 in step.tools
|
||||
assert mock_tool2 in step.tools
|
||||
|
||||
def test_workflow_step_tools_accepts_callable(self):
|
||||
"""WorkflowStep should accept callable tools."""
|
||||
def search_tool(query):
|
||||
return f"Results for {query}"
|
||||
|
||||
step = WorkflowStep(
|
||||
name="search",
|
||||
action="Search for info",
|
||||
tools=[search_tool]
|
||||
)
|
||||
|
||||
assert step.tools is not None
|
||||
assert callable(step.tools[0])
|
||||
|
||||
def test_workflow_step_tools_in_to_dict(self):
|
||||
"""to_dict() should include tools field."""
|
||||
step = WorkflowStep(
|
||||
name="test",
|
||||
action="test action",
|
||||
tools=["tool1", "tool2"]
|
||||
)
|
||||
|
||||
d = step.to_dict()
|
||||
assert "tools" in d
|
||||
assert d["tools"] == ["tool1", "tool2"]
|
||||
|
||||
|
||||
class TestToolsPassedToAgent:
|
||||
"""Test that tools are passed to agent during step execution."""
|
||||
|
||||
@staticmethod
|
||||
def _register_workflow(manager, workflow):
|
||||
"""Helper to register workflow and mark as loaded."""
|
||||
manager._workflows[workflow.name.lower()] = workflow
|
||||
manager._loaded = True
|
||||
|
||||
def test_step_tools_passed_to_agent(self):
|
||||
"""Step's tools should be passed to created agent."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
mock_tool = Mock()
|
||||
step = WorkflowStep(
|
||||
name="research",
|
||||
action="Research topic",
|
||||
agent_config={"role": "Researcher"},
|
||||
tools=[mock_tool]
|
||||
)
|
||||
|
||||
with patch('praisonaiagents.agent.agent.Agent') as MockAgent:
|
||||
MockAgent.return_value = Mock()
|
||||
manager._create_step_agent(step=step, default_llm="gpt-4o-mini")
|
||||
|
||||
call_kwargs = MockAgent.call_args[1]
|
||||
assert "tools" in call_kwargs
|
||||
assert mock_tool in call_kwargs["tools"]
|
||||
|
||||
def test_multiple_tools_passed_to_agent(self):
|
||||
"""Multiple tools should all be passed to agent."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
tool1 = Mock(name="search")
|
||||
tool2 = Mock(name="calculator")
|
||||
tool3 = Mock(name="browser")
|
||||
|
||||
step = WorkflowStep(
|
||||
name="research",
|
||||
action="Research topic",
|
||||
agent_config={"role": "Researcher"},
|
||||
tools=[tool1, tool2, tool3]
|
||||
)
|
||||
|
||||
with patch('praisonaiagents.agent.agent.Agent') as MockAgent:
|
||||
MockAgent.return_value = Mock()
|
||||
manager._create_step_agent(step=step, default_llm="gpt-4o-mini")
|
||||
|
||||
call_kwargs = MockAgent.call_args[1]
|
||||
assert len(call_kwargs["tools"]) == 3
|
||||
|
||||
|
||||
class TestDifferentToolsPerStep:
|
||||
"""Test different tools for different steps."""
|
||||
|
||||
@staticmethod
|
||||
def _register_workflow(manager, workflow):
|
||||
"""Helper to register workflow and mark as loaded."""
|
||||
manager._workflows[workflow.name.lower()] = workflow
|
||||
manager._loaded = True
|
||||
|
||||
def test_different_steps_different_tools(self):
|
||||
"""Different steps can have different tools."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
search_tool = Mock(name="search")
|
||||
write_tool = Mock(name="write")
|
||||
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
steps=[
|
||||
WorkflowStep(
|
||||
name="research",
|
||||
action="Research AI",
|
||||
agent_config={"role": "Researcher"},
|
||||
tools=[search_tool]
|
||||
),
|
||||
WorkflowStep(
|
||||
name="write",
|
||||
action="Write article",
|
||||
agent_config={"role": "Writer"},
|
||||
tools=[write_tool]
|
||||
)
|
||||
]
|
||||
)
|
||||
self._register_workflow(manager, workflow)
|
||||
|
||||
created_agents = []
|
||||
|
||||
with patch('praisonaiagents.agent.agent.Agent') as MockAgent:
|
||||
def track_agent(**kwargs):
|
||||
agent = Mock()
|
||||
agent.chat = Mock(return_value="Response")
|
||||
agent.tools = kwargs.get("tools", [])
|
||||
created_agents.append(kwargs)
|
||||
return agent
|
||||
|
||||
MockAgent.side_effect = track_agent
|
||||
|
||||
manager.execute("test_workflow", default_llm="gpt-4o-mini")
|
||||
|
||||
# Verify different tools for each step
|
||||
assert len(created_agents) == 2
|
||||
assert search_tool in created_agents[0]["tools"]
|
||||
assert write_tool in created_agents[1]["tools"]
|
||||
|
||||
def test_step_without_tools(self):
|
||||
"""Steps without tools should work correctly."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
steps=[
|
||||
WorkflowStep(
|
||||
name="think",
|
||||
action="Think about topic",
|
||||
agent_config={"role": "Thinker"}
|
||||
# No tools
|
||||
)
|
||||
]
|
||||
)
|
||||
self._register_workflow(manager, workflow)
|
||||
|
||||
with patch('praisonaiagents.agent.agent.Agent') as MockAgent:
|
||||
mock_agent = Mock()
|
||||
mock_agent.chat = Mock(return_value="Thoughts")
|
||||
MockAgent.return_value = mock_agent
|
||||
|
||||
result = manager.execute("test_workflow", default_llm="gpt-4o-mini")
|
||||
|
||||
assert result["success"]
|
||||
# tools should not be in kwargs or should be None
|
||||
call_kwargs = MockAgent.call_args[1]
|
||||
assert call_kwargs.get("tools") is None or call_kwargs.get("tools") == []
|
||||
|
||||
|
||||
class TestToolsWithExecutor:
|
||||
"""Test tools behavior when using custom executor."""
|
||||
|
||||
@staticmethod
|
||||
def _register_workflow(manager, workflow):
|
||||
"""Helper to register workflow and mark as loaded."""
|
||||
manager._workflows[workflow.name.lower()] = workflow
|
||||
manager._loaded = True
|
||||
|
||||
def test_executor_ignores_step_tools(self):
|
||||
"""When executor is provided, step tools are not used for agent creation."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
mock_tool = Mock()
|
||||
|
||||
workflow = Workflow(
|
||||
name="test_workflow",
|
||||
steps=[
|
||||
WorkflowStep(
|
||||
name="step1",
|
||||
action="Action 1",
|
||||
tools=[mock_tool] # Tools defined but executor provided
|
||||
)
|
||||
]
|
||||
)
|
||||
self._register_workflow(manager, workflow)
|
||||
|
||||
executor_called = [False]
|
||||
|
||||
def custom_executor(action):
|
||||
executor_called[0] = True
|
||||
return "Executor result"
|
||||
|
||||
# Should not try to create agent since executor is provided
|
||||
with patch('praisonaiagents.agent.agent.Agent') as MockAgent:
|
||||
result = manager.execute("test_workflow", executor=custom_executor)
|
||||
|
||||
assert result["success"]
|
||||
assert executor_called[0]
|
||||
MockAgent.assert_not_called()
|
||||
|
||||
|
||||
class TestToolsIntegration:
|
||||
"""Integration tests for tools in workflow execution."""
|
||||
|
||||
@staticmethod
|
||||
def _register_workflow(manager, workflow):
|
||||
"""Helper to register workflow and mark as loaded."""
|
||||
manager._workflows[workflow.name.lower()] = workflow
|
||||
manager._loaded = True
|
||||
|
||||
def test_full_workflow_with_tools(self):
|
||||
"""Full workflow execution with tools should work."""
|
||||
manager = WorkflowManager()
|
||||
|
||||
# Create mock tools
|
||||
search_tool = Mock(name="search")
|
||||
analyze_tool = Mock(name="analyze")
|
||||
|
||||
workflow = Workflow(
|
||||
name="research_workflow",
|
||||
steps=[
|
||||
WorkflowStep(
|
||||
name="search",
|
||||
action="Search for {{topic}}",
|
||||
agent_config={"role": "Searcher"},
|
||||
tools=[search_tool]
|
||||
),
|
||||
WorkflowStep(
|
||||
name="analyze",
|
||||
action="Analyze: {{previous_output}}",
|
||||
agent_config={"role": "Analyzer"},
|
||||
tools=[analyze_tool]
|
||||
),
|
||||
WorkflowStep(
|
||||
name="summarize",
|
||||
action="Summarize findings",
|
||||
agent_config={"role": "Summarizer"}
|
||||
# No tools for final step
|
||||
)
|
||||
],
|
||||
variables={"topic": "AI trends"}
|
||||
)
|
||||
self._register_workflow(manager, workflow)
|
||||
|
||||
with patch('praisonaiagents.agent.agent.Agent') as MockAgent:
|
||||
def create_mock_agent(**kwargs):
|
||||
agent = Mock()
|
||||
agent.chat = Mock(return_value=f"Response from {kwargs.get('role', 'agent')}")
|
||||
return agent
|
||||
|
||||
MockAgent.side_effect = create_mock_agent
|
||||
|
||||
result = manager.execute("research_workflow")
|
||||
|
||||
assert result["success"]
|
||||
assert len(result["results"]) == 3
|
||||
assert MockAgent.call_count == 3
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
Generated
+4
-1
@@ -2422,7 +2422,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "0.0.185"
|
||||
version = "0.0.196"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -2450,6 +2450,7 @@ all = [
|
||||
{ name = "python-jose", extra = ["cryptography"] },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "uvicorn" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
api = [
|
||||
{ name = "fastapi" },
|
||||
@@ -2479,6 +2480,7 @@ mcp = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "mcp" },
|
||||
{ name = "uvicorn" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
memory = [
|
||||
{ name = "chromadb" },
|
||||
@@ -2530,6 +2532,7 @@ requires-dist = [
|
||||
{ name = "rich" },
|
||||
{ name = "uvicorn", marker = "extra == 'api'", specifier = ">=0.34.0" },
|
||||
{ name = "uvicorn", marker = "extra == 'mcp'", specifier = ">=0.34.0" },
|
||||
{ name = "websockets", marker = "extra == 'mcp'", specifier = ">=12.0" },
|
||||
]
|
||||
provides-extras = ["mcp", "memory", "knowledge", "graph", "llm", "api", "telemetry", "mongodb", "auth", "all"]
|
||||
|
||||
|
||||
+790
-39
@@ -32,38 +32,111 @@ PraisonAI is a production-ready Multi-AI Agents framework with self-reflection,
|
||||
|
||||
## Key Features
|
||||
|
||||
- 🤖 Automated AI Agents Creation
|
||||
- 🔄 Self Reflection AI Agents
|
||||
- 🧠 Reasoning AI Agents
|
||||
- 👁️ Multi Modal AI Agents
|
||||
- 🤝 Multi Agent Collaboration
|
||||
- 🎭 AI Agent Workflow
|
||||
- 📚 Add Custom Knowledge
|
||||
- 🧠 Agents with Short and Long Term Memory
|
||||
- 📄 Chat with PDF Agents
|
||||
- 💻 Code Interpreter Agents
|
||||
- 📚 RAG Agents
|
||||
- 🤔 Async & Parallel Processing
|
||||
- 🔄 Auto Agents
|
||||
- 🔢 Math Agents
|
||||
- 🎯 Structured Output Agents
|
||||
- 🔗 LangChain Integrated Agents
|
||||
- 📞 Callback Agents
|
||||
- 🤏 Mini AI Agents
|
||||
- 🛠️ 100+ Custom Tools
|
||||
- 📄 YAML Configuration
|
||||
- 💯 100+ LLM Support
|
||||
- 🔬 **Deep Research Agents** (OpenAI & Gemini)
|
||||
- 🔄 **Query Rewriter Agent** (HyDE, Step-back, Multi-query)
|
||||
- 🌐 **Native Web Search** (OpenAI, Gemini, Anthropic, xAI, Perplexity)
|
||||
- 📥 **Web Fetch** (Retrieve full content from URLs - Anthropic)
|
||||
- 💾 **Prompt Caching** (Reduce costs & latency - OpenAI, Anthropic, Bedrock, Deepseek)
|
||||
- 🧠 **Claude Memory Tool** (Persistent cross-conversation memory - Anthropic Beta)
|
||||
- 💾 **File-Based Memory** (Zero-dependency persistent memory for all agents)
|
||||
- 🔍 **Built-in Search Tools** (Tavily, You.com, Exa - web search, news, content extraction)
|
||||
- 📋 **Planning Mode** (Plan before execution - Agent & Multi-Agent)
|
||||
- 🔧 **Planning Tools** (Research with tools during planning)
|
||||
- 🧠 **Planning Reasoning** (Chain-of-thought planning)
|
||||
| Feature | Code | Docs |
|
||||
|---------|:----:|:----:|
|
||||
| 🚀 Single Agent | [Example](examples/python/agents/single-agent.py) | [📖](https://docs.praison.ai/agents/single) |
|
||||
| 🤝 Multi Agents | [Example](examples/python/general/mini_agents_example.py) | [📖](https://docs.praison.ai/concepts/agents) |
|
||||
| 🤖 Auto Agents | [Example](examples/python/general/auto_agents_example.py) | [📖](https://docs.praison.ai/features/autoagents) |
|
||||
| 🔄 Self Reflection AI Agents | [Example](examples/python/concepts/self-reflection-details.py) | [📖](https://docs.praison.ai/features/selfreflection) |
|
||||
| 🧠 Reasoning AI Agents | [Example](examples/python/concepts/reasoning-extraction.py) | [📖](https://docs.praison.ai/features/reasoning) |
|
||||
| 👁️ Multi Modal AI Agents | [Example](examples/python/general/multimodal.py) | [📖](https://docs.praison.ai/features/multimodal) |
|
||||
| 🎭 AI Agent Workflow (Context Passing, Per-Step Agents) | [Example](examples/python/stateful/workflow-state-example.py) | [📖](https://docs.praison.ai/features/workflows) |
|
||||
| 📚 Add Custom Knowledge | [Example](examples/python/concepts/knowledge-agents.py) | [📖](https://docs.praison.ai/features/knowledge) |
|
||||
| 🧠 Memory (Short & Long Term) | [Example](examples/python/general/memory_example.py) | [📖](https://docs.praison.ai/concepts/memory) |
|
||||
| 📄 Chat with PDF Agents | [Example](examples/python/concepts/chat-with-pdf.py) | [📖](https://docs.praison.ai/features/chat-with-pdf) |
|
||||
| 💻 Code Interpreter Agents | [Example](examples/python/agents/code-agent.py) | [📖](https://docs.praison.ai/features/codeagent) |
|
||||
| 📚 RAG Agents | [Example](examples/python/concepts/rag-agents.py) | [📖](https://docs.praison.ai/features/rag) |
|
||||
| 🤔 Async & Parallel Processing | [Example](examples/python/general/async_example.py) | [📖](https://docs.praison.ai/features/async) |
|
||||
| 🔢 Math Agents | [Example](examples/python/agents/math-agent.py) | [📖](https://docs.praison.ai/features/mathagent) |
|
||||
| 🎯 Structured Output Agents | [Example](examples/python/general/structured_agents_example.py) | [📖](https://docs.praison.ai/features/structured) |
|
||||
| 🔗 LangChain Integrated Agents | [Example](examples/python/general/langchain_example.py) | [📖](https://docs.praison.ai/features/langchain) |
|
||||
| 📞 Callback Agents | [Example](examples/python/general/advanced-callback-systems.py) | [📖](https://docs.praison.ai/features/callbacks) |
|
||||
| 🛠️ 100+ Custom Tools | [Example](examples/python/general/tools_example.py) | [📖](https://docs.praison.ai/tools/tools) |
|
||||
| 📄 YAML Configuration | [Example](examples/cookbooks/yaml/secondary_market_research_agents.yaml) | [📖](https://docs.praison.ai/developers/agents-playbook) |
|
||||
| 💯 100+ LLM Support | [Example](examples/python/providers/openai/openai_gpt4_example.py) | [📖](https://docs.praison.ai/models) |
|
||||
| 🔬 Deep Research Agents | [Example](examples/python/agents/research-agent.py) | [📖](https://docs.praison.ai/agents/deep-research) |
|
||||
| 🔄 Query Rewriter Agent | [Example](#5-query-rewriter-agent) | [📖](https://docs.praison.ai/agents/query-rewriter) |
|
||||
| 🌐 Native Web Search | [Example](examples/python/agents/websearch-agent.py) | [📖](https://docs.praison.ai/agents/websearch) |
|
||||
| 📥 Web Fetch (Anthropic) | [Example](#web-search-web-fetch--prompt-caching) | [📖](https://docs.praison.ai/features/model-capabilities) |
|
||||
| 💾 Prompt Caching | [Example](#web-search-web-fetch--prompt-caching) | [📖](https://docs.praison.ai/features/model-capabilities) |
|
||||
| 🧠 Claude Memory Tool | [Example](#claude-memory-tool-cli) | [📖](https://docs.praison.ai/features/claude-memory-tool) |
|
||||
| 💾 File-Based Memory | [Example](examples/python/general/memory_example.py) | [📖](https://docs.praison.ai/concepts/memory) |
|
||||
| 🔍 Built-in Search Tools | [Example](examples/python/agents/websearch-agent.py) | [📖](https://docs.praison.ai/tools/tavily) |
|
||||
| 📋 Planning Mode | [Example](examples/python/agents/planning-agent.py) | [📖](https://docs.praison.ai/features/planning-mode) |
|
||||
| 🔧 Planning Tools | [Example](#3-agent-with-planning-mode) | [📖](https://docs.praison.ai/features/planning-mode) |
|
||||
| 🧠 Planning Reasoning | [Example](#3-agent-with-planning-mode) | [📖](https://docs.praison.ai/features/planning-mode) |
|
||||
| 🔌 MCP Transports | [Example](examples/python/mcp/mcp-transports-overview.py) | [📖](https://docs.praison.ai/mcp/transports) |
|
||||
| 🌐 WebSocket MCP | [Example](examples/python/mcp/websocket-mcp.py) | [📖](https://docs.praison.ai/mcp/sse-transport) |
|
||||
| 🔐 MCP Security | [Example](examples/python/mcp/mcp-security.py) | [📖](https://docs.praison.ai/mcp/transports) |
|
||||
| 🔄 MCP Resumability | [Example](examples/python/mcp/mcp-resumability.py) | [📖](https://docs.praison.ai/mcp/sse-transport) |
|
||||
| ⚡ Fast Context | [Example](examples/context/00_agent_fast_context_basic.py) | [📖](https://docs.praison.ai/features/fast-context) |
|
||||
| 🖼️ Image Generation Agent | [Example](examples/python/image/image-agent.py) | [📖](https://docs.praison.ai/features/image-generation) |
|
||||
| 📷 Image to Text Agent | [Example](examples/python/agents/image-to-text-agent.py) | [📖](https://docs.praison.ai/agents/image-to-text) |
|
||||
| 🎬 Video Agent | [Example](examples/python/agents/video-agent.py) | [📖](https://docs.praison.ai/agents/video) |
|
||||
| 📊 Data Analyst Agent | [Example](examples/python/agents/data-analyst-agent.py) | [📖](https://docs.praison.ai/agents/data-analyst) |
|
||||
| 💰 Finance Agent | [Example](examples/python/agents/finance-agent.py) | [📖](https://docs.praison.ai/agents/finance) |
|
||||
| 🛒 Shopping Agent | [Example](examples/python/agents/shopping-agent.py) | [📖](https://docs.praison.ai/agents/shopping) |
|
||||
| ⭐ Recommendation Agent | [Example](examples/python/agents/recommendation-agent.py) | [📖](https://docs.praison.ai/agents/recommendation) |
|
||||
| 📖 Wikipedia Agent | [Example](examples/python/agents/wikipedia-agent.py) | [📖](https://docs.praison.ai/agents/wikipedia) |
|
||||
| 💻 Programming Agent | [Example](examples/python/agents/programming-agent.py) | [📖](https://docs.praison.ai/agents/programming) |
|
||||
| 📝 Markdown Agent | [Example](examples/python/agents/markdown-agent.py) | [📖](https://docs.praison.ai/agents/markdown) |
|
||||
| 📝 Prompt Expander Agent | [Example](#prompt-expansion) | [📖](https://docs.praison.ai/agents/prompt-expander) |
|
||||
| 🔀 Router Agent | [Example](examples/python/agents/router-agent-cost-optimization.py) | [📖](https://docs.praison.ai/features/routing) |
|
||||
| ⛓️ Prompt Chaining | [Example](examples/python/general/prompt_chaining.py) | [📖](https://docs.praison.ai/features/promptchaining) |
|
||||
| 🔍 Evaluator Optimiser | [Example](examples/python/general/evaluator-optimiser.py) | [📖](https://docs.praison.ai/features/evaluator-optimiser) |
|
||||
| 👷 Orchestrator Workers | [Example](examples/python/general/orchestrator-workers.py) | [📖](https://docs.praison.ai/features/orchestrator-worker) |
|
||||
| ⚡ Parallelisation | [Example](examples/python/general/parallelisation.py) | [📖](https://docs.praison.ai/features/parallelisation) |
|
||||
| 🔁 Repetitive Agents | [Example](examples/python/concepts/repetitive-agents.py) | [📖](https://docs.praison.ai/features/repetitive) |
|
||||
| 🤝 Agent Handoffs | [Example](examples/python/handoff/handoff_basic.py) | [📖](https://docs.praison.ai/features/handoffs) |
|
||||
| 🛡️ Guardrails | [Example](examples/python/guardrails/comprehensive-guardrails-example.py) | [📖](https://docs.praison.ai/features/guardrails) |
|
||||
| 💬 Sessions Management | [Example](examples/python/sessions/comprehensive-session-management.py) | [📖](https://docs.praison.ai/features/sessions) |
|
||||
| ✅ Human Approval | [Example](examples/python/general/human_approval_example.py) | [📖](https://docs.praison.ai/features/approval) |
|
||||
| 🔄 Stateful Agents | [Example](examples/python/stateful/workflow-state-example.py) | [📖](https://docs.praison.ai/features/stateful-agents) |
|
||||
| 🤖 Autonomous Workflow | [Example](examples/python/general/autonomous-agent.py) | [📖](https://docs.praison.ai/features/autonomous-workflow) |
|
||||
| 📜 Rules & Instructions | [Example](#6-rules--instructions) | [📖](https://docs.praison.ai/features/rules) |
|
||||
| 🪝 Hooks | [Example](#9-hooks) | [📖](https://docs.praison.ai/features/hooks) |
|
||||
| 📈 Telemetry | [Example](examples/python/telemetry/production-telemetry-example.py) | [📖](https://docs.praison.ai/features/telemetry) |
|
||||
| 📹 Camera Integration | [Example](examples/python/camera/) | [📖](https://docs.praison.ai/features/camera-integration) |
|
||||
| 📄 Project Docs (.praison/docs/) | [Example](#docs-cli) | [📖](https://docs.praison.ai/cli/docs) |
|
||||
| 🔌 MCP Config Management | [Example](#mcp-config-cli) | [📖](https://docs.praison.ai/cli/mcp) |
|
||||
| 💬 AI Commit Messages | [Example](#ai-commit-cli) | [📖](https://docs.praison.ai/cli/commit) |
|
||||
| @ @Mentions in Prompts | [Example](#mentions-in-prompts) | [📖](https://docs.praison.ai/cli/mentions) |
|
||||
| 💾 Auto-Save Sessions | [Example](#session-management-python) | [📖](https://docs.praison.ai/cli/session) |
|
||||
| 📜 History in Context | [Example](#session-management-python) | [📖](https://docs.praison.ai/cli/session) |
|
||||
| ⏸️ Workflow Checkpoints | [Example](examples/python/workflows/workflow_checkpoints.py) | [📖](https://docs.praison.ai/cli/session) |
|
||||
| 🔀 Workflow Branching | [Example](examples/python/workflows/workflow_branching.py) | [📖](https://docs.praison.ai/features/workflows) |
|
||||
| 🔁 Workflow Loops | [Example](examples/python/workflows/workflow_loops.py) | [📖](https://docs.praison.ai/features/workflows) |
|
||||
| 📞 Task Callbacks | [Example](examples/python/workflows/task_callbacks.py) | [📖](https://docs.praison.ai/features/workflows) |
|
||||
| 🛑 Workflow Early Stop | [Example](examples/python/workflows/workflow_early_stop.py) | [📖](https://docs.praison.ai/features/workflows) |
|
||||
|
||||
## Supported Providers
|
||||
|
||||
| Provider | Example |
|
||||
|----------|:-------:|
|
||||
| OpenAI | [Example](examples/python/providers/openai/openai_gpt4_example.py) |
|
||||
| Anthropic | [Example](examples/python/providers/anthropic/anthropic_claude_example.py) |
|
||||
| Google Gemini | [Example](examples/python/providers/google/google_gemini_example.py) |
|
||||
| Ollama | [Example](examples/python/providers/ollama/ollama-agents.py) |
|
||||
| Groq | [Example](examples/python/providers/groq/kimi_with_groq_example.py) |
|
||||
| DeepSeek | [Example](examples/python/providers/deepseek/deepseek_example.py) |
|
||||
| xAI Grok | [Example](examples/python/providers/xai/xai_grok_example.py) |
|
||||
| Mistral | [Example](examples/python/providers/mistral/mistral_example.py) |
|
||||
| Cohere | [Example](examples/python/providers/cohere/cohere_example.py) |
|
||||
| Perplexity | [Example](examples/python/providers/perplexity/perplexity_example.py) |
|
||||
| Fireworks | [Example](examples/python/providers/fireworks/fireworks_example.py) |
|
||||
| Together AI | [Example](examples/python/providers/together/together_ai_example.py) |
|
||||
| OpenRouter | [Example](examples/python/providers/openrouter/openrouter_example.py) |
|
||||
| HuggingFace | [Example](examples/python/providers/huggingface/huggingface_example.py) |
|
||||
| Azure OpenAI | [Example](examples/python/providers/azure/azure_openai_example.py) |
|
||||
| AWS Bedrock | [Example](examples/python/providers/aws/aws_bedrock_example.py) |
|
||||
| Google Vertex | [Example](examples/python/providers/vertex/vertex_example.py) |
|
||||
| Databricks | [Example](examples/python/providers/databricks/databricks_example.py) |
|
||||
| Cloudflare | [Example](examples/python/providers/cloudflare/cloudflare_example.py) |
|
||||
| AI21 | [Example](examples/python/providers/ai21/ai21_example.py) |
|
||||
| Replicate | [Example](examples/python/providers/replicate/replicate_example.py) |
|
||||
| SageMaker | [Example](examples/python/providers/sagemaker/sagemaker_example.py) |
|
||||
| Moonshot | [Example](examples/python/providers/moonshot/moonshot_example.py) |
|
||||
| vLLM | [Example](examples/python/providers/vllm/vllm_example.py) |
|
||||
|
||||
## Using Python Code
|
||||
|
||||
@@ -330,19 +403,104 @@ memories = auto.process_interaction(
|
||||
|
||||
### 8. Workflows
|
||||
|
||||
Create reusable multi-step workflows in `.praison/workflows/`:
|
||||
Create reusable multi-step workflows with context passing and per-step agents:
|
||||
|
||||
```python
|
||||
from praisonaiagents.memory import WorkflowManager
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.memory import WorkflowManager, Workflow, WorkflowStep
|
||||
|
||||
# Simple execution with default agent
|
||||
agent = Agent(name="Assistant", llm="gpt-4o-mini")
|
||||
manager = WorkflowManager()
|
||||
|
||||
# Execute a workflow
|
||||
result = manager.execute(
|
||||
"deploy",
|
||||
executor=lambda prompt: agent.chat(prompt),
|
||||
default_agent=agent,
|
||||
variables={"environment": "production"}
|
||||
)
|
||||
|
||||
# Advanced: Per-step agent configuration
|
||||
workflow = Workflow(
|
||||
name="research_pipeline",
|
||||
default_llm="gpt-4o-mini",
|
||||
steps=[
|
||||
WorkflowStep(
|
||||
name="research",
|
||||
action="Research {{topic}}",
|
||||
agent_config={"role": "Researcher", "goal": "Find information"},
|
||||
tools=["tavily_search"]
|
||||
),
|
||||
WorkflowStep(
|
||||
name="write",
|
||||
action="Write report based on {{previous_output}}",
|
||||
agent_config={"role": "Writer", "goal": "Write content"},
|
||||
context_from=["research"] # Only include research output
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
# Async execution
|
||||
import asyncio
|
||||
result = asyncio.run(manager.aexecute("deploy", default_llm="gpt-4o-mini"))
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
- **Context Passing**: Use `{{previous_output}}` and `{{step_name_output}}` variables
|
||||
- **Per-Step Agents**: Configure different agents with roles, goals, tools for each step
|
||||
- **Async Execution**: Use `aexecute()` for async workflows
|
||||
- **Planning Mode**: Enable at workflow level with `planning=True`
|
||||
- **Branching**: Use `next_steps` and `branch_condition` for conditional routing
|
||||
- **Loops**: Use `loop_over` and `loop_var` to iterate over data
|
||||
|
||||
### Workflow Branching & Loops
|
||||
|
||||
```python
|
||||
from praisonaiagents.memory.workflows import WorkflowStep
|
||||
|
||||
# Branching step
|
||||
decision_step = WorkflowStep(
|
||||
name="decide",
|
||||
action="Evaluate if task is complete",
|
||||
next_steps=["success_step", "retry_step"],
|
||||
branch_condition={"success": ["success_step"], "failure": ["retry_step"]}
|
||||
)
|
||||
|
||||
# Loop step
|
||||
loop_step = WorkflowStep(
|
||||
name="process_items",
|
||||
action="Process {{item}}",
|
||||
loop_over="items", # Variable containing list
|
||||
loop_var="item" # Current item variable name
|
||||
)
|
||||
```
|
||||
|
||||
### Task Callbacks & Variables (process="workflow")
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent, Task, PraisonAIAgents
|
||||
|
||||
# Per-task agent config (no need to create agent separately)
|
||||
task = Task(
|
||||
description="Research {{topic}}",
|
||||
agent_config={"name": "Researcher", "role": "Expert", "llm": "gpt-4o-mini"},
|
||||
variables={"topic": "AI trends"}
|
||||
)
|
||||
|
||||
# Callbacks for workflow execution
|
||||
def on_start(task, task_id):
|
||||
print(f"Starting: {task.name}")
|
||||
|
||||
def on_complete(task, output):
|
||||
print(f"Completed: {task.name}")
|
||||
|
||||
agents = PraisonAIAgents(
|
||||
agents=[agent],
|
||||
tasks=[task],
|
||||
process="workflow",
|
||||
on_task_start=on_start,
|
||||
on_task_complete=on_complete,
|
||||
variables={"global_var": "shared_value"} # Global variables for all tasks
|
||||
)
|
||||
```
|
||||
|
||||
### 9. Hooks
|
||||
@@ -479,17 +637,26 @@ praisonai "Task" --include-rules security,testing
|
||||
# List available workflows
|
||||
praisonai workflow list
|
||||
|
||||
# Execute a workflow
|
||||
praisonai workflow run deploy
|
||||
# Execute a workflow with tools and save output
|
||||
praisonai workflow run "Research Blog" --tools tavily --save
|
||||
|
||||
# Execute with variables
|
||||
praisonai workflow run deploy --workflow-var environment=staging --workflow-var branch=main
|
||||
|
||||
# Execute with planning mode (AI creates sub-steps for each workflow step)
|
||||
praisonai workflow run "Research Blog" --planning --verbose
|
||||
|
||||
# Show workflow details
|
||||
praisonai workflow show deploy
|
||||
|
||||
# Create a new workflow template
|
||||
praisonai workflow create my_workflow
|
||||
|
||||
# Inline workflow (no template file needed)
|
||||
praisonai "What is AI?" --workflow "Research,Summarize" --save
|
||||
|
||||
# Inline workflow with step actions
|
||||
praisonai "GPT-5" --workflow "Research:Search for info,Write:Write blog" --tools tavily
|
||||
```
|
||||
|
||||
### Hooks CLI:
|
||||
@@ -510,6 +677,465 @@ praisonai hooks init
|
||||
praisonai "Research and remember findings" --claude-memory --llm anthropic/claude-sonnet-4-20250514
|
||||
```
|
||||
|
||||
### Guardrail CLI:
|
||||
```bash
|
||||
# Validate output with LLM guardrail
|
||||
praisonai "Write code" --guardrail "Ensure code is secure and follows best practices"
|
||||
|
||||
# Combine with other flags
|
||||
praisonai "Generate SQL query" --guardrail "No DROP or DELETE statements" --save
|
||||
```
|
||||
|
||||
### Metrics CLI:
|
||||
```bash
|
||||
# Display token usage and cost metrics
|
||||
praisonai "Analyze this data" --metrics
|
||||
|
||||
# Combine with other features
|
||||
praisonai "Complex task" --metrics --planning
|
||||
```
|
||||
|
||||
### Image Processing CLI:
|
||||
```bash
|
||||
# Process images with vision-based tasks
|
||||
praisonai "Describe this image" --image path/to/image.png
|
||||
|
||||
# Analyze image content
|
||||
praisonai "What objects are in this photo?" --image photo.jpg --llm openai/gpt-4o
|
||||
```
|
||||
|
||||
### Telemetry CLI:
|
||||
```bash
|
||||
# Enable usage monitoring and analytics
|
||||
praisonai "Task" --telemetry
|
||||
|
||||
# Combine with metrics for full observability
|
||||
praisonai "Complex analysis" --telemetry --metrics
|
||||
```
|
||||
|
||||
### MCP (Model Context Protocol) CLI:
|
||||
```bash
|
||||
# Use MCP server tools
|
||||
praisonai "Search files" --mcp "npx -y @modelcontextprotocol/server-filesystem ."
|
||||
|
||||
# MCP with environment variables
|
||||
praisonai "Search web" --mcp "npx -y @modelcontextprotocol/server-brave-search" --mcp-env "BRAVE_API_KEY=your_key"
|
||||
|
||||
# Multiple MCP options
|
||||
praisonai "Task" --mcp "npx server" --mcp-env "KEY1=value1,KEY2=value2"
|
||||
```
|
||||
|
||||
### Fast Context CLI:
|
||||
```bash
|
||||
# Search codebase for relevant context
|
||||
praisonai "Find authentication code" --fast-context ./src
|
||||
|
||||
# Add code context to any task
|
||||
praisonai "Explain this function" --fast-context /path/to/project
|
||||
```
|
||||
|
||||
### Knowledge CLI:
|
||||
```bash
|
||||
# Add documents to knowledge base
|
||||
praisonai knowledge add document.pdf
|
||||
praisonai knowledge add ./docs/
|
||||
|
||||
# Search knowledge base
|
||||
praisonai knowledge search "API authentication"
|
||||
|
||||
# List indexed documents
|
||||
praisonai knowledge list
|
||||
|
||||
# Clear knowledge base
|
||||
praisonai knowledge clear
|
||||
|
||||
# Show knowledge base info
|
||||
praisonai knowledge info
|
||||
|
||||
# Show all commands
|
||||
praisonai knowledge help
|
||||
```
|
||||
|
||||
### Session CLI:
|
||||
```bash
|
||||
# List all saved sessions
|
||||
praisonai session list
|
||||
|
||||
# Show session details
|
||||
praisonai session show my-project
|
||||
|
||||
# Resume a session (load into memory)
|
||||
praisonai session resume my-project
|
||||
|
||||
# Delete a session
|
||||
praisonai session delete my-project
|
||||
|
||||
# Auto-save session after each run
|
||||
praisonai "Analyze this code" --auto-save my-project
|
||||
|
||||
# Load history from last N sessions into context
|
||||
praisonai "Continue our discussion" --history 5
|
||||
```
|
||||
|
||||
### Session Management (Python):
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Auto-save session after each run
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
memory=True,
|
||||
auto_save="my-project"
|
||||
)
|
||||
|
||||
# Load history from last 5 sessions
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
memory=True,
|
||||
history_in_context=5
|
||||
)
|
||||
```
|
||||
|
||||
### Workflow Checkpoints:
|
||||
```python
|
||||
from praisonaiagents.memory.workflows import WorkflowManager
|
||||
|
||||
manager = WorkflowManager()
|
||||
|
||||
# Save checkpoint after each step
|
||||
result = manager.execute("deploy", checkpoint="deploy-v1")
|
||||
|
||||
# Resume from checkpoint
|
||||
result = manager.execute("deploy", resume="deploy-v1")
|
||||
|
||||
# List/delete checkpoints
|
||||
manager.list_checkpoints()
|
||||
manager.delete_checkpoint("deploy-v1")
|
||||
```
|
||||
|
||||
### Tools CLI:
|
||||
```bash
|
||||
# List all available tools
|
||||
praisonai tools list
|
||||
|
||||
# Get info about a specific tool
|
||||
praisonai tools info internet_search
|
||||
|
||||
# Search for tools
|
||||
praisonai tools search "web"
|
||||
|
||||
# Show all commands
|
||||
praisonai tools help
|
||||
```
|
||||
|
||||
### Handoff CLI:
|
||||
```bash
|
||||
# Enable agent-to-agent task delegation
|
||||
praisonai "Research and write article" --handoff "researcher,writer,editor"
|
||||
|
||||
# Complex multi-agent workflow
|
||||
praisonai "Analyze data and create report" --handoff "analyst,visualizer,writer"
|
||||
```
|
||||
|
||||
### Auto Memory CLI:
|
||||
```bash
|
||||
# Enable automatic memory extraction
|
||||
praisonai "Learn about user preferences" --auto-memory
|
||||
|
||||
# Combine with user isolation
|
||||
praisonai "Remember my settings" --auto-memory --user-id user123
|
||||
```
|
||||
|
||||
### Todo CLI:
|
||||
```bash
|
||||
# Generate todo list from task
|
||||
praisonai "Plan the project" --todo
|
||||
|
||||
# Add a todo item
|
||||
praisonai todo add "Implement feature X"
|
||||
|
||||
# List all todos
|
||||
praisonai todo list
|
||||
|
||||
# Complete a todo
|
||||
praisonai todo complete 1
|
||||
|
||||
# Delete a todo
|
||||
praisonai todo delete 1
|
||||
|
||||
# Clear all todos
|
||||
praisonai todo clear
|
||||
|
||||
# Show all commands
|
||||
praisonai todo help
|
||||
```
|
||||
|
||||
### Router CLI:
|
||||
```bash
|
||||
# Auto-select best model based on task complexity
|
||||
praisonai "Simple question" --router
|
||||
|
||||
# Specify preferred provider
|
||||
praisonai "Complex analysis" --router --router-provider anthropic
|
||||
|
||||
# Router automatically selects:
|
||||
# - Simple tasks → gpt-4o-mini, claude-3-haiku
|
||||
# - Complex tasks → gpt-4-turbo, claude-3-opus
|
||||
```
|
||||
|
||||
### Flow Display CLI:
|
||||
```bash
|
||||
# Enable visual workflow tracking
|
||||
praisonai agents.yaml --flow-display
|
||||
|
||||
# Combine with other features
|
||||
praisonai "Multi-step task" --planning --flow-display
|
||||
```
|
||||
|
||||
### Docs CLI:
|
||||
```bash
|
||||
# List all project docs
|
||||
praisonai docs list
|
||||
|
||||
# Create a new doc
|
||||
praisonai docs create project-overview "This project is a Python web app..."
|
||||
|
||||
# Show a specific doc
|
||||
praisonai docs show project-overview
|
||||
|
||||
# Delete a doc
|
||||
praisonai docs delete old-doc
|
||||
|
||||
# Show all commands
|
||||
praisonai docs help
|
||||
```
|
||||
|
||||
### MCP Config CLI:
|
||||
```bash
|
||||
# List all MCP configurations
|
||||
praisonai mcp list
|
||||
|
||||
# Create a new MCP config
|
||||
praisonai mcp create filesystem npx -y @modelcontextprotocol/server-filesystem .
|
||||
|
||||
# Show a specific config
|
||||
praisonai mcp show filesystem
|
||||
|
||||
# Enable/disable a config
|
||||
praisonai mcp enable filesystem
|
||||
praisonai mcp disable filesystem
|
||||
|
||||
# Delete a config
|
||||
praisonai mcp delete filesystem
|
||||
|
||||
# Show all commands
|
||||
praisonai mcp help
|
||||
```
|
||||
|
||||
### AI Commit CLI:
|
||||
```bash
|
||||
# Generate AI commit message for staged changes
|
||||
praisonai commit
|
||||
|
||||
# Generate, commit, and push
|
||||
praisonai commit --push
|
||||
```
|
||||
|
||||
### @Mentions in Prompts:
|
||||
```bash
|
||||
# Include file content in prompt
|
||||
praisonai "@file:src/main.py explain this code"
|
||||
|
||||
# Include project doc
|
||||
praisonai "@doc:project-overview help me add a feature"
|
||||
|
||||
# Search the web
|
||||
praisonai "@web:python best practices give me tips"
|
||||
|
||||
# Fetch URL content
|
||||
praisonai "@url:https://docs.python.org summarize this"
|
||||
|
||||
# Combine multiple mentions
|
||||
praisonai "@file:main.py @doc:coding-standards review this code"
|
||||
```
|
||||
|
||||
## Prompt Expansion
|
||||
|
||||
Expand short prompts into detailed, actionable prompts:
|
||||
|
||||
### CLI Usage
|
||||
```bash
|
||||
# Expand a short prompt into detailed prompt
|
||||
praisonai "write a movie script in 3 lines" --expand-prompt
|
||||
|
||||
# With verbose output
|
||||
praisonai "blog about AI" --expand-prompt -v
|
||||
|
||||
# With tools for context gathering
|
||||
praisonai "latest AI trends" --expand-prompt --expand-tools tools.py
|
||||
|
||||
# Combine with query rewrite
|
||||
praisonai "AI news" --query-rewrite --expand-prompt
|
||||
```
|
||||
|
||||
### Programmatic Usage
|
||||
```python
|
||||
from praisonaiagents import PromptExpanderAgent, ExpandStrategy
|
||||
|
||||
# Basic usage
|
||||
agent = PromptExpanderAgent()
|
||||
result = agent.expand("write a movie script in 3 lines")
|
||||
print(result.expanded_prompt)
|
||||
|
||||
# With specific strategy
|
||||
result = agent.expand("blog about AI", strategy=ExpandStrategy.DETAILED)
|
||||
|
||||
# Available strategies: BASIC, DETAILED, STRUCTURED, CREATIVE, AUTO
|
||||
```
|
||||
|
||||
**Key Difference:**
|
||||
- `--query-rewrite`: Optimizes queries for search/retrieval (RAG)
|
||||
- `--expand-prompt`: Expands prompts for detailed task execution
|
||||
|
||||
## Web Search, Web Fetch & Prompt Caching
|
||||
|
||||
### CLI Usage
|
||||
```bash
|
||||
# Web Search - Get real-time information
|
||||
praisonai "What are the latest AI news today?" --web-search --llm openai/gpt-4o-search-preview
|
||||
|
||||
# Web Fetch - Retrieve and analyze URL content (Anthropic only)
|
||||
praisonai "Summarize https://docs.praison.ai" --web-fetch --llm anthropic/claude-sonnet-4-20250514
|
||||
|
||||
# Prompt Caching - Reduce costs for repeated prompts
|
||||
praisonai "Analyze this document..." --prompt-caching --llm anthropic/claude-sonnet-4-20250514
|
||||
```
|
||||
|
||||
### Programmatic Usage
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Web Search
|
||||
agent = Agent(
|
||||
instructions="You are a research assistant",
|
||||
llm="openai/gpt-4o-search-preview",
|
||||
web_search=True
|
||||
)
|
||||
|
||||
# Web Fetch (Anthropic only)
|
||||
agent = Agent(
|
||||
instructions="You are a content analyzer",
|
||||
llm="anthropic/claude-sonnet-4-20250514",
|
||||
web_fetch=True
|
||||
)
|
||||
|
||||
# Prompt Caching
|
||||
agent = Agent(
|
||||
instructions="You are an AI assistant..." * 50, # Long system prompt
|
||||
llm="anthropic/claude-sonnet-4-20250514",
|
||||
prompt_caching=True
|
||||
)
|
||||
```
|
||||
|
||||
**Supported Providers:**
|
||||
| Feature | Providers |
|
||||
|---------|----------|
|
||||
| Web Search | OpenAI, Gemini, Anthropic, xAI, Perplexity |
|
||||
| Web Fetch | Anthropic |
|
||||
| Prompt Caching | OpenAI (auto), Anthropic, Bedrock, Deepseek |
|
||||
|
||||
## MCP (Model Context Protocol)
|
||||
|
||||
PraisonAI supports MCP Protocol Revision 2025-11-25 with multiple transports.
|
||||
|
||||
### MCP Client (Consume MCP Servers)
|
||||
```python
|
||||
from praisonaiagents import Agent, MCP
|
||||
|
||||
# stdio - Local NPX/Python servers
|
||||
agent = Agent(tools=MCP("npx @modelcontextprotocol/server-memory"))
|
||||
|
||||
# Streamable HTTP - Production servers
|
||||
agent = Agent(tools=MCP("https://api.example.com/mcp"))
|
||||
|
||||
# WebSocket - Real-time bidirectional
|
||||
agent = Agent(tools=MCP("wss://api.example.com/mcp", auth_token="token"))
|
||||
|
||||
# SSE (Legacy) - Backward compatibility
|
||||
agent = Agent(tools=MCP("http://localhost:8080/sse"))
|
||||
|
||||
# With environment variables
|
||||
agent = Agent(
|
||||
tools=MCP(
|
||||
command="npx",
|
||||
args=["-y", "@modelcontextprotocol/server-brave-search"],
|
||||
env={"BRAVE_API_KEY": "your-key"}
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### MCP Server (Expose Tools as MCP Server)
|
||||
|
||||
Expose your Python functions as MCP tools for Claude Desktop, Cursor, and other MCP clients:
|
||||
|
||||
```python
|
||||
from praisonaiagents.mcp import ToolsMCPServer
|
||||
|
||||
def search_web(query: str, max_results: int = 5) -> dict:
|
||||
"""Search the web for information."""
|
||||
return {"results": [f"Result for {query}"]}
|
||||
|
||||
def calculate(expression: str) -> dict:
|
||||
"""Evaluate a mathematical expression."""
|
||||
return {"result": eval(expression)}
|
||||
|
||||
# Create and run MCP server
|
||||
server = ToolsMCPServer(name="my-tools")
|
||||
server.register_tools([search_web, calculate])
|
||||
server.run() # stdio for Claude Desktop
|
||||
# server.run_sse(host="0.0.0.0", port=8080) # SSE for web clients
|
||||
```
|
||||
|
||||
### MCP Features
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| Session Management | Automatic Mcp-Session-Id handling |
|
||||
| Protocol Versioning | Mcp-Protocol-Version header |
|
||||
| Resumability | SSE stream recovery via Last-Event-ID |
|
||||
| Security | Origin validation, DNS rebinding prevention |
|
||||
| WebSocket | Auto-reconnect with exponential backoff |
|
||||
|
||||
## CLI Features
|
||||
|
||||
| Feature | Docs |
|
||||
|---------|:----:|
|
||||
| 🔄 Query Rewrite - RAG optimization | [📖](https://docs.praison.ai/cli/query-rewrite) |
|
||||
| 🔬 Deep Research - Automated research | [📖](https://docs.praison.ai/cli/deep-research) |
|
||||
| 📋 Planning - Step-by-step execution | [📖](https://docs.praison.ai/cli/planning) |
|
||||
| 💾 Memory - Persistent agent memory | [📖](https://docs.praison.ai/cli/memory) |
|
||||
| 📜 Rules - Auto-discovered instructions | [📖](https://docs.praison.ai/cli/rules) |
|
||||
| 🔄 Workflow - Multi-step workflows | [📖](https://docs.praison.ai/cli/workflow) |
|
||||
| 🪝 Hooks - Event-driven actions | [📖](https://docs.praison.ai/cli/hooks) |
|
||||
| 🧠 Claude Memory - Anthropic memory tool | [📖](https://docs.praison.ai/cli/claude-memory) |
|
||||
| 🛡️ Guardrail - Output validation | [📖](https://docs.praison.ai/cli/guardrail) |
|
||||
| 📊 Metrics - Token usage tracking | [📖](https://docs.praison.ai/cli/metrics) |
|
||||
| 🖼️ Image - Vision processing | [📖](https://docs.praison.ai/cli/image) |
|
||||
| 📡 Telemetry - Usage monitoring | [📖](https://docs.praison.ai/cli/telemetry) |
|
||||
| 🔌 MCP - Model Context Protocol | [📖](https://docs.praison.ai/cli/mcp) |
|
||||
| ⚡ Fast Context - Codebase search | [📖](https://docs.praison.ai/cli/fast-context) |
|
||||
| 📚 Knowledge - RAG management | [📖](https://docs.praison.ai/cli/knowledge) |
|
||||
| 💬 Session - Conversation management | [📖](https://docs.praison.ai/cli/session) |
|
||||
| 🔧 Tools - Tool discovery | [📖](https://docs.praison.ai/cli/tools) |
|
||||
| 🤝 Handoff - Agent delegation | [📖](https://docs.praison.ai/cli/handoff) |
|
||||
| 🧠 Auto Memory - Memory extraction | [📖](https://docs.praison.ai/cli/auto-memory) |
|
||||
| 📋 Todo - Task management | [📖](https://docs.praison.ai/cli/todo) |
|
||||
| 🎯 Router - Smart model selection | [📖](https://docs.praison.ai/cli/router) |
|
||||
| 📈 Flow Display - Visual workflow | [📖](https://docs.praison.ai/cli/flow-display) |
|
||||
| ✨ Prompt Expansion - Detailed prompts | [📖](https://docs.praison.ai/cli/prompt-expansion) |
|
||||
| 🌐 Web Search - Real-time search | [📖](https://docs.praison.ai/cli/web-search) |
|
||||
| 📥 Web Fetch - URL content retrieval | [📖](https://docs.praison.ai/cli/web-fetch) |
|
||||
| 💾 Prompt Caching - Cost reduction | [📖](https://docs.praison.ai/cli/prompt-caching) |
|
||||
|
||||
## Using JavaScript Code
|
||||
|
||||
```bash
|
||||
@@ -928,7 +1554,6 @@ praisonai agents.yaml
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
## Custom Tools
|
||||
|
||||
### Using `@tool` Decorator
|
||||
@@ -1094,6 +1719,73 @@ agent = Agent(
|
||||
| Prompt Caching | OpenAI (auto), Anthropic, Bedrock, Deepseek |
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## MCP (Model Context Protocol)
|
||||
|
||||
PraisonAI supports MCP Protocol Revision 2025-11-25 with multiple transports.
|
||||
|
||||
### MCP Client (Consume MCP Servers)
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent, MCP
|
||||
|
||||
# stdio - Local NPX/Python servers
|
||||
agent = Agent(tools=MCP("npx @modelcontextprotocol/server-memory"))
|
||||
|
||||
# Streamable HTTP - Production servers
|
||||
agent = Agent(tools=MCP("https://api.example.com/mcp"))
|
||||
|
||||
# WebSocket - Real-time bidirectional
|
||||
agent = Agent(tools=MCP("wss://api.example.com/mcp", auth_token="token"))
|
||||
|
||||
# SSE (Legacy) - Backward compatibility
|
||||
agent = Agent(tools=MCP("http://localhost:8080/sse"))
|
||||
|
||||
# With environment variables
|
||||
agent = Agent(
|
||||
tools=MCP(
|
||||
command="npx",
|
||||
args=["-y", "@modelcontextprotocol/server-brave-search"],
|
||||
env={"BRAVE_API_KEY": "your-key"}
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### MCP Server (Expose Tools as MCP Server)
|
||||
|
||||
Expose your Python functions as MCP tools for Claude Desktop, Cursor, and other MCP clients:
|
||||
|
||||
```python
|
||||
from praisonaiagents.mcp import ToolsMCPServer
|
||||
|
||||
def search_web(query: str, max_results: int = 5) -> dict:
|
||||
"""Search the web for information."""
|
||||
return {"results": [f"Result for {query}"]}
|
||||
|
||||
def calculate(expression: str) -> dict:
|
||||
"""Evaluate a mathematical expression."""
|
||||
return {"result": eval(expression)}
|
||||
|
||||
# Create and run MCP server
|
||||
server = ToolsMCPServer(name="my-tools")
|
||||
server.register_tools([search_web, calculate])
|
||||
server.run() # stdio for Claude Desktop
|
||||
# server.run_sse(host="0.0.0.0", port=8080) # SSE for web clients
|
||||
```
|
||||
|
||||
### MCP Features
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Session Management** | Automatic `Mcp-Session-Id` handling |
|
||||
| **Protocol Versioning** | `Mcp-Protocol-Version` header |
|
||||
| **Resumability** | SSE stream recovery via `Last-Event-ID` |
|
||||
| **Security** | Origin validation, DNS rebinding prevention |
|
||||
| **WebSocket** | Auto-reconnect with exponential backoff |
|
||||
|
||||
|
||||
## Development:
|
||||
|
||||
Below is used for development only.
|
||||
@@ -1134,6 +1826,65 @@ cd src/praisonai && uv publish
|
||||
- Submit a pull request via GitHub's web interface.
|
||||
- Await feedback from project maintainers.
|
||||
|
||||
## Advanced Features
|
||||
|
||||
**Research & Intelligence:**
|
||||
- 🔬 **Deep Research Agents** (OpenAI & Gemini)
|
||||
- 🔄 **Query Rewriter Agent** (HyDE, Step-back, Multi-query)
|
||||
- 🌐 **Native Web Search** (OpenAI, Gemini, Anthropic, xAI, Perplexity)
|
||||
- 📥 **Web Fetch** (Retrieve full content from URLs - Anthropic)
|
||||
- 📝 **Prompt Expander Agent** (Expand short prompts into detailed instructions)
|
||||
|
||||
**Memory & Caching:**
|
||||
- 💾 **Prompt Caching** (Reduce costs & latency - OpenAI, Anthropic, Bedrock, Deepseek)
|
||||
- 🧠 **Claude Memory Tool** (Persistent cross-conversation memory - Anthropic Beta)
|
||||
- 💾 **File-Based Memory** (Zero-dependency persistent memory for all agents)
|
||||
- 🔍 **Built-in Search Tools** (Tavily, You.com, Exa - web search, news, content extraction)
|
||||
|
||||
**Planning & Workflows:**
|
||||
- 📋 **Planning Mode** (Plan before execution - Agent & Multi-Agent)
|
||||
- 🔧 **Planning Tools** (Research with tools during planning)
|
||||
- 🧠 **Planning Reasoning** (Chain-of-thought planning)
|
||||
- ⛓️ **Prompt Chaining** (Sequential prompt workflows with gates)
|
||||
- 🔍 **Evaluator Optimiser** (Generate and optimize through iterative feedback)
|
||||
- 👷 **Orchestrator Workers** (Distribute tasks among specialized workers)
|
||||
- ⚡ **Parallelisation** (Execute tasks in parallel for improved performance)
|
||||
- 🔁 **Repetitive Agents** (Handle repetitive tasks through automated loops)
|
||||
- 🤖 **Autonomous Workflow** (Monitor, act, adapt based on environment feedback)
|
||||
|
||||
**Agent Types:**
|
||||
- 🖼️ **Image Generation Agent** (Create images from text descriptions)
|
||||
- 📷 **Image to Text Agent** (Extract text and descriptions from images)
|
||||
- 🎬 **Video Agent** (Analyze and process video content)
|
||||
- 📊 **Data Analyst Agent** (Analyze data and generate insights)
|
||||
- 💰 **Finance Agent** (Financial analysis and recommendations)
|
||||
- 🛒 **Shopping Agent** (Price comparison and shopping assistance)
|
||||
- ⭐ **Recommendation Agent** (Personalized recommendations)
|
||||
- 📖 **Wikipedia Agent** (Search and extract Wikipedia information)
|
||||
- 💻 **Programming Agent** (Code development and analysis)
|
||||
- 📝 **Markdown Agent** (Generate and format Markdown content)
|
||||
- 🔀 **Router Agent** (Dynamic task routing with cost optimization)
|
||||
|
||||
**MCP Protocol:**
|
||||
- 🔌 **MCP Transports** (stdio, Streamable HTTP, WebSocket, SSE - Protocol 2025-11-25)
|
||||
- 🌐 **WebSocket MCP** (Real-time bidirectional connections with auto-reconnect)
|
||||
- 🔐 **MCP Security** (Origin validation, DNS rebinding prevention, secure sessions)
|
||||
- 🔄 **MCP Resumability** (SSE stream recovery via Last-Event-ID)
|
||||
|
||||
**Safety & Control:**
|
||||
- 🤝 **Agent Handoffs** (Transfer context between specialized agents)
|
||||
- 🛡️ **Guardrails** (Input/output validation and safety checks)
|
||||
- ✅ **Human Approval** (Require human confirmation for critical actions)
|
||||
- 💬 **Sessions Management** (Isolated conversation contexts)
|
||||
- 🔄 **Stateful Agents** (Maintain state across interactions)
|
||||
|
||||
**Developer Tools:**
|
||||
- ⚡ **Fast Context** (Rapid parallel code search - 10-20x faster than traditional methods)
|
||||
- 📜 **Rules & Instructions** (Auto-discover CLAUDE.md, AGENTS.md, GEMINI.md)
|
||||
- 🪝 **Hooks** (Pre/post operation hooks for custom logic)
|
||||
- 📈 **Telemetry** (Track agent performance and usage)
|
||||
- 📹 **Camera Integration** (Capture and analyze camera input)
|
||||
|
||||
## Other Features
|
||||
|
||||
- 🔄 Use CrewAI or AG2 (Formerly AutoGen) Framework
|
||||
|
||||
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user