Compare commits

...
80 Commits
Author SHA1 Message Date
Mervin Praison ab68b2d296 Release v2.3.32 2025-12-18 13:17:32 +00:00
Mervin Praison abcc5b3b04 Release v2.3.31 2025-12-18 12:57:05 +00:00
Mervin Praison e584f56099 Release v2.3.30 2025-12-18 11:59:22 +00:00
Mervin Praison b6b01af066 Release v2.3.33 2025-12-18 11:19:29 +00:00
Mervin Praison 9aae3eff86 Release v2.3.32 2025-12-18 10:48:09 +00:00
Mervin Praison f90582c66b Release v2.3.31 2025-12-18 10:27:03 +00:00
Mervin Praison 67a8d65d8e Release v2.3.30 2025-12-18 09:07:42 +00:00
Mervin Praison d60c31a86d Release v2.3.29 2025-12-18 02:47:04 +00:00
Mervin Praison fcb7b2a0d0 fix(tests): Mark test_phase2_refactor tests as @pytest.mark.real
These tests make real API calls and should be excluded from CI.
2025-12-17 13:31:01 +00:00
Mervin Praison 81ad7db679 Release v2.3.28 2025-12-17 13:22:14 +00:00
Mervin Praison 0482c3ebb0 chore: Update dependencies and versioning for PraisonAI
- Bump PraisonAI version to 2.3.28 across Dockerfiles and deployment scripts.
- Update praisonaiagents version to 0.1.7 in pyproject.toml.
- Enhance README with a structured Table of Contents and improved section headers for better navigation.
- Remove outdated README.md from praisonai-agents and link to the main README.
2025-12-17 13:20:29 +00:00
Mervin Praison 3107458016 docs: Enhance README with emoji for section headers for better readability 2025-12-17 13:09:58 +00:00
Mervin Praison 44c8ceebae fix: Fix 15 test failures across multiple test files
Test fixes:
1. Import Errors (5 tests):
   - test_llm_self_reflection_direct.py: Define calculator locally
   - test_self_reflection_comprehensive.py: Define duckduckgo_search locally
   - test_self_reflection_fix_simple.py: Define calculator locally
   - test_self_reflection_fix_verification.py: Define calculator locally
   - test_fix_comprehensive.py: Convert to proper pytest with skipif

2. Workflow Assertion (1 test):
   - test_workflow_agents.py: Check error in results array

3. Ollama Type Assertion (1 test):
   - test_ollama_sequential_fix.py: Accept both str and int types

4. Ollama Missing Fixture (3 tests):
   - test_ollama_fix.py: Add proper pytest functions with skipif
   - test_ollama_async_fix.py: Add proper pytest functions with skipif

5. Memory Stale Data (2 tests):
   - test_file_memory.py: Use unique user_id per test

6. Planning Async Mocks (3 tests):
   - test_planning.py: Use get_response_async instead of achat

Results: 921 passed, 9 failed (down from 12), 16 skipped
2025-12-17 13:05:12 +00:00
Mervin Praison 20c0b02528 fix(ci): Fix Docker publish workflow - trigger on release, update actions 2025-12-17 13:00:51 +00:00
Mervin Praison cfc886b591 docs: Update examples to use canonical field names (agents, instructions) 2025-12-17 12:34:32 +00:00
Mervin Praison 77ac4b7a09 fix: Use _yaml_action from YAML parser when executing workflow steps
The action/description field from YAML was being stored on the agent but not
used when executing the workflow. Now the _normalize_single_step method
checks for _yaml_action and uses it as the prompt.

This fixes the issue where workflow steps were not using the action as the prompt.
2025-12-17 12:33:07 +00:00
Mervin Praison 5897cedc45 docs: Update README examples to use canonical field names 2025-12-17 12:29:47 +00:00
Mervin Praison a4d1079813 feat: Add unified field names with A-I-G-S mnemonic
- Add canonical field name suggestions to CLI validate command
- Document A-I-G-S mnemonic (Agents, Instructions, Goal, Steps)
- Add field name reference table to README
- Add comprehensive tests for field name normalization
- Accept both old and new names: roles/agents, backstory/instructions, description/action, topic/name

Implements 'Accept liberally, suggest canonically' principle
2025-12-17 12:24:11 +00:00
Mervin Praison 4eed985c4d Release v2.3.27 2025-12-17 12:17:02 +00:00
Mervin Praison 0658491072 docs: Update README with workflow features and feature parity
- Add YAML workflow file documentation
- Add workflow auto-generation documentation
- Add complete workflow.yaml reference with all features
- Add feature parity note between agents.yaml and workflow.yaml
- Update workflow CLI options table
2025-12-17 11:52:33 +00:00
Mervin Praison 9f82368b4c feat: Add workflow auto-generation (--auto for workflow.yaml)
- Add WorkflowAutoGenerator class in auto.py with lazy loading
- Support sequential, routing, parallel, loop patterns
- Add 'praisonai workflow auto' CLI command
- Add action_auto to WorkflowHandler
- Add _auto_generate_workflow to main.py
- Uses instructor for structured output generation
- Lazy loading ensures no performance impact when not used

Usage:
  praisonai workflow auto "Research AI trends" --pattern parallel
  praisonai workflow auto "Build a chatbot" --pattern routing
2025-12-17 11:50:02 +00:00
Mervin Praison c267cdda87 Release v2.3.26 2025-12-17 11:47:04 +00:00
Mervin Praison b88bb04090 feat: Add feature parity between agents.yaml and workflow.yaml
Workflow.yaml now supports all agents.yaml features:

Agent-level features:
- framework (praisonai, crewai, autogen)
- process (sequential, hierarchical, workflow)
- manager_llm for hierarchical process
- function_calling_llm
- max_rpm (rate limiting)
- max_execution_time (timeout)
- reflect_llm, min_reflect, max_reflect
- system_template, prompt_template, response_template
- backstory (alias for instructions)

Step-level features:
- expected_output (like agents.yaml tasks)
- context (task dependencies)
- output_json, output_pydantic (structured output)
- create_directory
- callback
- async_execution

Added 14 new TDD tests for feature parity verification.
All 41 YAML parser tests pass.
2025-12-17 11:40:38 +00:00
Mervin Praison 709a35fbdc refactor: Remove duplicate code
- Remove duplicate _convert_roles_to_agents from agents_generator.py
  (YAMLWorkflowParser already handles this conversion)
- Refactor _create_workflow_from_template in main.py to use
  WorkflowHandler.TEMPLATES instead of duplicating templates

This reduces code duplication and ensures single source of truth for:
1. Roles-to-agents conversion logic (in yaml_parser.py)
2. Workflow templates (in workflow.py)
2025-12-17 11:16:51 +00:00
Mervin Praison 8b792424b6 feat: Extend agents.yaml with workflow patterns (Option D)
- Add workflow detection in agents_generator.py (process: workflow)
- Route to YAMLWorkflowParser when workflow mode detected
- Support both 'roles' and 'agents' keys for backward compatibility
- Map 'backstory' to 'instructions' automatically
- Add _convert_roles_to_agents method in YAMLWorkflowParser
- Add _run_yaml_workflow method in AgentsGenerator
- Add 7 new tests for roles-to-agents conversion
- Add extended agents.yaml example with all workflow patterns
- Update README with extended agents.yaml documentation

This allows users to use advanced workflow patterns (route, parallel,
loop, repeat) directly in agents.yaml while maintaining full backward
compatibility with existing agents.yaml files.

Usage:
  praisonai agents.yaml  # Works for both standard and workflow mode
2025-12-17 11:11:46 +00:00
Mervin Praison ef2a8cd658 feat(cli): Add YAML workflow CLI commands
- Add WorkflowHandler for 'praisonai workflow' commands
- Add 'praisonai workflow run <file.yaml>' to run YAML workflows
- Add 'praisonai workflow validate <file.yaml>' to validate YAML workflows
- Add 'praisonai workflow template <name>' to create from templates
- Templates: simple, routing, parallel, loop, evaluator-optimizer
- Support --var key=value for passing variables
- Add unit tests for WorkflowHandler (8 tests)
- Update main.py to support YAML workflow execution
2025-12-17 10:52:23 +00:00
Mervin Praison d41d2dcba2 docs: Add YAML workflow loading documentation to README 2025-12-17 10:43:20 +00:00
Mervin Praison 3bc1786b49 feat(workflows): Add YAMLWorkflowParser for YAML-based workflow definitions
- Add YAMLWorkflowParser class to parse YAML workflow files
- Support all workflow patterns: route(), parallel(), loop(), repeat()
- Support agent definitions with planning, reasoning, tools
- Support workflow config: planning, reasoning, memory_config, verbose
- Support variables, callbacks, and guardrails
- Add load_yaml(), execute_yaml(), aexecute_yaml() to WorkflowManager
- Add comprehensive test suite (20 tests)
- Add example YAML workflow files:
  - simple_workflow.yaml
  - routing_workflow.yaml
  - parallel_workflow.yaml
  - loop_workflow.yaml
  - evaluator_optimizer_workflow.yaml
  - complete_workflow.yaml
- Add example_usage.py demonstrating YAML workflow usage
2025-12-17 10:42:44 +00:00
Mervin Praison 2eec3e2359 fix: exclude real tests from all pattern and fix session test assertions
- test_runner.py: Add '-m not real' to exclude tests requiring API keys when running 'all' pattern
- test_cli_features.py: Fix test_session_get_actions to match actual SessionHandler.get_actions() implementation (list, show, resume, delete instead of start)
2025-12-17 08:33:24 +00:00
Mervin Praison 6168401a69 feat(docs): Simplify workflow documentation to focus on agent-first approach
- Replace WorkflowManager examples with Workflow class
- Emphasize Agent objects as workflow steps
- Streamline pattern examples (route, parallel, loop, repeat) with agent-based usage
- Remove complex WorkflowStep configuration examples
- Consolidate workflow features into clearer, more concise examples
- Remove workflow comparison table and memory config examples
- Focus on core workflow patterns with practical agent examples
2025-12-17 08:30:10 +00:00
Mervin Praison 473fda653d Release v2.3.25 2025-12-17 08:17:15 +00:00
Mervin Praison 30f1a440b6 Release v2.3.24 2025-12-17 08:04:24 +00:00
Mervin Praison f991f37cfe Release v2.3.23 2025-12-17 00:41:48 +00:00
Mervin Praison a43b3ac72c Release v2.3.22 2025-12-17 00:25:14 +00:00
Mervin Praison 06f82249e1 Release v2.3.21 2025-12-17 00:05:40 +00:00
Mervin Praison d3aa78241a Release v2.3.20 2025-12-16 23:46:47 +00:00
Mervin Praison dfb77c39f9 Release v2.3.19 2025-12-16 23:26:53 +00:00
Mervin Praison 7e8f9b0574 Release v2.3.18 2025-12-16 23:18:36 +00:00
Mervin Praison 19bcdcd000 Release v2.3.16 2025-12-16 22:59:29 +00:00
Mervin Praison 8f5160f827 feat(workflows): Add branching, loops, callbacks, and per-step variables
- Add branching support with next_steps and branch_condition fields
- Add loop support with loop_over and loop_var for iterating over variables
- Add on_task_start and on_task_complete callbacks to PraisonAIAgents
- Add variables parameter for global variable substitution across tasks
- Add per-step agent creation from agent_config when no agent assigned
- Add task-level variable substitution in task descriptions
- Refactor workflow
2025-12-16 22:58:56 +00:00
Mervin Praison 3574a4d2c0 Release v2.3.15 2025-12-16 22:50:37 +00:00
Mervin Praison b552843fa9 docs: Update README workflow CLI with new features
- Add inline workflow examples (--workflow 'step1,step2')
- Add planning mode example (--planning)
- Add tools and save examples (--tools, --save)
- Update to use global flags instead of workflow-specific flags
2025-12-16 21:46:30 +00:00
Mervin Praison 0461a133df fix(workflow): Actually use planning mode when --planning is enabled
Before: --planning flag was stored but never used, agent.chat() was called
After: --planning flag creates agent with planning=True and calls agent.start()

The agent.start() method checks if planning is enabled and calls
_start_with_planning() which:
1. Creates a plan using PlanningAgent
2. Displays the generated plan
3. Executes each step with context passing
4. Compiles results into final output
2025-12-16 19:12:28 +00:00
Mervin Praison b33857de33 refactor(cli): Use global flags for workflow instead of workflow-specific flags
BREAKING CHANGE: Removed workflow-specific flags in favor of global flags

Before:
  praisonai workflow run 'name' --workflow-tools tavily --workflow-save --workflow-verbose

After:
  praisonai workflow run 'name' --tools tavily --save --verbose

This simplifies the CLI by reusing existing global flags:
- --llm (instead of --workflow-llm)
- --tools (instead of --workflow-tools)
- --planning (instead of --workflow-planning)
- --memory (instead of --workflow-memory)
- --save (instead of --workflow-save)
- --verbose (instead of --workflow-verbose)

Only --workflow-var remains workflow-specific for variable substitution.
2025-12-16 19:09:52 +00:00
Mervin Praison 433c643f6c feat(cli): Add inline workflow support without .md template
Usage: praisonai 'prompt' --workflow 'step1:action1,step2:action2'

Example:
  praisonai 'What is AI?' --workflow 'Research:Research the topic,Summarize:Write a summary' --workflow-save

Features:
- Parse inline workflow steps from --workflow flag
- Sequential execution with context passing
- All workflow options work: --workflow-tools, --workflow-save, etc.
- No .md template file required
2025-12-16 19:05:20 +00:00
Mervin Praison 214119a063 feat(cli): Add complete workflow CLI options
- Add --workflow-llm for LLM model selection
- Add --workflow-tools for tools (comma-separated)
- Add --workflow-planning for planning mode
- Add --workflow-memory for memory support
- Add --workflow-stream for streaming output
- Add --workflow-verbose for verbose output
- Add --workflow-save to save output to file
- Show final output after workflow completion
- Save all step outputs to markdown file
- Update help text with all options
2025-12-16 19:01:57 +00:00
Mervin Praison 989a9f6fc5 Release v2.3.14 2025-12-16 18:50:59 +00:00
Mervin Praison cf276a5640 feat(workflows): Add context passing, per-step agents, async execution
## New Features:
- Context passing between workflow steps with {{previous_output}} and {{step_name_output}}
- Per-step agent configuration with role, goal, backstory, and tools
- Per-step tools support
- Async execution with aexecute() method
- Planning mode at workflow level
- Enhanced markdown parser for agent/tools blocks

## WorkflowStep Enhancements:
- agent_config: Optional[Dict] - Per-step agent configuration
- tools: Optional[List] - Per-step tools
- context_from: Optional[List[str]] - Specific steps to include context from
- retain_full_context: bool - Include all previous outputs vs only last
- output_variable: Optional[str] - Custom variable name for step output

## Workflow Enhancements:
- default_llm: Optional[str] - Default LLM for all steps
- planning: bool - Enable planning mode
- planning_llm: Optional[str] - LLM for planning
- memory_config: Optional[Dict] - Memory configuration

## Execute Method Enhancements:
- default_agent parameter (executor now optional)
- default_llm, memory, planning, stream, verbose parameters

## Tests:
- 74 new tests covering all features
2025-12-16 18:49:43 +00:00
Mervin Praison b5134ffb2f Add --final-agent flag for post-processing output with specialized agent 2025-12-16 16:40:22 +00:00
Mervin Praison 5664036712 Release v2.3.13 2025-12-16 16:33:03 +00:00
Mervin Praison 6c2ca64cbe Remove unused --compile-results flag 2025-12-16 16:32:42 +00:00
Mervin Praison 08c742287c Fix: Pass max_tokens as dict config to Agent 2025-12-16 16:27:29 +00:00
Mervin Praison 4ee32b8eb2 Improve planning mode: compile all step results into comprehensive final report 2025-12-16 16:22:44 +00:00
Mervin Praison 35f07e5653 Add --max-tokens flag and improve planning mode output compilation 2025-12-16 16:22:37 +00:00
Mervin Praison 6481aec376 Add --tools support for all agents including planning mode 2025-12-16 16:14:39 +00:00
Mervin Praison 98c846ef78 Improve: Dynamic tool instructions from docstrings instead of hardcoded search 2025-12-16 16:01:30 +00:00
Mervin Praison 6e4a9faaf8 Fix: Instruct PromptExpanderAgent to use tools for context gathering 2025-12-16 15:59:46 +00:00
Mervin Praison 05d62db355 Reorganize README.md by moving Prompt Expansion, Web Search/Fetch, and MCP sections after CLI Features table, adding comprehensive programmatic usage examples and feature comparison tables for better documentation structure and clarity. 2025-12-16 15:52:36 +00:00
Mervin Praison 375c8c9826 Fix CLI docs links to use /docs/cli/ path 2025-12-16 15:41:42 +00:00
Mervin Praison c6977c8221 Add CLI Features table with docs links 2025-12-16 15:19:29 +00:00
Mervin Praison d407c83cf3 Add comprehensive CLI feature documentation to README.md, including Guardrail, Metrics, Image Processing, Telemetry, MCP, Fast Context, Knowledge, Session, Tools, Handoff, Auto Memory, Todo, Router, Flow Display, and Prompt Expansion with usage examples and command options for each feature. 2025-12-16 15:06:41 +00:00
Mervin Praison 214add68d9 Release v2.3.12 2025-12-16 15:03:23 +00:00
Mervin Praison 6d2cb9676d Update README.md to consolidate and enhance the Supported Providers section, adding new AI providers and their corresponding example links for improved documentation and user guidance. 2025-12-16 11:54:00 +00:00
Mervin Praison c519cb1379 Add supported providers section to README.md, listing various AI providers with example links for each, enhancing documentation and usability. 2025-12-16 11:52:19 +00:00
Mervin Praison 356dc4909a Update README.md to refine feature table, enhancing clarity by renaming 'Automated AI Agents Creation' to 'Single Agent' and reorganizing agent types for better categorization. 2025-12-16 11:25:42 +00:00
Mervin Praison dc278ad07b Enhance README.md with new agent types and advanced features, including Image Generation, Data Analyst, and Finance Agents, along with updates to existing feature descriptions and examples. 2025-12-16 11:22:23 +00:00
Mervin Praison 5cc53eb4bb Add advanced features section to README.md, detailing new capabilities including Deep Research Agents, Query Rewriter Agent, and various MCP enhancements. 2025-12-16 11:13:38 +00:00
Mervin Praison b72576d086 Refactor feature table in README.md for improved clarity and consistency, updating formatting and links for AI agent capabilities. 2025-12-16 11:11:04 +00:00
Mervin Praison 30314b1573 Update README.md to include detailed feature table with example code and documentation links for each AI agent capability. 2025-12-16 11:05:51 +00:00
Mervin Praison f721bd5e9a Add Fast Context feature to README highlights 2025-12-16 10:52:37 +00:00
Mervin Praison d00ea3e3c3 Release v2.3.11 2025-12-16 10:45:36 +00:00
Mervin Praison 67dbd1007a Release v2.3.10 2025-12-16 09:54:57 +00:00
Mervin Praison cf2a5a135f workflow 2025-12-16 09:23:49 +00:00
Mervin Praison bc23d3a104 Release v2.3.9 2025-12-16 08:18:09 +00:00
Mervin Praison bb4e1d8b9e Release v2.3.8 2025-12-16 07:55:55 +00:00
Mervin Praison 69740baa7c Release v2.3.7 2025-12-16 07:46:40 +00:00
Mervin Praison 5035978a29 fix: resolve Mintlify parsing errors
- Fix corrupted/duplicated database service in docker-compose.dev.yml
- Fix MDX parsing error in tests/README.md (<1s -> under 1s)
2025-12-15 23:11:04 +00:00
Mervin Praison 4ddb043324 Release v2.3.7 2025-12-15 22:49:12 +00:00
Mervin Praison 337014f4db planning example 2025-12-15 22:25:09 +00:00
Mervin Praison 35c371b25e update 2025-12-15 22:22:42 +00:00
202 changed files with 46110 additions and 3174 deletions

No files matched your search

+38 -22
View File
@@ -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
+37 -28
View File
@@ -1,45 +1,54 @@
name: Daily Publish Docker
name: Publish Docker
on:
push:
branches:
- main
schedule:
- cron: '0 0 * * *'
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: 'Docker image tag (default: latest)'
required: false
default: 'latest'
jobs:
check-and-build:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Check last commit time
id: check_commit
run: |
last_commit_time=$(git log -1 --format=%ct)
current_time=$(date +%s)
time_diff=$((current_time - last_commit_time))
if [ $time_diff -ge 120 ] && [ $time_diff -lt 240 ]; then
echo "should_run=true" >> $GITHUB_OUTPUT
else
echo "should_run=false" >> $GITHUB_OUTPUT
fi
uses: actions/checkout@v4
- name: Set up Docker Buildx
if: steps.check_commit.outputs.should_run == 'true'
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v3
- name: Log in to the GitHub Container Registry
if: steps.check_commit.outputs.should_run == 'true'
run: echo ${{ secrets.GH_TOKEN }} | docker login ghcr.io -u USERNAME --password-stdin
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/mervinpraison/praisonai
tags: |
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
type=semver,pattern={{version}},enable=${{ github.event_name == 'release' }}
type=semver,pattern={{major}}.{{minor}},enable=${{ github.event_name == 'release' }}
type=raw,value=${{ github.event.inputs.tag || 'latest' }},enable=${{ github.event_name == 'workflow_dispatch' }}
- name: Build and push Docker image
if: steps.check_commit.outputs.should_run == 'true'
uses: docker/build-push-action@v2
uses: docker/build-push-action@v5
with:
context: .
file: ./docker/Dockerfile
push: true
tags: ghcr.io/mervinpraison/praisonai:latest
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+10 -6
View File
@@ -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
+13 -7
View File
@@ -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"
+1393 -195
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -14,9 +14,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
# Note: praisonai is installed from PyPI - remove version pin to use latest available
RUN pip install --no-cache-dir \
flask \
"praisonai>=2.3.6" \
praisonai \
"praisonai[api]" \
gunicorn \
markdown
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.3.6" \
"praisonai>=2.3.32" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.3.6" \
"praisonai>=2.3.32" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.3.6" \
"praisonai>=2.3.32" \
"praisonai[ui]" \
"praisonai[crewai]"
+4 -15
View File
@@ -137,22 +137,11 @@ services:
POSTGRES_USER: praisonai
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-praisonai_dev_pass}
volumes:
database:
image: postgres:15-alpine
environment:
POSTGRES_DB: praisonai_dev
POSTGRES_USER: praisonai
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-praisonai_dev_pass}
volumes:
- postgres_data:/var/lib/postgresql/data
read_only: true
tmpfs:
- /tmp
- /var/tmp
ports:
- "5432:5432"
networks:
- postgres_data:/var/lib/postgresql/data
read_only: true
tmpfs:
- /tmp
- /var/tmp
ports:
- "5432:5432"
networks:
@@ -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()
+84
View File
@@ -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()
+132
View File
@@ -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()
+112
View File
@@ -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()
+163
View File
@@ -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()
+213
View File
@@ -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()
@@ -29,7 +29,7 @@
},
{
"cell_type": "code",
"execution_count": 1,
"execution_count": null,
"metadata": {
"colab": {
"base_uri": "https://localhost:8080/"
@@ -71,7 +71,6 @@
"import pandas as pd\n",
"\n",
"print(\"📦 Dependencies installed!\")\n",
"print(\"🔄 Converting from AGNO → PraisonAI\")\n",
"print(\"🎯 Building Job Hunting Agent System\")"
]
},
@@ -1,10 +1,10 @@
framework: crewai
topic: "Technology Research and Content Creation using AWS Bedrock"
roles:
agents: # Canonical: use 'agents' instead of 'roles'
research_specialist:
role: "Research Specialist"
backstory: |
instructions: # Canonical |
You are an experienced technology researcher with deep expertise in analyzing
emerging technologies and market trends. You excel at gathering comprehensive
information from various sources and synthesizing it into actionable insights.
@@ -35,7 +35,7 @@ roles:
content_creator:
role: "Technical Content Creator"
backstory: |
instructions: # Canonical |
You are a skilled technical writer and content creator who specializes in
translating complex technology concepts into engaging, accessible content
for various audiences including developers, business leaders, and end users.
@@ -64,7 +64,7 @@ roles:
quality_reviewer:
role: "Content Quality Reviewer"
backstory: |
instructions: # Canonical |
You are a meticulous editor and quality assurance specialist with extensive
experience in technical content review. You ensure accuracy, clarity, and
consistency while maintaining the intended tone and message.
@@ -8,10 +8,10 @@ variables:
industry: "Electric Vehicles"
research_focus: "comprehensive market analysis"
roles:
agents: # Canonical: use 'agents' instead of 'roles'
market_overview_specialist:
role: "Market Overview Specialist"
backstory: |
instructions: # Canonical |
You are a seasoned market research analyst with 10+ years of experience
in analyzing market dynamics, trends, and growth patterns across various
industries. You excel at synthesizing complex market data into clear,
@@ -64,7 +64,7 @@ roles:
competitive_intelligence_analyst:
role: "Competitive Intelligence Analyst"
backstory: |
instructions: # Canonical |
You are a competitive intelligence expert specializing in analyzing
competitive landscapes, market positioning, and strategic intelligence.
You have deep expertise in competitor analysis, market share assessment,
@@ -119,7 +119,7 @@ roles:
financial_performance_analyst:
role: "Financial Performance Analyst"
backstory: |
instructions: # Canonical |
You are a financial analyst with expertise in corporate finance,
financial statement analysis, and industry benchmarking. You specialize
in analyzing financial performance metrics, trends, and comparative
@@ -173,7 +173,7 @@ roles:
growth_opportunities_researcher:
role: "Growth Opportunities Researcher"
backstory: |
instructions: # Canonical |
You are a strategic business analyst specializing in identifying growth
opportunities, market expansion strategies, and emerging business models.
You excel at spotting trends, gaps, and untapped potential in markets
@@ -228,7 +228,7 @@ roles:
risk_assessment_specialist:
role: "Risk Assessment Specialist"
backstory: |
instructions: # Canonical |
You are a risk management expert with extensive experience in identifying,
analyzing, and assessing business risks across various industries. You
specialize in market risks, competitive threats, regulatory challenges,
@@ -287,7 +287,7 @@ roles:
research_report_synthesizer:
role: "Research Report Synthesizer"
backstory: |
instructions: # Canonical |
You are an expert business report writer and analyst with extensive
experience in synthesizing complex research findings into clear,
actionable business intelligence reports. You excel at creating
@@ -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()}")
+110
View File
@@ -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")
+47
View File
@@ -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()
+79
View File
@@ -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)
+140
View File
@@ -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")
+108
View File
@@ -0,0 +1,108 @@
"""
Multiple MCP Servers Example
This example demonstrates how to use multiple MCP servers with a single agent.
You can combine different MCP servers (time, memory, filesystem, etc.) along
with regular Python functions as tools.
Requirements:
- pip install praisonaiagents
- uvx (for mcp-server-time)
- npx (for @modelcontextprotocol servers)
"""
from praisonaiagents import Agent
from praisonaiagents.mcp import MCP
def my_custom_tool(query: str) -> str:
"""
A custom Python function that can be used alongside MCP tools.
Args:
query: The search query
Returns:
A formatted result string
"""
return f"Custom tool result for: {query}"
def main():
# Example 1: Single MCP server (basic usage)
print("=" * 60)
print("Example 1: Single MCP Server")
print("=" * 60)
agent_single = Agent(
name="TimeAgent",
instructions="You are a helpful assistant that can tell time.",
tools=MCP("uvx mcp-server-time")
)
response = agent_single.start("What time is it in Tokyo?")
print(f"Response: {response}\n")
# Example 2: Multiple MCP servers
print("=" * 60)
print("Example 2: Multiple MCP Servers")
print("=" * 60)
agent_multi = Agent(
name="MultiMCPAgent",
instructions="""You are a helpful assistant with access to multiple tools:
- Time tools for getting current time and converting timezones
- Memory tools for storing and retrieving information
Use the appropriate tool based on the user's request.""",
tools=[
MCP("uvx mcp-server-time"), # Time tools
MCP("npx @modelcontextprotocol/server-memory"), # Memory tools
]
)
response = agent_multi.start("What time is it in New York and London?")
print(f"Response: {response}\n")
# Example 3: Multiple MCP servers + custom functions
print("=" * 60)
print("Example 3: MCP Servers + Custom Functions")
print("=" * 60)
agent_mixed = Agent(
name="MixedToolsAgent",
instructions="""You are a helpful assistant with access to:
- Time tools for timezone operations
- A custom search tool for general queries
Choose the right tool for each task.""",
tools=[
MCP("uvx mcp-server-time"), # MCP server
my_custom_tool # Regular Python function
]
)
response = agent_mixed.start("What time is it in Paris?")
print(f"Response: {response}\n")
# Example 4: MCP with environment variables
print("=" * 60)
print("Example 4: MCP with Environment Variables")
print("=" * 60)
# Note: Replace with your actual API key
# agent_with_env = Agent(
# name="SearchAgent",
# instructions="You can search the web for information.",
# tools=[
# MCP(
# command="npx",
# args=["-y", "@modelcontextprotocol/server-brave-search"],
# env={"BRAVE_API_KEY": "your-api-key-here"}
# ),
# MCP("uvx mcp-server-time")
# ]
# )
print("(Skipped - requires BRAVE_API_KEY)")
if __name__ == "__main__":
main()
@@ -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")
+55
View File
@@ -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")
+4 -4
View File
@@ -1,8 +1,8 @@
framework: praisonai
topic: crawl and extract all data from each url from this page https://news.ycombinator.com/
roles:
agents: # Canonical: use 'agents' instead of 'roles'
web_scraper:
backstory: An expert in data extraction from websites, adept at navigating and
instructions: # Canonical An expert in data extraction from websites, adept at navigating and
retrieving detailed information.
goal: Extract URLs from the source page
role: Web Scraper
@@ -15,7 +15,7 @@ roles:
tools:
- 'code_interpret'
url_data_extractor:
backstory: Specializes in crawling websites to gather comprehensive data, ensuring
instructions: # Canonical Specializes in crawling websites to gather comprehensive data, ensuring
nothing is missed from each link.
goal: Crawl each URL for data extraction
role: URL Data Extractor
@@ -29,7 +29,7 @@ roles:
tools:
- 'code_interpret'
data_organizer:
backstory: Known for keen attention to detail, capable of structuring vast amounts
instructions: # Canonical Known for keen attention to detail, capable of structuring vast amounts
of information into usable formats efficiently.
goal: Organize extracted data into a structured format
role: Data Organizer
@@ -0,0 +1,38 @@
"""
Simple Agentic Workflow Example
Demonstrates using Agent instances directly as workflow steps.
Each agent processes the input and passes output to the next agent.
"""
from praisonaiagents import Agent, Workflow
# Create agents with specific roles
researcher = Agent(
name="Researcher",
role="Research Analyst",
goal="Research and provide information about topics",
instructions="You are a research analyst. Provide concise, factual information."
)
writer = Agent(
name="Writer",
role="Content Writer",
goal="Write engaging content based on research",
instructions="You are a content writer. Write clear, engaging content based on the research provided."
)
# Create workflow with agents as steps
workflow = Workflow(
name="Simple Agentic Pipeline",
steps=[researcher, writer]
)
if __name__ == "__main__":
# Run workflow - agents process sequentially
result = workflow.start(
"What are the key benefits of AI agents?",
verbose=True
)
print(f"\nFinal output:\n{result['output']}")
@@ -0,0 +1,36 @@
"""
Simple Workflow Example
The easiest way to create and run workflows in PraisonAI.
Just pass functions or agents as steps - no complex configuration needed!
"""
from praisonaiagents import Workflow, WorkflowContext, StepResult
# Define simple handler functions
def validate(ctx: WorkflowContext) -> StepResult:
"""Validate the input data."""
if not ctx.input:
return StepResult(output="No input provided", stop_workflow=True)
return StepResult(output=f"Validated: {ctx.input}")
def process(ctx: WorkflowContext) -> StepResult:
"""Process the validated data."""
return StepResult(output=f"Processed: {ctx.previous_result}")
def format_output(ctx: WorkflowContext) -> StepResult:
"""Format the final output."""
return StepResult(output=f"✅ Final: {ctx.previous_result}")
# Create workflow - just list your functions!
workflow = Workflow(
name="Simple Pipeline",
steps=[validate, process, format_output]
)
if __name__ == "__main__":
# Run the workflow - PraisonAI style uses .start()
result = workflow.start("Hello, World!", verbose=True)
print(f"\nFinal output: {result['output']}")
print(f"Steps completed: {len(result['steps'])}")
@@ -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,83 @@
"""
Agentic Conditional Workflow Example
Demonstrates using should_run to conditionally execute agent steps
based on input or previous results.
"""
from praisonaiagents import Agent, Workflow, WorkflowStep
# Condition functions
def is_technical(ctx) -> bool:
"""Only run technical review for technical content."""
keywords = ["code", "programming", "algorithm", "database", "api"]
return any(keyword in ctx.input.lower() for keyword in keywords)
def needs_creative(ctx) -> bool:
"""Only run creative enhancement for creative content."""
keywords = ["story", "poem", "creative", "write", "narrative"]
return any(keyword in ctx.input.lower() for keyword in keywords)
# Create agents
analyzer = Agent(
name="Analyzer",
role="Content Analyzer",
goal="Analyze and process content",
instructions="Analyze the given content and provide insights."
)
tech_reviewer = Agent(
name="TechReviewer",
role="Technical Reviewer",
goal="Review technical content",
instructions="Review the technical aspects and provide technical feedback."
)
creative_enhancer = Agent(
name="CreativeEnhancer",
role="Creative Enhancer",
goal="Enhance creative content",
instructions="Enhance the creative aspects and make it more engaging."
)
finalizer = Agent(
name="Finalizer",
role="Content Finalizer",
goal="Finalize the content",
instructions="Provide the final polished version of the content."
)
# Create workflow with conditional steps
workflow = Workflow(
name="Conditional Agentic Pipeline",
steps=[
analyzer, # Always runs
WorkflowStep(
name="tech_review",
agent=tech_reviewer,
should_run=is_technical # Only runs for technical content
),
WorkflowStep(
name="creative_enhance",
agent=creative_enhancer,
should_run=needs_creative # Only runs for creative content
),
finalizer # Always runs
]
)
if __name__ == "__main__":
# Test 1: Technical content
print("=== Test 1: Technical Content ===")
result = workflow.start("Review this Python code for best practices", verbose=True)
print(f"Output: {result['output'][:200]}...\n")
# Test 2: Creative content
print("=== Test 2: Creative Content ===")
result = workflow.start("Write a short story about a robot", verbose=True)
print(f"Output: {result['output'][:200]}...\n")
# Test 3: General content (neither technical nor creative)
print("=== Test 3: General Content ===")
result = workflow.start("What is the weather like today?", verbose=True)
print(f"Output: {result['output'][:200]}...")
@@ -0,0 +1,68 @@
"""
Workflow Conditional Execution Example
Demonstrates using should_run to conditionally execute steps
based on input or previous results.
"""
from praisonaiagents import Workflow, WorkflowStep, WorkflowContext, StepResult
# Condition functions - return True to run the step, False to skip
def is_sensitive_content(ctx: WorkflowContext) -> bool:
"""Only run compliance check for sensitive topics."""
sensitive_keywords = ["legal", "medical", "financial", "security"]
return any(keyword in ctx.input.lower() for keyword in sensitive_keywords)
def needs_translation(ctx: WorkflowContext) -> bool:
"""Only translate if content is not in English."""
return ctx.variables.get("language", "en") != "en"
# Handler functions
def process_content(ctx: WorkflowContext) -> StepResult:
return StepResult(output=f"Processed: {ctx.input}")
def compliance_check(ctx: WorkflowContext) -> StepResult:
return StepResult(output=f"✅ Compliance verified for: {ctx.previous_result}")
def translate(ctx: WorkflowContext) -> StepResult:
lang = ctx.variables.get("language", "unknown")
return StepResult(output=f"🌐 Translated to {lang}: {ctx.previous_result}")
def finalize(ctx: WorkflowContext) -> StepResult:
return StepResult(output=f"📄 Final: {ctx.previous_result}")
# Create workflow with conditional steps
workflow = Workflow(
name="Conditional Pipeline",
steps=[
WorkflowStep(name="process", handler=process_content),
WorkflowStep(
name="compliance",
handler=compliance_check,
should_run=is_sensitive_content # Only runs for sensitive content
),
WorkflowStep(
name="translate",
handler=translate,
should_run=needs_translation # Only runs if language != "en"
),
WorkflowStep(name="finalize", handler=finalize)
]
)
if __name__ == "__main__":
# Test 1: Normal content (skips compliance)
print("=== Test 1: Normal content ===")
result = workflow.start("Hello world", verbose=True)
print(f"Output: {result['output']}\n")
# Test 2: Sensitive content (runs compliance)
print("=== Test 2: Legal content ===")
result = workflow.start("Review this legal document", verbose=True)
print(f"Output: {result['output']}\n")
# Test 3: Non-English content (runs translation)
print("=== Test 3: Non-English content ===")
workflow.variables = {"language": "es"}
result = workflow.start("Hola mundo", verbose=True)
print(f"Output: {result['output']}")
@@ -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,59 @@
"""
Agentic Loop with CSV Workflow Example
Demonstrates iterating over a CSV file with an agent processing each row.
"""
from praisonaiagents import Agent, Workflow
from praisonaiagents.workflows import loop
import tempfile
import os
# Create sample CSV file content
csv_content = """topic,description
AI Ethics,Ethical considerations in artificial intelligence
Machine Learning,Algorithms that learn from data
Neural Networks,Computing systems inspired by biological neural networks
Natural Language Processing,AI understanding human language"""
# Create processor agent
processor = Agent(
name="TopicProcessor",
role="Topic Analyst",
goal="Analyze and explain topics",
instructions="Provide a brief, insightful analysis of the given topic."
)
# Create summarizer agent
summarizer = Agent(
name="Summarizer",
role="Results Summarizer",
goal="Summarize all processed results",
instructions="Create a comprehensive summary of all the analyzed topics."
)
if __name__ == "__main__":
# Create temp CSV file
with tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False) as f:
f.write(csv_content)
csv_path = f.name
try:
print("=== Testing Agentic Loop with CSV ===\n")
print(f"Processing CSV: {csv_path}\n")
# Create workflow with loop over CSV
workflow = Workflow(
name="CSV Topic Processor",
steps=[
loop(processor, from_csv=csv_path),
summarizer
]
)
result = workflow.start("Analyze these AI topics", verbose=True)
print(f"\nFinal Summary:\n{result['output']}")
finally:
os.unlink(csv_path)
@@ -0,0 +1,55 @@
"""
Workflow Loop with CSV Example
Demonstrates iterating over a CSV file, processing each row.
"""
from praisonaiagents import Workflow, WorkflowContext, StepResult
from praisonaiagents.workflows import loop
import tempfile
import os
# Create sample CSV file
csv_content = """name,email,task
Alice,[email protected],Write documentation
Bob,[email protected],Review pull requests
Charlie,[email protected],Fix critical bug
Diana,[email protected],Deploy to production"""
# Processor for each CSV row
def process_task(ctx: WorkflowContext) -> StepResult:
"""Process a single task from CSV."""
item = ctx.variables.get("item", {})
name = item.get("name", "Unknown")
task = item.get("task", "No task")
index = ctx.variables.get("loop_index", 0)
return StepResult(
output=f"[{index+1}] {name}: {task} ✅",
variables={"last_processed": name}
)
if __name__ == "__main__":
# Create temp CSV file
with tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False) as f:
f.write(csv_content)
csv_path = f.name
try:
print("=== Testing Loop with CSV ===\n")
print(f"CSV file: {csv_path}\n")
# Create workflow
workflow = Workflow(
name="CSV Task Processor",
steps=[loop(process_task, from_csv=csv_path)]
)
result = workflow.start("Process all tasks", verbose=True)
print(f"\nAll outputs:\n{result['output']}")
print(f"\nProcessed {len(result['variables']['loop_outputs'])} items")
print(f"Last processed: {result['variables'].get('last_processed')}")
finally:
os.unlink(csv_path)
@@ -0,0 +1,52 @@
"""
Workflow Loop with List Example
Demonstrates iterating over a list of items, processing each one.
"""
from praisonaiagents import Workflow, WorkflowContext, StepResult
from praisonaiagents.workflows import loop
# Sample data
fruits = ["apple", "banana", "cherry", "date", "elderberry"]
# Processor for each item
def process_fruit(ctx: WorkflowContext) -> StepResult:
"""Process a single fruit from the list."""
fruit = ctx.variables.get("item", "unknown")
index = ctx.variables.get("loop_index", 0)
# Simulate processing
result = f"🍎 [{index + 1}] {fruit.upper()} - processed successfully"
return StepResult(
output=result,
variables={"last_fruit": fruit}
)
# Aggregator to summarize results
def summarize(ctx: WorkflowContext) -> StepResult:
"""Summarize all processed fruits."""
outputs = ctx.variables.get("loop_outputs", [])
summary = f"📊 Processed {len(outputs)} fruits:\n"
summary += "\n".join(f" {o}" for o in outputs)
return StepResult(output=summary)
# Create workflow
workflow = Workflow(
name="Fruit Processor",
steps=[
loop(process_fruit, over="fruits"),
summarize
],
variables={"fruits": fruits}
)
if __name__ == "__main__":
print("=== Testing Loop with List ===\n")
result = workflow.start("Process all fruits", verbose=True)
print(f"\nFinal Output:\n{result['output']}")
print(f"\nLoop outputs count: {len(result['variables']['loop_outputs'])}")
print(f"Last processed: {result['variables'].get('last_fruit')}")
@@ -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")
@@ -0,0 +1,68 @@
"""
Mixed Steps Workflow Example
Demonstrates combining different step types in a single workflow:
- Agent instances
- Handler functions
- WorkflowStep with action strings
"""
from praisonaiagents import Workflow, WorkflowStep, WorkflowContext, StepResult, Agent
# Create an agent
analyzer = Agent(
name="Analyzer",
role="Data Analyst",
goal="Analyze and summarize data",
llm="gpt-4o-mini"
)
# Custom handler functions
def validate_input(ctx: WorkflowContext) -> StepResult:
"""Validate the input before processing."""
if len(ctx.input) < 5:
return StepResult(
output="Input too short. Minimum 5 characters required.",
stop_workflow=True
)
return StepResult(output=f"✅ Valid input: {ctx.input}")
def format_output(ctx: WorkflowContext) -> StepResult:
"""Format the final output."""
return StepResult(
output=f"📊 Report:\n{ctx.previous_result}",
variables={"report_generated": True}
)
# Create workflow with mixed step types
workflow = Workflow(
name="Mixed Pipeline",
steps=[
# Step 1: Function - validates input
validate_input,
# Step 2: Agent - analyzes content
analyzer,
# Step 3: WorkflowStep with action string
WorkflowStep(
name="enhance",
action="Add a conclusion to this analysis: {{previous_output}}"
),
# Step 4: Function - formats output
format_output
],
default_llm="gpt-4o-mini" # Used for action-based steps
)
if __name__ == "__main__":
# Test with valid input
print("=== Running mixed workflow ===")
result = workflow.start(
"Analyze the trends in AI adoption for 2024",
verbose=True
)
print(f"\nFinal output:\n{result['output']}")
print(f"\nVariables: {result['variables']}")
@@ -0,0 +1,56 @@
"""
Agentic Parallel Workflow Example
Demonstrates running multiple agents concurrently and
combining their results with an aggregator agent.
"""
from praisonaiagents import Agent, Workflow
from praisonaiagents.workflows import parallel
# Create parallel research agents
market_researcher = Agent(
name="MarketResearcher",
role="Market Research Analyst",
goal="Research market trends and opportunities",
instructions="Analyze market trends. Provide concise market insights."
)
competitor_researcher = Agent(
name="CompetitorResearcher",
role="Competitive Intelligence Analyst",
goal="Research competitor strategies",
instructions="Analyze competitors. Provide key competitive insights."
)
customer_researcher = Agent(
name="CustomerResearcher",
role="Customer Research Analyst",
goal="Research customer needs and behaviors",
instructions="Analyze customer segments. Provide customer insights."
)
# Create aggregator agent
aggregator = Agent(
name="Aggregator",
role="Research Synthesizer",
goal="Synthesize research findings",
instructions="Combine all research findings into a comprehensive summary."
)
# Create workflow with parallel execution
workflow = Workflow(
name="Parallel Research Pipeline",
steps=[
parallel([market_researcher, competitor_researcher, customer_researcher]),
aggregator
]
)
if __name__ == "__main__":
print("=== Testing Agentic Parallel Workflow ===\n")
# Run workflow - all researchers work in parallel, then aggregator summarizes
result = workflow.start("Research the AI industry", verbose=True)
print(f"\nFinal Summary:\n{result['output']}")
@@ -0,0 +1,53 @@
"""
Workflow Parallel Execution Example
Demonstrates running multiple steps concurrently and
combining their results.
"""
from praisonaiagents import Workflow, WorkflowContext, StepResult
from praisonaiagents.workflows import parallel
import time
# Parallel workers - each does independent work
def research_market(ctx: WorkflowContext) -> StepResult:
"""Research market trends."""
time.sleep(0.1) # Simulate work
return StepResult(output="📊 Market: Growth expected 15% YoY")
def research_competitors(ctx: WorkflowContext) -> StepResult:
"""Research competitor analysis."""
time.sleep(0.1) # Simulate work
return StepResult(output="🏢 Competitors: 3 major players identified")
def research_customers(ctx: WorkflowContext) -> StepResult:
"""Research customer feedback."""
time.sleep(0.1) # Simulate work
return StepResult(output="👥 Customers: 85% satisfaction rate")
# Aggregator - combines parallel results
def summarize_research(ctx: WorkflowContext) -> StepResult:
"""Summarize all research findings."""
outputs = ctx.variables.get("parallel_outputs", [])
summary = "📋 RESEARCH SUMMARY:\n" + "\n".join(f" • {o}" for o in outputs)
return StepResult(output=summary)
# Create workflow with parallel execution
workflow = Workflow(
name="Parallel Research",
steps=[
parallel([research_market, research_competitors, research_customers]),
summarize_research
]
)
if __name__ == "__main__":
print("=== Testing Parallel Workflow ===\n")
start_time = time.time()
result = workflow.start("Analyze the business landscape", verbose=True)
elapsed = time.time() - start_time
print(f"\nFinal Output:\n{result['output']}")
print(f"\nCompleted in {elapsed:.2f}s (parallel execution)")
print(f"Individual outputs: {len(result['variables']['parallel_outputs'])}")
@@ -0,0 +1,53 @@
"""
Agentic Evaluator-Optimizer (Repeat) Workflow Example
Demonstrates the evaluator-optimizer pattern where an agent
generates content and another evaluates it, repeating until approved.
"""
from praisonaiagents import Agent, Workflow
from praisonaiagents.workflows import repeat
# Create generator agent
generator = Agent(
name="Generator",
role="Content Generator",
goal="Generate high-quality content",
instructions="Generate content based on the topic. Improve based on feedback if provided."
)
# Create evaluator agent
evaluator = Agent(
name="Evaluator",
role="Content Evaluator",
goal="Evaluate and provide feedback on content",
instructions="Evaluate the content quality. If good, respond with 'APPROVED'. Otherwise provide specific feedback for improvement."
)
# Evaluation function to check if content is approved
def is_approved(ctx) -> bool:
return "approved" in ctx.previous_result.lower()
# Create workflow with evaluator-optimizer pattern
workflow = Workflow(
name="Evaluator-Optimizer Pipeline",
steps=[
generator, # First, generate content
repeat(
evaluator, # Evaluate and provide feedback
until=is_approved,
max_iterations=3
)
]
)
if __name__ == "__main__":
print("=== Testing Agentic Evaluator-Optimizer Workflow ===\n")
# Run optimization workflow
result = workflow.start(
"Write a compelling product description for an AI assistant",
verbose=True
)
print(f"\nFinal Result:\n{result['output']}")
@@ -0,0 +1,69 @@
"""
Workflow Repeat (Evaluator-Optimizer) Example
Demonstrates repeating a step until a condition is met.
This is useful for iterative improvement patterns.
"""
from praisonaiagents import Workflow, WorkflowContext, StepResult
from praisonaiagents.workflows import repeat
# Simulated content generator that improves each iteration
class ContentGenerator:
def __init__(self):
self.iteration = 0
self.content = []
def generate(self, ctx: WorkflowContext) -> StepResult:
"""Generate content, adding more each iteration."""
self.iteration += 1
# Add new content each iteration
new_items = [
f"Point {self.iteration}.1: Key insight about the topic",
f"Point {self.iteration}.2: Supporting evidence"
]
self.content.extend(new_items)
output = f"Iteration {self.iteration}: Generated {len(self.content)} points total"
return StepResult(
output=output,
variables={
"total_points": len(self.content),
"content": self.content.copy()
}
)
# Condition: stop when we have enough content
def has_enough_content(ctx: WorkflowContext) -> bool:
"""Check if we have generated enough content."""
total = ctx.variables.get("total_points", 0)
return total >= 6 # Stop when we have 6+ points
# Create generator instance
generator = ContentGenerator()
# Create workflow with repeat pattern
workflow = Workflow(
name="Content Generator",
steps=[
repeat(
generator.generate,
until=has_enough_content,
max_iterations=10
)
]
)
if __name__ == "__main__":
print("=== Testing Repeat (Evaluator-Optimizer) Pattern ===\n")
result = workflow.start("Generate comprehensive content", verbose=True)
print(f"\nFinal output: {result['output']}")
print(f"Total iterations: {result['variables']['repeat_iterations']}")
print(f"Total points generated: {result['variables']['total_points']}")
print("\nGenerated content:")
for point in result['variables']['content']:
print(f" • {point}")
@@ -0,0 +1,65 @@
"""
Agentic Routing Workflow Example
Demonstrates decision-based routing where an agent classifier
routes to specialized handler agents based on the request type.
"""
from praisonaiagents import Agent, Workflow
from praisonaiagents.workflows import route
# Create classifier agent
classifier = Agent(
name="Classifier",
role="Request Classifier",
goal="Classify incoming requests",
instructions="Classify the request. Respond with ONLY 'technical', 'creative', or 'general'."
)
# Create specialized handler agents
tech_agent = Agent(
name="TechExpert",
role="Technical Expert",
goal="Handle technical questions",
instructions="You are a technical expert. Provide detailed technical answers."
)
creative_agent = Agent(
name="CreativeWriter",
role="Creative Writer",
goal="Handle creative requests",
instructions="You are a creative writer. Write engaging, creative content."
)
general_agent = Agent(
name="GeneralAssistant",
role="General Assistant",
goal="Handle general requests",
instructions="You are a helpful assistant. Provide clear, helpful responses."
)
# Create workflow with routing
workflow = Workflow(
name="Agentic Router",
steps=[
classifier,
route({
"technical": [tech_agent],
"creative": [creative_agent],
"default": [general_agent]
})
]
)
if __name__ == "__main__":
print("=== Testing Agentic Routing Workflow ===\n")
# Test 1: Technical question
print("--- Technical Request ---")
result = workflow.start("How does machine learning work?", verbose=True)
print(f"Result: {result['output'][:200]}...\n")
# Test 2: Creative request
print("--- Creative Request ---")
result = workflow.start("Write a poem about the ocean", verbose=True)
print(f"Result: {result['output'][:200]}...")
@@ -0,0 +1,61 @@
"""
Workflow Routing Example
Demonstrates decision-based routing where the workflow
takes different paths based on the output of a decision step.
"""
from praisonaiagents import Workflow, WorkflowContext, StepResult
from praisonaiagents.workflows import route
# Decision maker - determines which route to take
def classify_request(ctx: WorkflowContext) -> StepResult:
"""Classify the input and decide the route."""
input_lower = ctx.input.lower()
if "urgent" in input_lower or "emergency" in input_lower:
return StepResult(output="priority: high")
elif "question" in input_lower or "help" in input_lower:
return StepResult(output="priority: support")
else:
return StepResult(output="priority: normal")
# Route handlers
def handle_high_priority(ctx: WorkflowContext) -> StepResult:
return StepResult(output="🚨 HIGH PRIORITY: Escalating to senior team immediately!")
def handle_support(ctx: WorkflowContext) -> StepResult:
return StepResult(output="💬 SUPPORT: Routing to help desk for assistance.")
def handle_normal(ctx: WorkflowContext) -> StepResult:
return StepResult(output="📋 NORMAL: Added to standard processing queue.")
# Create workflow with routing
workflow = Workflow(
name="Request Router",
steps=[
classify_request,
route({
"high": [handle_high_priority],
"support": [handle_support],
"normal": [handle_normal],
"default": [handle_normal]
})
]
)
if __name__ == "__main__":
# Test different inputs
print("=== Testing Routing Workflow ===\n")
# Test 1: Urgent request
result = workflow.start("This is an URGENT matter!", verbose=True)
print(f"Result: {result['output']}\n")
# Test 2: Support request
result = workflow.start("I have a question about my account", verbose=True)
print(f"Result: {result['output']}\n")
# Test 3: Normal request
result = workflow.start("Please process my order", verbose=True)
print(f"Result: {result['output']}")
@@ -0,0 +1,45 @@
"""
Workflow with Agents Example
Demonstrates using Agent instances directly as workflow steps.
Agents are automatically wrapped and executed in sequence.
"""
from praisonaiagents import Workflow, Agent
# Create agents
researcher = Agent(
name="Researcher",
role="Research Specialist",
goal="Find accurate information",
llm="gpt-4o-mini"
)
writer = Agent(
name="Writer",
role="Content Writer",
goal="Write engaging content",
llm="gpt-4o-mini"
)
editor = Agent(
name="Editor",
role="Content Editor",
goal="Polish and improve content",
llm="gpt-4o-mini"
)
# Create workflow with agents as steps
workflow = Workflow(
name="Content Pipeline",
steps=[researcher, writer, editor]
)
if __name__ == "__main__":
# Run the workflow
result = workflow.start(
"Write a short paragraph about artificial intelligence",
verbose=True
)
print(f"\nFinal output:\n{result['output']}")
+117
View File
@@ -0,0 +1,117 @@
# Extended agents.yaml with Workflow Patterns
# This file demonstrates the extended agents.yaml format that supports
# advanced workflow patterns while maintaining backward compatibility.
#
# Usage: praisonai agents_workflow.yaml
#
# Key differences from standard agents.yaml:
# - process: workflow (triggers workflow mode)
# - workflow: section for planning, reasoning, memory
# - steps: section for workflow patterns (route, parallel, loop, repeat)
framework: praisonai
process: workflow
input: "Research AI trends and create a comprehensive report" # The input passed to {{input}} in steps
# Workflow configuration (new)
workflow:
planning: true
reasoning: true
verbose: true
memory_config:
provider: chroma
persist: true
# Variables for template substitution
variables:
topic: AI trends
topics:
- Machine Learning
- Neural Networks
- Natural Language Processing
# Agent definitions using familiar 'roles' format
agents: # Canonical: use 'agents' instead of 'roles'
classifier:
role: Request Classifier
instructions: # Canonical |
You are an expert at classifying requests into categories.
Respond with ONLY one word: 'technical', 'creative', or 'research'.
goal: Classify incoming requests accurately
researcher:
role: Research Analyst
instructions: # Canonical |
You are a skilled research analyst with expertise in technology trends.
You provide concise, factual research findings with citations.
goal: Research topics thoroughly and provide insights
tools:
- tavily_search
llm:
model: gpt-4o-mini
tech_expert:
role: Technical Expert
instructions: # Canonical |
You are a technical expert with deep knowledge in AI and ML.
You provide detailed technical explanations.
goal: Handle technical questions with expertise
creative_writer:
role: Creative Writer
instructions: # Canonical |
You are a creative writer who can make complex topics engaging.
You write in a clear, accessible style.
goal: Create engaging content from research
market_analyst:
role: Market Analyst
instructions: # Canonical |
You analyze market trends and competitive landscapes.
You provide actionable business insights.
goal: Analyze market trends and opportunities
aggregator:
role: Synthesizer
instructions: # Canonical |
You combine findings from multiple sources into coherent reports.
You identify patterns and key insights.
goal: Synthesize all findings into comprehensive reports
# Workflow steps with patterns (new)
steps:
# Step 1: Classify the request
- agent: classifier
action: "Classify this request: {{topic}}"
# Step 2: Route to appropriate handler based on classification
- name: routing
route:
technical: [tech_expert]
creative: [creative_writer]
default: [researcher]
# Step 3: Parallel research on multiple aspects
- name: parallel_research
parallel:
- agent: researcher
action: "Research technology trends for {{topic}}"
- agent: market_analyst
action: "Analyze market opportunities for {{topic}}"
# Step 4: Loop over specific topics
- agent: researcher
action: "Deep dive into {{item}}"
loop:
over: topics
# Step 5: Aggregate all findings
- agent: aggregator
action: "Synthesize all research findings into a comprehensive report"
repeat:
until: "comprehensive"
max_iterations: 2
# Optional callbacks
callbacks:
on_step_complete: log_step
@@ -0,0 +1,116 @@
# Complete Workflow Example
# Demonstrates all workflow patterns in a single comprehensive workflow
name: Complete Research Workflow
description: A comprehensive workflow demonstrating all patterns - route, parallel, loop, repeat
framework: praisonai
workflow:
planning: true
reasoning: true
verbose: true
memory_config:
provider: chroma
persist: true
variables:
topic: AI trends
topics:
- Machine Learning
- Neural Networks
- Natural Language Processing
agents:
classifier:
name: Classifier
role: Request Classifier
goal: Classify incoming requests
instructions: |
Classify the request. Respond with ONLY one word:
- 'technical' for technical questions
- 'creative' for creative requests
- 'research' for research tasks
researcher:
name: Researcher
role: Research Analyst
goal: Research topics thoroughly
instructions: "You are a research analyst. Provide concise, factual research findings."
tech_expert:
name: TechExpert
role: Technical Expert
goal: Handle technical questions
instructions: "You are a technical expert. Provide detailed technical answers."
creative_writer:
name: CreativeWriter
role: Creative Writer
goal: Handle creative requests
instructions: "You are a creative writer. Write engaging, creative content."
market_analyst:
name: MarketAnalyst
role: Market Analyst
goal: Analyze market trends
instructions: "You are a market analyst. Provide market insights."
competitor_analyst:
name: CompetitorAnalyst
role: Competitor Analyst
goal: Analyze competitors
instructions: "You are a competitor analyst. Provide competitive insights."
aggregator:
name: Aggregator
role: Synthesizer
goal: Combine and synthesize findings
instructions: "You are a synthesizer. Combine all findings into a comprehensive report."
evaluator:
name: Evaluator
role: Quality Evaluator
goal: Evaluate content quality
instructions: |
Evaluate the content. If it's comprehensive and well-structured, respond with "APPROVED".
Otherwise, provide specific feedback for improvement.
steps:
# Step 1: Classify the request
- agent: classifier
action: "Classify: {{input}}"
# Step 2: Route to appropriate handler
- name: routing
route:
technical: [tech_expert]
creative: [creative_writer]
default: [researcher]
# Step 3: Parallel research
- name: parallel_research
parallel:
- agent: market_analyst
action: "Analyze market for {{topic}}"
- agent: competitor_analyst
action: "Analyze competitors for {{topic}}"
# Step 4: Loop over topics
- agent: researcher
action: "Research {{item}}"
loop:
over: topics
# Step 5: Aggregate findings
- agent: aggregator
action: "Synthesize all findings into a comprehensive report"
# Step 6: Evaluator-optimizer
- agent: evaluator
action: "Evaluate the report quality"
repeat:
until: "approved"
max_iterations: 2
callbacks:
on_step_complete: log_step
@@ -0,0 +1,43 @@
# Evaluator-Optimizer Workflow
# Demonstrates the repeat() pattern for iterative improvement
name: Evaluator Optimizer Workflow
description: A generator creates content, an evaluator reviews it repeatedly until quality standards are met
workflow:
verbose: true
agents:
generator:
name: Generator
role: Content Generator
goal: Generate high-quality content
instructions: |
You are a content generator. Generate content based on the request.
If feedback is provided, improve your content based on that feedback.
Always strive for clarity, accuracy, and engagement.
evaluator:
name: Evaluator
role: Content Evaluator
goal: Evaluate content quality and provide feedback
instructions: |
You are a content evaluator. Review the content provided.
If the content is high quality (clear, accurate, engaging), respond with:
"APPROVED: [brief reason]"
If the content needs improvement, respond with:
"NEEDS IMPROVEMENT: [specific feedback for improvement]"
Be constructive and specific in your feedback.
steps:
- agent: generator
action: "Generate content for: {{input}}"
- agent: evaluator
action: "Evaluate this content and provide feedback: {{previous_output}}"
repeat:
until: "approved"
max_iterations: 3
+152
View File
@@ -0,0 +1,152 @@
"""
Example usage of YAML Workflow Parser.
This script demonstrates how to load and execute YAML workflow files.
"""
import os
from pathlib import Path
# Ensure OPENAI_API_KEY is set
if not os.getenv("OPENAI_API_KEY"):
print("Please set OPENAI_API_KEY environment variable")
print("export OPENAI_API_KEY='your-api-key'")
exit(1)
from praisonaiagents.workflows import YAMLWorkflowParser, WorkflowManager
def example_1_parse_and_run():
"""Example 1: Parse YAML and run workflow directly."""
print("=" * 60)
print("Example 1: Parse YAML and run workflow")
print("=" * 60)
yaml_content = """
name: Quick Research
description: A quick research workflow
agents:
researcher:
name: Researcher
role: Research Analyst
goal: Research topics
instructions: "Provide brief, factual information."
steps:
- agent: researcher
action: "Research: {{input}}"
"""
parser = YAMLWorkflowParser()
workflow = parser.parse_string(yaml_content)
print(f"Workflow Name: {workflow.name}")
print(f"Steps: {len(workflow.steps)}")
# Run the workflow
result = workflow.start("What is Python?")
print(f"Result: {result['output'][:200]}...")
print()
def example_2_load_yaml_file():
"""Example 2: Load workflow from YAML file using WorkflowManager."""
print("=" * 60)
print("Example 2: Load YAML file with WorkflowManager")
print("=" * 60)
# Get the directory of this script
script_dir = Path(__file__).parent
yaml_file = script_dir / "simple_workflow.yaml"
if not yaml_file.exists():
print(f"YAML file not found: {yaml_file}")
return
manager = WorkflowManager()
workflow = manager.load_yaml(yaml_file)
print(f"Loaded workflow: {workflow.name}")
print(f"Steps: {len(workflow.steps)}")
# Run the workflow
result = workflow.start("Benefits of renewable energy")
print(f"Result: {result['output'][:200]}...")
print()
def example_3_execute_yaml_directly():
"""Example 3: Execute YAML file directly with WorkflowManager."""
print("=" * 60)
print("Example 3: Execute YAML directly")
print("=" * 60)
script_dir = Path(__file__).parent
yaml_file = script_dir / "simple_workflow.yaml"
if not yaml_file.exists():
print(f"YAML file not found: {yaml_file}")
return
manager = WorkflowManager()
result = manager.execute_yaml(
yaml_file,
input_data="The future of AI",
verbose=True
)
print(f"Status: {result['status']}")
print(f"Result: {result['output'][:200]}...")
print()
def example_4_with_variables():
"""Example 4: YAML workflow with variables."""
print("=" * 60)
print("Example 4: Workflow with variables")
print("=" * 60)
yaml_content = """
name: Variable Workflow
variables:
topic: Machine Learning
style: concise
agents:
writer:
name: Writer
role: Content Writer
goal: Write content
instructions: "Write {{style}} content about the topic."
steps:
- agent: writer
action: "Write about {{topic}}"
"""
parser = YAMLWorkflowParser()
workflow = parser.parse_string(yaml_content)
print(f"Variables: {workflow.variables}")
# Run with default variables
result = workflow.start("")
print(f"Result: {result['output'][:200]}...")
print()
if __name__ == "__main__":
print("YAML Workflow Examples")
print("=" * 60)
print()
# Run examples
example_1_parse_and_run()
example_2_load_yaml_file()
example_3_execute_yaml_directly()
example_4_with_variables()
print("=" * 60)
print("All examples completed!")
print("=" * 60)
@@ -0,0 +1,42 @@
# Loop Workflow
# Demonstrates the loop() pattern for iterating over items
name: Repetitive Processing Workflow
description: An agent processes multiple items in a loop, then a summarizer combines results
workflow:
verbose: true
variables:
topics:
- Artificial Intelligence
- Machine Learning
- Neural Networks
- Natural Language Processing
agents:
processor:
name: Processor
role: Topic Processor
goal: Process and analyze each topic thoroughly
instructions: |
You are a topic processor. For each topic given, provide:
1. A brief definition
2. Key concepts
3. Real-world applications
Keep your response concise but informative.
summarizer:
name: Summarizer
role: Results Summarizer
goal: Summarize all processed results into a final report
instructions: "You are a summarizer. Combine all the processed topics into a cohesive final report."
steps:
- agent: processor
action: "Analyze this topic: {{item}}"
loop:
over: topics
- agent: summarizer
action: "Create a comprehensive summary of all the analyzed topics based on these results: {{previous_output}}"
@@ -0,0 +1,47 @@
# Parallel Workflow
# Demonstrates the parallel() pattern for concurrent execution
name: Parallel Research Workflow
description: Multiple agents research different aspects concurrently, then an aggregator combines results
input: "AI-powered customer service solutions" # The input passed to {{input}} in steps
workflow:
verbose: true
agents:
market_researcher:
name: MarketResearcher
role: Market Research Analyst
goal: Research market trends and opportunities
instructions: "You are a market research analyst. Analyze market trends and provide concise insights."
competitor_researcher:
name: CompetitorResearcher
role: Competitive Intelligence Analyst
goal: Research competitor strategies and positioning
instructions: "You are a competitive intelligence analyst. Analyze competitors and provide key insights."
customer_researcher:
name: CustomerResearcher
role: Customer Research Analyst
goal: Research customer needs and behaviors
instructions: "You are a customer research analyst. Analyze customer segments and provide insights."
aggregator:
name: Aggregator
role: Research Synthesizer
goal: Synthesize all research findings into a comprehensive summary
instructions: "You are a research synthesizer. Combine all research findings into a clear, comprehensive summary."
steps:
- name: parallel_research
parallel:
- agent: market_researcher
action: "Research market trends for: {{input}}"
- agent: competitor_researcher
action: "Research competitors for: {{input}}"
- agent: customer_researcher
action: "Research customer needs for: {{input}}"
- agent: aggregator
action: "Synthesize all the research findings into a comprehensive report: {{previous_output}}"
@@ -0,0 +1,50 @@
# Routing Workflow
# Demonstrates the route() pattern for decision-based branching
name: Agentic Routing Workflow
description: A classifier agent routes requests to specialized handler agents
input: "Write a poem about the ocean" # The input passed to {{input}} in steps
workflow:
verbose: true
agents:
classifier:
name: Classifier
role: Request Classifier
goal: Classify incoming requests by type
instructions: |
You are a request classifier. Analyze the request and respond with ONLY one word:
- 'technical' for technical/coding questions
- 'creative' for creative writing requests
- 'general' for everything else
Respond with ONLY the classification word, nothing else.
tech_expert:
name: TechExpert
role: Technical Expert
goal: Answer technical questions with expertise
instructions: "You are a technical expert. Provide detailed, accurate technical answers."
creative_writer:
name: CreativeWriter
role: Creative Writer
goal: Create engaging creative content
instructions: "You are a creative writer. Write engaging, imaginative content."
general_assistant:
name: GeneralAssistant
role: General Assistant
goal: Help with general requests
instructions: "You are a helpful assistant. Provide clear, helpful responses."
steps:
- agent: classifier
action: "Classify this request: {{input}}"
- name: routing
route:
technical: [tech_expert]
creative: [creative_writer]
default: [general_assistant]
@@ -0,0 +1,29 @@
# Simple Sequential Workflow
# Demonstrates basic agent-to-agent workflow
name: Simple Research Workflow
description: A simple workflow where a researcher gathers info and a writer summarizes it
input: "The benefits of renewable energy" # The input passed to {{input}} in steps
workflow:
verbose: true
agents:
researcher:
name: Researcher
role: Research Analyst
goal: Research and gather information about topics
instructions: "You are a research analyst. Provide concise, factual information about the given topic."
writer:
name: Writer
role: Content Writer
goal: Write clear and engaging summaries
instructions: "You are a content writer. Take the research provided and write a clear, engaging summary."
steps:
- agent: researcher
action: "Research the following topic: {{input}}"
- agent: writer
action: "Write a summary based on this research: {{previous_output}}"
+2
View File
@@ -86,3 +86,5 @@ build
graph.py
chroma_db/
.qodo
*_COMPARISON_ANALYSIS.md
*_REPORT.md
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
../../README.md
@@ -0,0 +1,368 @@
#!/usr/bin/env python3
"""
PraisonAI Agents - Deep Profiling Benchmark
Profiles each step of agent initialization to identify performance bottlenecks.
Key Optimizations Applied:
1. Lazy RulesManager - filesystem operations deferred until first access
2. Lazy os.getcwd() - fast_context_path resolved only when needed
3. Lazy console initialization - Rich Console created on demand
Usage:
python benchmarks/deep_profiling.py
"""
import time
import tracemalloc
import cProfile
import pstats
import io
from typing import Literal, Callable, Dict, List
from dataclasses import dataclass
# ============================================================================
# Timing Utilities
# ============================================================================
@dataclass
class StepTiming:
name: str
duration_us: float # microseconds
memory_kb: float # kilobytes
class StepProfiler:
"""Profile individual steps during initialization"""
def __init__(self):
self.steps: List[StepTiming] = []
self._start_time = None
self._start_memory = None
def start_step(self, name: str):
tracemalloc.start()
self._start_time = time.perf_counter()
self._step_name = name
def end_step(self):
end_time = time.perf_counter()
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
duration_us = (end_time - self._start_time) * 1_000_000
memory_kb = peak / 1024
self.steps.append(StepTiming(
name=self._step_name,
duration_us=duration_us,
memory_kb=memory_kb
))
def print_report(self, title: str):
print(f"\n{'='*70}")
print(f"STEP-BY-STEP PROFILING: {title}")
print(f"{'='*70}")
total_time = sum(s.duration_us for s in self.steps)
total_memory = sum(s.memory_kb for s in self.steps)
print(f"\n{'Step':<40} {'Time (μs)':<15} {'Memory (KB)':<15} {'% Time':<10}")
print("-" * 80)
for step in sorted(self.steps, key=lambda x: x.duration_us, reverse=True):
pct = (step.duration_us / total_time * 100) if total_time > 0 else 0
print(f"{step.name:<40} {step.duration_us:<15.2f} {step.memory_kb:<15.2f} {pct:<10.1f}%")
print("-" * 80)
print(f"{'TOTAL':<40} {total_time:<15.2f} {total_memory:<15.2f} {'100.0':<10}%")
def time_function(func: Callable, iterations: int = 100) -> Dict:
"""Time a function with detailed statistics"""
times = []
# Warmup
for _ in range(10):
func()
# Measure
for _ in range(iterations):
start = time.perf_counter()
func()
end = time.perf_counter()
times.append((end - start) * 1_000_000) # Convert to microseconds
return {
'avg': sum(times) / len(times),
'min': min(times),
'max': max(times),
'median': sorted(times)[len(times)//2]
}
# ============================================================================
# Profile PraisonAI Agent Initialization - Step by Step
# ============================================================================
def profile_praisonai_detailed():
"""Profile each step of PraisonAI Agent initialization"""
print("\n" + "="*70)
print("DETAILED PROFILING: PraisonAI Agent")
print("="*70)
profiler = StepProfiler()
# Step 1: Import
profiler.start_step("1. Import praisonaiagents")
from praisonaiagents import Agent as PraisonAgent
profiler.end_step()
# Step 2: Define tool
profiler.start_step("2. Define tool function")
def get_weather(city: Literal["nyc", "sf"]):
"""Use this to get weather information."""
if city == "nyc":
return "It might be cloudy in nyc"
elif city == "sf":
return "It's always sunny in sf"
tools = [get_weather]
profiler.end_step()
# Step 3: Full instantiation
profiler.start_step("3. Full Agent instantiation")
agent = PraisonAgent(
name="Test Agent",
instructions="Be concise, reply with one sentence.",
llm="gpt-4o-mini",
tools=tools,
verbose=False
)
profiler.end_step()
profiler.print_report("PraisonAI Agent")
# Now profile the __init__ internals using cProfile
print("\n" + "="*70)
print("cProfile ANALYSIS: PraisonAI Agent.__init__")
print("="*70)
pr = cProfile.Profile()
pr.enable()
for _ in range(100):
agent = PraisonAgent(
name="Test Agent",
instructions="Be concise, reply with one sentence.",
llm="gpt-4o-mini",
tools=tools,
verbose=False
)
pr.disable()
# Print top 30 functions by cumulative time
s = io.StringIO()
ps = pstats.Stats(pr, stream=s).sort_stats('cumulative')
ps.print_stats(30)
print(s.getvalue())
return agent
# ============================================================================
# Profile Agno Agent Initialization - Step by Step
# ============================================================================
def profile_agno_detailed():
"""Profile each step of Agno Agent initialization"""
print("\n" + "="*70)
print("DETAILED PROFILING: Agno Agent")
print("="*70)
profiler = StepProfiler()
# Step 1: Import
profiler.start_step("1. Import agno")
from agno.agent import Agent as AgnoAgent
from agno.models.openai import OpenAIChat
profiler.end_step()
# Step 2: Define tool
profiler.start_step("2. Define tool function")
def get_weather(city: Literal["nyc", "sf"]):
"""Use this to get weather information."""
if city == "nyc":
return "It might be cloudy in nyc"
elif city == "sf":
return "It's always sunny in sf"
tools = [get_weather]
profiler.end_step()
# Step 3: Full instantiation
profiler.start_step("3. Full Agent instantiation")
agent = AgnoAgent(model=OpenAIChat(id="gpt-4o-mini"), tools=tools)
profiler.end_step()
profiler.print_report("Agno Agent")
# Now profile the __init__ internals using cProfile
print("\n" + "="*70)
print("cProfile ANALYSIS: Agno Agent.__init__")
print("="*70)
pr = cProfile.Profile()
pr.enable()
for _ in range(100):
agent = AgnoAgent(model=OpenAIChat(id="gpt-4o-mini"), tools=tools)
pr.disable()
# Print top 30 functions by cumulative time
s = io.StringIO()
ps = pstats.Stats(pr, stream=s).sort_stats('cumulative')
ps.print_stats(30)
print(s.getvalue())
return agent
# ============================================================================
# Profile Individual Components of PraisonAI
# ============================================================================
def profile_praisonai_components():
"""Profile individual components of PraisonAI Agent initialization"""
print("\n" + "="*70)
print("COMPONENT-LEVEL PROFILING: PraisonAI Agent")
print("="*70)
from praisonaiagents import Agent as PraisonAgent
def get_weather(city: Literal["nyc", "sf"]):
if city == "nyc":
return "It might be cloudy in nyc"
elif city == "sf":
return "It's always sunny in sf"
tools = [get_weather]
# Test different configurations
configs = [
("Minimal (no tools, no llm)", {"name": "Test", "verbose": False}),
("With name only", {"name": "Test Agent", "verbose": False}),
("With instructions", {"name": "Test", "instructions": "Be concise", "verbose": False}),
("With llm (gpt-4o-mini)", {"name": "Test", "llm": "gpt-4o-mini", "verbose": False}),
("With llm (openai/gpt-4o-mini)", {"name": "Test", "llm": "openai/gpt-4o-mini", "verbose": False}),
("With tools", {"name": "Test", "tools": tools, "verbose": False}),
("Full config (OpenAI SDK)", {"name": "Test", "instructions": "Be concise", "llm": "gpt-4o-mini", "tools": tools, "verbose": False}),
("Full config (LiteLLM)", {"name": "Test", "instructions": "Be concise", "llm": "openai/gpt-4o-mini", "tools": tools, "verbose": False}),
]
print(f"\n{'Configuration':<40} {'Avg Time (μs)':<15} {'Min (μs)':<15} {'Max (μs)':<15}")
print("-" * 85)
for name, config in configs:
def create_agent():
return PraisonAgent(**config)
stats = time_function(create_agent, iterations=100)
print(f"{name:<40} {stats['avg']:<15.2f} {stats['min']:<15.2f} {stats['max']:<15.2f}")
# ============================================================================
# Compare Specific Operations
# ============================================================================
def compare_specific_operations():
"""Compare specific operations between PraisonAI and Agno"""
print("\n" + "="*70)
print("OPERATION COMPARISON: PraisonAI vs Agno")
print("="*70)
from praisonaiagents import Agent as PraisonAgent
from agno.agent import Agent as AgnoAgent
from agno.models.openai import OpenAIChat
def get_weather(city: Literal["nyc", "sf"]):
if city == "nyc":
return "It might be cloudy in nyc"
elif city == "sf":
return "It's always sunny in sf"
tools = [get_weather]
operations = []
# Operation 1: Bare minimum instantiation
def praisonai_minimal():
return PraisonAgent(name="Test", verbose=False)
def agno_minimal():
return AgnoAgent(model=OpenAIChat(id="gpt-4o-mini"))
praison_stats = time_function(praisonai_minimal, 100)
agno_stats = time_function(agno_minimal, 100)
operations.append(("Minimal instantiation", praison_stats['avg'], agno_stats['avg']))
# Operation 2: With tools
def praisonai_with_tools():
return PraisonAgent(name="Test", tools=tools, verbose=False)
def agno_with_tools():
return AgnoAgent(model=OpenAIChat(id="gpt-4o-mini"), tools=tools)
praison_stats = time_function(praisonai_with_tools, 100)
agno_stats = time_function(agno_with_tools, 100)
operations.append(("With tools", praison_stats['avg'], agno_stats['avg']))
# Operation 3: Full config
def praisonai_full():
return PraisonAgent(
name="Test Agent",
instructions="Be concise",
llm="gpt-4o-mini",
tools=tools,
verbose=False
)
def agno_full():
return AgnoAgent(
model=OpenAIChat(id="gpt-4o-mini"),
tools=tools,
instructions=["Be concise"]
)
praison_stats = time_function(praisonai_full, 100)
agno_stats = time_function(agno_full, 100)
operations.append(("Full config", praison_stats['avg'], agno_stats['avg']))
print(f"\n{'Operation':<30} {'PraisonAI (μs)':<18} {'Agno (μs)':<18} {'Ratio':<10}")
print("-" * 76)
for op_name, praison_time, agno_time in operations:
ratio = praison_time / agno_time if agno_time > 0 else 0
print(f"{op_name:<30} {praison_time:<18.2f} {agno_time:<18.2f} {ratio:<10.1f}x")
# ============================================================================
# Main
# ============================================================================
if __name__ == "__main__":
import os
os.environ.setdefault("OPENAI_API_KEY", "sk-test")
print("="*70)
print("PraisonAI Agents - Deep Profiling Benchmark")
print("="*70)
print("\nThis analysis identifies performance bottlenecks in agent initialization.\n")
# Run all profiling
profile_praisonai_detailed()
profile_agno_detailed()
profile_praisonai_components()
compare_specific_operations()
print("\n" + "="*70)
print("PROFILING COMPLETE")
print("="*70)
@@ -0,0 +1,416 @@
"""
PraisonAI Agents - Comprehensive Performance Benchmark
Measures agent instantiation time and memory footprint across
multiple AI agent frameworks using industry-standard methodology.
Usage:
python benchmarks/performance_benchmark.py
"""
import gc
import time
import tracemalloc
import statistics
from typing import Literal, List, Callable
from dataclasses import dataclass, field
@dataclass
class PerformanceResult:
"""Performance measurement results"""
run_times: List[float] = field(default_factory=list)
memory_usages: List[float] = field(default_factory=list)
@property
def avg_run_time(self) -> float:
return statistics.mean(self.run_times) if self.run_times else 0
@property
def avg_memory_usage(self) -> float:
return statistics.mean(self.memory_usages) if self.memory_usages else 0
@property
def min_run_time(self) -> float:
return min(self.run_times) if self.run_times else 0
@property
def max_run_time(self) -> float:
return max(self.run_times) if self.run_times else 0
@property
def std_dev_run_time(self) -> float:
return statistics.stdev(self.run_times) if len(self.run_times) > 1 else 0
@property
def median_run_time(self) -> float:
return statistics.median(self.run_times) if self.run_times else 0
def compute_tracemalloc_baseline(samples: int = 3) -> float:
"""Compute baseline memory usage for tracemalloc"""
def empty_func():
return
results = []
for _ in range(samples):
gc.collect()
tracemalloc.start()
empty_func()
_, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
results.append(peak / 1024 / 1024) # Convert to MiB
return sum(results) / len(results) if results else 0
def measure_time(func: Callable, iterations: int = 1000, warmup: int = 10) -> List[float]:
"""Measure execution time for a function"""
# Warmup runs
for _ in range(warmup):
func()
# Measured runs
times = []
for _ in range(iterations):
start = time.perf_counter()
func()
end = time.perf_counter()
times.append(end - start)
return times
def measure_memory(func: Callable, iterations: int = 1000, warmup: int = 10) -> List[float]:
"""Measure memory usage for a function"""
baseline = compute_tracemalloc_baseline()
# Warmup runs
for _ in range(warmup):
func()
# Measured runs
memory_usages = []
for _ in range(iterations):
gc.collect()
tracemalloc.start()
func()
_, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
peak_mib = peak / 1024 / 1024
adjusted = max(0, peak_mib - baseline)
memory_usages.append(adjusted)
return memory_usages
def run_benchmark(func: Callable, name: str, iterations: int = 1000, warmup: int = 10) -> PerformanceResult:
"""Run full benchmark for a function"""
print(f"\n{'='*60}")
print(f"Benchmarking: {name}")
print(f"{'='*60}")
print(f"Warmup runs: {warmup}")
print(f"Measured iterations: {iterations}")
print("\nMeasuring runtime...")
run_times = measure_time(func, iterations, warmup)
print("Measuring memory...")
memory_usages = measure_memory(func, iterations, warmup)
result = PerformanceResult(run_times=run_times, memory_usages=memory_usages)
print(f"\nResults for {name}:")
print(f" Average Time: {result.avg_run_time:.6f} seconds ({result.avg_run_time * 1_000_000:.2f} μs)")
print(f" Min Time: {result.min_run_time:.6f} seconds")
print(f" Max Time: {result.max_run_time:.6f} seconds")
print(f" Std Dev: {result.std_dev_run_time:.6f} seconds")
print(f" Median Time: {result.median_run_time:.6f} seconds")
print(f" Avg Memory: {result.avg_memory_usage:.6f} MiB ({result.avg_memory_usage * 1024:.2f} KiB)")
return result
# ============================================================================
# Framework Imports
# ============================================================================
from praisonaiagents import Agent as PraisonAgent
from agno.agent import Agent as AgnoAgent
from agno.models.openai import OpenAIChat
# Additional frameworks for comparison
try:
from pydantic_ai import Agent as PydanticAgent
PYDANTIC_AVAILABLE = True
except ImportError:
PYDANTIC_AVAILABLE = False
try:
from crewai.agent import Agent as CrewAgent
from crewai.tools import tool as crewai_tool
CREWAI_AVAILABLE = True
except ImportError:
CREWAI_AVAILABLE = False
try:
from langchain_core.tools import tool as langchain_tool
from langchain_openai import ChatOpenAI
from langgraph.prebuilt import create_react_agent
LANGGRAPH_AVAILABLE = True
except ImportError:
LANGGRAPH_AVAILABLE = False
try:
from agents import Agent as OpenAIAgent, function_tool
OPENAI_AGENTS_AVAILABLE = True
except ImportError:
OPENAI_AGENTS_AVAILABLE = False
def get_weather(city: Literal["nyc", "sf"]):
"""Use this to get weather information."""
if city == "nyc":
return "It might be cloudy in nyc"
elif city == "sf":
return "It's always sunny in sf"
tools = [get_weather]
def instantiate_praisonai_agent():
"""Instantiate a PraisonAI agent with one tool."""
return PraisonAgent(
name="Test Agent",
instructions="Be concise, reply with one sentence.",
llm="gpt-4o-mini",
tools=tools,
verbose=False
)
def instantiate_praisonai_litellm_agent():
"""Instantiate a PraisonAI agent with LiteLLM backend."""
return PraisonAgent(
name="Test Agent",
instructions="Be concise, reply with one sentence.",
llm="openai/gpt-4o-mini",
tools=tools,
verbose=False
)
def instantiate_agno_agent():
"""Instantiate an Agno agent with one tool."""
return AgnoAgent(model=OpenAIChat(id="gpt-4o-mini"), tools=tools)
def instantiate_pydantic_agent():
"""Instantiate a PydanticAI agent with one tool."""
agent = PydanticAgent("openai:gpt-4o-mini", system_prompt="Be concise, reply with one sentence.")
@agent.tool_plain
def get_weather_pydantic(city: Literal["nyc", "sf"]):
"""Use this to get weather information."""
if city == "nyc":
return "It might be cloudy in nyc"
elif city == "sf":
return "It's always sunny in sf"
return agent
def instantiate_openai_agents():
"""Instantiate an OpenAI Agents SDK agent with one tool."""
return OpenAIAgent(
name="Test agent",
instructions="Be concise, reply with one sentence.",
model="gpt-4o-mini",
tools=[function_tool(get_weather)],
)
def instantiate_langgraph_agent():
"""Instantiate a LangGraph agent with one tool."""
@langchain_tool
def get_weather_lg(city: Literal["nyc", "sf"]):
"""Use this to get weather information."""
if city == "nyc":
return "It might be cloudy in nyc"
elif city == "sf":
return "It's always sunny in sf"
return create_react_agent(model=ChatOpenAI(model="gpt-4o-mini"), tools=[get_weather_lg])
def instantiate_crewai_agent():
"""Instantiate a CrewAI agent with one tool."""
@crewai_tool("Weather Tool")
def get_weather_crew(city: Literal["nyc", "sf"]):
"""Use this to get weather information."""
if city == "nyc":
return "It might be cloudy in nyc"
elif city == "sf":
return "It's always sunny in sf"
return CrewAgent(
llm="gpt-4o-mini",
role="Test Agent",
goal="Be concise, reply with one sentence.",
tools=[get_weather_crew],
backstory="Test",
)
if __name__ == "__main__":
print("="*70)
print("PraisonAI Agents - Comprehensive Performance Benchmark")
print("="*70)
print("\nThis benchmark measures:")
print(" 1. Agent instantiation time (how fast an agent object is created)")
print(" 2. Memory footprint (how much memory an agent uses)")
print("\nMethodology:")
print(" - 10 warmup runs (not measured)")
print(" - 1000 measured iterations")
print(" - tracemalloc for memory measurement")
print(" - Baseline subtraction for accuracy")
print("\nFrameworks available:")
print(" - PraisonAI: ✅")
print(f" - Agno: ✅")
print(f" - PydanticAI: {'✅' if PYDANTIC_AVAILABLE else '❌'}")
print(f" - OpenAI Agents SDK: {'✅' if OPENAI_AGENTS_AVAILABLE else '❌'}")
print(f" - LangGraph: {'✅' if LANGGRAPH_AVAILABLE else '❌'}")
print(f" - CrewAI: {'✅' if CREWAI_AVAILABLE else '❌'}")
results = {}
# Test PraisonAI first
try:
print("\n" + "="*70)
print("Testing PraisonAI Agents")
print("="*70)
results['praisonai'] = run_benchmark(
instantiate_praisonai_agent,
"PraisonAI",
iterations=1000,
warmup=10
)
except Exception as e:
print(f"Error testing PraisonAI: {e}")
try:
print("\n" + "="*70)
print("Testing PraisonAI Agents (LiteLLM)")
print("="*70)
results['praisonai_litellm'] = run_benchmark(
instantiate_praisonai_litellm_agent,
"PraisonAI (LiteLLM)",
iterations=1000,
warmup=10
)
except Exception as e:
print(f"Error testing PraisonAI (LiteLLM): {e}")
# Test Agno
try:
print("\n" + "="*70)
print("Testing Agno")
print("="*70)
results['agno'] = run_benchmark(
instantiate_agno_agent,
"Agno",
iterations=1000,
warmup=10
)
except Exception as e:
print(f"Error testing Agno: {e}")
# Test PydanticAI
if PYDANTIC_AVAILABLE:
try:
print("\n" + "="*70)
print("Testing PydanticAI")
print("="*70)
results['pydantic'] = run_benchmark(
instantiate_pydantic_agent,
"PydanticAI Agent Instantiation",
iterations=1000,
warmup=10
)
except Exception as e:
print(f"Error testing PydanticAI: {e}")
# Test OpenAI Agents SDK
if OPENAI_AGENTS_AVAILABLE:
try:
print("\n" + "="*70)
print("Testing OpenAI Agents SDK")
print("="*70)
results['openai_agents'] = run_benchmark(
instantiate_openai_agents,
"OpenAI Agents SDK Instantiation",
iterations=1000,
warmup=10
)
except Exception as e:
print(f"Error testing OpenAI Agents SDK: {e}")
# Test LangGraph
if LANGGRAPH_AVAILABLE:
try:
print("\n" + "="*70)
print("Testing LangGraph")
print("="*70)
results['langgraph'] = run_benchmark(
instantiate_langgraph_agent,
"LangGraph Agent Instantiation",
iterations=1000,
warmup=10
)
except Exception as e:
print(f"Error testing LangGraph: {e}")
# Test CrewAI
if CREWAI_AVAILABLE:
try:
print("\n" + "="*70)
print("Testing CrewAI")
print("="*70)
results['crewai'] = run_benchmark(
instantiate_crewai_agent,
"CrewAI Agent Instantiation",
iterations=1000,
warmup=10
)
except Exception as e:
print(f"Error testing CrewAI: {e}")
# Final Comparison Table
print("\n" + "="*70)
print("RESULTS SUMMARY")
print("="*70)
if 'praisonai' in results:
baseline = results['praisonai']
print(f"\n{'Framework':<25} {'Avg Time (μs)':<15} {'Avg Memory (KiB)':<18} {'Relative':<15}")
print("-" * 73)
# Sort by time
sorted_results = sorted(results.items(), key=lambda x: x[1].avg_run_time)
for name, result in sorted_results:
time_ratio = result.avg_run_time / baseline.avg_run_time if baseline.avg_run_time > 0 else 0
time_str = f"{time_ratio:.2f}x"
display_name = {
'agno': 'Agno',
'praisonai': 'PraisonAI',
'praisonai_litellm': 'PraisonAI (LiteLLM)',
'pydantic': 'PydanticAI',
'openai_agents': 'OpenAI Agents SDK',
'langgraph': 'LangGraph',
'crewai': 'CrewAI'
}.get(name, name)
print(f"{display_name:<25} {result.avg_run_time*1e6:<15.2f} {result.avg_memory_usage*1024:<18.2f} {time_str:<15}")
print("\n" + "="*70)
@@ -0,0 +1,235 @@
"""
PraisonAI Agents - Response Time Benchmark
Measures actual LLM response time with gpt-4o-mini across frameworks.
Usage:
python benchmarks/response_benchmark.py
"""
import time
import statistics
import os
from typing import List, Literal
from dataclasses import dataclass, field
# Ensure we use gpt-4o-mini
os.environ['OPENAI_MODEL_NAME'] = 'gpt-4o-mini'
@dataclass
class ResponseResult:
"""Response time measurement results"""
response_times: List[float] = field(default_factory=list)
@property
def avg_time(self) -> float:
return statistics.mean(self.response_times) if self.response_times else 0
@property
def min_time(self) -> float:
return min(self.response_times) if self.response_times else 0
@property
def max_time(self) -> float:
return max(self.response_times) if self.response_times else 0
@property
def std_dev(self) -> float:
return statistics.stdev(self.response_times) if len(self.response_times) > 1 else 0
def get_weather(city: Literal["nyc", "sf"]):
"""Use this to get weather information."""
if city == "nyc":
return "It might be cloudy in nyc"
elif city == "sf":
return "It's always sunny in sf"
def benchmark_praisonai_response(iterations: int = 5) -> ResponseResult:
"""Benchmark PraisonAI agent response time"""
from praisonaiagents import Agent
result = ResponseResult()
for i in range(iterations):
print(f" PraisonAI iteration {i+1}/{iterations}...")
# Create fresh agent each time
agent = Agent(
name="Test Agent",
instructions="Be concise, reply with one sentence.",
llm="gpt-4o-mini",
verbose=False
)
start = time.perf_counter()
response = agent.start("What is the capital of France?")
end = time.perf_counter()
result.response_times.append(end - start)
print(f" Response time: {end - start:.3f}s")
return result
def benchmark_agno_response(iterations: int = 5) -> ResponseResult:
"""Benchmark Agno agent response time"""
from agno.agent import Agent
from agno.models.openai import OpenAIChat
result = ResponseResult()
for i in range(iterations):
print(f" Agno iteration {i+1}/{iterations}...")
# Create fresh agent each time
agent = Agent(
model=OpenAIChat(id="gpt-4o-mini"),
system_message="Be concise, reply with one sentence.",
)
start = time.perf_counter()
response = agent.run("What is the capital of France?")
end = time.perf_counter()
result.response_times.append(end - start)
print(f" Response time: {end - start:.3f}s")
return result
def benchmark_praisonai_with_tool(iterations: int = 5) -> ResponseResult:
"""Benchmark PraisonAI agent response time with tool calling"""
from praisonaiagents import Agent
result = ResponseResult()
for i in range(iterations):
print(f" PraisonAI (with tool) iteration {i+1}/{iterations}...")
agent = Agent(
name="Weather Agent",
instructions="You are a weather assistant. Use the get_weather tool to answer questions.",
llm="gpt-4o-mini",
tools=[get_weather],
verbose=False
)
start = time.perf_counter()
response = agent.start("What's the weather in NYC?")
end = time.perf_counter()
result.response_times.append(end - start)
print(f" Response time: {end - start:.3f}s")
return result
def benchmark_agno_with_tool(iterations: int = 5) -> ResponseResult:
"""Benchmark Agno agent response time with tool calling"""
from agno.agent import Agent
from agno.models.openai import OpenAIChat
result = ResponseResult()
for i in range(iterations):
print(f" Agno (with tool) iteration {i+1}/{iterations}...")
agent = Agent(
model=OpenAIChat(id="gpt-4o-mini"),
system_message="You are a weather assistant. Use the get_weather tool to answer questions.",
tools=[get_weather],
)
start = time.perf_counter()
response = agent.run("What's the weather in NYC?")
end = time.perf_counter()
result.response_times.append(end - start)
print(f" Response time: {end - start:.3f}s")
return result
if __name__ == "__main__":
print("="*70)
print("PraisonAI Agents - Response Time Benchmark")
print("="*70)
print("\nModel: gpt-4o-mini")
print("Measures end-to-end response time including:")
print(" - Agent instantiation")
print(" - API call to OpenAI")
print(" - Response processing")
iterations = 5
results = {}
# Test 1: Simple response (no tools)
print("\n" + "="*70)
print("TEST 1: Simple Response (No Tools)")
print("="*70)
print("\nBenchmarking PraisonAI...")
try:
results['praisonai_simple'] = benchmark_praisonai_response(iterations)
except Exception as e:
print(f"Error: {e}")
import traceback
traceback.print_exc()
print("\nBenchmarking Agno...")
try:
results['agno_simple'] = benchmark_agno_response(iterations)
except Exception as e:
print(f"Error: {e}")
import traceback
traceback.print_exc()
# Test 2: Response with tool calling
print("\n" + "="*70)
print("TEST 2: Response with Tool Calling")
print("="*70)
print("\nBenchmarking PraisonAI with tool...")
try:
results['praisonai_tool'] = benchmark_praisonai_with_tool(iterations)
except Exception as e:
print(f"Error: {e}")
import traceback
traceback.print_exc()
print("\nBenchmarking Agno with tool...")
try:
results['agno_tool'] = benchmark_agno_with_tool(iterations)
except Exception as e:
print(f"Error: {e}")
import traceback
traceback.print_exc()
# Summary
print("\n" + "="*70)
print("SUMMARY RESULTS")
print("="*70)
print(f"\n{'Test':<30} {'PraisonAI (avg)':<20} {'Agno (avg)':<20} {'Ratio':<15}")
print("-" * 85)
if 'praisonai_simple' in results and 'agno_simple' in results:
p_time = results['praisonai_simple'].avg_time
a_time = results['agno_simple'].avg_time
ratio = p_time / a_time if a_time > 0 else float('inf')
print(f"{'Simple Response':<30} {p_time:<20.3f}s {a_time:<20.3f}s {ratio:.2f}x")
if 'praisonai_tool' in results and 'agno_tool' in results:
p_time = results['praisonai_tool'].avg_time
a_time = results['agno_tool'].avg_time
ratio = p_time / a_time if a_time > 0 else float('inf')
print(f"{'With Tool Calling':<30} {p_time:<20.3f}s {a_time:<20.3f}s {ratio:.2f}x")
print("\n" + "="*70)
print("NOTE")
print("="*70)
print("\nResponse time is dominated by OpenAI API latency.")
print("Agent instantiation overhead is negligible in real-world usage.")
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""
PraisonAI Agents - Performance Benchmark
Compares agent instantiation times across popular AI agent frameworks.
Usage:
python benchmarks/simple_benchmark.py
"""
import time
from typing import Literal
ITERATIONS = 100
def sample_tool(city: Literal['nyc', 'sf']):
"""Sample tool for benchmark testing."""
if city == 'nyc':
return 'cloudy'
return 'sunny'
def measure_instantiation(create_fn, iterations=ITERATIONS):
"""Measure average instantiation time in microseconds."""
times = []
for _ in range(iterations):
start = time.perf_counter()
create_fn()
times.append((time.perf_counter() - start) * 1_000_000)
return sum(times) / len(times)
def run_benchmark():
"""Run the benchmark across all available frameworks."""
tools = [sample_tool]
results = {}
print('=' * 60)
print('PraisonAI Agents - Performance Benchmark')
print('=' * 60)
print(f'\nIterations: {ITERATIONS}')
print('Metric: Agent instantiation time (microseconds)\n')
# PraisonAI
print("Testing PraisonAI...")
from praisonaiagents import Agent as PraisonAgent
results['PraisonAI'] = measure_instantiation(
lambda: PraisonAgent(name='Test', llm='gpt-4o-mini', tools=tools, verbose=False)
)
results['PraisonAI (LiteLLM)'] = measure_instantiation(
lambda: PraisonAgent(name='Test', llm='openai/gpt-4o-mini', tools=tools, verbose=False)
)
# Other frameworks for comparison
print("Testing other frameworks...")
try:
from agno.agent import Agent as AgnoAgent
from agno.models.openai import OpenAIChat
results['Agno'] = measure_instantiation(
lambda: AgnoAgent(model=OpenAIChat(id='gpt-4o-mini'), tools=tools)
)
except ImportError:
pass
try:
from pydantic_ai import Agent as PydanticAgent
results['PydanticAI'] = measure_instantiation(
lambda: PydanticAgent('openai:gpt-4o-mini')
)
except ImportError:
pass
try:
from agents import Agent as OpenAIAgent
results['OpenAI Agents SDK'] = measure_instantiation(
lambda: OpenAIAgent(name='Test', model='gpt-4o-mini')
)
except ImportError:
pass
# Print results
print('\n' + '=' * 60)
print('RESULTS')
print('=' * 60)
baseline = results.get('PraisonAI', 1)
print(f"\n{'Framework':<25} {'Avg Time (μs)':<15} {'Relative':<10}")
print('-' * 50)
for name, avg in sorted(results.items(), key=lambda x: x[1]):
ratio = avg / baseline
print(f'{name:<25} {avg:<15.2f} {ratio:.2f}x')
print('\n' + '=' * 60)
return results
if __name__ == '__main__':
run_benchmark()
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,9 @@
Starting test with verbose=False
CALLBACK EXECUTED!
Message:
You need to do the following task: Say hello world.
Expected Output: A greeting.
Please provide only the final result of your work. Do not add any conversation or extra explanation.
Response: Hello, World!
Other kwargs: {'markdown': True, 'generation_time': 2.685833215713501, 'agent_name': 'TestAgent', 'agent_role': 'Assistant', 'agent_tools': None, 'task_name': 'simple_task', 'task_description': 'Say hello world', 'task_id': 0}
--------------------------------------------------
@@ -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()
@@ -54,8 +54,32 @@ except ImportError:
MCP = None
from .session import Session
from .memory.memory import Memory
# Workflows - import from dedicated workflows module
from .workflows import (
Workflow, WorkflowStep, WorkflowContext, StepResult,
Route, Parallel, Loop, Repeat,
route, parallel, loop, repeat,
Pipeline # Alias for Workflow
)
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 +297,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,9 +546,14 @@ 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)
# NOTE: Lazy initialization - rules are loaded only when accessed (performance optimization)
self._rules_manager = None
self._init_rules_manager()
self._rules_manager_initialized = False
# Handle web_search fallback: inject DuckDuckGo tool for unsupported models
if web_search and not self._model_supports_web_search():
@@ -585,6 +598,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 # Store raw, resolve lazily
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."""
@@ -593,6 +615,17 @@ Your Goal: {self.goal}
self._console = Console()
return self._console
@property
def fast_context_path(self):
"""Lazily resolve fast_context_path - avoids os.getcwd() on every init."""
if self._fast_context_path is None:
self._fast_context_path = os.getcwd()
return self._fast_context_path
@fast_context_path.setter
def fast_context_path(self, value):
self._fast_context_path = value
@property
def _openai_client(self):
"""Lazily initialize OpenAI client only when needed."""
@@ -713,6 +746,72 @@ 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
@property
def rules_manager(self):
"""
Lazy-initialized RulesManager for persistent rules/instructions.
This property initializes the RulesManager only when first accessed,
avoiding expensive filesystem operations during agent instantiation.
Returns:
RulesManager instance or None if not available
"""
if not self._rules_manager_initialized:
self._init_rules_manager()
return self._rules_manager
def _init_rules_manager(self):
"""
Initialize RulesManager for persistent rules/instructions.
@@ -721,7 +820,10 @@ Your Goal: {self.goal}
- ~/.praison/rules/ (global)
- .praison/rules/ (workspace)
- Subdirectory rules
NOTE: This is called lazily via the rules_manager property for performance.
"""
self._rules_manager_initialized = True
try:
from ..memory.rules_manager import RulesManager
import os
@@ -756,10 +858,10 @@ Your Goal: {self.goal}
Returns:
Formatted rules context string
"""
if not self._rules_manager:
if not self.rules_manager:
return ""
return self._rules_manager.build_rules_context(
return self.rules_manager.build_rules_context(
file_path=file_path,
include_manual=include_manual
)
@@ -1167,8 +1269,8 @@ Your Goal: {self.goal}
Your Role: {self.role}\n
Your Goal: {self.goal}"""
# Add rules context if rules manager is enabled
if self._rules_manager:
# Add rules context if rules manager is enabled (lazy initialization)
if self._rules_manager_initialized and self._rules_manager:
rules_context = self.get_rules_context()
if rules_context:
system_prompt += f"\n\n## Rules (Guidelines you must follow)\n{rules_context}"
@@ -1467,23 +1569,54 @@ Your Goal: {self.goal}"""
# Special handling for MCP tools
# Check if tools is an MCP instance with the requested function name
from ..mcp.mcp import MCP
# Helper function to execute MCP tool
def _execute_mcp_tool(mcp_instance, func_name, args):
"""Execute a tool from an MCP instance."""
# Handle SSE MCP client
if hasattr(mcp_instance, 'is_sse') and mcp_instance.is_sse:
if hasattr(mcp_instance, 'sse_client'):
for tool in mcp_instance.sse_client.tools:
if tool.name == func_name:
logging.debug(f"Found matching SSE MCP tool: {func_name}")
return True, tool(**args)
# Handle HTTP Stream MCP client
if hasattr(mcp_instance, 'is_http_stream') and mcp_instance.is_http_stream:
if hasattr(mcp_instance, 'http_stream_client'):
for tool in mcp_instance.http_stream_client.tools:
if tool.name == func_name:
logging.debug(f"Found matching HTTP Stream MCP tool: {func_name}")
return True, tool(**args)
# Handle WebSocket MCP client
if hasattr(mcp_instance, 'is_websocket') and mcp_instance.is_websocket:
if hasattr(mcp_instance, 'websocket_client'):
for tool in mcp_instance.websocket_client.tools:
if tool.name == func_name:
logging.debug(f"Found matching WebSocket MCP tool: {func_name}")
return True, tool(**args)
# Handle stdio MCP client
if hasattr(mcp_instance, 'runner'):
for mcp_tool in mcp_instance.runner.tools:
if hasattr(mcp_tool, 'name') and mcp_tool.name == func_name:
logging.debug(f"Found matching MCP tool: {func_name}")
return True, mcp_instance.runner.call_tool(func_name, args)
return False, None
# Check if tools is a single MCP instance
if isinstance(self.tools, MCP):
logging.debug(f"Looking for MCP tool {function_name}")
# Handle SSE MCP client
if hasattr(self.tools, 'is_sse') and self.tools.is_sse:
if hasattr(self.tools, 'sse_client'):
for tool in self.tools.sse_client.tools:
if tool.name == function_name:
logging.debug(f"Found matching SSE MCP tool: {function_name}")
return tool(**arguments)
# Handle stdio MCP client
elif hasattr(self.tools, 'runner'):
# Check if any of the MCP tools match the function name
for mcp_tool in self.tools.runner.tools:
if hasattr(mcp_tool, 'name') and mcp_tool.name == function_name:
logging.debug(f"Found matching MCP tool: {function_name}")
return self.tools.runner.call_tool(function_name, arguments)
found, result = _execute_mcp_tool(self.tools, function_name, arguments)
if found:
return result
# Check if tools is a list that may contain MCP instances
if isinstance(self.tools, (list, tuple)):
for tool in self.tools:
if isinstance(tool, MCP):
logging.debug(f"Looking for MCP tool {function_name} in MCP instance")
found, result = _execute_mcp_tool(tool, function_name, arguments)
if found:
return result
# Try to find the function in the agent's tools list first
func = None
@@ -1798,7 +1931,8 @@ Your Goal: {self.goal}"""
if tool_param is not None:
from ..mcp.mcp import MCP
if isinstance(tool_param, MCP) and hasattr(tool_param, 'to_openai_tool'):
logging.debug("Converting MCP tool to OpenAI format")
# Single MCP instance
logging.debug("Converting single MCP tool to OpenAI format")
openai_tool = tool_param.to_openai_tool()
if openai_tool:
# Handle both single tool and list of tools
@@ -1807,6 +1941,22 @@ Your Goal: {self.goal}"""
else:
tool_param = [openai_tool]
logging.debug(f"Converted MCP tool: {tool_param}")
elif isinstance(tool_param, (list, tuple)):
# List that may contain MCP instances - convert each MCP to OpenAI format
converted_tools = []
for t in tool_param:
if isinstance(t, MCP) and hasattr(t, 'to_openai_tool'):
logging.debug("Converting MCP instance in list to OpenAI format")
openai_tools = t.to_openai_tool()
if isinstance(openai_tools, list):
converted_tools.extend(openai_tools)
elif openai_tools:
converted_tools.append(openai_tools)
else:
# Keep non-MCP tools as-is
converted_tools.append(t)
tool_param = converted_tools
logging.debug(f"Converted {len(converted_tools)} tools from list")
# Store chat history length for potential rollback
chat_history_length = len(self.chat_history)
@@ -2561,10 +2711,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 +2722,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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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}")
Loaded 100 of 202 files, more files were not shown because too many files have changed in this diff. Show more