mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-09 03:39:37 -10:00
Compare commits
156
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ac0d883cf | ||
|
|
78af42457c | ||
|
|
0accebb2e3 | ||
|
|
757559f67f | ||
|
|
43a5d097dd | ||
|
|
18b4914de2 | ||
|
|
4675577a3f | ||
|
|
426ecfc90a | ||
|
|
c9b8685d27 | ||
|
|
7ea4b9ad13 | ||
|
|
defa325754 | ||
|
|
64d048c897 | ||
|
|
2806f47093 | ||
|
|
8db093245e | ||
|
|
78523f8911 | ||
|
|
51b8761303 | ||
|
|
c8144d6532 | ||
|
|
9327462a89 | ||
|
|
24f1945c70 | ||
|
|
9ac9dc5aed | ||
|
|
fde17acdc8 | ||
|
|
5ed5f1a6a9 | ||
|
|
cc1853f0dc | ||
|
|
147aa701dd | ||
|
|
6496455c45 | ||
|
|
239660715a | ||
|
|
8907022b3a | ||
|
|
bb0d1968ad | ||
|
|
2e7ffe7338 | ||
|
|
a2b44dc100 | ||
|
|
ca4ee2e42b | ||
|
|
40f8d0d62c | ||
|
|
f74012e374 | ||
|
|
1145252406 | ||
|
|
f74ee5d619 | ||
|
|
fe30293e85 | ||
|
|
6865574255 | ||
|
|
b4e3a8a84a | ||
|
|
0889f800c1 | ||
|
|
e84d2b18da | ||
|
|
dfbc68f92f | ||
|
|
4b806f8eca | ||
|
|
f00763937b | ||
|
|
d0523d5b64 | ||
|
|
dd41a3c426 | ||
|
|
e87cc650dc | ||
|
|
3ac10ab851 | ||
|
|
2ecc383e6e | ||
|
|
35761ea88e | ||
|
|
e122d7d227 | ||
|
|
2284535f65 | ||
|
|
eb2cb40b30 | ||
|
|
459688d32d | ||
|
|
b7f3e55669 | ||
|
|
1cd48fde4e | ||
|
|
584d85f305 | ||
|
|
42807f1740 | ||
|
|
0d40fa93aa | ||
|
|
75439026e7 | ||
|
|
616532d4ed | ||
|
|
6693274671 | ||
|
|
142db6c5d5 | ||
|
|
a2d915f6ab | ||
|
|
e40269d194 | ||
|
|
d2ce315ce1 | ||
|
|
f069930d67 | ||
|
|
b58a85a669 | ||
|
|
6d60068661 | ||
|
|
5fa2a7fc47 | ||
|
|
fbdcb92ff2 | ||
|
|
18da39991a | ||
|
|
1c32d811f7 | ||
|
|
27de2635eb | ||
|
|
d6bdc7e0de | ||
|
|
92c6a13cac | ||
|
|
5882eb0ffc | ||
|
|
7bcba30b14 | ||
|
|
ce3c3c8d59 | ||
|
|
4dd917e303 | ||
|
|
16c08ba682 | ||
|
|
9b7f458b35 | ||
|
|
83f7babd89 | ||
|
|
0d65b5aaf9 | ||
|
|
ab90f57c8e | ||
|
|
6fbf238628 | ||
|
|
22e1e0bdb0 | ||
|
|
cc5683c5c4 | ||
|
|
d4e82d3909 | ||
|
|
35146cd1c8 | ||
|
|
3ad181950c | ||
|
|
78d07eb09f | ||
|
|
09970cd348 | ||
|
|
387b81d324 | ||
|
|
ae1cf98fc9 | ||
|
|
52354bf3f4 | ||
|
|
5bd317178a | ||
|
|
94bc2ce611 | ||
|
|
55287d4cc3 | ||
|
|
a8b72b046f | ||
|
|
69fce71ad5 | ||
|
|
e4e2657f17 | ||
|
|
56443bf3f8 | ||
|
|
258ab1cd20 | ||
|
|
63248a3a46 | ||
|
|
331b47013e | ||
|
|
162c10c16f | ||
|
|
cdefa98c09 | ||
|
|
5b2d924121 | ||
|
|
9fcf1b4acd | ||
|
|
7bec5453d0 | ||
|
|
7c87046753 | ||
|
|
81ea4d7706 | ||
|
|
f1d53a409b | ||
|
|
384bb06210 | ||
|
|
2dd1a80fde | ||
|
|
d4a1b6e64b | ||
|
|
09e85a24d2 | ||
|
|
5990367b3d | ||
|
|
01a20e307c | ||
|
|
ed3a689f8c | ||
|
|
7313b64544 | ||
|
|
dacb1dc42c | ||
|
|
74241c1459 | ||
|
|
b1048da6a5 | ||
|
|
ffc8cb1bbc | ||
|
|
eceaa96141 | ||
|
|
e632aadd42 | ||
|
|
bbcc505693 | ||
|
|
cc0544a4e3 | ||
|
|
b8455205f6 | ||
|
|
a10af4e839 | ||
|
|
ff37cbcf6f | ||
|
|
a42c6d325e | ||
|
|
724e4af963 | ||
|
|
7cc9718c41 | ||
|
|
72e477f4a3 | ||
|
|
ecfd858269 | ||
|
|
92c6690ab2 | ||
|
|
efb92fdd2e | ||
|
|
9c2f14e042 | ||
|
|
8b0567262d | ||
|
|
d80bff2d9a | ||
|
|
3e29936e86 | ||
|
|
34c6423dbc | ||
|
|
6b4e1b3590 | ||
|
|
7eb01489fd | ||
|
|
731e222a53 | ||
|
|
df50822ff6 | ||
|
|
7480c4389a | ||
|
|
494cd8b44a | ||
|
|
2e733e16d7 | ||
|
|
97141c75bd | ||
|
|
c660495911 | ||
|
|
0cb35a4fe3 | ||
|
|
3622b1a3d9 | ||
|
|
20d418e1de |
No files matched your search
@@ -70,6 +70,8 @@ runs:
|
||||
|
||||
- name: Install GitHub MCP Server
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_GITHUB_TOKEN: ${{ inputs.github_token }}
|
||||
run: |
|
||||
claude mcp add-json github '{
|
||||
"command": "docker",
|
||||
@@ -82,7 +84,7 @@ runs:
|
||||
"ghcr.io/github/github-mcp-server:sha-ff3036d"
|
||||
],
|
||||
"env": {
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "${{ inputs.github_token }}"
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "'"${INPUT_GITHUB_TOKEN}"'"
|
||||
}
|
||||
}'
|
||||
|
||||
@@ -207,21 +209,44 @@ runs:
|
||||
User Request:
|
||||
$USER_REQUEST
|
||||
|
||||
Please analyze the request and provide helpful assistance. You have access to repository tools and can help with:
|
||||
- Code analysis and implementation
|
||||
- GitHub workflows and automation
|
||||
- Pull request reviews and feedback
|
||||
- Issue resolution and bug fixes
|
||||
- Documentation updates
|
||||
- Testing and deployment
|
||||
|
||||
Please analyze the request and provide helpful assistance.
|
||||
|
||||
CRITICAL INSTRUCTION: If your task involves modifying codebase files to add a feature or fix a bug:
|
||||
1. ALWAYS create a new git branch appropriately named (e.g. \`feature/issue-<number>\`).
|
||||
2. MANDATORY: Before writing any code, you MUST carefully read "src/praisonai-agents/AGENTS.md" to understand this repository's extremely specific Architecture, Protocol-First design rules, and Agent-Centric Philosophy.
|
||||
3. Make your necessary code changes honoring the rules in AGENTS.md.
|
||||
4. Commit your changes and push the branch to the remote repository.
|
||||
5. Use the GitHub CLI (\`gh pr create --title "..." --body "Fixes #<number>"\`) to open a Pull Request containing your work.
|
||||
|
||||
|
||||
STEP 0 — SETUP GIT IDENTITY:
|
||||
git config user.name "MervinPraison"
|
||||
git config user.email "[email protected]"
|
||||
|
||||
STEP 1 — READ GUIDELINES:
|
||||
MANDATORY: Before writing any code, you MUST carefully read "src/praisonai-agents/AGENTS.md" to understand this repository's extremely specific Architecture, Protocol-First design rules, and Agent-Centric Philosophy.
|
||||
|
||||
STEP 2 — ARCHITECTURE VALIDATION & ROUTING (MANDATORY before writing code):
|
||||
Before implementing anything, answer these questions:
|
||||
- CORE vs WRAPPER vs TOOLS ROUTING:
|
||||
1. Core SDK (praisonaiagents/): Only core protocols, base classes, decorators. No heavy implementations.
|
||||
2. Wrapper (praisonai/): CLI, heavy implementations, optional dependencies.
|
||||
3. Tools (PraisonAI-Tools): Specific integrations, external tools (e.g. SurrealDB, Slack), and community extensions MUST go to the `MervinPraison/PraisonAI-Tools` repository. If the feature is a tool, clone `https://github.com/MervinPraison/PraisonAI-Tools`, implement it there, and create the PR using `gh pr create -R MervinPraison/PraisonAI-Tools`.
|
||||
- Does it duplicate existing functionality? Check if Agent already supports this via existing params (reflection, planning, tools, hooks, memory).
|
||||
- Does it inherit from Agent properly? New agent types MUST inherit Agent, not wrap it with composition.
|
||||
- Does it add new dependencies? Only optional deps allowed, must be lazy-imported.
|
||||
- Will agent.py grow larger? If the change adds >50 lines to agent.py, find a way to extract instead.
|
||||
- Is there a name collision with existing exports in __init__.py?
|
||||
If ANY of these conceptual checks fail (excluding routing), add a comment to the issue explaining why and close it. Do NOT create a PR.
|
||||
|
||||
STEP 3 — IMPLEMENT:
|
||||
- Create a new git branch appropriately named (e.g. \`feature/issue-<number>\`)
|
||||
- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper
|
||||
- Keep changes small and backward-compatible
|
||||
|
||||
STEP 4 — TEST:
|
||||
- Run: cd src/praisonai-agents && PYTHONPATH=. python -m pytest tests/ -x -q --timeout=30
|
||||
- Ensure no regressions
|
||||
|
||||
STEP 5 — CREATE PR:
|
||||
- Commit with descriptive message, push the branch
|
||||
- Use the GitHub CLI (\`gh pr create --title "..." --body "Fixes #<number>"\`) to open a Pull Request
|
||||
CRITICAL: You MUST create the PR automatically using \`gh pr create\`. Do NOT just provide a link.
|
||||
|
||||
Respond naturally and helpfully to the user's request using the available tools.
|
||||
EOF
|
||||
|
||||
@@ -231,6 +256,14 @@ runs:
|
||||
- name: Run Claude Code
|
||||
if: env.SKIP_EXECUTION != 'true'
|
||||
shell: bash
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ inputs.claude_code_oauth_token }}
|
||||
GITHUB_TOKEN: ${{ inputs.github_token }}
|
||||
INPUT_MAX_TURNS: ${{ inputs.max_turns }}
|
||||
INPUT_ALLOWED_TOOLS: ${{ inputs.allowed_tools }}
|
||||
INPUT_DISALLOWED_TOOLS: ${{ inputs.disallowed_tools }}
|
||||
INPUT_TIMEOUT_MINUTES: ${{ inputs.timeout_minutes }}
|
||||
run: |
|
||||
echo "🚀 Running Claude Code with GitHub context..."
|
||||
|
||||
@@ -238,30 +271,26 @@ runs:
|
||||
CMD_ARGS=("-p" "--verbose" "--output-format" "stream-json")
|
||||
|
||||
# Add max turns if specified
|
||||
if [[ -n "${{ inputs.max_turns }}" ]]; then
|
||||
CMD_ARGS+=("--max-turns" "${{ inputs.max_turns }}")
|
||||
if [[ -n "$INPUT_MAX_TURNS" ]]; then
|
||||
CMD_ARGS+=("--max-turns" "$INPUT_MAX_TURNS")
|
||||
fi
|
||||
|
||||
# Add allowed tools (include GitHub tools by default)
|
||||
TOOLS="mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues,mcp__github__create_comment,Read,Write,Edit,Bash"
|
||||
if [[ -n "${{ inputs.allowed_tools }}" ]]; then
|
||||
TOOLS="$TOOLS,${{ inputs.allowed_tools }}"
|
||||
if [[ -n "$INPUT_ALLOWED_TOOLS" ]]; then
|
||||
TOOLS="$TOOLS,$INPUT_ALLOWED_TOOLS"
|
||||
fi
|
||||
CMD_ARGS+=("--allowedTools" "$TOOLS")
|
||||
|
||||
# Add disallowed tools
|
||||
if [[ -n "${{ inputs.disallowed_tools }}" ]]; then
|
||||
CMD_ARGS+=("--disallowedTools" "${{ inputs.disallowed_tools }}")
|
||||
if [[ -n "$INPUT_DISALLOWED_TOOLS" ]]; then
|
||||
CMD_ARGS+=("--disallowedTools" "$INPUT_DISALLOWED_TOOLS")
|
||||
fi
|
||||
|
||||
echo "📝 Executing Claude Code with prompt from file..."
|
||||
|
||||
# Execute Claude Code with timeout, using stdin for the prompt
|
||||
TIMEOUT_SECONDS=$((${{ inputs.timeout_minutes }} * 60))
|
||||
timeout $TIMEOUT_SECONDS claude "${CMD_ARGS[@]}" < "${{ env.PROMPT_FILE }}"
|
||||
TIMEOUT_SECONDS=$(($INPUT_TIMEOUT_MINUTES * 60))
|
||||
timeout $TIMEOUT_SECONDS claude "${CMD_ARGS[@]}" < "$PROMPT_FILE"
|
||||
|
||||
echo "✅ Claude Code execution completed"
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ inputs.claude_code_oauth_token }}
|
||||
GITHUB_TOKEN: ${{ inputs.github_token }}
|
||||
echo "✅ Claude Code execution completed"
|
||||
@@ -57,13 +57,15 @@ runs:
|
||||
|
||||
- name: Install Gemini CLI
|
||||
id: install
|
||||
env:
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
if [[ "${{ inputs.version }}" == "latest" || "${{ inputs.version }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9\.-]+)?(\+[a-zA-Z0-9\.-]+)?$ ]]; then
|
||||
echo "Installing Gemini CLI from npm: @google/gemini-cli@${{ inputs.version }}"
|
||||
npm install -g @google/gemini-cli@${{ inputs.version }}
|
||||
if [[ "$INPUT_VERSION" == "latest" || "$INPUT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9\.-]+)?(\+[a-zA-Z0-9\.-]+)?$ ]]; then
|
||||
echo "Installing Gemini CLI from npm: @google/gemini-cli@$INPUT_VERSION"
|
||||
npm install -g @google/gemini-cli@$INPUT_VERSION
|
||||
else
|
||||
echo "Installing Gemini CLI from GitHub: github:google-gemini/gemini-cli#${{ inputs.version }}"
|
||||
npm install -g github:google-gemini/gemini-cli#${{ inputs.version }}
|
||||
echo "Installing Gemini CLI from GitHub: github:google-gemini/gemini-cli#$INPUT_VERSION"
|
||||
npm install -g github:google-gemini/gemini-cli#$INPUT_VERSION
|
||||
fi
|
||||
echo "Verifying installation:"
|
||||
if command -v gemini >/dev/null 2>&1; then
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
framework: "praisonai"
|
||||
topic: "Autonomous Issue Resolution and PR Creation"
|
||||
planning: true
|
||||
|
||||
roles:
|
||||
issue_analyst:
|
||||
role: "GitHub Issue Analyst"
|
||||
goal: "Read the GitHub issue, understand the required changes, and map the problem to the local codebase"
|
||||
backstory: "You are an expert software analyst who reads bug reports and feature requests. You excel at taking a problem description and finding exactly which files in the codebase need to be modified. Your primary job is discovery."
|
||||
tools:
|
||||
- "execute_command"
|
||||
|
||||
software_engineer:
|
||||
role: "Autonomous Software Engineer"
|
||||
goal: "Checkout a new branch, rewrite the code to fix the issue, and ensure the changes are correct locally"
|
||||
backstory: "You are a senior software engineer who writes flawless code. You branch off main, use terminal commands to rewrite files using sed, echo, or other secure tools, and verify that your changes perfectly address the analyst's findings."
|
||||
tools:
|
||||
- "execute_command"
|
||||
- "github_create_branch"
|
||||
|
||||
release_manager:
|
||||
role: "Release and CI Manager"
|
||||
goal: "Commit the changes, push the branch to remote, and create the Pull Request linking back to the issue"
|
||||
backstory: "You are a DevOps Release Engineer. Your job is strictly to ensure that any code changes are properly committed, pushed to the GitHub repository, and framed as a clean Pull Request that automatically links to the issue."
|
||||
tools:
|
||||
- "github_create_pull_request"
|
||||
- "github_create_branch"
|
||||
- "github_commit_and_push"
|
||||
- "execute_command"
|
||||
|
||||
steps:
|
||||
- name: analyze_issue
|
||||
agent: issue_analyst
|
||||
action: |
|
||||
Thoroughly analyze the Issue and the repository:
|
||||
0. CRITICAL: Your wrapper has already pre-fetched the complete issue description and Pull Request diff securely.
|
||||
1. Read the pre-fetched context natively using `execute_command` with the `command` argument set to `cat .github/triage-context.md`. Do NOT provide a `cwd` argument.
|
||||
2. Identify the exact problem or feature request.
|
||||
3. Use tools like `grep`, `find`, or `cat` to explore the codebase and find the specific files that need modification.
|
||||
4. Create a specific, file-level plan of what needs to change.
|
||||
expected_output: "A detailed analysis report including the files that must be changed and exactly what lines or logic must be altered to solve the Issue."
|
||||
|
||||
- name: implement_fix
|
||||
agent: software_engineer
|
||||
action: |
|
||||
Implement the changes required to solve the issue:
|
||||
1. Review the analyst's plan.
|
||||
2. Check out a dedicated fix branch by using the `github_create_branch` tool with `branch_name` set to `praisonai/issue-{{ISSUE_NUMBER}}`.
|
||||
3. Carefully modify the necessary files in the repository using bash commands (e.g. `cat`, `sed`, `echo`, or small inline scripts).
|
||||
4. Double-check your edits by inspecting the file outputs or running `git diff` to ensure you changed what was required.
|
||||
expected_output: "Confirmation that the git branch was successfully created and the files were reliably modified without syntax errors."
|
||||
|
||||
- name: create_pull_request
|
||||
agent: release_manager
|
||||
action: |
|
||||
Push the code and submit the PR:
|
||||
1. Use the native `github_commit_and_push` tool and provide a commit message like "PraisonAI Automated Fix".
|
||||
2. Use the native `github_create_pull_request` tool with the arguments:
|
||||
- title: "Fix Issue {{ISSUE_NUMBER}}"
|
||||
- body: "Automated triage by PraisonAI Native Issue Triage."
|
||||
- head_branch: "praisonai/issue-{{ISSUE_NUMBER}}"
|
||||
- base_branch: "main"
|
||||
3. Post a comment on the original issue letting the user know: `execute_command`: `gh issue comment {{ISSUE_NUMBER}} -b "I have autonomously analyzed the codebase and written a fix for this issue! I've opened a Pull Request for your review."`
|
||||
expected_output: "The URL of the successfully created Pull Request."
|
||||
dependencies: [analyze_issue, implement_fix]
|
||||
@@ -0,0 +1,116 @@
|
||||
framework: "praisonai"
|
||||
topic: "Pull Request Code Review Analysis"
|
||||
|
||||
roles:
|
||||
security_reviewer:
|
||||
role: "Security Code Reviewer"
|
||||
goal: "Identify security vulnerabilities, authentication issues, input validation problems, and unsafe code practices in the PR changes"
|
||||
backstory: "You are a cybersecurity expert specializing in code analysis. You have extensive experience in identifying common security flaws like injection attacks, authentication bypasses, exposed secrets, and unsafe data handling patterns."
|
||||
tools:
|
||||
- "execute_command"
|
||||
|
||||
performance_reviewer:
|
||||
role: "Performance Code Reviewer"
|
||||
goal: "Analyze code changes for performance implications, identify bottlenecks, inefficient algorithms, and resource usage issues"
|
||||
backstory: "You are a performance optimization specialist with deep knowledge of algorithm efficiency, memory management, and system performance. You excel at spotting code that could cause performance degradation."
|
||||
tools:
|
||||
- "execute_command"
|
||||
|
||||
maintainability_reviewer:
|
||||
role: "Code Quality & Maintainability Reviewer"
|
||||
goal: "Evaluate code structure, readability, documentation, naming conventions, and adherence to best practices"
|
||||
backstory: "You are a software engineering expert focused on code quality and maintainability. You ensure code follows established patterns, is well-documented, and will be easy for future developers to understand and modify."
|
||||
tools:
|
||||
- "execute_command"
|
||||
|
||||
lead_reviewer:
|
||||
role: "Lead Technical Reviewer"
|
||||
goal: "Synthesize all review feedback, make final recommendations, and post comprehensive review comments to the GitHub PR"
|
||||
backstory: "You are a senior technical lead responsible for final review decisions. You coordinate input from security, performance, and maintainability reviewers to provide balanced, actionable feedback to the development team."
|
||||
tools:
|
||||
- "execute_command"
|
||||
|
||||
steps:
|
||||
- name: security_analysis
|
||||
agent: security_reviewer
|
||||
action: |
|
||||
Analyze the PR diff for security issues:
|
||||
1. Extract PR details: `echo "$PR_DATA"`
|
||||
2. Get changed files: `echo "$CHANGED_FILES"`
|
||||
3. Review full diff: `gh pr diff ${PR_NUMBER}`
|
||||
4. Look for:
|
||||
- Hardcoded secrets, API keys, passwords
|
||||
- SQL injection vulnerabilities
|
||||
- XSS vulnerabilities
|
||||
- Authentication/authorization bypasses
|
||||
- Unsafe file operations
|
||||
- Command injection risks
|
||||
- Missing input validation
|
||||
- Exposed sensitive data
|
||||
5. Document findings with file paths and line numbers
|
||||
expected_output: "Detailed security analysis report with specific vulnerabilities found, severity levels, and remediation suggestions"
|
||||
|
||||
- name: performance_analysis
|
||||
agent: performance_reviewer
|
||||
action: |
|
||||
Analyze the PR diff for performance issues:
|
||||
1. Review algorithm complexity changes
|
||||
2. Check for inefficient database queries
|
||||
3. Identify memory leaks or excessive allocations
|
||||
4. Look for hot-path regressions
|
||||
5. Check for blocking I/O operations
|
||||
6. Analyze module-level imports that could slow startup
|
||||
7. Review caching implementations
|
||||
8. Check for recursive operations without termination
|
||||
9. Document performance concerns with impact estimates
|
||||
expected_output: "Performance analysis report highlighting bottlenecks, efficiency concerns, and optimization recommendations"
|
||||
|
||||
- name: maintainability_analysis
|
||||
agent: maintainability_reviewer
|
||||
action: |
|
||||
Analyze the PR diff for code quality and maintainability:
|
||||
1. Check naming conventions consistency
|
||||
2. Evaluate code structure and organization
|
||||
3. Review documentation completeness
|
||||
4. Check for DRY violations
|
||||
5. Analyze error handling patterns
|
||||
6. Review test coverage for changes
|
||||
7. Check adherence to repository coding standards
|
||||
8. Evaluate API design consistency
|
||||
9. Look for technical debt introduction
|
||||
expected_output: "Code quality analysis with specific recommendations for improving maintainability and readability"
|
||||
|
||||
- name: final_review
|
||||
agent: lead_reviewer
|
||||
action: |
|
||||
Synthesize all reviews and post final comprehensive feedback:
|
||||
1. Collect input from security, performance, and maintainability reviews
|
||||
2. Prioritize findings by severity (Critical, High, Medium, Low)
|
||||
3. Create structured review comment using this format:
|
||||
|
||||
## 📋 Review Summary
|
||||
[2-3 sentence overview and assessment]
|
||||
|
||||
## 🔍 General Feedback
|
||||
[Overall patterns and observations]
|
||||
|
||||
## 🎯 Specific Feedback
|
||||
### 🔴 Critical (if any)
|
||||
[Security vulnerabilities, breaking changes, major bugs]
|
||||
|
||||
### 🟡 High (if any)
|
||||
[Performance issues, design flaws, significant bugs]
|
||||
|
||||
### 🟢 Medium (if any)
|
||||
[Code quality improvements, minor optimizations]
|
||||
|
||||
### 🔵 Low (if any)
|
||||
[Documentation, naming suggestions, minor refactoring]
|
||||
|
||||
## ✅ Highlights (if any)
|
||||
[Positive aspects worth mentioning]
|
||||
|
||||
4. Post comprehensive review: `echo "[REVIEW_CONTENT]" | gh pr comment ${PR_NUMBER} -F -`
|
||||
5. If critical issues found, suggest not merging until resolved
|
||||
expected_output: "Final review comment posted to GitHub PR with comprehensive analysis from all reviewers"
|
||||
dependencies: [security_analysis, performance_analysis, maintainability_analysis]
|
||||
@@ -1,11 +1,14 @@
|
||||
name: Auto PR Comment
|
||||
|
||||
# Review chain: CodeRabbit/Qodo/Gemini review first → THEN trigger Copilot
|
||||
# For human PRs: CodeRabbit/Qodo auto-review → this workflow triggers Copilot
|
||||
# Review chain: CodeRabbit/Qodo/Gemini review first → Copilot → Claude (final)
|
||||
# For human PRs: CodeRabbit/Qodo auto-review → this workflow triggers Copilot → then Claude
|
||||
# For bot PRs: CodeRabbit/Qodo skip bots, so we trigger them explicitly first
|
||||
#
|
||||
# IMPORTANT: @copilot ignores bot comments. All @copilot mentions must use
|
||||
# PAT_TOKEN so the comment posts as MervinPraison (a real user).
|
||||
#
|
||||
# Claude trigger runs IN-BAND (same workflow) to avoid GitHub Actions
|
||||
# blocking bot-triggered workflows with action_required approval gates.
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
@@ -14,6 +17,11 @@ on:
|
||||
pull_request:
|
||||
types: [opened]
|
||||
|
||||
env:
|
||||
MAX_POLL_ATTEMPTS: 20
|
||||
POLL_DELAY_MS: 30000
|
||||
CLAUDE_TRIGGER_LOGINS: '["MervinPraison","github-actions[bot]"]'
|
||||
|
||||
jobs:
|
||||
# ================================================================
|
||||
# HUMAN PRs: Trigger @copilot AFTER CodeRabbit posts its summary
|
||||
@@ -27,8 +35,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
outputs:
|
||||
pr_number: ${{ steps.trigger.outputs.pr_number }}
|
||||
triggered: ${{ steps.trigger.outputs.triggered }}
|
||||
steps:
|
||||
- name: Post Copilot review request (as user via PAT)
|
||||
id: trigger
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
@@ -45,14 +57,18 @@ jobs:
|
||||
);
|
||||
if (alreadyPosted) {
|
||||
console.log('Copilot already triggered, skipping');
|
||||
core.setOutput('triggered', 'false');
|
||||
core.setOutput('pr_number', context.issue.number.toString());
|
||||
return;
|
||||
}
|
||||
github.rest.issues.createComment({
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@copilot Do a thorough review of this PR. Read ALL existing reviewer comments above from Qodo, Coderabbit, and Gemini first — incorporate their findings.\n\nReview areas:\n1. **Bloat check**: Are changes minimal and focused? Any unnecessary code or scope creep?\n2. **Security**: Any hardcoded secrets, unsafe eval/exec, missing input validation?\n3. **Performance**: Any module-level heavy imports? Hot-path regressions?\n4. **Tests**: Are tests included? Do they cover the changes adequately?\n5. **Backward compat**: Any public API changes without deprecation?\n6. **Code quality**: DRY violations, naming conventions, error handling?\n7. **Address reviewer feedback**: If Qodo, Coderabbit, or Gemini flagged valid issues, include them in your review\n8. Suggest specific improvements with code examples where possible'
|
||||
})
|
||||
});
|
||||
core.setOutput('triggered', 'true');
|
||||
core.setOutput('pr_number', context.issue.number.toString());
|
||||
|
||||
# Fallback: Trigger @copilot after Qodo review (if Coderabbit doesn't fire)
|
||||
copilot-after-qodo:
|
||||
@@ -62,8 +78,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
outputs:
|
||||
pr_number: ${{ steps.trigger.outputs.pr_number }}
|
||||
triggered: ${{ steps.trigger.outputs.triggered }}
|
||||
steps:
|
||||
- name: Post Copilot review request (as user via PAT)
|
||||
id: trigger
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
@@ -79,14 +99,297 @@ jobs:
|
||||
);
|
||||
if (alreadyPosted) {
|
||||
console.log('Copilot already triggered, skipping');
|
||||
core.setOutput('triggered', 'false');
|
||||
core.setOutput('pr_number', context.issue.number.toString());
|
||||
return;
|
||||
}
|
||||
github.rest.issues.createComment({
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@copilot Do a thorough review of this PR. Read ALL existing reviewer comments above first.\n\nReview areas:\n1. **Bloat check**: Are changes minimal and focused?\n2. **Security**: Any hardcoded secrets, unsafe eval/exec, missing input validation?\n3. **Performance**: Any module-level heavy imports? Hot-path regressions?\n4. **Tests**: Are tests included? Do they cover the changes adequately?\n5. **Backward compat**: Any public API changes without deprecation?\n6. **Code quality**: DRY violations, naming conventions, error handling?\n7. Suggest specific improvements with code examples where possible'
|
||||
})
|
||||
});
|
||||
core.setOutput('triggered', 'true');
|
||||
core.setOutput('pr_number', context.issue.number.toString());
|
||||
|
||||
# ================================================================
|
||||
# Claude FINAL review — polls for Copilot's response, then triggers
|
||||
# This runs IN-BAND under the PAT context (MervinPraison), avoiding
|
||||
# the action_required approval gate that blocks bot-triggered workflows.
|
||||
# ================================================================
|
||||
claude-after-copilot:
|
||||
needs: [copilot-after-coderabbit]
|
||||
if: |
|
||||
always() &&
|
||||
needs.copilot-after-coderabbit.result == 'success' &&
|
||||
needs.copilot-after-coderabbit.outputs.triggered == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Wait for Copilot to respond (poll)
|
||||
id: poll
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
|
||||
MAX_POLL_ATTEMPTS: ${{ env.MAX_POLL_ATTEMPTS }}
|
||||
POLL_DELAY_MS: ${{ env.POLL_DELAY_MS }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
// Poll for Copilot's response — check every 30s for up to 10 minutes
|
||||
const maxAttempts = parseInt(process.env.MAX_POLL_ATTEMPTS, 10);
|
||||
const delayMs = parseInt(process.env.POLL_DELAY_MS, 10);
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
console.log(`Poll attempt ${i + 1}/${maxAttempts}...`);
|
||||
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr,
|
||||
owner,
|
||||
repo,
|
||||
per_page: 50,
|
||||
sort: 'created',
|
||||
direction: 'desc'
|
||||
});
|
||||
|
||||
// Look for Copilot's response (copilot-swe-agent or Copilot)
|
||||
const copilotResponse = comments.data.find(c =>
|
||||
(c.user.login === 'copilot-swe-agent' ||
|
||||
c.user.login === 'Copilot' ||
|
||||
c.user.login.toLowerCase().includes('copilot')) &&
|
||||
c.user.type === 'Bot'
|
||||
);
|
||||
|
||||
if (copilotResponse) {
|
||||
console.log(`Copilot responded at ${copilotResponse.created_at}`);
|
||||
core.setOutput('copilot_responded', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
// Also check PR reviews for Copilot
|
||||
const reviews = await github.rest.pulls.listReviews({
|
||||
pull_number: pr,
|
||||
owner,
|
||||
repo
|
||||
});
|
||||
|
||||
const copilotReview = reviews.data.find(r =>
|
||||
r.user.login.toLowerCase().includes('copilot')
|
||||
);
|
||||
|
||||
if (copilotReview) {
|
||||
console.log(`Copilot review submitted at ${copilotReview.submitted_at}`);
|
||||
core.setOutput('copilot_responded', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
if (i < maxAttempts - 1) {
|
||||
console.log(`Copilot hasn't responded yet, waiting ${delayMs/1000}s...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Copilot did not respond within timeout, triggering Claude anyway');
|
||||
core.setOutput('copilot_responded', 'timeout');
|
||||
|
||||
- name: Check if Claude already triggered
|
||||
id: check_claude
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
|
||||
- name: Trigger Claude final review
|
||||
if: steps.check_claude.outputs.already_triggered != 'true'
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the FINAL architecture reviewer. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items'
|
||||
});
|
||||
console.log(`Claude triggered on PR #${pr}`);
|
||||
|
||||
# ================================================================
|
||||
# Claude after Qodo fallback path (same polling approach)
|
||||
# ================================================================
|
||||
claude-after-copilot-qodo:
|
||||
needs: [copilot-after-qodo]
|
||||
if: |
|
||||
always() &&
|
||||
needs.copilot-after-qodo.result == 'success' &&
|
||||
needs.copilot-after-qodo.outputs.triggered == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Wait for Copilot to respond (poll)
|
||||
id: poll
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
|
||||
MAX_POLL_ATTEMPTS: ${{ env.MAX_POLL_ATTEMPTS }}
|
||||
POLL_DELAY_MS: ${{ env.POLL_DELAY_MS }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const maxAttempts = parseInt(process.env.MAX_POLL_ATTEMPTS, 10);
|
||||
const delayMs = parseInt(process.env.POLL_DELAY_MS, 10);
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
console.log(`Poll attempt ${i + 1}/${maxAttempts}...`);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr, owner, repo,
|
||||
per_page: 50, sort: 'created', direction: 'desc'
|
||||
});
|
||||
const copilotResponse = comments.data.find(c =>
|
||||
(c.user.login === 'copilot-swe-agent' ||
|
||||
c.user.login === 'Copilot' ||
|
||||
c.user.login.toLowerCase().includes('copilot')) &&
|
||||
c.user.type === 'Bot'
|
||||
);
|
||||
if (copilotResponse) {
|
||||
console.log(`Copilot responded at ${copilotResponse.created_at}`);
|
||||
core.setOutput('copilot_responded', 'true');
|
||||
return;
|
||||
}
|
||||
const reviews = await github.rest.pulls.listReviews({
|
||||
pull_number: pr, owner, repo
|
||||
});
|
||||
const copilotReview = reviews.data.find(r =>
|
||||
r.user.login.toLowerCase().includes('copilot')
|
||||
);
|
||||
if (copilotReview) {
|
||||
console.log(`Copilot review submitted at ${copilotReview.submitted_at}`);
|
||||
core.setOutput('copilot_responded', 'true');
|
||||
return;
|
||||
}
|
||||
if (i < maxAttempts - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
console.log('Copilot did not respond within timeout, triggering Claude anyway');
|
||||
core.setOutput('copilot_responded', 'timeout');
|
||||
|
||||
- name: Check if Claude already triggered
|
||||
id: check_claude
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
|
||||
- name: Trigger Claude final review
|
||||
if: steps.check_claude.outputs.already_triggered != 'true'
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the FINAL architecture reviewer. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items'
|
||||
});
|
||||
console.log(`Claude triggered on PR #${pr}`);
|
||||
|
||||
# ================================================================
|
||||
# Claude after Gemini — restores the trigger lost from the deleted
|
||||
# chain-claude-after-copilot.yml workflow.
|
||||
# Fires when Gemini posts a review comment on a PR or issue.
|
||||
# ================================================================
|
||||
claude-after-gemini:
|
||||
if: |
|
||||
github.event_name == 'issue_comment' &&
|
||||
(
|
||||
github.event.comment.user.login == 'gemini-code-assist[bot]' ||
|
||||
contains(github.event.comment.body, 'Review completed by Gemini CLI') ||
|
||||
contains(github.event.comment.body, 'The fix is ready for review. Please test')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check if Claude already triggered
|
||||
id: check_claude
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
|
||||
- name: Post Claude analysis request (Lead Engineer)
|
||||
if: steps.check_claude.outputs.already_triggered != 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the Lead Engineer. Read ALL analysis and reviews above carefully (Gemini, CodeRabbit, Qodo, Copilot, etc).\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items'
|
||||
});
|
||||
|
||||
# ================================================================
|
||||
# BOT PRs: CodeRabbit/Qodo skip bot-authored PRs by default.
|
||||
@@ -106,7 +409,7 @@ jobs:
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Trigger CodeRabbit, Qodo, and Gemini reviews
|
||||
- name: Trigger CodeRabbit and Qodo reviews
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
@@ -129,12 +432,12 @@ jobs:
|
||||
});
|
||||
console.log(`Triggered Qodo for bot PR #${pr}`);
|
||||
|
||||
// 3. Trigger Gemini review
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr, owner, repo,
|
||||
body: '@gemini review this PR'
|
||||
});
|
||||
console.log(`Triggered Gemini for bot PR #${pr}`);
|
||||
// 3. Trigger Gemini review (commented out)
|
||||
// await github.rest.issues.createComment({
|
||||
// issue_number: pr, owner, repo,
|
||||
// body: '@gemini review this PR'
|
||||
// });
|
||||
// console.log(`Triggered Gemini for bot PR #${pr}`);
|
||||
|
||||
// NOTE: @copilot is NOT triggered here.
|
||||
// It will be triggered by copilot-after-coderabbit or copilot-after-qodo
|
||||
|
||||
@@ -35,6 +35,8 @@ jobs:
|
||||
|
||||
- name: Run Import Time Benchmark
|
||||
id: import_time
|
||||
env:
|
||||
FAIL_ON_REGRESSION: ${{ inputs.fail_on_regression }}
|
||||
run: |
|
||||
cd src/praisonai-agents
|
||||
python benchmarks/import_time.py 2>&1 | tee import_time_output.txt
|
||||
@@ -46,7 +48,7 @@ jobs:
|
||||
# Check threshold (200ms)
|
||||
if (( $(echo "$IMPORT_TIME > 200" | bc -l) )); then
|
||||
echo "::warning::Import time ${IMPORT_TIME}ms exceeds 200ms threshold"
|
||||
if [ "${{ inputs.fail_on_regression }}" == "true" ]; then
|
||||
if [ "$FAIL_ON_REGRESSION" == "true" ]; then
|
||||
echo "import_passed=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
else
|
||||
@@ -55,6 +57,8 @@ jobs:
|
||||
|
||||
- name: Run Memory Usage Benchmark
|
||||
id: memory
|
||||
env:
|
||||
FAIL_ON_REGRESSION: ${{ inputs.fail_on_regression }}
|
||||
run: |
|
||||
cd src/praisonai-agents
|
||||
python benchmarks/memory_usage.py 2>&1 | tee memory_output.txt
|
||||
@@ -66,7 +70,7 @@ jobs:
|
||||
# Check threshold (30MB)
|
||||
if (( $(echo "$PEAK_MEMORY > 30" | bc -l) )); then
|
||||
echo "::warning::Memory usage ${PEAK_MEMORY}MB exceeds 30MB threshold"
|
||||
if [ "${{ inputs.fail_on_regression }}" == "true" ]; then
|
||||
if [ "$FAIL_ON_REGRESSION" == "true" ]; then
|
||||
echo "memory_passed=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
else
|
||||
@@ -95,9 +99,11 @@ jobs:
|
||||
|
||||
- name: Run Parameter Impact Benchmark
|
||||
id: param_impact
|
||||
env:
|
||||
INPUT_ITERATIONS: ${{ inputs.iterations }}
|
||||
run: |
|
||||
cd src/praisonai-agents
|
||||
python benchmarks/param_impact_benchmark.py --no-save --iterations ${{ inputs.iterations }} 2>&1 | tee param_impact_output.txt
|
||||
python benchmarks/param_impact_benchmark.py --no-save --iterations "$INPUT_ITERATIONS" 2>&1 | tee param_impact_output.txt
|
||||
|
||||
- name: Check Lazy Imports
|
||||
id: lazy_imports
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
name: Chain Claude After Reviewers
|
||||
|
||||
# Event-driven: triggers when Copilot finishes reviewing a PR
|
||||
on:
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
# Trigger Claude after Copilot finishes on a PR — Claude is the FINAL reviewer
|
||||
# Claude reads ALL previous reviews (Qodo, Coderabbit, Copilot) and fixes any valid issues
|
||||
claude-final-review:
|
||||
if: |
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.user.login, 'copilot')) ||
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.user.login, 'copilot'))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Post Claude final review + fix request
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the FINAL architecture reviewer. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items'
|
||||
})
|
||||
|
||||
# Trigger Claude after Gemini finishes on an Issue or PR
|
||||
claude-after-gemini:
|
||||
if: |
|
||||
github.event_name == 'issue_comment' &&
|
||||
(
|
||||
github.event.comment.user.login == 'gemini-code-assist[bot]' ||
|
||||
contains(github.event.comment.body, 'Review completed by Gemini CLI') ||
|
||||
contains(github.event.comment.body, 'The fix is ready for review. Please test')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Post Claude analysis request (final reviewer)
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the Lead Engineer. Read ALL analysis and reviews above carefully (Gemini, CodeRabbit, Qodo, Copilot, etc).\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items'
|
||||
})
|
||||
@@ -15,15 +15,18 @@ jobs:
|
||||
issues: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.CLAUDE_APP_ID }}
|
||||
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Run Claude Issue Triage
|
||||
uses: ./.github/actions/claude-issue-triage-action
|
||||
|
||||
+192
-15
@@ -11,10 +11,116 @@ on:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
# ================================================================
|
||||
# Job 1: Lightweight triage for ALL new issues (including external users)
|
||||
# Posts welcome comment + adds triage labels. No Claude code execution.
|
||||
# ================================================================
|
||||
issue-triage:
|
||||
if: |
|
||||
github.event_name == 'issues' &&
|
||||
github.event.action == 'opened' &&
|
||||
!contains(toJSON(github.event.issue.labels), 'claude')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.CLAUDE_APP_ID }}
|
||||
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- name: Triage and acknowledge issue
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
const title = (issue.title || '').toLowerCase();
|
||||
const body = (issue.body || '').toLowerCase();
|
||||
const content = title + ' ' + body;
|
||||
const isOwner = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(
|
||||
issue.author_association
|
||||
);
|
||||
|
||||
// --- Label triage ---
|
||||
const labels = [];
|
||||
|
||||
// Kind labels
|
||||
if (content.includes('feature request') || content.includes('[feature')) {
|
||||
labels.push('enhancement');
|
||||
} else if (content.includes('bug') || content.includes('error') || content.includes('crash') || content.includes('traceback')) {
|
||||
labels.push('bug');
|
||||
} else if (content.includes('question') || content.includes('how do i') || content.includes('how to')) {
|
||||
labels.push('question');
|
||||
}
|
||||
|
||||
// Area labels
|
||||
if (content.includes('security') || content.includes('cve') || content.includes('vulnerability')) {
|
||||
labels.push('security');
|
||||
}
|
||||
if (content.includes('performance') || content.includes('slow') || content.includes('memory leak')) {
|
||||
labels.push('performance');
|
||||
}
|
||||
if (content.includes('documentation') || content.includes('docs')) {
|
||||
labels.push('documentation');
|
||||
}
|
||||
if (content.includes('typescript') || content.includes('javascript') || content.includes('npm')) {
|
||||
labels.push('javascript');
|
||||
}
|
||||
|
||||
// Apply labels
|
||||
if (labels.length > 0) {
|
||||
await github.rest.issues.addLabels({
|
||||
issue_number: issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels
|
||||
});
|
||||
}
|
||||
|
||||
// --- Acknowledgment comment (external users only) ---
|
||||
if (!isOwner) {
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: [
|
||||
`👋 Thanks for opening this issue, @${issue.user.login}!`,
|
||||
'',
|
||||
'A maintainer will review this shortly. In the meantime:',
|
||||
'- Make sure you\'ve included steps to reproduce (for bugs)',
|
||||
'- Check [existing issues](https://github.com/MervinPraison/PraisonAI/issues) for duplicates',
|
||||
'- Review the [documentation](https://docs.praison.ai) for related guides',
|
||||
'',
|
||||
'_A maintainer can trigger deeper analysis by commenting `@claude` on this issue._'
|
||||
].join('\n')
|
||||
});
|
||||
}
|
||||
|
||||
// --- For owner: add 'claude' label to trigger the code-fix job ---
|
||||
if (isOwner) {
|
||||
await github.rest.issues.addLabels({
|
||||
issue_number: issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: ['claude']
|
||||
});
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# Job 2: Full Claude code-fix (owner/collaborator only)
|
||||
# Triggers on: labeled 'claude', @claude comments, assignments
|
||||
# ================================================================
|
||||
claude-response:
|
||||
# Allow: human users, github-actions[bot] (auto-comments/labels)
|
||||
# Block: dependabot, cursor, renovate, other bots
|
||||
# Skip 'opened' events — triage job handles those above
|
||||
if: |
|
||||
github.event.action != 'opened' &&
|
||||
(github.event.action != 'labeled' || github.event.label.name == 'claude') &&
|
||||
(github.event_name != 'issue_comment' || contains(github.event.comment.body, '@claude')) &&
|
||||
(
|
||||
@@ -33,58 +139,129 @@ jobs:
|
||||
actions: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.CLAUDE_APP_ID }}
|
||||
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- name: Check for Fork PR
|
||||
id: check_fork
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const isPR = context.eventName === 'pull_request' || (context.payload.issue && context.payload.issue.pull_request);
|
||||
if (isPR) {
|
||||
const prNumber = context.payload.pull_request ? context.payload.pull_request.number : context.payload.issue.number;
|
||||
const pr = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber
|
||||
});
|
||||
|
||||
core.setOutput('pr_branch', pr.data.head.ref);
|
||||
|
||||
if (pr.data.head.repo && pr.data.head.repo.full_name !== context.repo.full_name) {
|
||||
core.setOutput('is_fork', 'true');
|
||||
core.setOutput('clone_url', pr.data.head.repo.clone_url);
|
||||
core.setOutput('branch', pr.data.head.ref);
|
||||
return;
|
||||
}
|
||||
}
|
||||
core.setOutput('is_fork', 'false');
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Fetch PR branch and Setup Remote
|
||||
if: github.event.issue.pull_request
|
||||
run: |
|
||||
# Fetch PR head from base repo and put it into local branch
|
||||
git fetch origin pull/${{ github.event.issue.number }}/head:${{ steps.check_fork.outputs.pr_branch }}
|
||||
|
||||
# If it's a fork, make origin point to local to trick claude-code-action's `git fetch origin <branch>`
|
||||
if [ "${{ steps.check_fork.outputs.is_fork }}" == "true" ]; then
|
||||
git remote set-url origin file://$(pwd)
|
||||
fi
|
||||
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
with:
|
||||
allowed_bots: 'praisonai-triage-agent[bot]'
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ steps.app-token.outputs.token }}
|
||||
trigger_phrase: "@claude"
|
||||
label_trigger: "claude"
|
||||
allowed_non_write_users: "*"
|
||||
|
||||
direct_prompt: |
|
||||
${{ steps.check_fork.outputs.is_fork == 'true' && 'CRITICAL: THIS IS A PULL REQUEST FROM A FORK. YOU DO NOT HAVE PUSH PERMISSIONS TO THIS REPOSITORY. ONLY READ FILES, VALIDATE CODE, AND PROVIDE YOUR COMMENTS/FEEDBACK DIRECTLY IN THIS PR VIA COMMENTS. DO NOT ATTEMPT TO PUSH OR CREATE PULL REQUESTS.\n\n' || '' }}
|
||||
You are working on the PraisonAI SDK. Follow AGENTS.md strictly.
|
||||
|
||||
STEP 0 — SETUP GIT IDENTITY:
|
||||
git config user.name "MervinPraison"
|
||||
git config user.email "[email protected]"
|
||||
STEP 0 — SETUP GIT IDENTITY & AUTH (GLOBAL — required for all repos):
|
||||
git config --global user.name "MervinPraison"
|
||||
git config --global user.email "[email protected]"
|
||||
gh auth setup-git
|
||||
|
||||
|
||||
STEP 1 — READ GUIDELINES:
|
||||
Read AGENTS.md to understand the architecture rules.
|
||||
|
||||
STEP 2 — ARCHITECTURE VALIDATION (MANDATORY before writing code):
|
||||
STEP 2 — ARCHITECTURE VALIDATION & ROUTING (MANDATORY before writing code):
|
||||
Before implementing anything, answer these questions:
|
||||
- Does this add heavy implementations to the core SDK (praisonaiagents)? If YES → move it to the wrapper (praisonai) or make it a plugin.
|
||||
- CORE vs WRAPPER vs TOOLS vs DOCS vs PLUGINS ROUTING:
|
||||
1. Core SDK (praisonaiagents/): Only core protocols, base classes, decorators. No heavy implementations.
|
||||
2. Wrapper (praisonai/): CLI, heavy implementations, optional dependencies.
|
||||
3. Tools (PraisonAI-Tools): Capabilities Agents actively call during execution (e.g. SurrealDB tool, Slack tool).
|
||||
4. Documentation (PraisonAIDocs): Documentation pages, guides, API references.
|
||||
5. Plugins (PraisonAI-Plugins): Framework lifecycle extensions replacing systemic behavior (e.g. tracing, logging, metrics, hooks, guardrails).
|
||||
For items routed to an EXTERNAL repository (Tools, Docs, Plugins), follow STEP 3-ALT below.
|
||||
- Does it duplicate existing functionality? Check if Agent already supports this via existing params (reflection, planning, tools, hooks, memory).
|
||||
- Does it inherit from Agent properly? New agent types MUST inherit Agent, not wrap it with composition.
|
||||
- Does it add new dependencies? Only optional deps allowed, must be lazy-imported.
|
||||
- Will agent.py grow larger? If the change adds >50 lines to agent.py, find a way to extract instead.
|
||||
- Is there a name collision with existing exports in __init__.py?
|
||||
If ANY of these checks fail, add a comment to the issue explaining why and close it. Do NOT create a PR.
|
||||
If ANY of these conceptual checks fail (excluding routing), add a comment to the issue explaining why and close it. Do NOT create a PR.
|
||||
|
||||
STEP 3 — IMPLEMENT:
|
||||
STEP 3 — IMPLEMENT (for changes in THIS repo — PraisonAI):
|
||||
- Create a fix branch and implement a minimal, focused fix
|
||||
- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper
|
||||
- Keep changes small and backward-compatible
|
||||
|
||||
STEP 3-ALT — IMPLEMENT IN EXTERNAL REPO (for PraisonAI-Tools, PraisonAIDocs, PraisonAI-Plugins):
|
||||
When work must happen in a different repository, follow these steps EXACTLY. Do NOT attempt to use `cd`, as directory state is not preserved. Use `git -C` for all commands.
|
||||
a) Clone the repository:
|
||||
gh repo clone MervinPraison/<REPO_NAME> /tmp/<REPO_NAME>
|
||||
b) Copy GitHub authentication from the main repository so push works seamlessly:
|
||||
git -C /tmp/<REPO_NAME> config http."https://github.com/".extraheader "$(git config --get http."https://github.com/".extraheader)"
|
||||
c) Create a feature branch (NEVER commit to main):
|
||||
git -C /tmp/<REPO_NAME> checkout -b claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)
|
||||
d) Make your changes using absolute paths (e.g. edit /tmp/<REPO_NAME>/docs.json)
|
||||
e) Commit:
|
||||
git -C /tmp/<REPO_NAME> add -A
|
||||
git -C /tmp/<REPO_NAME> commit -m "feat: <description> (fixes #$ISSUE_NUMBER)"
|
||||
f) Push the branch:
|
||||
git -C /tmp/<REPO_NAME> push origin claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)
|
||||
g) Create PR in the EXTERNAL repo:
|
||||
gh pr create -R MervinPraison/<REPO_NAME> --head claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d) --title "feat: <title>" --body "Fixes MervinPraison/PraisonAI#$ISSUE_NUMBER
|
||||
|
||||
<body>"
|
||||
|
||||
STEP 4 — TEST:
|
||||
- Run: cd src/praisonai-agents && PYTHONPATH=. python -m pytest tests/ -x -q --timeout=30
|
||||
- For SDK changes: cd src/praisonai-agents && PYTHONPATH=. python -m pytest tests/ -x -q --timeout=30
|
||||
- For docs changes: verify files exist and are valid markdown/MDX
|
||||
- Ensure no regressions
|
||||
|
||||
STEP 5 — CREATE PR:
|
||||
- Commit with descriptive message, push, and create PR using `gh pr create`
|
||||
CRITICAL: You MUST create the PR automatically using `gh pr create`. Do NOT just provide a link.
|
||||
- For external repos, use `gh pr create -R MervinPraison/<REPO_NAME>`
|
||||
CRITICAL: You MUST create the PR automatically using `gh pr create`. Do NOT just provide a link or say "manual push required".
|
||||
NOTE: If you worked on an external repo, the wrapper action will report "No commits" for the main repo at the end. This is expected, do not worry about it.
|
||||
allowed_tools: |
|
||||
Bash(git:*)
|
||||
Bash(python:*)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
name: PraisonAI Issue Triage
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue_number:
|
||||
description: 'Issue Number'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
!github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '@praisonai') &&
|
||||
github.actor != 'github-actions[bot]' &&
|
||||
github.event.comment.user.type != 'Bot' &&
|
||||
(github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR'))
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate GitHub App Token
|
||||
id: generate_token
|
||||
uses: tibdex/github-app-token@v2
|
||||
with:
|
||||
app_id: ${{ secrets.PRAISONAI_APP_ID }}
|
||||
private_key: ${{ secrets.PRAISONAI_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Set up Git
|
||||
run: |
|
||||
git config --local user.email "[email protected]"
|
||||
git config --local user.name "PraisonAI Bot"
|
||||
|
||||
- name: Install PraisonAI
|
||||
run: |
|
||||
pip install uv
|
||||
cd src/praisonai-agents && uv pip install --system -e ".[all]"
|
||||
cd ../praisonai && uv pip install --system -e ".[all]"
|
||||
|
||||
- name: Run PraisonAI Issue Triage
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
PRAISONAI_AUTO_APPROVE: "true"
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
export ISSUE_NUMBER="${{ inputs.issue_number }}"
|
||||
else
|
||||
export ISSUE_NUMBER="${{ github.event.issue.number }}"
|
||||
fi
|
||||
praisonai github triage --issue $ISSUE_NUMBER --agent-file .github/praisonai-issue-triage.yaml
|
||||
@@ -0,0 +1,68 @@
|
||||
name: PraisonAI PR Reviewer
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'Pull Request Number'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
review:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '@praisonai') &&
|
||||
github.actor != 'github-actions[bot]' &&
|
||||
github.event.comment.user.type != 'Bot' &&
|
||||
(github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR'))
|
||||
|
||||
steps:
|
||||
- name: Determine checkout ref
|
||||
id: dest
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "issue_comment" ]; then
|
||||
echo "ref=refs/pull/${{ github.event.issue.number }}/head" >> "$GITHUB_OUTPUT"
|
||||
elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "ref=refs/pull/${{ inputs.pr_number }}/head" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ref=${{ github.event.pull_request.head.sha }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.dest.outputs.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate GitHub App Token
|
||||
id: generate_token
|
||||
uses: tibdex/github-app-token@v2
|
||||
with:
|
||||
app_id: ${{ secrets.PRAISONAI_APP_ID }}
|
||||
private_key: ${{ secrets.PRAISONAI_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install PraisonAI
|
||||
run: pip install "praisonai[all]"
|
||||
|
||||
- name: Run PraisonAI PR Review
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
run: |
|
||||
praisonai workflow run .github/praisonai-reviewer.yaml
|
||||
@@ -34,10 +34,12 @@ jobs:
|
||||
# Get the commit hash from the tag
|
||||
- name: Get commit hash
|
||||
id: get_commit
|
||||
env:
|
||||
INPUT_TAG: ${{ github.event.inputs.tag }}
|
||||
run: |
|
||||
COMMIT_HASH=$(git rev-parse HEAD)
|
||||
echo "commit_hash=$COMMIT_HASH" >> $GITHUB_OUTPUT
|
||||
echo "Found commit: $COMMIT_HASH for tag ${{ github.event.inputs.tag }}"
|
||||
echo "Found commit: $COMMIT_HASH for tag $INPUT_TAG"
|
||||
|
||||
# Now checkout the fork at main branch
|
||||
- name: Checkout fork
|
||||
@@ -60,8 +62,11 @@ jobs:
|
||||
# Fetch from upstream
|
||||
git fetch upstream --tags --force
|
||||
|
||||
# Reset to upstream commit
|
||||
git reset --hard ${{ steps.get_commit.outputs.commit_hash }}
|
||||
- name: Hard reset to tag commit
|
||||
env:
|
||||
COMMIT_HASH: ${{ steps.get_commit.outputs.commit_hash }}
|
||||
run: |
|
||||
git reset --hard $COMMIT_HASH
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
@@ -445,55 +450,64 @@ jobs:
|
||||
# Add upstream and get commit from tag
|
||||
- name: Get upstream tag commit
|
||||
id: get_upstream_commit
|
||||
env:
|
||||
INPUT_TAG: ${{ github.event.inputs.tag }}
|
||||
run: |
|
||||
# Add upstream remote
|
||||
git remote add upstream https://github.com/MervinPraison/PraisonAI.git
|
||||
|
||||
# Delete local tag if it exists
|
||||
git tag -d ${{ github.event.inputs.tag }} || true
|
||||
git tag -d "$INPUT_TAG" || true
|
||||
|
||||
# Fetch from upstream including tags with force
|
||||
git fetch upstream --tags --force
|
||||
|
||||
# Verify tag exists in upstream
|
||||
if ! git ls-remote --tags upstream | grep -q "refs/tags/${{ github.event.inputs.tag }}$"; then
|
||||
echo "::error::Tag ${{ github.event.inputs.tag }} not found in upstream repository"
|
||||
if ! git ls-remote --tags upstream | grep -q "refs/tags/$INPUT_TAG$"; then
|
||||
echo "::error::Tag $INPUT_TAG not found in upstream repository"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get the commit hash from the upstream tag
|
||||
UPSTREAM_COMMIT=$(git rev-list -n 1 refs/tags/${{ github.event.inputs.tag }})
|
||||
UPSTREAM_COMMIT=$(git rev-list -n 1 refs/tags/"$INPUT_TAG")
|
||||
if [ -z "$UPSTREAM_COMMIT" ]; then
|
||||
echo "::error::Could not find commit for tag ${{ github.event.inputs.tag }}"
|
||||
echo "::error::Could not find commit for tag $INPUT_TAG"
|
||||
exit 1
|
||||
fi
|
||||
echo "upstream_commit=$UPSTREAM_COMMIT" >> $GITHUB_OUTPUT
|
||||
echo "Found commit: $UPSTREAM_COMMIT for tag ${{ github.event.inputs.tag }}"
|
||||
echo "Found commit: $UPSTREAM_COMMIT for tag $INPUT_TAG"
|
||||
|
||||
# Create tag at the same commit
|
||||
- name: Create tag at commit
|
||||
env:
|
||||
GIT_ASKPASS: /bin/echo
|
||||
PAT_TOKEN: ${{ secrets.PAT_TOKEN }}
|
||||
INPUT_TAG: ${{ github.event.inputs.tag }}
|
||||
run: |
|
||||
git config --global user.name "MervinPraison"
|
||||
git config --global user.email "[email protected]"
|
||||
|
||||
# Remove tag if it exists locally
|
||||
git tag -d ${{ github.event.inputs.tag }} || true
|
||||
git tag -d "$INPUT_TAG" || true
|
||||
|
||||
# Remove tag if it exists remotely
|
||||
git push origin :refs/tags/${{ github.event.inputs.tag }} || true
|
||||
git push origin :refs/tags/"$INPUT_TAG" || true
|
||||
|
||||
# Fetch the specific commit from upstream
|
||||
git fetch upstream ${{ steps.get_upstream_commit.outputs.upstream_commit }} --force
|
||||
- name: Fetch tag from upstream
|
||||
env:
|
||||
UPSTREAM_COMMIT: ${{ steps.get_upstream_commit.outputs.upstream_commit }}
|
||||
run: |
|
||||
git fetch upstream $UPSTREAM_COMMIT --force
|
||||
|
||||
# Reset to upstream commit
|
||||
git reset --hard ${{ steps.get_upstream_commit.outputs.upstream_commit }}
|
||||
- name: Reset to upstream commit
|
||||
env:
|
||||
UPSTREAM_COMMIT: ${{ steps.get_upstream_commit.outputs.upstream_commit }}
|
||||
run: |
|
||||
git reset --hard $UPSTREAM_COMMIT
|
||||
|
||||
# Create and push tag
|
||||
git tag -a ${{ github.event.inputs.tag }} -m "Release ${{ github.event.inputs.tag }} (synced from upstream MervinPraison/PraisonAI@${{ github.event.inputs.tag }})"
|
||||
git push origin ${{ github.event.inputs.tag }}
|
||||
env:
|
||||
GIT_ASKPASS: /bin/echo
|
||||
PAT_TOKEN: ${{ secrets.PAT_TOKEN }}
|
||||
git tag -a "$INPUT_TAG" -m "Release $INPUT_TAG (synced from upstream MervinPraison/PraisonAI@$INPUT_TAG)"
|
||||
git push origin "$INPUT_TAG"
|
||||
|
||||
# Create the release
|
||||
- name: Create Release
|
||||
|
||||
@@ -67,9 +67,11 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Run Comprehensive Test Suite
|
||||
env:
|
||||
INPUT_TEST_TYPE: ${{ github.event.inputs.test_type || 'all' }}
|
||||
run: |
|
||||
# Determine test type from input or default to 'all'
|
||||
TEST_TYPE="${{ github.event.inputs.test_type || 'all' }}"
|
||||
TEST_TYPE="$INPUT_TEST_TYPE"
|
||||
|
||||
echo "🧪 Running comprehensive test suite (type: $TEST_TYPE)"
|
||||
|
||||
@@ -102,13 +104,16 @@ jobs:
|
||||
|
||||
- name: Generate Comprehensive Test Report
|
||||
if: always()
|
||||
env:
|
||||
INPUT_TEST_TYPE: ${{ github.event.inputs.test_type || 'all' }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
echo "# 📋 Comprehensive Test Report" > comprehensive_report.md
|
||||
echo "" >> comprehensive_report.md
|
||||
echo "**Python Version:** ${{ matrix.python-version }}" >> comprehensive_report.md
|
||||
echo "**Test Type:** ${{ github.event.inputs.test_type || 'all' }}" >> comprehensive_report.md
|
||||
echo "**Trigger:** ${{ github.event_name }}" >> comprehensive_report.md
|
||||
echo "**Date:** $(date -u)" >> comprehensive_report.md
|
||||
echo "**Test Type:** $INPUT_TEST_TYPE" >> comprehensive_report.md
|
||||
echo "**Trigger:** $EVENT_NAME" >> comprehensive_report.md
|
||||
echo "**Status:** 🟢 All Tests Passed" >> comprehensive_report.md
|
||||
echo "" >> comprehensive_report.md
|
||||
|
||||
echo "## 🧪 Test Categories Covered:" >> comprehensive_report.md
|
||||
|
||||
@@ -34,11 +34,14 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 🚨 Cost Warning
|
||||
env:
|
||||
INPUT_FRAMEWORK: ${{ github.event.inputs.framework }}
|
||||
INPUT_CONFIRM_COSTS: ${{ github.event.inputs.confirm_costs }}
|
||||
run: |
|
||||
echo "⚠️ WARNING: This workflow will make real API calls!"
|
||||
echo "💰 This may incur charges on your API accounts"
|
||||
echo "🎯 Framework: ${{ github.event.inputs.framework }}"
|
||||
echo "✅ Cost confirmation: ${{ github.event.inputs.confirm_costs }}"
|
||||
echo "🎯 Framework: $INPUT_FRAMEWORK"
|
||||
echo "✅ Cost confirmation: $INPUT_CONFIRM_COSTS"
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -93,21 +96,23 @@ jobs:
|
||||
|
||||
- name: Generate Real Test Report
|
||||
if: always()
|
||||
env:
|
||||
INPUT_FRAMEWORK: ${{ github.event.inputs.framework }}
|
||||
run: |
|
||||
echo "# 🔥 Real End-to-End Test Report" > real_test_report.md
|
||||
echo "" >> real_test_report.md
|
||||
echo "⚠️ **WARNING: This report represents tests that made real API calls**" >> real_test_report.md
|
||||
echo "" >> real_test_report.md
|
||||
echo "**Framework Tested:** ${{ github.event.inputs.framework }}" >> real_test_report.md
|
||||
echo "**Framework Tested:** $INPUT_FRAMEWORK" >> real_test_report.md
|
||||
echo "**Python Version:** ${{ matrix.python-version }}" >> real_test_report.md
|
||||
echo "**Date:** $(date -u)" >> real_test_report.md
|
||||
echo "**Triggered by:** ${{ github.actor }}" >> real_test_report.md
|
||||
echo "**Triggered by:** $GITHUB_ACTOR" >> real_test_report.md
|
||||
echo "" >> real_test_report.md
|
||||
|
||||
echo "## 🧪 Test Results" >> real_test_report.md
|
||||
echo "" >> real_test_report.md
|
||||
|
||||
if [ "${{ github.event.inputs.framework }}" == "autogen" ] || [ "${{ github.event.inputs.framework }}" == "all" ]; then
|
||||
if [ "$INPUT_FRAMEWORK" == "autogen" ] || [ "$INPUT_FRAMEWORK" == "all" ]; then
|
||||
echo "### AutoGen Real Tests:" >> real_test_report.md
|
||||
echo "- Environment verification" >> real_test_report.md
|
||||
echo "- Agent creation with real API calls" >> real_test_report.md
|
||||
@@ -115,7 +120,7 @@ jobs:
|
||||
echo "" >> real_test_report.md
|
||||
fi
|
||||
|
||||
if [ "${{ github.event.inputs.framework }}" == "crewai" ] || [ "${{ github.event.inputs.framework }}" == "all" ]; then
|
||||
if [ "$INPUT_FRAMEWORK" == "crewai" ] || [ "$INPUT_FRAMEWORK" == "all" ]; then
|
||||
echo "### CrewAI Real Tests:" >> real_test_report.md
|
||||
echo "- Environment verification" >> real_test_report.md
|
||||
echo "- Crew creation with real API calls" >> real_test_report.md
|
||||
@@ -150,11 +155,14 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 🛡️ Safety Check Failed
|
||||
env:
|
||||
INPUT_FRAMEWORK: ${{ github.event.inputs.framework }}
|
||||
INPUT_CONFIRM_COSTS: ${{ github.event.inputs.confirm_costs }}
|
||||
run: |
|
||||
echo "🚨 Real tests not executed due to safety checks:"
|
||||
echo ""
|
||||
echo "✅ Costs confirmed: ${{ github.event.inputs.confirm_costs }}"
|
||||
echo "✅ Framework selected: ${{ github.event.inputs.framework }}"
|
||||
echo "✅ Costs confirmed: $INPUT_CONFIRM_COSTS"
|
||||
echo "✅ Framework selected: $INPUT_FRAMEWORK"
|
||||
echo ""
|
||||
echo "To run real tests:"
|
||||
echo "1. Select a framework (autogen, crewai, or all)"
|
||||
|
||||
@@ -23,14 +23,13 @@
|
||||
|
||||
</div>
|
||||
|
||||
PraisonAI 🦞 — Automate and solve complex challenges with AI agent teams that plan, research, code, and deliver results to Telegram, Discord, and WhatsApp — running 24/7. A low-code, production-ready multi-agent framework with handoffs, guardrails, memory, RAG, and 100+ LLM providers, built around simplicity, customisation, and effective human-agent collaboration.
|
||||
PraisonAI 🦞 — **Hire a 24/7 AI Workforce.** Stop writing boilerplate and start shipping autonomous agents that research, plan, and execute tasks across your apps. From one agent to an entire organization, deployed in 5 lines of code.
|
||||
|
||||
<div align="center">
|
||||
<br>
|
||||
<a href="https://x.com/elonmusk/status/1893870468249141688" target="_blank">
|
||||
<img src="https://img.shields.io/badge/Highlighted_by_Elon_Musk-000000?style=for-the-badge&logo=x&logoColor=white" alt="Highlighted by Elon Musk" />
|
||||
</a>
|
||||
<p><em>"Grok 3 customer support" — <a href="https://x.com/elonmusk/status/1893870468249141688">Elon Musk quoting PraisonAI's tutorial</a></em></p>
|
||||
<br>
|
||||
</div>
|
||||
|
||||
@@ -69,26 +68,6 @@ PraisonAI 🦞 — Automate and solve complex challenges with AI agent teams tha
|
||||
|
||||
---
|
||||
|
||||
> **Quick Paths:**
|
||||
> - 🆕 **New here?** → [Quick Start](#-quick-start) *(1 minute to first agent)*
|
||||
> - 📦 **Installing?** → [Installation](#-installation)
|
||||
> - 🐍 **Python SDK?** → [Python Examples](#-using-python-code)
|
||||
> - 📄 **YAML/No-Code?** → [YAML Examples](#-using-yaml-no-code)
|
||||
> - 🎯 **CLI user?** → [CLI Quick Reference](#-cli-quick-reference)
|
||||
> - 🤝 **Contributing?** → [Contributing](#-contributing)
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Performance
|
||||
|
||||
PraisonAI is built for speed, with agent instantiation in under 4μs. This reduces overhead, improves responsiveness, and helps multi-agent systems scale efficiently in real-world production workloads.
|
||||
|
||||
| Performance Metric | PraisonAI |
|
||||
|--------------------|-----------|
|
||||
| Avg Instantiation Time | **3.77 μs** |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Use Cases
|
||||
|
||||
AI agents solving real-world problems across industries:
|
||||
@@ -104,9 +83,44 @@ AI agents solving real-world problems across industries:
|
||||
|
||||
---
|
||||
|
||||
## Supported Providers
|
||||
## 🚀 Meet your first Agent (Under 1 Minute)
|
||||
|
||||
PraisonAI supports 100+ LLM providers through seamless integration:
|
||||
1. Install the lightweight core SDK:
|
||||
```bash
|
||||
pip install praisonaiagents
|
||||
export OPENAI_API_KEY="your-api-key"
|
||||
```
|
||||
|
||||
2. Run your first autonomous agent:
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Give your agent a goal, and watch it work.
|
||||
agent = Agent(instructions="You are a senior data analyst.")
|
||||
agent.start("Analyze the top 3 tech trends of 2026 and format as a markdown table.")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌌 The PraisonAI Ecosystem
|
||||
|
||||
Start simple with the core SDK, or expand to full visual builders and dashboards when you're ready.
|
||||
|
||||
* **Core SDK (`praisonaiagents`)**: For pure Python development. `pip install praisonaiagents`
|
||||
* 💻 **PraisonAI CLI (`praisonai`)**: For terminal-based developers. `pip install praisonai`
|
||||
* 🦞 **Claw Dashboard**: Connect agents directly to Telegram, Slack, or Discord. `pip install "praisonai[claw]"`
|
||||
* 🔗 **Flow Visual Builder**: Drag-and-drop workflow creation. `pip install "praisonai[flow]"`
|
||||
* 🤖 **PraisonAI UI**: Clean chat interface. `pip install "praisonai[ui]"`
|
||||
|
||||
### JavaScript SDK
|
||||
|
||||
```bash
|
||||
npm install praisonai
|
||||
```
|
||||
|
||||
## 🧠 Supported Providers & Features
|
||||
|
||||
Powered by 100+ LLMs (OpenAI, Anthropic, Gemini & local models).
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/OpenAI-412991?style=flat&logo=openai&logoColor=white" alt="OpenAI" />
|
||||
@@ -164,6 +178,14 @@ PraisonAI supports 100+ LLM providers through seamless integration:
|
||||
|
||||
</details>
|
||||
|
||||
<div align="center">
|
||||
<a href="https://x.com/elonmusk/status/1893870468249141688" target="_blank">
|
||||
<img src=".github/images/elon_musk_praisonai.png" alt="Highlighted by Elon Musk" width="600" />
|
||||
</a>
|
||||
<p><em>"Grok 3 customer support" — <a href="https://x.com/elonmusk/status/1893870468249141688">Elon Musk quoting PraisonAI's tutorial</a></em></p>
|
||||
</div>
|
||||
<br>
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Why PraisonAI?
|
||||
@@ -205,78 +227,6 @@ PraisonAI supports 100+ LLM providers through seamless integration:
|
||||
</details>
|
||||
|
||||
|
||||
<div align="center">
|
||||
<a href="https://x.com/elonmusk/status/1893870468249141688" target="_blank">
|
||||
<img src=".github/images/elon_musk_praisonai.png" alt="Highlighted by Elon Musk" width="600" />
|
||||
</a>
|
||||
<p><em>"Grok 3 customer support" — <a href="https://x.com/elonmusk/status/1893870468249141688">Elon Musk quoting PraisonAI's tutorial</a></em></p>
|
||||
</div>
|
||||
<br>
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
Get started with PraisonAI in under 1 minute:
|
||||
|
||||
```bash
|
||||
# Install
|
||||
pip install praisonaiagents
|
||||
|
||||
# Set API key
|
||||
export OPENAI_API_KEY=your_key_here
|
||||
|
||||
# Create a simple agent
|
||||
python -c "from praisonaiagents import Agent; Agent(instructions='You are a helpful AI assistant').start('Write a haiku about AI')"
|
||||
```
|
||||
|
||||
> **Next Steps:** [Single Agent Example](#1-single-agent) | [Multi Agents](#2-multi-agents) | [Full Docs](https://docs.praison.ai)
|
||||
|
||||
---
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### Python SDK
|
||||
|
||||
Lightweight package dedicated for coding:
|
||||
|
||||
```bash
|
||||
pip install praisonaiagents
|
||||
```
|
||||
|
||||
For the full framework with CLI support:
|
||||
|
||||
```bash
|
||||
pip install praisonai
|
||||
```
|
||||
|
||||
🦞 **PraisonAI Claw** — full UI with bots, memory, knowledge, and gateway:
|
||||
|
||||
```bash
|
||||
pip install "praisonai[claw]"
|
||||
praisonai claw
|
||||
```
|
||||
|
||||
🔗 **PraisonAI Flow** — Langflow Visual Flow Builder:
|
||||
|
||||
```bash
|
||||
pip install "praisonai[flow]"
|
||||
praisonai flow
|
||||
```
|
||||
|
||||
🤖 **PraisonAI UI** — Clean chat interface:
|
||||
|
||||
```bash
|
||||
pip install "praisonai[ui]"
|
||||
praisonai ui
|
||||
```
|
||||
|
||||
### JavaScript SDK
|
||||
|
||||
```bash
|
||||
npm install praisonai
|
||||
```
|
||||
|
||||
|
||||
|
||||
---
|
||||
@@ -774,6 +724,20 @@ const agent = new Agent({ instructions: 'You are a helpful AI assistant' });
|
||||
agent.start('Write a movie script about a robot in Mars');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Performance
|
||||
|
||||
PraisonAI is built for speed, with agent instantiation in under 4μs. This reduces overhead, improves responsiveness, and helps multi-agent systems scale efficiently in real-world production workloads.
|
||||
|
||||
| Performance Metric | PraisonAI |
|
||||
|--------------------|-----------|
|
||||
| Avg Instantiation Time | **3.77 μs** |
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
@@ -28,6 +28,7 @@ from praisonaiagents import Agent, AutoAgents, AutoRagAgent, ContextAgent, DeepR
|
||||
|
||||
Methods:
|
||||
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">aclose</a>() -> None</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">agent_id</a>() -> str</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">analyze_prompt</a>(prompt: str) -> set</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">auto_memory</a>() -> Optional[bool]</code>
|
||||
@@ -37,6 +38,7 @@ Methods:
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">chat_with_context</a>(message: str, context: 'ContextPack', **kwargs) -> str</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">checkpoints</a>() -> Optional[bool]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">checkpoints</a>(value: Optional[bool]) -> None</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">close</a>() -> None</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">console</a>() -> Optional[Any]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">context_manager</a>() -> Optional[Any]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">context_manager</a>(value)</code>
|
||||
@@ -52,6 +54,7 @@ Methods:
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">get_skills_prompt</a>() -> str</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">handoff_to</a>(target_agent: 'Agent', prompt: str, context: Optional[Dict[str, Any]] = None, config: Optional['HandoffConfig'] = None) -> 'HandoffResult'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">handoff_to_async</a>(target_agent: 'Agent', prompt: str, context: Optional[Dict[str, Any]] = None, config: Optional['HandoffConfig'] = None) -> 'HandoffResult'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">is_closed</a>() -> bool</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">llm_model</a>() -> Optional[str]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">output_style</a>() -> Optional[str]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">output_style</a>(value: Optional[str]) -> None</code>
|
||||
@@ -855,6 +858,7 @@ Methods:
|
||||
* <code title="cli">praisonai gateway send <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway start <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway status <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai github triage <a href="./src/praisonai/praisonai/cli/commands/github.py">--help</a></code>
|
||||
* <code title="cli">praisonai hooks add <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
|
||||
* <code title="cli">praisonai hooks list <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
|
||||
* <code title="cli">praisonai hooks remove <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
|
||||
@@ -862,6 +866,13 @@ Methods:
|
||||
* <code title="cli">praisonai knowledge index <a href="./src/praisonai/praisonai/cli/commands/knowledge.py">--help</a></code>
|
||||
* <code title="cli">praisonai knowledge list <a href="./src/praisonai/praisonai/cli/commands/knowledge.py">--help</a></code>
|
||||
* <code title="cli">praisonai knowledge search <a href="./src/praisonai/praisonai/cli/commands/knowledge.py">--help</a></code>
|
||||
* <code title="cli">praisonai langfuse config <a href="./src/praisonai/praisonai/cli/commands/langfuse.py">--help</a></code>
|
||||
* <code title="cli">praisonai langfuse connect <a href="./src/praisonai/praisonai/cli/commands/langfuse.py">--help</a></code>
|
||||
* <code title="cli">praisonai langfuse langfuse-start <a href="./src/praisonai/praisonai/cli/commands/langfuse.py">--help</a></code>
|
||||
* <code title="cli">praisonai langfuse status <a href="./src/praisonai/praisonai/cli/commands/langfuse.py">--help</a></code>
|
||||
* <code title="cli">praisonai langfuse stop <a href="./src/praisonai/praisonai/cli/commands/langfuse.py">--help</a></code>
|
||||
* <code title="cli">praisonai langfuse test <a href="./src/praisonai/praisonai/cli/commands/langfuse.py">--help</a></code>
|
||||
* <code title="cli">praisonai langfuse version <a href="./src/praisonai/praisonai/cli/commands/langfuse.py">--help</a></code>
|
||||
* <code title="cli">praisonai loop help <a href="./src/praisonai/praisonai/cli/commands/loop.py">--help</a></code>
|
||||
* <code title="cli">praisonai loop loop-main <a href="./src/praisonai/praisonai/cli/commands/loop.py">--help</a></code>
|
||||
* <code title="cli">praisonai lsp logs <a href="./src/praisonai/praisonai/cli/commands/lsp.py">--help</a></code>
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.5.112" \
|
||||
"praisonai>=4.5.133" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.5.112" \
|
||||
"praisonai>=4.5.133" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.5.112" \
|
||||
"praisonai>=4.5.133" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -43,14 +43,34 @@ app = FastAPI(
|
||||
redoc_url="/redoc"
|
||||
)
|
||||
|
||||
# Add CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
# Add CORS middleware with secure configuration
|
||||
cors_origins = os.getenv("API_CORS_ORIGINS", "").split(",")
|
||||
cors_origins = [origin.strip() for origin in cors_origins if origin.strip() and origin.strip() != "*"]
|
||||
|
||||
# Default secure origins if none specified
|
||||
if not cors_origins:
|
||||
# Secure defaults for different environments
|
||||
if os.getenv("ENVIRONMENT") == "production":
|
||||
# In production, require explicit configuration
|
||||
cors_origins = []
|
||||
else:
|
||||
# Development defaults - restrict to local origins
|
||||
cors_origins = [
|
||||
"http://localhost:3000", # Development frontend
|
||||
"http://localhost:8000", # Local development
|
||||
"http://127.0.0.1:3000", # Local development
|
||||
"http://127.0.0.1:8000", # Local development
|
||||
]
|
||||
|
||||
# Only add CORS middleware if origins are specified
|
||||
if cors_origins:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Authorization", "Content-Type", "Origin", "Accept"],
|
||||
)
|
||||
|
||||
# Create directories for storing reports
|
||||
REPORTS_DIR = Path("generated_reports")
|
||||
|
||||
@@ -14,14 +14,17 @@ import subprocess
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run_cli(cmd: str, cwd: str = None) -> tuple[int, str, str]:
|
||||
"""Run a CLI command and return (exit_code, stdout, stderr)."""
|
||||
# Use shell=False with shlex.split for safer execution
|
||||
args = shlex.split(cmd)
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
shell=True,
|
||||
args,
|
||||
shell=False, # Use shell=False for security
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=cwd,
|
||||
|
||||
@@ -308,7 +308,7 @@ def manage_state_versioning() -> Dict[str, Any]:
|
||||
versions[version_id] = {
|
||||
"config": current_config.copy(),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"hash": hashlib.md5(json.dumps(current_config, sort_keys=True).encode()).hexdigest()
|
||||
"hash": hashlib.sha256(json.dumps(current_config, sort_keys=True).encode()).hexdigest()
|
||||
}
|
||||
|
||||
# Make changes
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from praisonaiagents import Agent, Task, AgentTeam
|
||||
import subprocess
|
||||
import shlex
|
||||
import os
|
||||
|
||||
def run_terminal_command(command: str):
|
||||
@@ -7,7 +8,9 @@ def run_terminal_command(command: str):
|
||||
Run a terminal command and return its output.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(command, shell=True, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
# Use shell=False with shlex.split for safer execution
|
||||
args = shlex.split(command)
|
||||
result = subprocess.run(args, shell=False, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
print(f"Command output: {result}")
|
||||
return {"stdout": result.stdout, "stderr": result.stderr}
|
||||
except subprocess.CalledProcessError as e:
|
||||
|
||||
@@ -2,6 +2,7 @@ from praisonaiagents import Agent, Task, AgentTeam, error_logs
|
||||
import json, os
|
||||
from e2b_code_interpreter import Sandbox
|
||||
import subprocess
|
||||
import shlex
|
||||
|
||||
def code_interpret(code: str):
|
||||
"""
|
||||
@@ -40,7 +41,9 @@ def run_terminal_command(command: str):
|
||||
Run a terminal command and return its output.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(command, shell=True, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
# Use shell=False with shlex.split for safer execution
|
||||
args = shlex.split(command)
|
||||
result = subprocess.run(args, shell=False, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
print(f"Command output: {result}")
|
||||
return {"stdout": result.stdout, "stderr": result.stderr}
|
||||
except subprocess.CalledProcessError as e:
|
||||
|
||||
@@ -18,9 +18,8 @@ workflow:
|
||||
planning: true
|
||||
reasoning: true
|
||||
verbose: true
|
||||
memory_config:
|
||||
provider: chroma
|
||||
persist: true
|
||||
memory:
|
||||
backend: chroma
|
||||
|
||||
# Variables for template substitution
|
||||
variables:
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
# PraisonAI PR Reviewer Integration Guide
|
||||
|
||||
This guide provides step-by-step instructions for integrating PraisonAI as an automated PR reviewer in your GitHub CI/CD pipeline.
|
||||
|
||||
## Overview
|
||||
|
||||
PraisonAI PR Reviewer implements a **Zero-Code, Multi-Agent PR Review System** that deploys specialized agents to analyze pull requests from multiple perspectives:
|
||||
|
||||
- 🔐 **Security Reviewer**: Identifies vulnerabilities and security issues
|
||||
- ⚡ **Performance Reviewer**: Analyzes for bottlenecks and inefficiencies
|
||||
- 📋 **Maintainability Reviewer**: Evaluates code quality and best practices
|
||||
- 👨💼 **Lead Reviewer**: Synthesizes feedback and posts comprehensive reviews
|
||||
|
||||
## Architecture
|
||||
|
||||
This integration follows PraisonAI's **Agent-Centric** and **Protocol-Driven Core** design principles:
|
||||
|
||||
```
|
||||
GitHub PR → @praisonai trigger → Multi-Agent Workflow → Comprehensive Review
|
||||
```
|
||||
|
||||
The solution leverages:
|
||||
- **GitHub Actions** for CI/CD orchestration
|
||||
- **PraisonAI CLI** for agent execution
|
||||
- **YAML Configuration** for agent team definition
|
||||
- **GitHub CLI** for PR interaction
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Repository Setup**:
|
||||
- GitHub repository with Actions enabled
|
||||
- Required secrets configured (see [Secrets Configuration](#secrets-configuration))
|
||||
|
||||
2. **PraisonAI Installation**:
|
||||
- The workflow automatically installs PraisonAI via `pip install praisonai`
|
||||
- No additional dependencies required
|
||||
|
||||
3. **GitHub App/Token**:
|
||||
- GitHub App with required permissions OR
|
||||
- Personal Access Token with `repo` and `pull_requests` permissions
|
||||
|
||||
## Installation Steps
|
||||
|
||||
### Step 1: Copy Agent Configuration
|
||||
|
||||
The agent configuration is already provided at:
|
||||
```
|
||||
.github/praisonai-reviewer.yaml
|
||||
```
|
||||
|
||||
This file defines the multi-agent team and their specific responsibilities.
|
||||
|
||||
### Step 2: Create GitHub Workflow
|
||||
|
||||
**IMPORTANT**: Due to GitHub App permissions, the workflow file must be manually created.
|
||||
|
||||
1. Copy the template from:
|
||||
```
|
||||
examples/yaml/praisonai-pr-review.yml.template
|
||||
```
|
||||
|
||||
2. Save it as:
|
||||
```
|
||||
.github/workflows/praisonai-pr-review.yml
|
||||
```
|
||||
|
||||
### Step 3: Configure Secrets
|
||||
|
||||
Add the following secrets to your repository (`Settings > Secrets and variables > Actions`):
|
||||
|
||||
| Secret | Description | Required |
|
||||
|--------|-------------|----------|
|
||||
| `PRAISONAI_APP_ID` | GitHub App ID | Yes (if using GitHub App) |
|
||||
| `PRAISONAI_APP_PRIVATE_KEY` | GitHub App private key | Yes (if using GitHub App) |
|
||||
| `OPENAI_API_KEY` | OpenAI API key for LLM access | Yes |
|
||||
|
||||
**Alternative**: Use `GH_TOKEN` instead of GitHub App if you prefer PAT authentication.
|
||||
|
||||
### Step 4: Update Review Chain (Optional)
|
||||
|
||||
The review chain documentation has been updated to include PraisonAI:
|
||||
```
|
||||
CodeRabbit/Qodo → Gemini/PraisonAI (parallel) → Copilot → Claude (final)
|
||||
```
|
||||
|
||||
This ensures PraisonAI integrates seamlessly with existing review workflows.
|
||||
|
||||
## Usage
|
||||
|
||||
### Manual Trigger
|
||||
|
||||
1. **Workflow Dispatch**:
|
||||
- Go to `Actions > PraisonAI PR Review > Run workflow`
|
||||
- Enter the PR number to review
|
||||
|
||||
### Automatic Trigger
|
||||
|
||||
1. **Comment Trigger**:
|
||||
- Comment `@praisonai` on any pull request
|
||||
- Only repository owners, members, and collaborators can trigger
|
||||
|
||||
2. **With Instructions**:
|
||||
- `@praisonai focus on security vulnerabilities`
|
||||
- `@praisonai check performance and memory usage`
|
||||
- `@praisonai review for maintainability issues`
|
||||
|
||||
## Expected Output
|
||||
|
||||
When triggered, PraisonAI will post a comprehensive review with this structure:
|
||||
|
||||
```markdown
|
||||
## 📋 Review Summary
|
||||
[Brief overview and assessment]
|
||||
|
||||
## 🔍 General Feedback
|
||||
[Overall patterns and observations]
|
||||
|
||||
## 🎯 Specific Feedback
|
||||
### 🔴 Critical
|
||||
[Security vulnerabilities, breaking changes, major bugs]
|
||||
|
||||
### 🟡 High
|
||||
[Performance issues, design flaws, significant bugs]
|
||||
|
||||
### 🟢 Medium
|
||||
[Code quality improvements, minor optimizations]
|
||||
|
||||
### 🔵 Low
|
||||
[Documentation, naming suggestions, minor refactoring]
|
||||
|
||||
## ✅ Highlights
|
||||
[Positive aspects worth mentioning]
|
||||
|
||||
---
|
||||
*Review completed by PraisonAI Multi-Agent Team*
|
||||
```
|
||||
|
||||
## Integration with Existing Workflows
|
||||
|
||||
PraisonAI integrates seamlessly with the existing review chain:
|
||||
|
||||
1. **Parallel Execution**: Runs alongside Gemini for faster reviews
|
||||
2. **No Conflicts**: Uses unique trigger (`@praisonai`) to avoid interference
|
||||
3. **Complementary Analysis**: Provides different perspectives from other tools
|
||||
4. **Chain Continuation**: Claude final review incorporates PraisonAI feedback
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Authentication Errors**:
|
||||
- Verify `PRAISONAI_APP_ID` and `PRAISONAI_APP_PRIVATE_KEY` secrets are correctly set
|
||||
- Ensure GitHub App has required permissions
|
||||
|
||||
2. **PraisonAI Installation Fails**:
|
||||
- Check if Python setup step completed successfully
|
||||
- Verify internet connectivity for pip installation
|
||||
|
||||
3. **Agent Execution Fails**:
|
||||
- Check `OPENAI_API_KEY` secret is valid
|
||||
- Verify agent configuration YAML syntax
|
||||
|
||||
4. **Permission Denied**:
|
||||
- Ensure triggering user has required repository permissions
|
||||
- Check workflow file permissions configuration
|
||||
|
||||
### Debug Steps
|
||||
|
||||
1. **Check Workflow Logs**:
|
||||
- Go to `Actions > PraisonAI PR Review`
|
||||
- Click on failed run to see detailed logs
|
||||
|
||||
2. **Validate Configuration**:
|
||||
- Ensure `.github/praisonai-reviewer.yaml` syntax is valid
|
||||
- Test agent configuration locally if possible
|
||||
|
||||
3. **Test Manual Trigger**:
|
||||
- Use workflow dispatch to isolate comment trigger issues
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Custom Agent Teams
|
||||
|
||||
Modify `.github/praisonai-reviewer.yaml` to:
|
||||
- Add specialized agents (e.g., Architecture Reviewer)
|
||||
- Adjust agent responsibilities
|
||||
- Customize review output format
|
||||
|
||||
### Integration with External Tools
|
||||
|
||||
Extend agents to integrate with:
|
||||
- Code quality tools (SonarQube, CodeClimate)
|
||||
- Security scanners (Snyk, SAST tools)
|
||||
- Performance profilers
|
||||
|
||||
### Environment-Specific Reviews
|
||||
|
||||
Configure different agent teams for:
|
||||
- Backend vs Frontend changes
|
||||
- Different programming languages
|
||||
- Specific project domains
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- **Execution Time**: Typically 3-5 minutes for comprehensive review
|
||||
- **Rate Limits**: Respects GitHub API and OpenAI rate limits
|
||||
- **Cost**: Uses OpenAI API - monitor usage for cost control
|
||||
- **Parallel Execution**: Agents run concurrently for efficiency
|
||||
|
||||
## Security
|
||||
|
||||
- **Secret Handling**: All credentials stored securely in GitHub Secrets
|
||||
- **Permissions**: Minimal required permissions for workflow execution
|
||||
- **Code Access**: Review-only access, no code modification capabilities
|
||||
- **Audit Trail**: All reviews logged in GitHub Actions logs
|
||||
|
||||
## Contributing
|
||||
|
||||
To improve the PraisonAI PR Reviewer:
|
||||
|
||||
1. **Agent Enhancement**: Improve agent prompts and capabilities
|
||||
2. **Workflow Optimization**: Enhance GitHub Actions workflow
|
||||
3. **Documentation**: Update guides and troubleshooting info
|
||||
4. **Integration**: Add support for additional tools and platforms
|
||||
|
||||
## Support
|
||||
|
||||
For issues and questions:
|
||||
1. Check this guide first
|
||||
2. Review GitHub Actions logs
|
||||
3. Open issue in PraisonAI repository
|
||||
4. Tag with `ci/cd` and `pr-review` labels
|
||||
|
||||
---
|
||||
|
||||
*Generated as part of PraisonAI CI/CD PR Reviewer Integration (Issue #1329)*
|
||||
@@ -0,0 +1,68 @@
|
||||
name: PraisonAI PR Reviewer
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'Pull Request Number'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
review:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '@praisonai') &&
|
||||
github.actor != 'github-actions[bot]' &&
|
||||
github.event.comment.user.type != 'Bot' &&
|
||||
(github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR'))
|
||||
|
||||
steps:
|
||||
- name: Determine checkout ref
|
||||
id: dest
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "issue_comment" ]; then
|
||||
echo "ref=refs/pull/${{ github.event.issue.number }}/head" >> "$GITHUB_OUTPUT"
|
||||
elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "ref=refs/pull/${{ inputs.pr_number }}/head" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ref=${{ github.event.pull_request.head.sha }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.dest.outputs.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate GitHub App Token
|
||||
id: generate_token
|
||||
uses: tibdex/github-app-token@v2
|
||||
with:
|
||||
app_id: ${{ secrets.PRAISONAI_APP_ID }}
|
||||
private_key: ${{ secrets.PRAISONAI_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install PraisonAI
|
||||
run: pip install "praisonai[all]"
|
||||
|
||||
- name: Run PraisonAI PR Review
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
run: |
|
||||
praisonai workflow run .github/praisonai-reviewer.yaml
|
||||
@@ -11,11 +11,13 @@ PR opened
|
||||
↓
|
||||
CodeRabbit (@coderabbitai) ─── auto for human PRs, triggered via comment for bot PRs
|
||||
Qodo (/review) ─── auto for human PRs, triggered via comment for bot PRs
|
||||
Gemini (@gemini) ─── triggered via comment for bot PRs/Issues
|
||||
<!-- Gemini (@gemini) ─── triggered via comment for bot PRs/Issues
|
||||
↓ (~3-5 min) ↓ (auto triggers Claude on completion) -->
|
||||
PraisonAI (@praisonai) ─── triggered via comment
|
||||
↓ (~3-5 min) ↓ (auto triggers Claude on completion)
|
||||
Copilot (@copilot) ─── triggered ONLY after CodeRabbit or Qodo post their review
|
||||
↓
|
||||
Claude (@claude) ─── triggered ONLY after Copilot OR Gemini finishes (final reviewer)
|
||||
Claude (@claude) ─── triggered ONLY after Copilot OR Gemini OR PraisonAI finishes (final reviewer)
|
||||
```
|
||||
|
||||
## Workflow Files
|
||||
@@ -23,6 +25,7 @@ Claude (@claude) ─── triggered ONLY after Copilot OR Gemini fini
|
||||
| File | Trigger | Does what |
|
||||
|------|---------|-----------|
|
||||
| `auto-pr-comment.yml` | `issue_comment`, `pull_request_review`, `pull_request:opened` | Triggers Copilot after CodeRabbit/Qodo finish. For bot PRs: triggers CodeRabbit+Qodo+Gemini first. |
|
||||
| `praisonai-pr-review.yml` | `issue_comment`, `workflow_dispatch` | PraisonAI multi-agent PR review triggered by @praisonai mentions. |
|
||||
| `chain-claude-after-copilot.yml` | `pull_request_review:submitted`, `issue_comment` | Triggers Claude after Copilot reviews, AND automatically after Gemini Code Assist finishes fixing issues/PRs. |
|
||||
| `claude.yml` | `issue_comment`, `pull_request_review_comment`, `issues:assigned/labeled` | Claude responds to @claude mentions. |
|
||||
|
||||
@@ -30,6 +33,17 @@ Claude (@claude) ─── triggered ONLY after Copilot OR Gemini fini
|
||||
|
||||
CodeRabbit and Qodo skip `github-actions[bot]` authored PRs by default. The `bot-pr-trigger-reviews` job in `auto-pr-comment.yml` explicitly triggers them via comments.
|
||||
|
||||
## PraisonAI Integration
|
||||
|
||||
PraisonAI provides multi-agent PR review capabilities through the native PraisonAI agent framework. When triggered with `@praisonai`, it deploys a team of specialized agents:
|
||||
|
||||
- **Security Reviewer**: Analyzes for vulnerabilities, authentication issues, and unsafe code practices
|
||||
- **Performance Reviewer**: Identifies bottlenecks, inefficient algorithms, and resource usage issues
|
||||
- **Maintainability Reviewer**: Evaluates code quality, documentation, and adherence to best practices
|
||||
- **Lead Reviewer**: Synthesizes feedback and posts comprehensive review comments
|
||||
|
||||
The workflow uses the agent configuration at `.github/praisonai-reviewer.yaml` and leverages the `praisonai` CLI for execution.
|
||||
|
||||
## Key rules
|
||||
|
||||
- **Copilot ignores bot comments.** All `@copilot` mentions MUST use `GH_TOKEN` (not `GITHUB_TOKEN`) so comments post as `MervinPraison`.
|
||||
|
||||
@@ -170,16 +170,17 @@ _LAZY_IMPORTS = {
|
||||
'Handoff': ('praisonaiagents.agent.handoff', 'Handoff'),
|
||||
'handoff': ('praisonaiagents.agent.handoff', 'handoff'),
|
||||
'handoff_filters': ('praisonaiagents.agent.handoff', 'handoff_filters'),
|
||||
'parallel_handoffs': ('praisonaiagents.agent.handoff', 'parallel_handoffs'),
|
||||
'RECOMMENDED_PROMPT_PREFIX': ('praisonaiagents.agent.handoff', 'RECOMMENDED_PROMPT_PREFIX'),
|
||||
'prompt_with_handoff_instructions': ('praisonaiagents.agent.handoff', 'prompt_with_handoff_instructions'),
|
||||
'HandoffConfig': ('praisonaiagents.agent.handoff', 'HandoffConfig'),
|
||||
'HandoffResult': ('praisonaiagents.agent.handoff', 'HandoffResult'),
|
||||
'HandoffInputData': ('praisonaiagents.agent.handoff', 'HandoffInputData'),
|
||||
'ContextPolicy': ('praisonaiagents.agent.handoff', 'ContextPolicy'),
|
||||
'HandoffError': ('praisonaiagents.agent.handoff', 'HandoffError'),
|
||||
'HandoffCycleError': ('praisonaiagents.agent.handoff', 'HandoffCycleError'),
|
||||
'HandoffDepthError': ('praisonaiagents.agent.handoff', 'HandoffDepthError'),
|
||||
'HandoffTimeoutError': ('praisonaiagents.agent.handoff', 'HandoffTimeoutError'),
|
||||
'HandoffError': ('praisonaiagents.errors', 'HandoffError'),
|
||||
'HandoffCycleError': ('praisonaiagents.errors', 'HandoffCycleError'),
|
||||
'HandoffDepthError': ('praisonaiagents.errors', 'HandoffDepthError'),
|
||||
'HandoffTimeoutError': ('praisonaiagents.errors', 'HandoffTimeoutError'),
|
||||
|
||||
# Embedding API (Note: embedding/embeddings handled in custom_handler to override subpackage)
|
||||
'aembedding': ('praisonaiagents.embedding.embed', 'aembedding'),
|
||||
@@ -203,7 +204,15 @@ _LAZY_IMPORTS = {
|
||||
|
||||
# Agent classes
|
||||
'Agent': ('praisonaiagents.agent.agent', 'Agent'),
|
||||
'BudgetExceededError': ('praisonaiagents.agent.agent', 'BudgetExceededError'),
|
||||
'BudgetExceededError': ('praisonaiagents.errors', 'BudgetExceededError'),
|
||||
|
||||
# Error hierarchy - structured exception handling
|
||||
'PraisonAIError': ('praisonaiagents.errors', 'PraisonAIError'),
|
||||
'ToolExecutionError': ('praisonaiagents.errors', 'ToolExecutionError'),
|
||||
'LLMError': ('praisonaiagents.errors', 'LLMError'),
|
||||
'ValidationError': ('praisonaiagents.errors', 'ValidationError'),
|
||||
'NetworkError': ('praisonaiagents.errors', 'NetworkError'),
|
||||
'ErrorContextProtocol': ('praisonaiagents.errors', 'ErrorContextProtocol'),
|
||||
'Heartbeat': ('praisonaiagents.agent.heartbeat', 'Heartbeat'),
|
||||
'HeartbeatConfig': ('praisonaiagents.agent.heartbeat', 'HeartbeatConfig'),
|
||||
'ImageAgent': ('praisonaiagents.agent.image_agent', 'ImageAgent'),
|
||||
|
||||
@@ -110,7 +110,7 @@ def __getattr__(name):
|
||||
|
||||
# Handoff - lightweight
|
||||
_handoff_names = {
|
||||
'Handoff', 'handoff', 'handoff_filters',
|
||||
'Handoff', 'handoff', 'handoff_filters', 'parallel_handoffs',
|
||||
'RECOMMENDED_PROMPT_PREFIX', 'prompt_with_handoff_instructions',
|
||||
'HandoffConfig', 'HandoffResult', 'HandoffInputData',
|
||||
'ContextPolicy', 'HandoffError', 'HandoffCycleError',
|
||||
@@ -214,6 +214,7 @@ __all__ = [
|
||||
'Handoff',
|
||||
'handoff',
|
||||
'handoff_filters',
|
||||
'parallel_handoffs',
|
||||
'RECOMMENDED_PROMPT_PREFIX',
|
||||
'prompt_with_handoff_instructions',
|
||||
'HandoffConfig',
|
||||
|
||||
@@ -176,28 +176,19 @@ _shared_apps = {} # Dict of port -> FastAPI app
|
||||
# Don't import FastAPI dependencies here - use lazy loading instead
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..approval.protocols import ApprovalConfig, ApprovalProtocol
|
||||
from ..config.feature_configs import LearnConfig, MemoryConfig
|
||||
from ..context.models import ContextConfig
|
||||
from ..context.manager import ContextManager
|
||||
from ..knowledge.knowledge import Knowledge
|
||||
from ..agent.autonomy import AutonomyConfig
|
||||
from ..task.task import Task
|
||||
from .handoff import Handoff, HandoffConfig, HandoffResult
|
||||
from ..rag.models import RAGResult, ContextPack
|
||||
from ..eval.results import EvaluationLoopResult
|
||||
|
||||
class BudgetExceededError(Exception):
|
||||
"""Raised when an agent exceeds its max_budget.
|
||||
|
||||
Usage:
|
||||
try:
|
||||
agent.start("...")
|
||||
except BudgetExceededError as e:
|
||||
print(f"Agent '{e.agent_name}' spent ${e.total_cost:.4f} of ${e.max_budget:.4f}")
|
||||
"""
|
||||
def __init__(self, agent_name: str, total_cost: float, max_budget: float):
|
||||
self.agent_name = agent_name
|
||||
self.total_cost = total_cost
|
||||
self.max_budget = max_budget
|
||||
super().__init__(
|
||||
f"Agent '{agent_name}' exceeded budget: "
|
||||
f"${total_cost:.4f} >= ${max_budget:.4f}"
|
||||
)
|
||||
# Import structured error from central errors module
|
||||
from ..errors import BudgetExceededError
|
||||
|
||||
class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin, ExecutionMixin, MemoryMixin):
|
||||
# Class-level counter for generating unique display names for nameless agents
|
||||
@@ -476,24 +467,24 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
|
||||
# CONSOLIDATED FEATURE PARAMS (agent-centric API)
|
||||
# Each follows: False=disabled, True=defaults, Config=custom
|
||||
# ============================================================
|
||||
memory: Optional[Any] = None, # Union[bool, MemoryConfig, MemoryManager]
|
||||
knowledge: Optional[Union[bool, List[str], Any]] = None, # Union[bool, list, KnowledgeConfig, Knowledge]
|
||||
planning: Optional[Union[bool, Any]] = False, # Union[bool, PlanningConfig]
|
||||
reflection: Optional[Union[bool, Any]] = None, # Union[bool, ReflectionConfig]
|
||||
guardrails: Optional[Union[bool, Callable, Any]] = None, # Union[bool, Callable, GuardrailConfig]
|
||||
web: Optional[Union[bool, Any]] = None, # Union[bool, WebConfig]
|
||||
context: Optional[Union[bool, Any]] = None, # Union[bool, ManagerConfig, ContextManager] - None=smart default
|
||||
autonomy: Optional[Union[bool, Dict[str, Any], Any]] = None, # Union[bool, dict, AutonomyConfig]
|
||||
memory: Optional[Union[bool, str, 'MemoryConfig', 'MemoryManager']] = None,
|
||||
knowledge: Optional[Union[bool, str, List[str], 'KnowledgeConfig', 'Knowledge']] = None,
|
||||
planning: Optional[Union[bool, str, 'PlanningConfig']] = False,
|
||||
reflection: Optional[Union[bool, str, 'ReflectionConfig']] = None,
|
||||
guardrails: Optional[Union[bool, str, Callable, 'GuardrailConfig']] = None,
|
||||
web: Optional[Union[bool, str, 'WebConfig']] = None,
|
||||
context: Optional[Union[bool, str, Dict[str, Any], 'ContextConfig', 'ContextManager']] = None,
|
||||
autonomy: Optional[Union[bool, str, Dict[str, Any], 'AutonomyConfig']] = None,
|
||||
verification_hooks: Optional[List[Any]] = None, # Deprecated: use autonomy=AutonomyConfig(verification_hooks=[...])
|
||||
output: Optional[Union[str, Any]] = None, # Union[str preset, OutputConfig]
|
||||
execution: Optional[Union[str, Any]] = None, # Union[str preset, ExecutionConfig]
|
||||
templates: Optional[Any] = None, # TemplateConfig
|
||||
caching: Optional[Union[bool, Any]] = None, # Union[bool, CachingConfig]
|
||||
hooks: Optional[Union[List[Any], Any]] = None, # Union[list, HooksConfig]
|
||||
skills: Optional[Union[List[str], Any]] = None, # Union[list, SkillsConfig]
|
||||
approval: Optional[Union[bool, Any]] = None, # Union[bool, ApprovalProtocol backend]
|
||||
output: Optional[Union[bool, str, Dict[str, Any], 'OutputConfig']] = None,
|
||||
execution: Optional[Union[bool, str, Dict[str, Any], 'ExecutionConfig']] = None,
|
||||
templates: Optional[Union[Dict[str, Any], 'TemplateConfig']] = None,
|
||||
caching: Optional[Union[bool, str, Dict[str, Any], 'CachingConfig']] = None,
|
||||
hooks: Optional[Union[List[Any], Dict[str, Any], 'HooksConfig']] = None,
|
||||
skills: Optional[Union[List[str], str, Dict[str, Any], 'SkillsConfig']] = None,
|
||||
approval: Optional[Union[bool, str, Dict[str, Any], 'ApprovalConfig', 'ApprovalProtocol']] = None,
|
||||
tool_timeout: Optional[int] = None, # P8/G11: Timeout in seconds for each tool call
|
||||
learn: Optional[Union[bool, Any]] = None, # Union[bool, LearnConfig] - Continuous learning (peer to memory)
|
||||
learn: Optional[Union[bool, str, Dict[str, Any], 'LearnConfig']] = None, # Continuous learning (peer to memory)
|
||||
):
|
||||
"""Initialize an Agent instance.
|
||||
|
||||
@@ -538,22 +529,32 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
|
||||
- WebConfig: Custom configuration
|
||||
context: Context management. Accepts:
|
||||
- bool: True enables with defaults
|
||||
- ManagerConfig: Custom configuration
|
||||
- str: Preset name ("sliding_window", "summarize", "truncate")
|
||||
- Dict[str, Any]: ContextConfig fields
|
||||
- ContextConfig: Custom configuration
|
||||
- ContextManager: Pre-configured instance
|
||||
autonomy: Autonomy settings. Accepts:
|
||||
- bool: True enables with defaults
|
||||
- Dict: Configuration dict
|
||||
- str: Level preset ("suggest", "auto_edit", "full_auto")
|
||||
- Dict[str, Any]: Configuration dict
|
||||
- AutonomyConfig: Custom configuration
|
||||
verification_hooks: **Deprecated** — use ``autonomy=AutonomyConfig(verification_hooks=[...])``.
|
||||
Still works for backward compatibility.
|
||||
output: Output configuration. Accepts:
|
||||
- bool: True=default OutputConfig, False=disabled
|
||||
- str: Preset name ("silent", "actions", "verbose", "json", "stream")
|
||||
- Dict[str, Any]: Config overrides (e.g. {"verbose": 2, "stream": True})
|
||||
- OutputConfig: Custom configuration
|
||||
Controls: verbose, markdown, stream, metrics, reasoning_steps
|
||||
execution: Execution configuration. Accepts:
|
||||
- bool: True=default ExecutionConfig, False=disabled
|
||||
- str: Preset name ("fast", "balanced", "thorough")
|
||||
- Dict[str, Any]: Config overrides (e.g. {"max_iter": 10, "max_rpm": 60})
|
||||
- ExecutionConfig: Custom configuration
|
||||
Controls: max_iter, max_rpm, max_execution_time, max_retry_limit
|
||||
templates: Template configuration (TemplateConfig).
|
||||
templates: Template configuration. Accepts:
|
||||
- Dict[str, Any]: Template fields (e.g. {"system": "...", "prompt": "..."})
|
||||
- TemplateConfig: Custom configuration
|
||||
Controls: system_template, prompt_template, response_template
|
||||
caching: Caching configuration. Accepts:
|
||||
- bool: True enables with defaults
|
||||
@@ -565,7 +566,9 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
|
||||
- List[str]: Skill directory paths
|
||||
- SkillsConfig: Custom configuration
|
||||
learn: Continuous learning configuration. Accepts:
|
||||
- bool: True enables with defaults, False disables
|
||||
- bool: True enables with defaults (AGENTIC mode), False disables
|
||||
- str: Mode string ("disabled", "agentic", "propose")
|
||||
- Dict[str, Any]: Config fields (e.g. {"mode": "agentic", "backend": "sqlite"})
|
||||
- LearnConfig: Custom configuration
|
||||
Learning is a first-class citizen, peer to memory. It captures patterns,
|
||||
preferences, and insights from interactions to improve future responses.
|
||||
@@ -790,6 +793,7 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
|
||||
editor_output = getattr(_output_config, 'editor_output', False) # Editor: Step N display
|
||||
output_file = getattr(_output_config, 'output_file', None) # Auto-save to file
|
||||
output_template = getattr(_output_config, 'template', None) # Response template
|
||||
tool_output_limit = getattr(_output_config, 'tool_output_limit', DEFAULT_TOOL_OUTPUT_LIMIT)
|
||||
else:
|
||||
# Fallback defaults match silent mode (zero overhead)
|
||||
verbose, markdown, stream, metrics, reasoning_steps = False, False, False, False, False
|
||||
@@ -798,6 +802,7 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
|
||||
status_trace = False
|
||||
simple_output = False
|
||||
editor_output = False
|
||||
tool_output_limit = DEFAULT_TOOL_OUTPUT_LIMIT
|
||||
|
||||
# Enable editor output mode if configured (beginner-friendly, takes priority)
|
||||
# Shows: Step 1: 📄 Creating file: path → ✓ Done
|
||||
@@ -1068,8 +1073,24 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
|
||||
_learn_config = learn
|
||||
elif isinstance(learn, dict):
|
||||
_learn_config = LearnConfig(**learn)
|
||||
elif isinstance(learn, str):
|
||||
# String mode: "disabled", "agentic", "propose"
|
||||
from ..memory.learn.protocols import LearnMode
|
||||
if learn == "disabled":
|
||||
_learn_config = None
|
||||
elif learn == "agentic":
|
||||
_learn_config = LearnConfig(mode=LearnMode.AGENTIC)
|
||||
elif learn == "propose":
|
||||
_learn_config = LearnConfig(mode=LearnMode.PROPOSE)
|
||||
else:
|
||||
# Unknown string mode, disable learning
|
||||
_learn_config = None
|
||||
else:
|
||||
_learn_config = learn # Pass through
|
||||
logging.warning(
|
||||
"Unsupported learn= value %r; expected bool, dict, or LearnConfig. "
|
||||
"Learning disabled.", learn
|
||||
)
|
||||
_learn_config = None
|
||||
elif _memory_config is not None and isinstance(_memory_config, MemoryConfig):
|
||||
# Fallback to memory.learn for backward compatibility
|
||||
if _memory_config.learn:
|
||||
@@ -1117,8 +1138,18 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
|
||||
session_id = _history_session_id
|
||||
elif _history_enabled and session_id is None and _history_session_id is None:
|
||||
import hashlib as _hl
|
||||
_agent_hash = _hl.md5((name or "agent").encode()).hexdigest()[:8]
|
||||
session_id = f"history_{_agent_hash}"
|
||||
_agent_hash = _hl.sha256((name or "agent").encode()).hexdigest()[:8]
|
||||
# Backward compat: check if legacy md5-based session exists first
|
||||
_legacy_hash = _hl.md5((name or "agent").encode()).hexdigest()[:8]
|
||||
_legacy_id = f"history_{_legacy_hash}"
|
||||
_new_id = f"history_{_agent_hash}"
|
||||
# Prefer legacy if it exists on disk, else use new SHA-256 ID
|
||||
import os as _os
|
||||
_session_dir = _os.path.join(_os.path.expanduser("~"), ".praisonai", "sessions")
|
||||
if _os.path.exists(_os.path.join(_session_dir, f"{_legacy_id}.json")):
|
||||
session_id = _legacy_id # preserve existing history
|
||||
else:
|
||||
session_id = _new_id
|
||||
_history_session_id = session_id
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
@@ -1518,6 +1549,7 @@ class Agent(ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin
|
||||
self._init_memory(memory, user_id)
|
||||
self.verbose = verbose
|
||||
self._has_explicit_output_config = _has_explicit_output # Track if user set output mode
|
||||
self.tool_output_limit = tool_output_limit # Configurable tool output limit
|
||||
self.allow_delegation = allow_delegation
|
||||
self.step_callback = step_callback
|
||||
# Token budget guard (zero overhead when _max_budget is None)
|
||||
@@ -1642,6 +1674,12 @@ Your Goal: {self.goal}
|
||||
self._approval_backend = approval.backend
|
||||
self._approve_all_tools = approval.all_tools
|
||||
self._approval_timeout = approval.timeout # None = indefinite, 0 = backend default
|
||||
elif isinstance(approval, dict):
|
||||
# Dict config: convert to ApprovalConfig
|
||||
approval_config = ApprovalConfig(**approval)
|
||||
self._approval_backend = approval_config.backend
|
||||
self._approve_all_tools = approval_config.all_tools
|
||||
self._approval_timeout = approval_config.timeout
|
||||
else:
|
||||
# Plain backend object — dangerous tools only, backend default timeout
|
||||
self._approval_backend = approval
|
||||
@@ -1949,6 +1987,64 @@ Your Goal: {self.goal}
|
||||
elif hasattr(self._context_param, 'process'):
|
||||
# Already a ContextManager instance
|
||||
self._context_manager = self._context_param
|
||||
elif isinstance(self._context_param, str):
|
||||
# String preset: "sliding_window", "summarize", "truncate"
|
||||
from ..config.presets import CONTEXT_PRESETS
|
||||
preset_config = CONTEXT_PRESETS.get(self._context_param)
|
||||
if preset_config is not None:
|
||||
# Convert preset to ContextConfig, then to ManagerConfig
|
||||
try:
|
||||
from ..context.models import ContextConfig as _ContextConfig
|
||||
context_config = _ContextConfig(**preset_config)
|
||||
manager_config = ManagerConfig(
|
||||
auto_compact=context_config.auto_compact,
|
||||
compact_threshold=context_config.compact_threshold,
|
||||
strategy=context_config.strategy,
|
||||
output_reserve=context_config.output_reserve,
|
||||
default_tool_output_max=context_config.tool_output_max,
|
||||
protected_tools=list(context_config.protected_tools),
|
||||
keep_recent_turns=context_config.keep_recent_turns,
|
||||
monitor_enabled=context_config.monitor.enabled if context_config.monitor else False,
|
||||
)
|
||||
self._context_manager = ContextManager(
|
||||
model=self.llm if isinstance(self.llm, str) else "gpt-4o-mini",
|
||||
config=manager_config,
|
||||
agent_name=self.name or "Agent",
|
||||
session_cache=self._session_dedup_cache,
|
||||
llm_summarize_fn=None,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.debug(f"Context preset conversion failed: {e}")
|
||||
self._context_manager = None
|
||||
else:
|
||||
# Unknown string preset, disable
|
||||
self._context_manager = None
|
||||
elif isinstance(self._context_param, dict):
|
||||
# Dict config: convert to ContextConfig, then to ManagerConfig
|
||||
try:
|
||||
from ..context.models import ContextConfig as _ContextConfig
|
||||
context_config = _ContextConfig(**self._context_param)
|
||||
manager_config = ManagerConfig(
|
||||
auto_compact=context_config.auto_compact,
|
||||
compact_threshold=context_config.compact_threshold,
|
||||
strategy=context_config.strategy,
|
||||
output_reserve=context_config.output_reserve,
|
||||
default_tool_output_max=context_config.tool_output_max,
|
||||
protected_tools=list(context_config.protected_tools),
|
||||
keep_recent_turns=context_config.keep_recent_turns,
|
||||
monitor_enabled=context_config.monitor.enabled if context_config.monitor else False,
|
||||
)
|
||||
llm_summarize_enabled = self._context_param.get('llm_summarize', False)
|
||||
self._context_manager = ContextManager(
|
||||
model=self.llm if isinstance(self.llm, str) else "gpt-4o-mini",
|
||||
config=manager_config,
|
||||
agent_name=self.name or "Agent",
|
||||
session_cache=self._session_dedup_cache,
|
||||
llm_summarize_fn=self._create_llm_summarize_fn() if llm_summarize_enabled else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.debug(f"Context dict conversion failed: {e}")
|
||||
self._context_manager = None
|
||||
else:
|
||||
# Unknown type, disable
|
||||
self._context_manager = None
|
||||
@@ -2165,6 +2261,25 @@ Summary:"""
|
||||
# Extract verification_hooks from AutonomyConfig if provided
|
||||
if autonomy.verification_hooks and not verification_hooks:
|
||||
self._verification_hooks = autonomy.verification_hooks
|
||||
elif isinstance(autonomy, str):
|
||||
# String preset: "suggest", "auto_edit", "full_auto"
|
||||
from ..config.presets import AUTONOMY_PRESETS
|
||||
preset_config = AUTONOMY_PRESETS.get(autonomy)
|
||||
if preset_config is not None:
|
||||
config = AutonomyConfig.from_dict(preset_config)
|
||||
else:
|
||||
# Unknown string preset — disable autonomy
|
||||
self.autonomy_enabled = False
|
||||
self.autonomy_config = {}
|
||||
self._autonomy_trigger = None
|
||||
self._doom_loop_tracker = None
|
||||
self._file_snapshot = None
|
||||
self._snapshot_stack = []
|
||||
self._redo_stack = []
|
||||
self._autonomy_turn_tool_count = 0
|
||||
self._consecutive_no_tool_turns = 0
|
||||
self._doom_recovery_active = False
|
||||
return
|
||||
else:
|
||||
self.autonomy_enabled = False
|
||||
self.autonomy_config = {}
|
||||
@@ -3920,17 +4035,28 @@ Summary:"""
|
||||
return "", None
|
||||
|
||||
# Normalize results format (filter out None values, handle None metadata)
|
||||
if isinstance(search_results, dict) and 'results' in search_results:
|
||||
# Normalize results format (filter out None values, handle None metadata)
|
||||
if hasattr(search_results, "results") and isinstance(getattr(search_results, "results"), list):
|
||||
search_results = getattr(search_results, "results")
|
||||
elif isinstance(search_results, dict) and 'results' in search_results:
|
||||
search_results = search_results['results']
|
||||
|
||||
if isinstance(search_results, list):
|
||||
results = []
|
||||
for r in search_results['results']:
|
||||
for r in search_results:
|
||||
if r is None:
|
||||
continue
|
||||
text = r.get('memory', '') or ''
|
||||
metadata = r.get('metadata') or {} # Handle None metadata
|
||||
if isinstance(r, dict):
|
||||
text = r.get('memory', '') or r.get('text', '') or ''
|
||||
metadata = r.get('metadata') or {}
|
||||
else:
|
||||
text = getattr(r, 'text', None) or getattr(r, 'memory', None) or ''
|
||||
metadata = getattr(r, 'metadata', None)
|
||||
if metadata is None:
|
||||
metadata = {}
|
||||
|
||||
if text:
|
||||
results.append({"text": text, "metadata": metadata})
|
||||
elif isinstance(search_results, list):
|
||||
results = [{"text": str(r), "metadata": {}} for r in search_results if r is not None and str(r)]
|
||||
results.append({"text": str(text), "metadata": metadata})
|
||||
else:
|
||||
results = [{"text": str(search_results), "metadata": {}}] if search_results else []
|
||||
|
||||
@@ -4338,6 +4464,149 @@ Answer:"""
|
||||
@contextlib.contextmanager
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Resource Lifecycle Management
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def close(self) -> None:
|
||||
"""Synchronously close the agent and clean up resources."""
|
||||
if getattr(self, '_closed', False):
|
||||
return
|
||||
|
||||
# Memory cleanup
|
||||
try:
|
||||
memory = getattr(self, "_memory_instance", None)
|
||||
if memory and hasattr(memory, 'close_connections'):
|
||||
memory.close_connections()
|
||||
except Exception as e:
|
||||
logger.warning(f"Memory cleanup failed: {e}")
|
||||
|
||||
# MCP cleanup
|
||||
try:
|
||||
if hasattr(self, '_mcp_clients') and self._mcp_clients:
|
||||
for client_name, client in self._mcp_clients.items():
|
||||
if hasattr(client, 'close'):
|
||||
client.close()
|
||||
self._mcp_clients.clear()
|
||||
except Exception as e:
|
||||
logger.warning(f"MCP cleanup failed: {e}")
|
||||
|
||||
# Server registry cleanup
|
||||
try:
|
||||
self._cleanup_server_registrations()
|
||||
except Exception as e:
|
||||
logger.warning(f"Server cleanup failed: {e}")
|
||||
|
||||
# Background tasks cleanup
|
||||
try:
|
||||
for task in getattr(self, '_background_tasks', []):
|
||||
if hasattr(task, 'cancel'):
|
||||
task.cancel()
|
||||
except Exception as e:
|
||||
logger.warning(f"Task cleanup failed: {e}")
|
||||
|
||||
# Always set closed flag
|
||||
self._closed = True
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Async version of close() for async context managers."""
|
||||
try:
|
||||
# Close memory connections asynchronously if supported
|
||||
if hasattr(self, 'memory') and self.memory:
|
||||
if hasattr(self.memory, 'aclose'):
|
||||
await self.memory.aclose()
|
||||
elif hasattr(self.memory, 'close_connections'):
|
||||
self.memory.close_connections()
|
||||
|
||||
# Close MCP sessions asynchronously if supported
|
||||
if hasattr(self, '_mcp_clients') and self._mcp_clients:
|
||||
for client in self._mcp_clients.values():
|
||||
if hasattr(client, 'aclose'):
|
||||
await client.aclose()
|
||||
elif hasattr(client, 'close'):
|
||||
client.close()
|
||||
self._mcp_clients.clear()
|
||||
|
||||
# Clean up server registrations and tasks
|
||||
self._cleanup_server_registrations()
|
||||
|
||||
if hasattr(self, '_background_tasks'):
|
||||
for task in self._background_tasks:
|
||||
if hasattr(task, 'cancel'):
|
||||
task.cancel()
|
||||
# Wait for cancellation to complete
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
self._closed = True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error during async agent cleanup: {e}")
|
||||
|
||||
def _cleanup_server_registrations(self) -> None:
|
||||
"""Clean up global server registry entries for this agent."""
|
||||
if getattr(self, '_agent_id', None) is None:
|
||||
return # No ID generated, nothing registered
|
||||
|
||||
try:
|
||||
agent_id = self._agent_id
|
||||
with _server_lock:
|
||||
# Remove from _registered_agents
|
||||
ports_to_clean = []
|
||||
for port, path_dict in _registered_agents.items():
|
||||
paths_to_remove = []
|
||||
for path, registered_id in path_dict.items():
|
||||
if registered_id == agent_id:
|
||||
paths_to_remove.append(path)
|
||||
|
||||
for path in paths_to_remove:
|
||||
del path_dict[path]
|
||||
|
||||
# If no paths left for this port, mark port for cleanup
|
||||
if not path_dict:
|
||||
ports_to_clean.append(port)
|
||||
|
||||
# Clean up empty port entries
|
||||
for port in ports_to_clean:
|
||||
_registered_agents.pop(port, None)
|
||||
_server_started.pop(port, None)
|
||||
# Note: We don't clean up _shared_apps here as other agents might be using them
|
||||
|
||||
except Exception as e:
|
||||
import sys
|
||||
if sys.meta_path is not None:
|
||||
try:
|
||||
logger.warning(f"Error cleaning up server registrations: {e}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
"""Context manager entry."""
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Context manager exit - clean up resources."""
|
||||
self.close()
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Async context manager entry."""
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Async context manager exit - clean up resources."""
|
||||
await self.aclose()
|
||||
|
||||
def __del__(self):
|
||||
"""Destructor safely does nothing to avoid GC pollution in test loops."""
|
||||
pass
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool:
|
||||
"""Returns True if the agent has been closed."""
|
||||
return getattr(self, '_closed', False)
|
||||
|
||||
def __str__(self):
|
||||
return f"Agent(name='{self.name}', role='{self.role}', goal='{self.goal}')"
|
||||
|
||||
@@ -13,6 +13,7 @@ import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
import asyncio
|
||||
import threading
|
||||
from ..errors import BudgetExceededError
|
||||
|
||||
# Fallback helpers to avoid circular imports
|
||||
def _get_console():
|
||||
@@ -535,8 +536,24 @@ Your Goal: {self.goal}"""
|
||||
formatted_tools = self._format_tools_for_completion(tools)
|
||||
|
||||
try:
|
||||
# Use the custom LLM instance if available
|
||||
if self._using_custom_llm and hasattr(self, 'llm_instance'):
|
||||
# NEW: Unified protocol dispatch path (Issue #1304)
|
||||
# Check if unified dispatch is enabled (opt-in for backward compatibility)
|
||||
if getattr(self, '_use_unified_llm_dispatch', False):
|
||||
# Use composition instead of runtime class mutation for safety
|
||||
final_response = self._execute_unified_chat_completion(
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
tools=formatted_tools,
|
||||
stream=stream,
|
||||
reasoning_steps=reasoning_steps,
|
||||
task_name=task_name,
|
||||
task_description=task_description,
|
||||
task_id=task_id,
|
||||
response_format=response_format
|
||||
)
|
||||
|
||||
# LEGACY: Maintain existing dual execution paths for backward compatibility
|
||||
elif self._using_custom_llm and hasattr(self, 'llm_instance'):
|
||||
if stream:
|
||||
# Debug logs for tool info
|
||||
if formatted_tools:
|
||||
@@ -666,7 +683,13 @@ Your Goal: {self.goal}"""
|
||||
self._llm_call_count += 1
|
||||
if self._max_budget and self._total_cost >= self._max_budget:
|
||||
if self._on_budget_exceeded == "stop":
|
||||
raise BudgetExceededError(self.name, self._total_cost, self._max_budget)
|
||||
raise BudgetExceededError(
|
||||
f"Agent '{self.name}' exceeded budget: ${self._total_cost:.4f} >= ${self._max_budget:.4f}",
|
||||
budget_type="cost",
|
||||
limit=self._max_budget,
|
||||
used=self._total_cost,
|
||||
agent_id=self.name
|
||||
)
|
||||
elif self._on_budget_exceeded == "warn":
|
||||
logging.warning(
|
||||
f"[budget] {self.name}: ${self._total_cost:.4f} exceeded "
|
||||
@@ -692,6 +715,8 @@ Your Goal: {self.goal}"""
|
||||
|
||||
return final_response
|
||||
|
||||
except BudgetExceededError:
|
||||
raise
|
||||
except Exception as e:
|
||||
error_str = str(e).lower()
|
||||
|
||||
@@ -744,6 +769,130 @@ Your Goal: {self.goal}"""
|
||||
_get_display_functions()['display_error'](f"Error in chat completion: {e}")
|
||||
return None
|
||||
|
||||
def _execute_unified_chat_completion(
|
||||
self,
|
||||
messages,
|
||||
temperature=1.0,
|
||||
tools=None,
|
||||
stream=True,
|
||||
reasoning_steps=False,
|
||||
task_name=None,
|
||||
task_description=None,
|
||||
task_id=None,
|
||||
response_format=None
|
||||
):
|
||||
"""
|
||||
Execute unified chat completion using composition instead of runtime class mutation.
|
||||
|
||||
This method provides the same functionality as UnifiedChatMixin but uses
|
||||
composition for safety and maintainability.
|
||||
"""
|
||||
from ..llm import create_llm_dispatcher
|
||||
|
||||
# Get or create unified dispatcher
|
||||
if not hasattr(self, '_unified_dispatcher') or self._unified_dispatcher is None:
|
||||
# Provider selection based on existing agent configuration
|
||||
if self._using_custom_llm and hasattr(self, 'llm_instance'):
|
||||
dispatcher = create_llm_dispatcher(llm_instance=self.llm_instance)
|
||||
else:
|
||||
# Initialize OpenAI client if not present
|
||||
if not hasattr(self, '_openai_client') or self._openai_client is None:
|
||||
from ..llm import get_openai_client
|
||||
self._openai_client = get_openai_client(api_key=getattr(self, 'api_key', None))
|
||||
dispatcher = create_llm_dispatcher(openai_client=self._openai_client, model=self.llm)
|
||||
|
||||
# Cache the dispatcher
|
||||
self._unified_dispatcher = dispatcher
|
||||
|
||||
# Execute unified dispatch with all necessary parameters
|
||||
try:
|
||||
final_response = self._unified_dispatcher.chat_completion(
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
temperature=temperature,
|
||||
max_tokens=getattr(self, 'max_tokens', None),
|
||||
stream=stream,
|
||||
response_format=response_format,
|
||||
execute_tool_fn=getattr(self, 'execute_tool', None),
|
||||
console=self.console if (self.verbose or stream) else None,
|
||||
display_fn=self._display_generating if self.verbose else None,
|
||||
stream_callback=getattr(self.stream_emitter, 'emit', None) if hasattr(self, 'stream_emitter') else None,
|
||||
emit_events=True,
|
||||
verbose=self.verbose,
|
||||
max_iterations=10,
|
||||
reasoning_steps=reasoning_steps,
|
||||
task_name=task_name,
|
||||
task_description=task_description,
|
||||
task_id=task_id
|
||||
)
|
||||
return final_response
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Unified chat completion failed: {e}")
|
||||
raise
|
||||
|
||||
async def _execute_unified_achat_completion(
|
||||
self,
|
||||
messages,
|
||||
temperature=1.0,
|
||||
tools=None,
|
||||
stream=True,
|
||||
reasoning_steps=False,
|
||||
task_name=None,
|
||||
task_description=None,
|
||||
task_id=None,
|
||||
response_format=None
|
||||
):
|
||||
"""
|
||||
Execute unified async chat completion using composition instead of runtime class mutation.
|
||||
|
||||
This method provides the same functionality as UnifiedChatMixin but uses
|
||||
composition for safety and maintainability.
|
||||
"""
|
||||
from ..llm import create_llm_dispatcher
|
||||
|
||||
# Get or create unified dispatcher
|
||||
if not hasattr(self, '_unified_dispatcher') or self._unified_dispatcher is None:
|
||||
# Provider selection based on existing agent configuration
|
||||
if self._using_custom_llm and hasattr(self, 'llm_instance'):
|
||||
dispatcher = create_llm_dispatcher(llm_instance=self.llm_instance)
|
||||
else:
|
||||
# Initialize OpenAI client if not present
|
||||
if not hasattr(self, '_openai_client') or self._openai_client is None:
|
||||
from ..llm import get_openai_client
|
||||
self._openai_client = get_openai_client(api_key=getattr(self, 'api_key', None))
|
||||
dispatcher = create_llm_dispatcher(openai_client=self._openai_client, model=self.llm)
|
||||
|
||||
# Cache the dispatcher
|
||||
self._unified_dispatcher = dispatcher
|
||||
|
||||
# Execute unified async dispatch with all necessary parameters
|
||||
try:
|
||||
final_response = await self._unified_dispatcher.achat_completion(
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
temperature=temperature,
|
||||
max_tokens=getattr(self, 'max_tokens', None),
|
||||
stream=stream,
|
||||
response_format=response_format,
|
||||
execute_tool_fn=getattr(self, 'execute_tool', None),
|
||||
console=self.console if (self.verbose or stream) else None,
|
||||
display_fn=self._display_generating if self.verbose else None,
|
||||
stream_callback=getattr(self.stream_emitter, 'emit', None) if hasattr(self, 'stream_emitter') else None,
|
||||
emit_events=True,
|
||||
verbose=self.verbose,
|
||||
max_iterations=10,
|
||||
reasoning_steps=reasoning_steps,
|
||||
task_name=task_name,
|
||||
task_description=task_description,
|
||||
task_id=task_id
|
||||
)
|
||||
return final_response
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Unified async chat completion failed: {e}")
|
||||
raise
|
||||
|
||||
def _execute_callback_and_display(self, prompt: str, response: str, generation_time: float, task_name=None, task_description=None, task_id=None):
|
||||
"""Helper method to execute callbacks and display interaction.
|
||||
|
||||
@@ -1689,53 +1838,71 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# Use the new _format_tools_for_completion helper method
|
||||
formatted_tools = self._format_tools_for_completion(tools)
|
||||
|
||||
# Check if OpenAI client is available
|
||||
if self._openai_client is None:
|
||||
error_msg = "OpenAI client is not initialized. Please provide OPENAI_API_KEY or use a custom LLM provider."
|
||||
_get_display_functions()['display_error'](error_msg)
|
||||
return None
|
||||
|
||||
# Make the API call based on the type of request
|
||||
if tools:
|
||||
effective_tool_choice = tool_choice or getattr(self, '_yaml_tool_choice', None)
|
||||
tool_call_kwargs = dict(
|
||||
model=self.llm,
|
||||
# NEW: Unified protocol dispatch path (Issue #1304) - Async version
|
||||
# Check if unified dispatch is enabled (opt-in for backward compatibility)
|
||||
if getattr(self, '_use_unified_llm_dispatch', False):
|
||||
# Use composition instead of runtime class mutation for safety
|
||||
response = await self._execute_unified_achat_completion(
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
tools=formatted_tools,
|
||||
stream=stream,
|
||||
reasoning_steps=reasoning_steps,
|
||||
task_name=task_name,
|
||||
task_description=task_description,
|
||||
task_id=task_id,
|
||||
response_format=response_format
|
||||
)
|
||||
if effective_tool_choice:
|
||||
tool_call_kwargs['tool_choice'] = effective_tool_choice
|
||||
response = await self._openai_client.async_client.chat.completions.create(
|
||||
**tool_call_kwargs
|
||||
)
|
||||
result = await self._achat_completion(response, tools)
|
||||
if get_logger().getEffectiveLevel() == logging.DEBUG:
|
||||
total_time = time.time() - start_time
|
||||
logging.debug(f"Agent.achat completed in {total_time:.2f} seconds")
|
||||
# Execute callback after tool completion
|
||||
self._execute_callback_and_display(original_prompt, result, time.time() - start_time, task_name, task_description, task_id)
|
||||
return await self._atrigger_after_agent_hook(original_prompt, result, start_time)
|
||||
elif output_json or output_pydantic:
|
||||
response = await self._openai_client.async_client.chat.completions.create(
|
||||
model=self.llm,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
response_format={"type": "json_object"}
|
||||
)
|
||||
response_text = response.choices[0].message.content
|
||||
if get_logger().getEffectiveLevel() == logging.DEBUG:
|
||||
total_time = time.time() - start_time
|
||||
logging.debug(f"Agent.achat completed in {total_time:.2f} seconds")
|
||||
# Execute callback after JSON/Pydantic completion
|
||||
self._execute_callback_and_display(original_prompt, response_text, time.time() - start_time, task_name, task_description, task_id)
|
||||
return await self._atrigger_after_agent_hook(original_prompt, response_text, start_time)
|
||||
# Continue to handle structured outputs and other processing
|
||||
# Don't return immediately - fall through to existing logic
|
||||
else:
|
||||
response = await self._openai_client.async_client.chat.completions.create(
|
||||
model=self.llm,
|
||||
messages=messages,
|
||||
temperature=temperature
|
||||
)
|
||||
# LEGACY: Check if OpenAI client is available
|
||||
if self._openai_client is None:
|
||||
error_msg = "OpenAI client is not initialized. Please provide OPENAI_API_KEY or use a custom LLM provider."
|
||||
_get_display_functions()['display_error'](error_msg)
|
||||
return None
|
||||
|
||||
# Make the API call based on the type of request
|
||||
if tools:
|
||||
effective_tool_choice = tool_choice or getattr(self, '_yaml_tool_choice', None)
|
||||
tool_call_kwargs = dict(
|
||||
model=self.llm,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
tools=formatted_tools,
|
||||
)
|
||||
if effective_tool_choice:
|
||||
tool_call_kwargs['tool_choice'] = effective_tool_choice
|
||||
response = await self._openai_client.async_client.chat.completions.create(
|
||||
**tool_call_kwargs
|
||||
)
|
||||
result = await self._achat_completion(response, tools)
|
||||
if get_logger().getEffectiveLevel() == logging.DEBUG:
|
||||
total_time = time.time() - start_time
|
||||
logging.debug(f"Agent.achat completed in {total_time:.2f} seconds")
|
||||
# Execute callback after tool completion
|
||||
self._execute_callback_and_display(original_prompt, result, time.time() - start_time, task_name, task_description, task_id)
|
||||
return await self._atrigger_after_agent_hook(original_prompt, result, start_time)
|
||||
elif output_json or output_pydantic:
|
||||
response = await self._openai_client.async_client.chat.completions.create(
|
||||
model=self.llm,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
response_format={"type": "json_object"}
|
||||
)
|
||||
response_text = response.choices[0].message.content
|
||||
if get_logger().getEffectiveLevel() == logging.DEBUG:
|
||||
total_time = time.time() - start_time
|
||||
logging.debug(f"Agent.achat completed in {total_time:.2f} seconds")
|
||||
# Execute callback after JSON/Pydantic completion
|
||||
self._execute_callback_and_display(original_prompt, response_text, time.time() - start_time, task_name, task_description, task_id)
|
||||
return await self._atrigger_after_agent_hook(original_prompt, response_text, start_time)
|
||||
else:
|
||||
response = await self._openai_client.async_client.chat.completions.create(
|
||||
model=self.llm,
|
||||
messages=messages,
|
||||
temperature=temperature
|
||||
)
|
||||
|
||||
response_text = response.choices[0].message.content
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from praisonaiagents._logging import get_logger
|
||||
import asyncio
|
||||
import threading
|
||||
|
||||
|
||||
# Fallback helpers to avoid circular imports
|
||||
def _get_console():
|
||||
from rich.console import Console
|
||||
@@ -53,6 +54,14 @@ if TYPE_CHECKING:
|
||||
class ExecutionMixin:
|
||||
"""Mixin providing execution methods for the Agent class."""
|
||||
|
||||
def _safe_sleep(self, duration: float) -> None:
|
||||
"""Synchronous sleep - safe for sync contexts only."""
|
||||
time.sleep(duration)
|
||||
|
||||
async def _safe_sleep_async(self, duration: float) -> None:
|
||||
"""Async sleep - use this in async contexts."""
|
||||
await asyncio.sleep(duration)
|
||||
|
||||
def generate_task(self) -> 'Task':
|
||||
"""Generate a Task object from the agent's instructions"""
|
||||
from ..task.task import Task
|
||||
@@ -953,10 +962,10 @@ Write the complete compiled report:"""
|
||||
server_thread.start()
|
||||
|
||||
# Wait for a moment to allow the server to start and register endpoints
|
||||
time.sleep(0.5)
|
||||
self._safe_sleep(0.5)
|
||||
else:
|
||||
# If server is already running, wait a moment to make sure the endpoint is registered
|
||||
time.sleep(0.1)
|
||||
self._safe_sleep(0.1)
|
||||
print(f"🔌 Available endpoints on port {port}: {', '.join(list(_registered_agents[port].keys()))}")
|
||||
|
||||
# Get the stack frame to check if this is the last launch() call in the script
|
||||
@@ -985,7 +994,7 @@ Write the complete compiled report:"""
|
||||
try:
|
||||
print("\nAll agents registered for HTTP mode. Press Ctrl+C to stop the servers.")
|
||||
while True:
|
||||
time.sleep(1)
|
||||
self._safe_sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
print("\nServers stopped")
|
||||
except Exception as e:
|
||||
@@ -994,7 +1003,7 @@ Write the complete compiled report:"""
|
||||
try:
|
||||
print("\nKeeping HTTP servers alive. Press Ctrl+C to stop.")
|
||||
while True:
|
||||
time.sleep(1)
|
||||
self._safe_sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
print("\nServers stopped")
|
||||
return None
|
||||
@@ -1013,6 +1022,7 @@ Write the complete compiled report:"""
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.server.sse import SseServerTransport
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Mount
|
||||
import threading
|
||||
import time
|
||||
import asyncio
|
||||
@@ -1053,12 +1063,12 @@ Write the complete compiled report:"""
|
||||
|
||||
server_thread = threading.Thread(target=run_mcp_server, daemon=True)
|
||||
server_thread.start()
|
||||
time.sleep(0.5)
|
||||
self._safe_sleep(0.5)
|
||||
|
||||
try:
|
||||
print("\nKeeping MCP server alive. Press Ctrl+C to stop.")
|
||||
while True:
|
||||
time.sleep(1)
|
||||
self._safe_sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
print("\nMCP Server stopped")
|
||||
return None
|
||||
|
||||
@@ -50,6 +50,7 @@ class HandoffConfig:
|
||||
detect_cycles: Enable cycle detection to prevent infinite loops
|
||||
max_depth: Maximum handoff chain depth
|
||||
async_mode: Enable async execution
|
||||
allow_parallel: Enable parallel execution (from Delegator)
|
||||
on_handoff: Callback when handoff starts
|
||||
on_complete: Callback when handoff completes
|
||||
on_error: Callback when handoff fails
|
||||
@@ -62,7 +63,7 @@ class HandoffConfig:
|
||||
|
||||
# Execution control
|
||||
timeout_seconds: float = 300.0
|
||||
max_concurrent: int = 3
|
||||
max_concurrent: int = 5
|
||||
|
||||
# Safety
|
||||
detect_cycles: bool = True
|
||||
@@ -70,6 +71,7 @@ class HandoffConfig:
|
||||
|
||||
# Execution mode
|
||||
async_mode: bool = False
|
||||
allow_parallel: bool = False
|
||||
|
||||
# Callbacks
|
||||
on_handoff: Optional[Callable] = None
|
||||
@@ -88,6 +90,7 @@ class HandoffConfig:
|
||||
"detect_cycles": self.detect_cycles,
|
||||
"max_depth": self.max_depth,
|
||||
"async_mode": self.async_mode,
|
||||
"allow_parallel": self.allow_parallel,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -97,29 +100,13 @@ class HandoffConfig:
|
||||
data["context_policy"] = ContextPolicy(data["context_policy"])
|
||||
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
|
||||
|
||||
class HandoffError(Exception):
|
||||
"""Base exception for handoff errors."""
|
||||
pass
|
||||
|
||||
class HandoffCycleError(HandoffError):
|
||||
"""Raised when a cycle is detected in handoff chain."""
|
||||
def __init__(self, chain: List[str]):
|
||||
self.chain = chain
|
||||
super().__init__(f"Handoff cycle detected: {' -> '.join(chain)}")
|
||||
|
||||
class HandoffDepthError(HandoffError):
|
||||
"""Raised when max handoff depth is exceeded."""
|
||||
def __init__(self, depth: int, max_depth: int):
|
||||
self.depth = depth
|
||||
self.max_depth = max_depth
|
||||
super().__init__(f"Max handoff depth exceeded: {depth} > {max_depth}")
|
||||
|
||||
class HandoffTimeoutError(HandoffError):
|
||||
"""Raised when handoff times out."""
|
||||
def __init__(self, timeout: float, agent_name: str):
|
||||
self.timeout = timeout
|
||||
self.agent_name = agent_name
|
||||
super().__init__(f"Handoff to {agent_name} timed out after {timeout}s")
|
||||
# Import structured error hierarchy from central errors module
|
||||
from ..errors import (
|
||||
HandoffError,
|
||||
HandoffCycleError,
|
||||
HandoffDepthError,
|
||||
HandoffTimeoutError
|
||||
)
|
||||
|
||||
# Thread-local storage for tracking handoff chains
|
||||
_handoff_context = threading.local()
|
||||
@@ -267,12 +254,26 @@ class Handoff:
|
||||
if self.config.detect_cycles:
|
||||
chain = _get_handoff_chain()
|
||||
if target_name in chain:
|
||||
raise HandoffCycleError(chain + [target_name])
|
||||
cycle_path = chain + [target_name]
|
||||
raise HandoffCycleError(
|
||||
f"Handoff cycle detected: {' -> '.join(cycle_path)}",
|
||||
source_agent=source_agent.name if hasattr(source_agent, 'name') else 'unknown',
|
||||
target_agent=target_name,
|
||||
agent_id=source_agent.name if hasattr(source_agent, 'name') else 'unknown',
|
||||
cycle_path=cycle_path
|
||||
)
|
||||
|
||||
# Check depth
|
||||
current_depth = _get_handoff_depth()
|
||||
if current_depth >= self.config.max_depth:
|
||||
raise HandoffDepthError(current_depth + 1, self.config.max_depth)
|
||||
raise HandoffDepthError(
|
||||
f"Max handoff depth exceeded: {current_depth + 1} > {self.config.max_depth}",
|
||||
source_agent=source_agent.name if hasattr(source_agent, 'name') else 'unknown',
|
||||
target_agent=target_name,
|
||||
agent_id=source_agent.name if hasattr(source_agent, 'name') else 'unknown',
|
||||
max_depth=self.config.max_depth,
|
||||
current_depth=current_depth + 1
|
||||
)
|
||||
|
||||
def _prepare_context(self, source_agent: 'Agent', kwargs: Dict[str, Any]) -> HandoffInputData:
|
||||
"""
|
||||
@@ -538,7 +539,13 @@ class Handoff:
|
||||
handoff_depth=_get_handoff_depth(),
|
||||
)
|
||||
self._execute_callback(self.config.on_error, source_agent, kwargs, result)
|
||||
raise HandoffTimeoutError(self.config.timeout_seconds, self.agent.name)
|
||||
raise HandoffTimeoutError(
|
||||
f"Handoff to {self.agent.name} timed out after {self.config.timeout_seconds}s",
|
||||
timeout_seconds=self.config.timeout_seconds,
|
||||
source_agent=source_agent.name if hasattr(source_agent, "name") else "unknown",
|
||||
target_agent=self.agent.name,
|
||||
agent_id=source_agent.name if hasattr(source_agent, "name") else "unknown"
|
||||
)
|
||||
except Exception as e:
|
||||
result = HandoffResult(
|
||||
success=False,
|
||||
@@ -818,6 +825,102 @@ When you determine that a task would be better handled by another agent with spe
|
||||
use the transfer tool to hand off the task. The receiving agent will have the full context of
|
||||
the conversation and will continue helping the user."""
|
||||
|
||||
async def parallel_handoffs(
|
||||
source: 'Agent',
|
||||
targets: List[tuple], # [(agent, prompt), ...]
|
||||
max_concurrent: int = 5,
|
||||
config: Optional[HandoffConfig] = None,
|
||||
) -> List['HandoffResult']:
|
||||
"""
|
||||
Execute multiple handoffs in parallel with concurrency control.
|
||||
|
||||
This function provides the parallel execution capabilities from SubagentDelegator
|
||||
while using the unified Handoff system.
|
||||
|
||||
Args:
|
||||
source: Source agent performing the handoffs
|
||||
targets: List of (agent, prompt) tuples for parallel execution
|
||||
max_concurrent: Maximum concurrent handoffs (overrides config)
|
||||
config: Optional HandoffConfig for additional settings
|
||||
|
||||
Returns:
|
||||
List of HandoffResult objects from each parallel handoff
|
||||
|
||||
Example:
|
||||
results = await parallel_handoffs(
|
||||
source=main_agent,
|
||||
targets=[
|
||||
(research_agent, "Research topic X"),
|
||||
(analysis_agent, "Analyze data Y"),
|
||||
(summary_agent, "Summarize findings Z")
|
||||
],
|
||||
max_concurrent=3
|
||||
)
|
||||
"""
|
||||
if not hasattr(source, 'handoff_to_async'):
|
||||
raise ValueError("Source agent must support async handoffs")
|
||||
|
||||
# Check if parallel execution is allowed by config
|
||||
if config and not config.allow_parallel:
|
||||
raise ValueError("Parallel handoffs are disabled in the provided HandoffConfig")
|
||||
|
||||
# Use config's max_concurrent if max_concurrent wasn't explicitly set and config is provided
|
||||
if config and max_concurrent == 5: # Check if default value
|
||||
effective_max_concurrent = config.max_concurrent
|
||||
else:
|
||||
effective_max_concurrent = max_concurrent
|
||||
|
||||
# Create semaphore for concurrency control (0 or negative = unlimited)
|
||||
semaphore = asyncio.Semaphore(effective_max_concurrent) if effective_max_concurrent > 0 else None
|
||||
|
||||
async def _run_one(agent, prompt):
|
||||
async def _do_handoff():
|
||||
try:
|
||||
return await source.handoff_to_async(agent, prompt, config=config)
|
||||
except Exception as e:
|
||||
logger.error(f"Parallel handoff failed to {getattr(agent, 'name', str(agent))}: {e}")
|
||||
# Return a failed HandoffResult object
|
||||
return HandoffResult(
|
||||
success=False,
|
||||
response="",
|
||||
target_agent=getattr(agent, 'name', str(agent)),
|
||||
source_agent=getattr(source, 'name', str(source)),
|
||||
duration_seconds=0.0,
|
||||
error=str(e),
|
||||
handoff_depth=0
|
||||
)
|
||||
|
||||
if semaphore:
|
||||
async with semaphore:
|
||||
return await _do_handoff()
|
||||
else:
|
||||
return await _do_handoff()
|
||||
|
||||
# Execute all handoffs concurrently
|
||||
tasks = [_run_one(agent, prompt) for agent, prompt in targets]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Convert any exceptions to failed results
|
||||
processed_results = []
|
||||
for i, result in enumerate(results):
|
||||
if isinstance(result, HandoffResult):
|
||||
processed_results.append(result)
|
||||
else:
|
||||
# Handle Exception, BaseException (like CancelledError), or any other unexpected result
|
||||
agent, _prompt = targets[i] # Prefix with _ to mark intentionally unused
|
||||
processed_results.append(HandoffResult(
|
||||
success=False,
|
||||
response="",
|
||||
target_agent=getattr(agent, 'name', str(agent)),
|
||||
source_agent=getattr(source, 'name', str(source)),
|
||||
duration_seconds=0.0,
|
||||
error=str(result),
|
||||
handoff_depth=0
|
||||
))
|
||||
|
||||
return processed_results
|
||||
|
||||
|
||||
def prompt_with_handoff_instructions(base_prompt: str, agent: 'Agent') -> str:
|
||||
"""
|
||||
Add handoff instructions to an agent's prompt.
|
||||
|
||||
@@ -262,7 +262,7 @@ class MemoryMixin:
|
||||
from datetime import datetime, timezone
|
||||
# Per-hour session ID: YYYYMMDDHH (UTC) + agent name hash for uniqueness
|
||||
hour_str = datetime.now(timezone.utc).strftime("%Y%m%d%H")
|
||||
agent_hash = hashlib.md5((self.name or "agent").encode()).hexdigest()[:6]
|
||||
agent_hash = hashlib.sha256((self.name or "agent").encode()).hexdigest()[:6]
|
||||
self._session_id = f"{hour_str}-{agent_hash}"
|
||||
|
||||
# Call db adapter's on_agent_start to get previous messages
|
||||
|
||||
@@ -216,10 +216,11 @@ class ToolExecutionMixin:
|
||||
else:
|
||||
# Apply default limit even without context management
|
||||
# This prevents runaway tool outputs from causing overflow
|
||||
if len(result_str) > DEFAULT_TOOL_OUTPUT_LIMIT:
|
||||
limit = getattr(self, 'tool_output_limit', 16000)
|
||||
if len(result_str) > limit:
|
||||
# Use smart truncation format that judge recognizes as OK
|
||||
tail_size = min(DEFAULT_TOOL_OUTPUT_LIMIT // 5, 2000)
|
||||
head = result_str[:DEFAULT_TOOL_OUTPUT_LIMIT - tail_size]
|
||||
tail_size = min(limit // 5, 2000)
|
||||
head = result_str[:limit - tail_size]
|
||||
tail = result_str[-tail_size:] if tail_size > 0 else ""
|
||||
truncated = f"{head}\n...[{len(result_str):,} chars, showing first/last portions]...\n{tail}"
|
||||
else:
|
||||
@@ -229,7 +230,7 @@ class ToolExecutionMixin:
|
||||
logging.debug(f"Truncated {function_name} output from {len(result_str)} to {len(truncated)} chars")
|
||||
# For dicts, truncate large string fields (e.g., raw_content from search)
|
||||
if isinstance(result, dict):
|
||||
max_field_chars = DEFAULT_TOOL_OUTPUT_LIMIT if not self.context_manager else None
|
||||
max_field_chars = getattr(self, 'tool_output_limit', 16000) if not self.context_manager else None
|
||||
result = self._truncate_dict_fields(result, function_name, max_field_chars)
|
||||
else:
|
||||
result = truncated
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
Unified Chat Mixin for Agent class.
|
||||
|
||||
This module consolidates the dual execution paths (custom LLM vs OpenAI)
|
||||
into a single async-first protocol-driven implementation, eliminating
|
||||
the DRY violation and maintenance burden described in Issue #1304.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from ..llm import create_llm_dispatcher, UnifiedLLMDispatcher
|
||||
|
||||
|
||||
class UnifiedChatMixin:
|
||||
"""
|
||||
Unified chat implementation that replaces dual execution paths.
|
||||
|
||||
This mixin consolidates:
|
||||
- Path A: LLM.get_response() / LLM.get_response_async()
|
||||
- Path B: OpenAIClient chat completion
|
||||
|
||||
Into a single async-first protocol-driven dispatch using UnifiedLLMDispatcher.
|
||||
"""
|
||||
|
||||
def _get_unified_dispatcher(self) -> UnifiedLLMDispatcher:
|
||||
"""
|
||||
Get or create the unified LLM dispatcher for this agent.
|
||||
|
||||
This method implements the provider selection logic that was
|
||||
previously scattered in _chat_completion() method.
|
||||
"""
|
||||
if hasattr(self, '_unified_dispatcher') and self._unified_dispatcher is not None:
|
||||
return self._unified_dispatcher
|
||||
|
||||
# Determine which adapter to use based on existing agent configuration
|
||||
if self._using_custom_llm and hasattr(self, 'llm_instance'):
|
||||
# Use LiteLLM adapter for custom LLM instances
|
||||
dispatcher = create_llm_dispatcher(llm_instance=self.llm_instance)
|
||||
else:
|
||||
# Use OpenAI adapter for direct OpenAI access
|
||||
if not hasattr(self, '_openai_client') or self._openai_client is None:
|
||||
# Initialize OpenAI client if not present
|
||||
from ..llm import get_openai_client
|
||||
self._openai_client = get_openai_client(api_key=getattr(self, 'api_key', None))
|
||||
dispatcher = create_llm_dispatcher(openai_client=self._openai_client, model=self.llm)
|
||||
|
||||
# Cache the dispatcher
|
||||
self._unified_dispatcher = dispatcher
|
||||
return dispatcher
|
||||
|
||||
def _unified_chat_completion(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
temperature: float = 1.0,
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
stream: bool = True,
|
||||
reasoning_steps: bool = False,
|
||||
task_name: Optional[str] = None,
|
||||
task_description: Optional[str] = None,
|
||||
task_id: Optional[str] = None,
|
||||
response_format: Optional[Dict[str, Any]] = None
|
||||
) -> Any:
|
||||
"""
|
||||
Unified sync chat completion method.
|
||||
|
||||
This replaces the existing _chat_completion() method's dual dispatch logic
|
||||
with a single path through UnifiedLLMDispatcher.
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
# Get unified dispatcher (handles provider selection)
|
||||
dispatcher = self._get_unified_dispatcher()
|
||||
|
||||
# Use the same message processing logic but route through unified dispatcher
|
||||
formatted_tools = self._format_tools_for_completion(tools)
|
||||
|
||||
try:
|
||||
# Single dispatch path for all providers
|
||||
final_response = dispatcher.chat_completion(
|
||||
messages=messages,
|
||||
tools=formatted_tools,
|
||||
temperature=temperature,
|
||||
max_tokens=getattr(self, 'max_tokens', None),
|
||||
stream=stream,
|
||||
response_format=response_format,
|
||||
**{
|
||||
'reasoning_steps': reasoning_steps,
|
||||
'task_name': task_name,
|
||||
'task_description': task_description,
|
||||
'task_id': task_id,
|
||||
}
|
||||
)
|
||||
|
||||
return final_response
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Unified chat completion failed: {e}")
|
||||
# Apply the same error handling as the original implementation
|
||||
raise
|
||||
|
||||
async def _unified_achat_completion(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
temperature: float = 1.0,
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
stream: bool = True,
|
||||
reasoning_steps: bool = False,
|
||||
task_name: Optional[str] = None,
|
||||
task_description: Optional[str] = None,
|
||||
task_id: Optional[str] = None,
|
||||
response_format: Optional[Dict[str, Any]] = None
|
||||
) -> Any:
|
||||
"""
|
||||
Unified async chat completion method.
|
||||
|
||||
This provides native async implementation, eliminating the need
|
||||
for separate async/sync implementations in the LLM layer.
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
# Get unified dispatcher (handles provider selection)
|
||||
dispatcher = self._get_unified_dispatcher()
|
||||
|
||||
# Use the same message processing logic but route through unified dispatcher
|
||||
formatted_tools = self._format_tools_for_completion(tools)
|
||||
|
||||
try:
|
||||
# Single async dispatch path for all providers
|
||||
final_response = await dispatcher.achat_completion(
|
||||
messages=messages,
|
||||
tools=formatted_tools,
|
||||
temperature=temperature,
|
||||
max_tokens=getattr(self, 'max_tokens', None),
|
||||
stream=stream,
|
||||
response_format=response_format,
|
||||
**{
|
||||
'reasoning_steps': reasoning_steps,
|
||||
'task_name': task_name,
|
||||
'task_description': task_description,
|
||||
'task_id': task_id,
|
||||
}
|
||||
)
|
||||
|
||||
return final_response
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Unified async chat completion failed: {e}")
|
||||
# Apply the same error handling as the original implementation
|
||||
raise
|
||||
@@ -6,7 +6,10 @@ AgentManager, Agents, PraisonAIAgents are silent aliases for backward compatibil
|
||||
from .agents import AgentTeam, AgentManager, Agents, PraisonAIAgents
|
||||
from .autoagents import AutoAgents
|
||||
from .auto_rag_agent import AutoRagAgent, AutoRagConfig, RetrievalPolicy
|
||||
from .protocols import MergeStrategyProtocol, FirstWinsMerge, ConcatMerge, DictMerge
|
||||
from .protocols import (
|
||||
MergeStrategyProtocol, FirstWinsMerge, ConcatMerge, DictMerge,
|
||||
TaskExecutorProtocol, ExecutionContext, TaskResult
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'AgentTeam', # Primary class (v1.0+)
|
||||
@@ -16,4 +19,5 @@ __all__ = [
|
||||
'AutoAgents',
|
||||
'AutoRagAgent', 'AutoRagConfig', 'RetrievalPolicy',
|
||||
'MergeStrategyProtocol', 'FirstWinsMerge', 'ConcatMerge', 'DictMerge',
|
||||
'TaskExecutorProtocol', 'ExecutionContext', 'TaskResult',
|
||||
]
|
||||
@@ -108,6 +108,244 @@ def get_multimodal_message(text_prompt: str, images: list) -> list:
|
||||
})
|
||||
return content
|
||||
|
||||
def _prepare_task_prompt(task, task_description, context_text=None):
|
||||
"""
|
||||
Prepare task prompt with context and memory (DRY helper).
|
||||
"""
|
||||
# Build task prompt - only use "User Input/Topic" format if there's actual content
|
||||
if context_text and context_text.strip():
|
||||
task_prompt = f"""
|
||||
User Input/Topic: {context_text}
|
||||
|
||||
Task: {task_description}
|
||||
Expected Output: {task.expected_output}
|
||||
|
||||
IMPORTANT: Your response must be about the user's input/topic above. Incorporate it into your task."""
|
||||
else:
|
||||
task_prompt = f"""
|
||||
You need to do the following task: {task_description}.
|
||||
Expected Output: {task.expected_output}."""
|
||||
|
||||
# Add memory context if available
|
||||
if task.memory:
|
||||
try:
|
||||
memory_context = task.memory.build_context_for_task(task.description)
|
||||
if memory_context:
|
||||
# Log detailed memory context for debugging
|
||||
logger.debug(f"Memory context for task '{task.description}': {memory_context}")
|
||||
# Include actual memory content without verbose headers (essential for AI agent functionality)
|
||||
task_prompt += f"\n\n{memory_context}"
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting memory context: {e}")
|
||||
|
||||
task_prompt += "\nPlease provide only the final result of your work. Do not add any conversation or extra explanation."
|
||||
return task_prompt
|
||||
|
||||
def _execute_with_agent_sync(executor_agent, task_prompt, task, tools, stream=None):
|
||||
"""
|
||||
Execute task with agent synchronously (DRY helper).
|
||||
"""
|
||||
if task.images:
|
||||
return executor_agent.chat(
|
||||
get_multimodal_message(task_prompt, task.images),
|
||||
tools=tools,
|
||||
output_json=task.output_json,
|
||||
output_pydantic=task.output_pydantic,
|
||||
stream=stream,
|
||||
task_name=task.name,
|
||||
task_description=task.description,
|
||||
task_id=task.id
|
||||
)
|
||||
else:
|
||||
return executor_agent.chat(
|
||||
task_prompt,
|
||||
tools=tools,
|
||||
output_json=task.output_json,
|
||||
output_pydantic=task.output_pydantic,
|
||||
stream=stream,
|
||||
task_name=task.name,
|
||||
task_description=task.description,
|
||||
task_id=task.id
|
||||
)
|
||||
|
||||
async def _execute_with_agent_async(executor_agent, task_prompt, task, tools, stream=None):
|
||||
"""
|
||||
Execute task with agent asynchronously (DRY helper).
|
||||
"""
|
||||
if task.images:
|
||||
return await executor_agent.achat(
|
||||
get_multimodal_message(task_prompt, task.images),
|
||||
tools=tools,
|
||||
output_json=task.output_json,
|
||||
output_pydantic=task.output_pydantic,
|
||||
stream=stream,
|
||||
task_name=task.name,
|
||||
task_description=task.description,
|
||||
task_id=task.id
|
||||
)
|
||||
else:
|
||||
return await executor_agent.achat(
|
||||
task_prompt,
|
||||
tools=tools,
|
||||
output_json=task.output_json,
|
||||
output_pydantic=task.output_pydantic,
|
||||
stream=stream,
|
||||
task_name=task.name,
|
||||
task_description=task.description,
|
||||
task_id=task.id
|
||||
)
|
||||
|
||||
|
||||
def _build_execution_context(agents_instance, task_id):
|
||||
"""
|
||||
Build unified execution context for task execution (DRY helper).
|
||||
Eliminates duplication between sync/async execution paths.
|
||||
"""
|
||||
from .protocols import ExecutionContext
|
||||
|
||||
task = agents_instance.tasks[task_id]
|
||||
|
||||
# Initialize memory before task execution
|
||||
if not task.memory:
|
||||
task.memory = task.initialize_memory()
|
||||
|
||||
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 = agents_instance._create_agent_from_config(task.agent_config)
|
||||
task.agent = executor_agent
|
||||
|
||||
# Validate executor agent exists
|
||||
if executor_agent is None:
|
||||
raise ValueError(
|
||||
f"Task {task_id} has no assigned agent. "
|
||||
"Set task.agent or provide task.agent_config before execution."
|
||||
)
|
||||
|
||||
# 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.display_name)
|
||||
|
||||
# Ensure tools are available from both task and agent (create copy to avoid mutation)
|
||||
tools = list(task.tools or [])
|
||||
if executor_agent and executor_agent.tools:
|
||||
tools.extend(executor_agent.tools)
|
||||
|
||||
# Substitute variables in task description if provided
|
||||
task_description = task.description
|
||||
variables = getattr(task, 'variables', None) or getattr(agents_instance, 'variables', None) or {}
|
||||
for key, value in variables.items():
|
||||
task_description = task_description.replace(f"{{{{{key}}}}}", str(value))
|
||||
|
||||
# Build context first to include in task prompt
|
||||
context_text = ""
|
||||
if task.context:
|
||||
context_results = [] # Collect contexts then de-duplicate
|
||||
for context_item in task.context:
|
||||
# Use the centralized helper function
|
||||
context_str = process_task_context(context_item, agents_instance.verbose, agents_instance.user_id)
|
||||
# Only add non-empty context strings
|
||||
if context_str and context_str.strip():
|
||||
context_results.append(context_str)
|
||||
|
||||
# Join unique context results with proper formatting
|
||||
unique_contexts = list(dict.fromkeys(context_results)) # Remove duplicates
|
||||
if agents_instance.verbose >= 3:
|
||||
logger.info(f"Task {task_id} context items: {len(unique_contexts)}")
|
||||
for i, ctx in enumerate(unique_contexts):
|
||||
logger.debug(f"Context {i+1}: {ctx[:100]}...")
|
||||
context_separator = '\n\n'
|
||||
context_text = context_separator.join(unique_contexts)
|
||||
|
||||
# Build task prompt using DRY helper
|
||||
task_prompt = _prepare_task_prompt(task, task_description, context_text)
|
||||
|
||||
if agents_instance.verbose >= 2:
|
||||
logger.info(f"Executing task {task_id}: {task_description} using {executor_agent.display_name}")
|
||||
logger.debug(f"Starting execution of task {task_id} with prompt:\n{task_prompt}")
|
||||
|
||||
return ExecutionContext(
|
||||
task_id=task_id,
|
||||
task=task,
|
||||
executor_agent=executor_agent,
|
||||
tools=tools,
|
||||
task_description=task_description,
|
||||
context_text=context_text,
|
||||
task_prompt=task_prompt,
|
||||
llm=llm,
|
||||
verbose=agents_instance.verbose,
|
||||
stream=agents_instance.stream,
|
||||
user_id=agents_instance.user_id
|
||||
)
|
||||
|
||||
|
||||
def _process_task_result(agents_instance, context, agent_output):
|
||||
"""
|
||||
Process task execution result (DRY helper).
|
||||
Eliminates duplication in result processing between sync/async paths.
|
||||
"""
|
||||
from .protocols import TaskResult
|
||||
|
||||
task = context.task
|
||||
task_id = context.task_id
|
||||
executor_agent = context.executor_agent
|
||||
llm = context.llm
|
||||
|
||||
if agent_output:
|
||||
# Store the response in memory
|
||||
if task.memory:
|
||||
try:
|
||||
task.store_in_memory(
|
||||
content=agent_output,
|
||||
agent_name=executor_agent.display_name,
|
||||
task_id=task_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store agent output in memory: {e}")
|
||||
|
||||
task_output = TaskOutput(
|
||||
description=task.description,
|
||||
summary=task.description[:10],
|
||||
raw=agent_output,
|
||||
agent=executor_agent.display_name,
|
||||
output_format="RAW"
|
||||
)
|
||||
|
||||
# Add token metrics if available
|
||||
if llm and hasattr(llm, 'last_token_metrics'):
|
||||
token_metrics = llm.last_token_metrics
|
||||
if token_metrics:
|
||||
task_output.token_metrics = token_metrics
|
||||
|
||||
if task.output_json:
|
||||
cleaned = agents_instance.clean_json_output(agent_output)
|
||||
try:
|
||||
parsed = json.loads(cleaned)
|
||||
task_output.json_dict = parsed
|
||||
task_output.output_format = "JSON"
|
||||
except Exception:
|
||||
logger.warning(f"Warning: Could not parse output of task {task_id} as JSON")
|
||||
logger.debug(f"Output that failed JSON parsing: {agent_output}")
|
||||
|
||||
if task.output_pydantic:
|
||||
cleaned = agents_instance.clean_json_output(agent_output)
|
||||
try:
|
||||
parsed = json.loads(cleaned)
|
||||
pyd_obj = task.output_pydantic(**parsed)
|
||||
task_output.pydantic = pyd_obj
|
||||
task_output.output_format = "Pydantic"
|
||||
except Exception:
|
||||
logger.warning(f"Warning: Could not parse output of task {task_id} as Pydantic Model")
|
||||
logger.debug(f"Output that failed Pydantic parsing: {agent_output}")
|
||||
|
||||
task.result = task_output
|
||||
return TaskResult(task_output=task_output, success=True)
|
||||
else:
|
||||
task.status = "failed"
|
||||
return TaskResult(task_output=None, success=False, error="Agent did not produce any output.")
|
||||
|
||||
def process_task_context(context_item, verbose=0, user_id=None):
|
||||
"""
|
||||
Process a single context item for task execution.
|
||||
@@ -632,7 +870,7 @@ class AgentTeam:
|
||||
return len(agent_output.strip()) > 0
|
||||
|
||||
async def aexecute_task(self, task_id):
|
||||
"""Async version of execute_task method"""
|
||||
"""Async version of execute_task method - now using DRY helpers"""
|
||||
if task_id not in self.tasks:
|
||||
display_error(f"Error: Task with ID {task_id} does not exist")
|
||||
return
|
||||
@@ -653,162 +891,18 @@ class AgentTeam:
|
||||
if task.status == "not started":
|
||||
task.status = "in progress"
|
||||
|
||||
# Initialize memory before task execution
|
||||
if not task.memory:
|
||||
task.memory = task.initialize_memory()
|
||||
# Build execution context using DRY helper
|
||||
context = _build_execution_context(self, task_id)
|
||||
|
||||
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.display_name)
|
||||
# Execute with agent using DRY helper
|
||||
agent_output = await _execute_with_agent_async(
|
||||
context.executor_agent, context.task_prompt, context.task,
|
||||
context.tools, stream=self.stream
|
||||
)
|
||||
|
||||
# Ensure tools are available from both task and agent (create copy to avoid mutation)
|
||||
tools = list(task.tools or [])
|
||||
if executor_agent and executor_agent.tools:
|
||||
tools.extend(executor_agent.tools)
|
||||
|
||||
logger.info(f"Task config: {task.config}")
|
||||
logger.info(f"Task memory status: {'Initialized' if task.memory else 'Not initialized'}")
|
||||
|
||||
# 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))
|
||||
|
||||
# Build context first to include in task prompt
|
||||
context_text = ""
|
||||
if task.context:
|
||||
context_results = [] # Use list to avoid duplicates
|
||||
for context_item in task.context:
|
||||
# Use the centralized helper function
|
||||
context_str = process_task_context(context_item, self.verbose, self.user_id)
|
||||
# Only add non-empty context strings
|
||||
if context_str and context_str.strip():
|
||||
context_results.append(context_str)
|
||||
|
||||
# Join unique context results with proper formatting
|
||||
unique_contexts = list(dict.fromkeys(context_results)) # Remove duplicates
|
||||
if self.verbose >= 3:
|
||||
logger.info(f"Task {task_id} context items: {len(unique_contexts)}")
|
||||
for i, ctx in enumerate(unique_contexts):
|
||||
logger.debug(f"Context {i+1}: {ctx[:100]}...")
|
||||
context_separator = '\n\n'
|
||||
context_text = context_separator.join(unique_contexts)
|
||||
|
||||
# Build task prompt - only use "User Input/Topic" format if there's actual content
|
||||
if context_text and context_text.strip():
|
||||
task_prompt = f"""
|
||||
User Input/Topic: {context_text}
|
||||
|
||||
Task: {task_description}
|
||||
Expected Output: {task.expected_output}
|
||||
|
||||
IMPORTANT: Your response must be about the user's input/topic above. Incorporate it into your task."""
|
||||
else:
|
||||
task_prompt = f"""
|
||||
You need to do the following task: {task_description}.
|
||||
Expected Output: {task.expected_output}."""
|
||||
|
||||
# Add memory context if available
|
||||
if task.memory:
|
||||
try:
|
||||
memory_context = task.memory.build_context_for_task(task.description)
|
||||
if memory_context:
|
||||
# Log detailed memory context for debugging
|
||||
logger.debug(f"Memory context for task '{task.description}': {memory_context}")
|
||||
# Include actual memory content without verbose headers (essential for AI agent functionality)
|
||||
task_prompt += f"\n\n{memory_context}"
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting memory context: {e}")
|
||||
|
||||
task_prompt += "\nPlease provide only the final result of your work. Do not add any conversation or extra explanation."
|
||||
|
||||
if self.verbose >= 2:
|
||||
logger.info(f"Executing task {task_id}: {task_description} using {executor_agent.display_name}")
|
||||
logger.debug(f"Starting execution of task {task_id} with prompt:\n{task_prompt}")
|
||||
|
||||
if task.images:
|
||||
# Use shared multimodal helper (DRY - defined at module level)
|
||||
agent_output = await executor_agent.achat(
|
||||
get_multimodal_message(task_prompt, task.images),
|
||||
tools=tools,
|
||||
output_json=task.output_json,
|
||||
output_pydantic=task.output_pydantic,
|
||||
task_name=task.name,
|
||||
task_description=task.description,
|
||||
task_id=task.id
|
||||
)
|
||||
else:
|
||||
agent_output = await executor_agent.achat(
|
||||
task_prompt,
|
||||
tools=tools,
|
||||
output_json=task.output_json,
|
||||
output_pydantic=task.output_pydantic,
|
||||
task_name=task.name,
|
||||
task_description=task.description,
|
||||
task_id=task.id
|
||||
)
|
||||
|
||||
if agent_output:
|
||||
# Store the response in memory
|
||||
if task.memory:
|
||||
try:
|
||||
task.store_in_memory(
|
||||
content=agent_output,
|
||||
agent_name=executor_agent.display_name,
|
||||
task_id=task_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store agent output in memory: {e}")
|
||||
|
||||
task_output = TaskOutput(
|
||||
description=task.description,
|
||||
summary=task.description[:10],
|
||||
raw=agent_output,
|
||||
agent=executor_agent.display_name,
|
||||
output_format="RAW"
|
||||
)
|
||||
|
||||
# Add token metrics if available
|
||||
if llm and hasattr(llm, 'last_token_metrics'):
|
||||
token_metrics = llm.last_token_metrics
|
||||
if token_metrics:
|
||||
task_output.token_metrics = token_metrics
|
||||
|
||||
if task.output_json:
|
||||
cleaned = self.clean_json_output(agent_output)
|
||||
try:
|
||||
parsed = json.loads(cleaned)
|
||||
task_output.json_dict = parsed
|
||||
task_output.output_format = "JSON"
|
||||
except Exception:
|
||||
logger.warning(f"Warning: Could not parse output of task {task_id} as JSON")
|
||||
logger.debug(f"Output that failed JSON parsing: {agent_output}")
|
||||
|
||||
if task.output_pydantic:
|
||||
cleaned = self.clean_json_output(agent_output)
|
||||
try:
|
||||
parsed = json.loads(cleaned)
|
||||
pyd_obj = task.output_pydantic(**parsed)
|
||||
task_output.pydantic = pyd_obj
|
||||
task_output.output_format = "Pydantic"
|
||||
except Exception:
|
||||
logger.warning(f"Warning: Could not parse output of task {task_id} as Pydantic Model")
|
||||
logger.debug(f"Output that failed Pydantic parsing: {agent_output}")
|
||||
|
||||
task.result = task_output
|
||||
return task_output
|
||||
else:
|
||||
task.status = "failed"
|
||||
return None
|
||||
# Process result using DRY helper
|
||||
task_result = _process_task_result(self, context, agent_output)
|
||||
return task_result.task_output
|
||||
|
||||
async def arun_task(self, task_id):
|
||||
"""Async version of run_task method"""
|
||||
@@ -993,7 +1087,7 @@ Expected Output: {task.expected_output}."""
|
||||
display_error(f"Error saving task output to file: {e}")
|
||||
|
||||
def execute_task(self, task_id):
|
||||
"""Synchronous version of execute_task method"""
|
||||
"""Synchronous version of execute_task method - now using DRY helpers"""
|
||||
if task_id not in self.tasks:
|
||||
display_error(f"Error: Task with ID {task_id} does not exist")
|
||||
return
|
||||
@@ -1002,12 +1096,6 @@ Expected Output: {task.expected_output}."""
|
||||
logger.info(f"Starting execution of task {task_id}")
|
||||
logger.info(f"Task config: {task.config}")
|
||||
|
||||
# Initialize memory before task execution
|
||||
if not task.memory:
|
||||
task.memory = task.initialize_memory()
|
||||
|
||||
logger.info(f"Task memory status: {'Initialized' if task.memory else 'Not initialized'}")
|
||||
|
||||
# Only import multimodal dependencies if task has images
|
||||
if task.images and task.status == "not started":
|
||||
try:
|
||||
@@ -1023,156 +1111,18 @@ Expected Output: {task.expected_output}."""
|
||||
if task.status == "not started":
|
||||
task.status = "in progress"
|
||||
|
||||
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.display_name)
|
||||
# Build execution context using DRY helper
|
||||
context = _build_execution_context(self, task_id)
|
||||
|
||||
# Ensure tools are available from both task and agent (create copy to avoid mutation)
|
||||
tools = list(task.tools or [])
|
||||
if executor_agent and executor_agent.tools:
|
||||
tools.extend(executor_agent.tools)
|
||||
# Execute with agent using DRY helper
|
||||
agent_output = _execute_with_agent_sync(
|
||||
context.executor_agent, context.task_prompt, context.task,
|
||||
context.tools, stream=self.stream
|
||||
)
|
||||
|
||||
# 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))
|
||||
|
||||
# Build context first to include in task prompt
|
||||
context_text = ""
|
||||
if task.context:
|
||||
context_results = [] # Use list to avoid duplicates
|
||||
for context_item in task.context:
|
||||
# Use the centralized helper function
|
||||
context_str = process_task_context(context_item, self.verbose, self.user_id)
|
||||
# Only add non-empty context strings
|
||||
if context_str and context_str.strip():
|
||||
context_results.append(context_str)
|
||||
|
||||
# Join unique context results with proper formatting
|
||||
unique_contexts = list(dict.fromkeys(context_results)) # Remove duplicates
|
||||
if self.verbose >= 3:
|
||||
logger.info(f"Task {task_id} context items: {len(unique_contexts)}")
|
||||
for i, ctx in enumerate(unique_contexts):
|
||||
logger.debug(f"Context {i+1}: {ctx[:100]}...")
|
||||
context_separator = '\n\n'
|
||||
context_text = context_separator.join(unique_contexts)
|
||||
|
||||
# Build task prompt - only use "User Input/Topic" format if there's actual content
|
||||
if context_text and context_text.strip():
|
||||
task_prompt = f"""
|
||||
User Input/Topic: {context_text}
|
||||
|
||||
Task: {task_description}
|
||||
Expected Output: {task.expected_output}
|
||||
|
||||
IMPORTANT: Your response must be about the user's input/topic above. Incorporate it into your task."""
|
||||
else:
|
||||
task_prompt = f"""
|
||||
You need to do the following task: {task_description}.
|
||||
Expected Output: {task.expected_output}."""
|
||||
|
||||
# Add memory context if available
|
||||
if task.memory:
|
||||
try:
|
||||
memory_context = task.memory.build_context_for_task(task.description)
|
||||
if memory_context:
|
||||
# Log detailed memory context for debugging
|
||||
logger.debug(f"Memory context for task '{task.description}': {memory_context}")
|
||||
# Include actual memory content without verbose headers (essential for AI agent functionality)
|
||||
task_prompt += f"\n\n{memory_context}"
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting memory context: {e}")
|
||||
|
||||
task_prompt += "\nPlease provide only the final result of your work. Do not add any conversation or extra explanation."
|
||||
|
||||
if self.verbose >= 2:
|
||||
logger.info(f"Executing task {task_id}: {task.description} using {executor_agent.display_name}")
|
||||
logger.debug(f"Starting execution of task {task_id} with prompt:\n{task_prompt}")
|
||||
|
||||
if task.images:
|
||||
# Use shared multimodal helper (DRY - defined at module level)
|
||||
agent_output = executor_agent.chat(
|
||||
get_multimodal_message(task_prompt, task.images),
|
||||
tools=tools,
|
||||
output_json=task.output_json,
|
||||
output_pydantic=task.output_pydantic,
|
||||
task_name=task.name,
|
||||
task_description=task.description,
|
||||
task_id=task_id
|
||||
)
|
||||
else:
|
||||
agent_output = executor_agent.chat(
|
||||
task_prompt,
|
||||
tools=tools,
|
||||
output_json=task.output_json,
|
||||
output_pydantic=task.output_pydantic,
|
||||
stream=self.stream,
|
||||
task_name=task.name,
|
||||
task_description=task.description,
|
||||
task_id=task_id
|
||||
)
|
||||
|
||||
if agent_output:
|
||||
# Store the response in memory
|
||||
if task.memory:
|
||||
try:
|
||||
task.store_in_memory(
|
||||
content=agent_output,
|
||||
agent_name=executor_agent.display_name,
|
||||
task_id=task_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store agent output in memory: {e}")
|
||||
|
||||
task_output = TaskOutput(
|
||||
description=task.description,
|
||||
summary=task.description[:10],
|
||||
raw=agent_output,
|
||||
agent=executor_agent.display_name,
|
||||
output_format="RAW"
|
||||
)
|
||||
|
||||
# Add token metrics if available
|
||||
if llm and hasattr(llm, 'last_token_metrics'):
|
||||
token_metrics = llm.last_token_metrics
|
||||
if token_metrics:
|
||||
task_output.token_metrics = token_metrics
|
||||
|
||||
if task.output_json:
|
||||
cleaned = self.clean_json_output(agent_output)
|
||||
try:
|
||||
parsed = json.loads(cleaned)
|
||||
task_output.json_dict = parsed
|
||||
task_output.output_format = "JSON"
|
||||
except Exception:
|
||||
logger.warning(f"Warning: Could not parse output of task {task_id} as JSON")
|
||||
logger.debug(f"Output that failed JSON parsing: {agent_output}")
|
||||
|
||||
if task.output_pydantic:
|
||||
cleaned = self.clean_json_output(agent_output)
|
||||
try:
|
||||
parsed = json.loads(cleaned)
|
||||
pyd_obj = task.output_pydantic(**parsed)
|
||||
task_output.pydantic = pyd_obj
|
||||
task_output.output_format = "Pydantic"
|
||||
except Exception:
|
||||
logger.warning(f"Warning: Could not parse output of task {task_id} as Pydantic Model")
|
||||
logger.debug(f"Output that failed Pydantic parsing: {agent_output}")
|
||||
|
||||
task.result = task_output
|
||||
return task_output
|
||||
else:
|
||||
task.status = "failed"
|
||||
return None
|
||||
# Process result using DRY helper
|
||||
task_result = _process_task_result(self, context, agent_output)
|
||||
return task_result.task_output
|
||||
|
||||
def run_task(self, task_id):
|
||||
"""Synchronous version of run_task method"""
|
||||
@@ -2386,6 +2336,22 @@ Expected Output: {task.expected_output}."""
|
||||
border_style="cyan"
|
||||
))
|
||||
|
||||
# Emit planning event for trace observers
|
||||
try:
|
||||
from ..trace.protocol import get_default_emitter, ActionEvent
|
||||
import time
|
||||
|
||||
emitter = get_default_emitter()
|
||||
if emitter and emitter.enabled:
|
||||
emitter.emit(ActionEvent(
|
||||
event_type="plan_created",
|
||||
timestamp=time.time(),
|
||||
agent_name="PlanningAgent",
|
||||
metadata={"plan": self._todo_list.to_markdown()}
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Step 4: Create proper Task objects from plan steps
|
||||
console.print("\n[bold blue]🚀 EXECUTION PHASE[/bold blue]\n")
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ and context. Inspired by Gemini CLI's delegate-to-agent pattern.
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
import asyncio
|
||||
import warnings
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Callable
|
||||
from enum import Enum
|
||||
@@ -127,6 +128,12 @@ class SubagentDelegator:
|
||||
agent_factory: Factory function to create agents
|
||||
on_task_complete: Callback when task completes
|
||||
"""
|
||||
warnings.warn(
|
||||
"SubagentDelegator is deprecated. Use Agent.handoff_to() or "
|
||||
"parallel_handoffs() instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
self.config = config or DelegationConfig()
|
||||
self.agent_factory = agent_factory
|
||||
self.on_task_complete = on_task_complete
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Agent protocols for extensibility."""
|
||||
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
|
||||
from typing import Any, Awaitable, Dict, List, Optional, Protocol, runtime_checkable
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
@@ -113,3 +114,72 @@ class DictMerge:
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExecutionContext:
|
||||
"""Unified context for task execution across sync/async boundaries.
|
||||
|
||||
Contains all data needed for task execution to eliminate duplication
|
||||
between sync and async execution paths.
|
||||
"""
|
||||
task_id: int
|
||||
task: Any # Task object
|
||||
executor_agent: Any # Agent object
|
||||
tools: List[Any]
|
||||
task_description: str
|
||||
context_text: str
|
||||
task_prompt: str
|
||||
llm: Any # LLM instance
|
||||
verbose: int = 0
|
||||
stream: bool = False
|
||||
user_id: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskResult:
|
||||
"""Standardized result from task execution."""
|
||||
task_output: Any # TaskOutput object
|
||||
success: bool
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TaskExecutorProtocol(Protocol):
|
||||
"""Protocol for unified task execution engine.
|
||||
|
||||
Defines the interface for executing tasks in a unified way,
|
||||
eliminating duplication between async and sync execution paths.
|
||||
|
||||
The implementation should handle all business logic including:
|
||||
- Task validation and status management
|
||||
- Memory initialization and storage
|
||||
- Agent execution with tools
|
||||
- Output processing and result creation
|
||||
|
||||
Example:
|
||||
class MyTaskExecutor:
|
||||
def execute_task_impl(self, context: ExecutionContext) -> TaskResult:
|
||||
# Single implementation handles all execution logic
|
||||
return TaskResult(task_output=result, success=True)
|
||||
|
||||
# Check protocol compliance
|
||||
assert isinstance(MyTaskExecutor(), TaskExecutorProtocol)
|
||||
"""
|
||||
|
||||
def execute_task_impl(
|
||||
self,
|
||||
context: ExecutionContext
|
||||
) -> Awaitable[TaskResult]:
|
||||
"""Execute a task with unified business logic.
|
||||
|
||||
This method contains the single source of truth for task execution
|
||||
logic, eliminating duplication between sync/async paths.
|
||||
|
||||
Args:
|
||||
context: All context data needed for execution
|
||||
|
||||
Returns:
|
||||
TaskResult containing the output and execution status
|
||||
"""
|
||||
...
|
||||
@@ -21,7 +21,7 @@ class AgentAppConfig:
|
||||
host: Host address to bind to (default: "0.0.0.0")
|
||||
port: Port number to listen on (default: 8000)
|
||||
reload: Enable auto-reload for development (default: False)
|
||||
cors_origins: List of allowed CORS origins (default: ["*"])
|
||||
cors_origins: List of allowed CORS origins (default: [])
|
||||
api_prefix: API route prefix (default: "/api")
|
||||
docs_url: URL for API documentation (default: "/docs")
|
||||
openapi_url: URL for OpenAPI schema (default: "/openapi.json")
|
||||
@@ -44,7 +44,7 @@ class AgentAppConfig:
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8000
|
||||
reload: bool = False
|
||||
cors_origins: List[str] = field(default_factory=lambda: ["*"])
|
||||
cors_origins: List[str] = field(default_factory=lambda: [])
|
||||
api_prefix: str = "/api"
|
||||
docs_url: str = "/docs"
|
||||
openapi_url: str = "/openapi.json"
|
||||
|
||||
@@ -50,20 +50,30 @@ logger = get_logger(__name__)
|
||||
|
||||
# ── Singleton registry ───────────────────────────────────────────────────────
|
||||
|
||||
import threading
|
||||
|
||||
_registry: Optional[ApprovalRegistry] = None
|
||||
_registry_lock = threading.Lock()
|
||||
|
||||
def get_approval_registry() -> ApprovalRegistry:
|
||||
"""Return the global singleton :class:`ApprovalRegistry`."""
|
||||
global _registry
|
||||
if _registry is None:
|
||||
_registry = ApprovalRegistry()
|
||||
return _registry
|
||||
with _registry_lock:
|
||||
if _registry is None:
|
||||
_registry = ApprovalRegistry()
|
||||
return _registry
|
||||
|
||||
# ── Backward-compatible API (delegates to registry) ─────────────────────────
|
||||
|
||||
# These globals are kept for code that imports them directly.
|
||||
APPROVAL_REQUIRED_TOOLS: Set[str] = get_approval_registry()._required_tools
|
||||
TOOL_RISK_LEVELS: Dict[str, str] = get_approval_registry()._risk_levels
|
||||
# NOTE: Accessing these will trigger lazy initialization of the registry
|
||||
def __getattr__(name):
|
||||
"""Module-level attribute access for lazy initialization."""
|
||||
if name == "APPROVAL_REQUIRED_TOOLS":
|
||||
return get_approval_registry()._required_tools
|
||||
elif name == "TOOL_RISK_LEVELS":
|
||||
return get_approval_registry()._risk_levels
|
||||
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
||||
|
||||
# Legacy global callback holder — set_approval_callback wraps it into a backend
|
||||
approval_callback: Optional[Callable] = None
|
||||
@@ -114,14 +124,16 @@ def configure_default_approvals() -> None:
|
||||
reg.add_requirement(tool_name, risk_level)
|
||||
|
||||
def add_approval_requirement(tool_name: str, risk_level: str = "high") -> None:
|
||||
get_approval_registry().add_requirement(tool_name, risk_level)
|
||||
APPROVAL_REQUIRED_TOOLS.add(tool_name)
|
||||
TOOL_RISK_LEVELS[tool_name] = risk_level
|
||||
reg = get_approval_registry()
|
||||
reg.add_requirement(tool_name, risk_level)
|
||||
reg._required_tools.add(tool_name)
|
||||
reg._risk_levels[tool_name] = risk_level
|
||||
|
||||
def remove_approval_requirement(tool_name: str) -> None:
|
||||
get_approval_registry().remove_requirement(tool_name)
|
||||
APPROVAL_REQUIRED_TOOLS.discard(tool_name)
|
||||
TOOL_RISK_LEVELS.pop(tool_name, None)
|
||||
reg = get_approval_registry()
|
||||
reg.remove_requirement(tool_name)
|
||||
reg._required_tools.discard(tool_name)
|
||||
reg._risk_levels.pop(tool_name, None)
|
||||
|
||||
def is_approval_required(tool_name: str) -> bool:
|
||||
return get_approval_registry().is_required(tool_name)
|
||||
@@ -157,8 +169,8 @@ def require_approval(risk_level: RiskLevel = "high"):
|
||||
tool_name = getattr(func, '__name__', str(func))
|
||||
reg = get_approval_registry()
|
||||
reg.add_requirement(tool_name, risk_level)
|
||||
APPROVAL_REQUIRED_TOOLS.add(tool_name)
|
||||
TOOL_RISK_LEVELS[tool_name] = risk_level
|
||||
reg._required_tools.add(tool_name)
|
||||
reg._risk_levels[tool_name] = risk_level
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
|
||||
@@ -147,6 +147,8 @@ class ApprovalRegistry:
|
||||
self._approved_context.set(approved)
|
||||
|
||||
def is_already_approved(self, tool_name: str) -> bool:
|
||||
if self.get_risk_level(tool_name) == "critical":
|
||||
return False
|
||||
return tool_name in self._approved_context.get(set())
|
||||
|
||||
def clear_approved(self) -> None:
|
||||
|
||||
@@ -217,7 +217,11 @@ class CheckpointService:
|
||||
cwd=self.config.workspace_dir
|
||||
)
|
||||
|
||||
stdout, stderr = await process.communicate()
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=30)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
raise RuntimeError(f"Git command timed out after 30 seconds: {' '.join(args)}")
|
||||
|
||||
if process.returncode != 0:
|
||||
error_msg = stderr.decode().strip()
|
||||
|
||||
@@ -90,6 +90,14 @@ __all__ = [
|
||||
"ConfigEntry",
|
||||
"getenv_cached",
|
||||
"clear_env_cache",
|
||||
# Configuration schema protocols (NEW)
|
||||
"ConfigSchemaProtocol",
|
||||
"ConfigMappingProtocol",
|
||||
"ConfigValidatorProtocol",
|
||||
"ConfigMergerProtocol",
|
||||
"ValidationResult",
|
||||
"CliMapping",
|
||||
"PrecedenceChain",
|
||||
]
|
||||
|
||||
# Module mapping for lazy loading
|
||||
@@ -163,6 +171,14 @@ _MODULE_MAP = {
|
||||
"ConfigEntry": "resolver",
|
||||
"getenv_cached": "resolver",
|
||||
"clear_env_cache": "resolver",
|
||||
# protocols
|
||||
"ConfigSchemaProtocol": "protocols",
|
||||
"ConfigMappingProtocol": "protocols",
|
||||
"ConfigValidatorProtocol": "protocols",
|
||||
"ConfigMergerProtocol": "protocols",
|
||||
"ValidationResult": "protocols",
|
||||
"CliMapping": "protocols",
|
||||
"PrecedenceChain": "protocols",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -626,6 +626,16 @@ class OutputConfig:
|
||||
# Example: "# {{title}}\n\n{{content}}\n\n---\nGenerated by AI"
|
||||
template: Optional[str] = None
|
||||
|
||||
# Tool output limit - maximum characters for tool output
|
||||
# Default: 16000 chars (≈ 4000 tokens) to prevent runaway tool outputs
|
||||
tool_output_limit: int = 16000
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.tool_output_limit, int) or self.tool_output_limit <= 0:
|
||||
raise ValueError(
|
||||
"OutputConfig.tool_output_limit must be a positive integer."
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
@@ -642,6 +652,7 @@ class OutputConfig:
|
||||
"editor_output": self.editor_output,
|
||||
"output_file": self.output_file,
|
||||
"template": self.template,
|
||||
"tool_output_limit": self.tool_output_limit,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -300,9 +300,9 @@ CONTEXT_PRESETS: Dict[str, Dict[str, Any]] = {
|
||||
# =============================================================================
|
||||
|
||||
AUTONOMY_PRESETS: Dict[str, Dict[str, Any]] = {
|
||||
"suggest": {"mode": "suggest"},
|
||||
"auto_edit": {"mode": "auto_edit"},
|
||||
"full_auto": {"mode": "full_auto"},
|
||||
"suggest": {"level": "suggest"},
|
||||
"auto_edit": {"level": "auto_edit"},
|
||||
"full_auto": {"level": "full_auto"},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
"""
|
||||
Configuration Schema Protocols for PraisonAI Agents.
|
||||
|
||||
Core protocols for unified configuration schema validation and mapping.
|
||||
These protocols define the contract for configuration schema providers
|
||||
without heavy implementations (following protocol-driven architecture).
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, Protocol, Tuple, runtime_checkable
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
"""
|
||||
Result of configuration validation.
|
||||
|
||||
Attributes:
|
||||
is_valid: Whether the configuration is valid
|
||||
errors: List of validation error messages
|
||||
warnings: List of validation warning messages
|
||||
normalized: Normalized configuration (after applying defaults, etc.)
|
||||
"""
|
||||
is_valid: bool
|
||||
errors: List[str]
|
||||
warnings: List[str] = None
|
||||
normalized: Optional[Dict[str, Any]] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.warnings is None:
|
||||
self.warnings = []
|
||||
|
||||
|
||||
@dataclass
|
||||
class CliMapping:
|
||||
"""
|
||||
Mapping between configuration field and CLI flag.
|
||||
|
||||
Attributes:
|
||||
field_name: Name of the configuration field
|
||||
cli_flag: CLI flag name (without --)
|
||||
description: Help text for CLI flag
|
||||
type_hint: Python type for CLI argument parsing
|
||||
default: Default value if not provided
|
||||
choices: Valid choices for enum/choice fields
|
||||
env_var: Environment variable name (optional)
|
||||
"""
|
||||
field_name: str
|
||||
cli_flag: str
|
||||
description: str
|
||||
type_hint: type
|
||||
default: Any = None
|
||||
choices: Optional[List[str]] = None
|
||||
env_var: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PrecedenceChain:
|
||||
"""
|
||||
Documented precedence chain for configuration resolution.
|
||||
|
||||
Attributes:
|
||||
chain: List of source names in precedence order (highest to lowest)
|
||||
descriptions: Dict mapping source names to descriptions
|
||||
"""
|
||||
chain: List[str]
|
||||
descriptions: Dict[str, str]
|
||||
|
||||
def __post_init__(self):
|
||||
# Validate chain is non-empty
|
||||
if not self.chain:
|
||||
raise ValueError("Precedence chain cannot be empty")
|
||||
|
||||
# Ensure all chain items have descriptions
|
||||
for source in self.chain:
|
||||
if source not in self.descriptions:
|
||||
self.descriptions[source] = f"Configuration from {source}"
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ConfigSchemaProtocol(Protocol):
|
||||
"""
|
||||
Protocol for configuration schema providers.
|
||||
|
||||
Defines the interface for configuration schema validation, CLI mapping,
|
||||
and precedence chain documentation. Implementations should provide
|
||||
concrete schema validation and mapping logic.
|
||||
|
||||
This protocol enables:
|
||||
- Schema-driven CLI generation
|
||||
- Guaranteed CLI/YAML/Python mapping
|
||||
- Single documented precedence chain
|
||||
- Strong validation with user-friendly errors
|
||||
"""
|
||||
|
||||
def validate(self, config: Dict[str, Any]) -> ValidationResult:
|
||||
"""
|
||||
Validate configuration against schema.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary to validate
|
||||
|
||||
Returns:
|
||||
ValidationResult with validation status and any errors/warnings
|
||||
"""
|
||||
...
|
||||
|
||||
def get_cli_mapping(self) -> List[CliMapping]:
|
||||
"""
|
||||
Get CLI flag mappings derived from schema.
|
||||
|
||||
Returns:
|
||||
List of CliMapping objects defining CLI flags
|
||||
"""
|
||||
...
|
||||
|
||||
def get_precedence_chain(self) -> PrecedenceChain:
|
||||
"""
|
||||
Get documented precedence chain for configuration resolution.
|
||||
|
||||
Returns:
|
||||
PrecedenceChain defining resolution order and descriptions
|
||||
"""
|
||||
...
|
||||
|
||||
def normalize_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Normalize configuration by applying defaults and transformations.
|
||||
|
||||
Args:
|
||||
config: Raw configuration dictionary
|
||||
|
||||
Returns:
|
||||
Normalized configuration dictionary
|
||||
"""
|
||||
...
|
||||
|
||||
def get_schema_info(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get schema metadata for documentation and introspection.
|
||||
|
||||
Returns:
|
||||
Dict containing schema information (fields, types, descriptions, etc.)
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ConfigMappingProtocol(Protocol):
|
||||
"""
|
||||
Protocol for configuration field mapping and transformation.
|
||||
|
||||
Handles mapping between different configuration interfaces:
|
||||
- YAML field names (e.g., 'role', 'backstory')
|
||||
- Python API field names (e.g., 'name', 'instructions')
|
||||
- CLI flag names (e.g., '--agent-role', '--agent-backstory')
|
||||
"""
|
||||
|
||||
def yaml_to_python(self, yaml_config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Map YAML configuration to Python API format.
|
||||
|
||||
Args:
|
||||
yaml_config: Configuration from YAML file
|
||||
|
||||
Returns:
|
||||
Configuration mapped to Python API field names
|
||||
"""
|
||||
...
|
||||
|
||||
def python_to_yaml(self, python_config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Map Python API configuration to YAML format.
|
||||
|
||||
Args:
|
||||
python_config: Configuration from Python API
|
||||
|
||||
Returns:
|
||||
Configuration mapped to YAML field names
|
||||
"""
|
||||
...
|
||||
|
||||
def cli_to_python(self, cli_args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Map CLI arguments to Python API format.
|
||||
|
||||
Args:
|
||||
cli_args: Parsed CLI arguments
|
||||
|
||||
Returns:
|
||||
Configuration mapped to Python API field names
|
||||
"""
|
||||
...
|
||||
|
||||
def get_field_aliases(self) -> Dict[str, List[str]]:
|
||||
"""
|
||||
Get field name aliases for backward compatibility.
|
||||
|
||||
Returns:
|
||||
Dict mapping canonical field names to lists of aliases
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ConfigValidatorProtocol(Protocol):
|
||||
"""
|
||||
Protocol for configuration validation with helpful error messages.
|
||||
|
||||
Provides detailed validation with suggestions for fixing common mistakes.
|
||||
"""
|
||||
|
||||
def validate_with_suggestions(self, config: Dict[str, Any]) -> ValidationResult:
|
||||
"""
|
||||
Validate configuration with suggestions for fixing errors.
|
||||
|
||||
Args:
|
||||
config: Configuration to validate
|
||||
|
||||
Returns:
|
||||
ValidationResult with detailed error messages and suggestions
|
||||
"""
|
||||
...
|
||||
|
||||
def suggest_corrections(self, field_name: str, invalid_value: Any) -> List[str]:
|
||||
"""
|
||||
Suggest corrections for invalid field values.
|
||||
|
||||
Args:
|
||||
field_name: Name of the invalid field
|
||||
invalid_value: The invalid value provided
|
||||
|
||||
Returns:
|
||||
List of suggested corrections
|
||||
"""
|
||||
...
|
||||
|
||||
def check_field_conflicts(self, config: Dict[str, Any]) -> List[str]:
|
||||
"""
|
||||
Check for conflicting configuration fields.
|
||||
|
||||
Args:
|
||||
config: Configuration to check
|
||||
|
||||
Returns:
|
||||
List of conflict error messages
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ConfigMergerProtocol(Protocol):
|
||||
"""
|
||||
Protocol for merging configurations from multiple sources with precedence.
|
||||
|
||||
Implements the single documented precedence chain for config resolution.
|
||||
"""
|
||||
|
||||
def merge_configs(
|
||||
self,
|
||||
sources: Dict[str, Dict[str, Any]],
|
||||
precedence_chain: List[str]
|
||||
) -> Tuple[Dict[str, Any], List[str]]:
|
||||
"""
|
||||
Merge configurations from multiple sources with precedence.
|
||||
|
||||
Args:
|
||||
sources: Dict mapping source names to configuration dicts
|
||||
precedence_chain: List of source names in precedence order (highest to lowest)
|
||||
|
||||
Returns:
|
||||
Tuple of (merged_config, resolution_log)
|
||||
"""
|
||||
...
|
||||
|
||||
def resolve_conflicts(
|
||||
self,
|
||||
field_name: str,
|
||||
values: Dict[str, Any]
|
||||
) -> Tuple[Any, str]:
|
||||
"""
|
||||
Resolve conflicts between multiple values for a field.
|
||||
|
||||
Args:
|
||||
field_name: Name of the conflicting field
|
||||
values: Dict mapping source names to values
|
||||
|
||||
Returns:
|
||||
Tuple of (resolved_value, resolution_reason)
|
||||
"""
|
||||
...
|
||||
@@ -180,7 +180,7 @@ class FastContext:
|
||||
**kwargs
|
||||
}
|
||||
key_str = json.dumps(key_data, sort_keys=True)
|
||||
return hashlib.md5(key_str.encode()).hexdigest()
|
||||
return hashlib.sha256(key_str.encode()).hexdigest()
|
||||
|
||||
def _get_cached(self, cache_key: str) -> Optional[FastContextResult]:
|
||||
"""Get cached result if valid."""
|
||||
|
||||
@@ -196,7 +196,7 @@ class FileIndexer:
|
||||
return None
|
||||
|
||||
try:
|
||||
hasher = hashlib.md5()
|
||||
hasher = hashlib.sha256()
|
||||
with open(filepath, 'rb') as f:
|
||||
for chunk in iter(lambda: f.read(8192), b''):
|
||||
hasher.update(chunk)
|
||||
|
||||
@@ -291,7 +291,20 @@ class MultiAgentLedger:
|
||||
self._shared_tokens: int = 0 # Tokens shared across agents
|
||||
|
||||
def get_agent_ledger(self, agent_id: str) -> ContextLedgerManager:
|
||||
"""Get or create ledger for an agent."""
|
||||
"""Get or create ledger for an agent.
|
||||
|
||||
Args:
|
||||
agent_id: Unique identifier for the agent.
|
||||
|
||||
Returns:
|
||||
ContextLedgerManager for this agent.
|
||||
|
||||
Raises:
|
||||
ValueError: If agent_id is empty or contains only whitespace.
|
||||
"""
|
||||
if not agent_id or not agent_id.strip():
|
||||
raise ValueError("agent_id must be a non-empty string")
|
||||
|
||||
if agent_id not in self._agents:
|
||||
self._agents[agent_id] = ContextLedgerManager(agent_id)
|
||||
return self._agents[agent_id]
|
||||
|
||||
@@ -129,7 +129,7 @@ def deduplicate_topics(topics: list, key: str = "title", similarity_threshold: f
|
||||
normalized = " ".join(w for w in normalized.split() if len(w) > 3)
|
||||
|
||||
# Check exact hash match
|
||||
content_hash = hashlib.md5(normalized.encode()).hexdigest()
|
||||
content_hash = hashlib.sha256(normalized.encode()).hexdigest()
|
||||
if content_hash in seen_hashes:
|
||||
continue
|
||||
|
||||
@@ -590,7 +590,7 @@ class ContextManager:
|
||||
if isinstance(content, str) and len(content) > 100:
|
||||
# Hash the content (first 2000 chars to avoid hashing huge content)
|
||||
content_key = content[:2000]
|
||||
content_hash = hashlib.md5(content_key.encode()).hexdigest()[:16]
|
||||
content_hash = hashlib.sha256(content_key.encode()).hexdigest()[:16]
|
||||
|
||||
# Check local seen hashes first
|
||||
if content_hash in seen_hashes:
|
||||
@@ -710,7 +710,7 @@ class ContextManager:
|
||||
Tuple of (token_count, metrics)
|
||||
"""
|
||||
# Check cache
|
||||
cache_key = hashlib.md5(text.encode()).hexdigest()
|
||||
cache_key = hashlib.sha256(text.encode()).hexdigest()
|
||||
if cache_key in self._estimation_cache:
|
||||
return self._estimation_cache[cache_key], None
|
||||
|
||||
|
||||
@@ -514,7 +514,10 @@ class ContextMonitor:
|
||||
return str(output_path)
|
||||
|
||||
def _get_output_path(self, agent_name: str = "") -> Path:
|
||||
"""Get output path, optionally per-agent."""
|
||||
"""Get output path, optionally per-agent.
|
||||
|
||||
Security: Agent names are sanitized to prevent path traversal.
|
||||
"""
|
||||
if not self.multi_agent_files or not agent_name:
|
||||
return self.path
|
||||
|
||||
@@ -522,10 +525,32 @@ class ContextMonitor:
|
||||
stem = self.path.stem
|
||||
suffix = self.path.suffix or (".json" if self.format == "json" else ".txt")
|
||||
|
||||
# Sanitize agent name
|
||||
safe_name = re.sub(r'[^\w\-]', '_', agent_name.lower())
|
||||
# Sanitize agent name:
|
||||
# 1. Strip any path separators first (defence in depth)
|
||||
safe_name = agent_name.replace('/', '_').replace('\\', '_')
|
||||
# 2. Collapse to safe chars only
|
||||
safe_name = re.sub(r'[^\w\-]', '_', safe_name.lower())
|
||||
# 3. Enforce length limit to prevent filesystem issues
|
||||
safe_name = safe_name[:64] if safe_name else 'unknown'
|
||||
# 4. Strip leading/trailing underscores / dots
|
||||
safe_name = safe_name.strip('_.')
|
||||
if not safe_name:
|
||||
safe_name = 'unknown'
|
||||
|
||||
return self.path.parent / f"{stem}_{safe_name}{suffix}"
|
||||
result = self.path.parent / f"{stem}_{safe_name}{suffix}"
|
||||
|
||||
# Verify the resolved path stays within the expected parent dir
|
||||
resolved = result.resolve()
|
||||
parent_resolved = self.path.parent.resolve()
|
||||
try:
|
||||
resolved.relative_to(parent_resolved)
|
||||
except ValueError:
|
||||
# Fallback to a safe hash-based name
|
||||
import hashlib
|
||||
hash_name = hashlib.sha256(agent_name.encode()).hexdigest()[:16]
|
||||
result = self.path.parent / f"{stem}_{hash_name}{suffix}"
|
||||
|
||||
return result
|
||||
|
||||
def _get_warnings(
|
||||
self,
|
||||
|
||||
@@ -153,7 +153,7 @@ class ContextMessage:
|
||||
def content_hash(self) -> str:
|
||||
"""Get hash of content for caching."""
|
||||
content_str = str(self.content) if not isinstance(self.content, str) else self.content
|
||||
return hashlib.md5(content_str.encode()).hexdigest()[:16]
|
||||
return hashlib.sha256(content_str.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
"""
|
||||
Structured exception hierarchy for PraisonAI SDK.
|
||||
|
||||
Provides uniform error semantics for consistent handling across:
|
||||
- Multi-agent orchestration
|
||||
- Tool execution
|
||||
- LLM interactions
|
||||
- Memory operations
|
||||
- External integrations
|
||||
"""
|
||||
|
||||
from typing import Literal, Protocol, runtime_checkable, Optional, Dict, Any
|
||||
import uuid
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ErrorContextProtocol(Protocol):
|
||||
"""Protocol for structured error context propagation."""
|
||||
|
||||
agent_id: str
|
||||
run_id: str
|
||||
is_retryable: bool
|
||||
error_category: Literal["tool", "llm", "budget", "validation", "network", "handoff"]
|
||||
|
||||
|
||||
class PraisonAIError(Exception):
|
||||
"""
|
||||
Base error class with structured context for PraisonAI SDK.
|
||||
|
||||
All PraisonAI errors inherit from this to ensure uniform error handling,
|
||||
context propagation, and observability hooks.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
agent_id: str = "unknown",
|
||||
run_id: Optional[str] = None,
|
||||
error_category: Literal["tool", "llm", "budget", "validation", "network", "handoff"] = "validation",
|
||||
is_retryable: bool = False,
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.agent_id = agent_id
|
||||
self.run_id = run_id or str(uuid.uuid4())
|
||||
self.error_category = error_category
|
||||
self.is_retryable = is_retryable
|
||||
self.context = context or {}
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"[{self.error_category}] {self.message} (agent: {self.agent_id}, run: {self.run_id})"
|
||||
|
||||
|
||||
class ToolExecutionError(PraisonAIError):
|
||||
"""
|
||||
Tool execution failed.
|
||||
|
||||
Includes tool name and execution context for better debugging
|
||||
and selective retry policies.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
tool_name: str = "unknown",
|
||||
agent_id: str = "unknown",
|
||||
run_id: Optional[str] = None,
|
||||
is_retryable: bool = True, # Most tool errors are retryable
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
context = context or {}
|
||||
context["tool_name"] = tool_name
|
||||
super().__init__(
|
||||
message,
|
||||
agent_id=agent_id,
|
||||
run_id=run_id,
|
||||
error_category="tool",
|
||||
is_retryable=is_retryable,
|
||||
context=context
|
||||
)
|
||||
self.tool_name = tool_name
|
||||
|
||||
|
||||
class LLMError(PraisonAIError):
|
||||
"""
|
||||
LLM interaction failed.
|
||||
|
||||
Distinguishes between rate limits (retryable) vs model errors (fatal).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
model_name: str = "unknown",
|
||||
agent_id: str = "unknown",
|
||||
run_id: Optional[str] = None,
|
||||
is_retryable: bool = False, # Default to non-retryable unless specified
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
context = context or {}
|
||||
context["model_name"] = model_name
|
||||
super().__init__(
|
||||
message,
|
||||
agent_id=agent_id,
|
||||
run_id=run_id,
|
||||
error_category="llm",
|
||||
is_retryable=is_retryable,
|
||||
context=context
|
||||
)
|
||||
self.model_name = model_name
|
||||
|
||||
|
||||
class BudgetExceededError(PraisonAIError):
|
||||
"""
|
||||
Budget limits exceeded (tokens, time, etc).
|
||||
|
||||
Generally not retryable without intervention.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message_or_agent_name,
|
||||
total_cost_or_budget_type = None,
|
||||
max_budget_or_limit = None,
|
||||
budget_type: str = "tokens",
|
||||
limit: Optional[float] = None,
|
||||
used: Optional[float] = None,
|
||||
agent_id: str = "unknown",
|
||||
run_id: Optional[str] = None,
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
# Handle backward compatibility: old constructor BudgetExceededError(agent_name, total_cost, max_budget)
|
||||
if (isinstance(message_or_agent_name, str) and
|
||||
isinstance(total_cost_or_budget_type, (int, float)) and
|
||||
isinstance(max_budget_or_limit, (int, float)) and
|
||||
budget_type == "tokens"): # Default value indicates old constructor
|
||||
|
||||
# Old constructor format
|
||||
agent_name = message_or_agent_name
|
||||
total_cost = float(total_cost_or_budget_type)
|
||||
max_budget = float(max_budget_or_limit)
|
||||
message = f"Agent '{agent_name}' exceeded budget: ${total_cost:.4f} >= ${max_budget:.4f}"
|
||||
|
||||
context = context or {}
|
||||
context.update({
|
||||
"budget_type": "cost", # Legacy errors are cost-based
|
||||
"limit": max_budget,
|
||||
"used": total_cost
|
||||
})
|
||||
super().__init__(
|
||||
message,
|
||||
agent_id=agent_name,
|
||||
run_id=run_id,
|
||||
error_category="budget",
|
||||
is_retryable=False,
|
||||
context=context
|
||||
)
|
||||
self.budget_type = "cost"
|
||||
self.limit = max_budget
|
||||
self.used = total_cost
|
||||
self.agent_name = agent_name
|
||||
self.total_cost = total_cost
|
||||
self.max_budget = max_budget
|
||||
else:
|
||||
# New constructor format
|
||||
message = str(message_or_agent_name)
|
||||
if total_cost_or_budget_type is not None and isinstance(total_cost_or_budget_type, str):
|
||||
budget_type = total_cost_or_budget_type
|
||||
if max_budget_or_limit is not None:
|
||||
if limit is None:
|
||||
limit = max_budget_or_limit
|
||||
|
||||
context = context or {}
|
||||
context.update({
|
||||
"budget_type": budget_type,
|
||||
"limit": limit,
|
||||
"used": used
|
||||
})
|
||||
super().__init__(
|
||||
message,
|
||||
agent_id=agent_id,
|
||||
run_id=run_id,
|
||||
error_category="budget",
|
||||
is_retryable=False,
|
||||
context=context
|
||||
)
|
||||
self.budget_type = budget_type
|
||||
self.limit = limit
|
||||
self.used = used
|
||||
|
||||
# Legacy attributes for backward compatibility
|
||||
self.agent_name = agent_id
|
||||
self.total_cost = used
|
||||
self.max_budget = limit
|
||||
|
||||
|
||||
class ValidationError(PraisonAIError):
|
||||
"""
|
||||
Input validation failed.
|
||||
|
||||
Usually indicates programming errors, not retryable.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
field_name: Optional[str] = None,
|
||||
agent_id: str = "unknown",
|
||||
run_id: Optional[str] = None,
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
context = context or {}
|
||||
if field_name:
|
||||
context["field_name"] = field_name
|
||||
super().__init__(
|
||||
message,
|
||||
agent_id=agent_id,
|
||||
run_id=run_id,
|
||||
error_category="validation",
|
||||
is_retryable=False, # Validation errors need code fixes
|
||||
context=context
|
||||
)
|
||||
self.field_name = field_name
|
||||
|
||||
|
||||
class NetworkError(PraisonAIError):
|
||||
"""
|
||||
Network/external service error.
|
||||
|
||||
Often retryable with backoff.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
service_name: str = "unknown",
|
||||
status_code: Optional[int] = None,
|
||||
agent_id: str = "unknown",
|
||||
run_id: Optional[str] = None,
|
||||
is_retryable: bool = True, # Most network errors are retryable
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
context = context or {}
|
||||
context.update({
|
||||
"service_name": service_name,
|
||||
"status_code": status_code
|
||||
})
|
||||
super().__init__(
|
||||
message,
|
||||
agent_id=agent_id,
|
||||
run_id=run_id,
|
||||
error_category="network",
|
||||
is_retryable=is_retryable,
|
||||
context=context
|
||||
)
|
||||
self.service_name = service_name
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class HandoffError(PraisonAIError):
|
||||
"""
|
||||
Agent handoff/delegation failed.
|
||||
|
||||
Includes source/target agent context for multi-agent debugging.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
source_agent: str = "unknown",
|
||||
target_agent: Optional[str] = None,
|
||||
agent_id: str = "unknown",
|
||||
run_id: Optional[str] = None,
|
||||
is_retryable: bool = False, # Handoff errors usually need investigation
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
context = context or {}
|
||||
context.update({
|
||||
"source_agent": source_agent,
|
||||
"target_agent": target_agent
|
||||
})
|
||||
super().__init__(
|
||||
message,
|
||||
agent_id=agent_id,
|
||||
run_id=run_id,
|
||||
error_category="handoff",
|
||||
is_retryable=is_retryable,
|
||||
context=context
|
||||
)
|
||||
self.source_agent = source_agent
|
||||
self.target_agent = target_agent
|
||||
|
||||
|
||||
# Specialized handoff errors (maintain backward compatibility)
|
||||
class HandoffCycleError(HandoffError):
|
||||
"""Circular handoff dependency detected."""
|
||||
|
||||
def __init__(self, message: str, cycle_path: Optional[list] = None, **kwargs):
|
||||
super().__init__(message, **kwargs)
|
||||
if cycle_path:
|
||||
self.context["cycle_path"] = cycle_path
|
||||
# Backward compatibility alias
|
||||
self.chain = cycle_path
|
||||
|
||||
|
||||
class HandoffDepthError(HandoffError):
|
||||
"""Maximum handoff depth exceeded."""
|
||||
|
||||
def __init__(self, message: str, max_depth: Optional[int] = None, current_depth: Optional[int] = None, **kwargs):
|
||||
super().__init__(message, **kwargs)
|
||||
self.context.update({
|
||||
"max_depth": max_depth,
|
||||
"current_depth": current_depth
|
||||
})
|
||||
# Backward compatibility aliases
|
||||
self.max_depth = max_depth
|
||||
self.depth = current_depth
|
||||
|
||||
|
||||
class HandoffTimeoutError(HandoffError):
|
||||
"""Handoff operation timed out."""
|
||||
|
||||
def __init__(self, message: str, timeout_seconds: Optional[float] = None, **kwargs):
|
||||
super().__init__(message, is_retryable=True, **kwargs) # Timeouts may be retryable
|
||||
if timeout_seconds:
|
||||
self.context["timeout_seconds"] = timeout_seconds
|
||||
# Backward compatibility alias
|
||||
self.timeout = timeout_seconds
|
||||
|
||||
|
||||
# Export all error types for easy importing
|
||||
__all__ = [
|
||||
"ErrorContextProtocol",
|
||||
"PraisonAIError",
|
||||
"ToolExecutionError",
|
||||
"LLMError",
|
||||
"BudgetExceededError",
|
||||
"ValidationError",
|
||||
"NetworkError",
|
||||
"HandoffError",
|
||||
"HandoffCycleError",
|
||||
"HandoffDepthError",
|
||||
"HandoffTimeoutError"
|
||||
]
|
||||
@@ -180,7 +180,7 @@ class DoomLoopDetector:
|
||||
chunk_size = self.config.content_chunk_size
|
||||
for i in range(0, len(text) - chunk_size + 1, chunk_size):
|
||||
chunk = text[i:i + chunk_size]
|
||||
chunk_hash = hashlib.md5(chunk.encode()).hexdigest()[:16]
|
||||
chunk_hash = hashlib.sha256(chunk.encode()).hexdigest()[:16]
|
||||
self._content_chunk_counts[chunk_hash] = self._content_chunk_counts.get(chunk_hash, 0) + 1
|
||||
|
||||
def mark_progress(self, marker: str):
|
||||
@@ -326,18 +326,18 @@ class DoomLoopDetector:
|
||||
def _hash_action(self, action_type: str, args: Dict[str, Any]) -> str:
|
||||
"""Create hash for action + args."""
|
||||
content = f"{action_type}:{self._hash_dict(args)}"
|
||||
return hashlib.md5(content.encode()).hexdigest()[:16]
|
||||
return hashlib.sha256(content.encode()).hexdigest()[:16]
|
||||
|
||||
def _hash_dict(self, d: Dict[str, Any]) -> str:
|
||||
"""Create hash for dictionary."""
|
||||
# Sort keys for consistent hashing
|
||||
content = str(sorted(d.items()))
|
||||
return hashlib.md5(content.encode()).hexdigest()[:16]
|
||||
return hashlib.sha256(content.encode()).hexdigest()[:16]
|
||||
|
||||
def _hash_result(self, result: Any) -> str:
|
||||
"""Create hash for result."""
|
||||
content = str(result)[:1000] # Limit size
|
||||
return hashlib.md5(content.encode()).hexdigest()[:16]
|
||||
return hashlib.sha256(content.encode()).hexdigest()[:16]
|
||||
|
||||
def _check_repeated_identical(self) -> bool:
|
||||
"""Check for repeated identical actions."""
|
||||
|
||||
@@ -291,21 +291,6 @@ class ObservabilityHooks:
|
||||
"final_stage": self._current_stage.name if self._current_stage else None,
|
||||
}
|
||||
|
||||
# Global hooks instance (opt-in)
|
||||
_global_hooks: Optional[ObservabilityHooks] = None
|
||||
|
||||
def get_hooks() -> Optional[ObservabilityHooks]:
|
||||
"""Get global observability hooks."""
|
||||
return _global_hooks
|
||||
|
||||
def enable_observability() -> ObservabilityHooks:
|
||||
"""Enable global observability."""
|
||||
global _global_hooks
|
||||
if _global_hooks is None:
|
||||
_global_hooks = ObservabilityHooks(enabled=True)
|
||||
return _global_hooks
|
||||
|
||||
def disable_observability():
|
||||
"""Disable global observability."""
|
||||
global _global_hooks
|
||||
_global_hooks = None
|
||||
# NOTE: Observability is now per-agent/per-pipeline to comply with multi-agent safety.
|
||||
# Each agent creates its own ObservabilityHooks instance when observe=True.
|
||||
# See agent.py:2236 for the correct per-instance pattern.
|
||||
@@ -218,6 +218,9 @@ class EvalSuite:
|
||||
|
||||
if hasattr(eval_result, 'avg_score'):
|
||||
return float(eval_result.avg_score)
|
||||
|
||||
if hasattr(eval_result, 'safety_score'):
|
||||
return float(eval_result.safety_score)
|
||||
|
||||
# Try to extract from result dictionary
|
||||
if isinstance(eval_result, dict):
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Gateway adapters package for PraisonAI.
|
||||
|
||||
This package contains adapters that bridge different systems with the Gateway infrastructure.
|
||||
"""
|
||||
|
||||
from .recipe_adapter import RecipeBotAdapter
|
||||
|
||||
__all__ = ["RecipeBotAdapter"]
|
||||
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Adapter to wrap a RecipeRuntime behind the standard Agent duck-typing for Gateway and BotOS.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, Any
|
||||
from praisonaiagents.streaming import StreamEventEmitter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RecipeBotAdapter:
|
||||
"""Wraps RecipeRuntime to act like an Agent for gateway/bot compatibility."""
|
||||
|
||||
def __init__(self, recipe_name: str, **kwargs):
|
||||
self.recipe_name = recipe_name
|
||||
self.config = kwargs
|
||||
self._runtime = None
|
||||
self.stream_emitter = StreamEventEmitter()
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return f"recipe-{self.recipe_name}"
|
||||
|
||||
def _get_runtime(self):
|
||||
"""Lazy load and instantiate RecipeRuntime."""
|
||||
if self._runtime is None:
|
||||
try:
|
||||
from praisonai.recipe.core import RecipeRuntime
|
||||
self._runtime = RecipeRuntime(recipe_name=self.recipe_name, **self.config)
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
f"Failed to load PraisonAI recipe components: {e}. "
|
||||
"Make sure you have praisonai installed."
|
||||
) from e
|
||||
return self._runtime
|
||||
|
||||
def chat(self, message: str) -> str:
|
||||
"""Process the message via the recipe runtime."""
|
||||
try:
|
||||
runtime = self._get_runtime()
|
||||
|
||||
# Forward the user_input to the recipe's variables
|
||||
variables = runtime.context.variables if hasattr(runtime, 'context') else {}
|
||||
variables["user_input"] = message
|
||||
|
||||
# Currently we rely on string return matching standard Agent
|
||||
# Note: We don't have direct streaming from recipe to emitter yet; this binds them generically.
|
||||
result = runtime.run()
|
||||
|
||||
if hasattr(result, "final_output"):
|
||||
return str(result.final_output)
|
||||
return str(result)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Recipe execution failed: {e}")
|
||||
return f"Error executing recipe: {e}"
|
||||
@@ -57,7 +57,7 @@ class GatewayConfig:
|
||||
|
||||
host: str = "127.0.0.1"
|
||||
port: int = 8765
|
||||
cors_origins: List[str] = field(default_factory=lambda: ["*"])
|
||||
cors_origins: List[str] = field(default_factory=lambda: [])
|
||||
auth_token: Optional[str] = None
|
||||
max_connections: int = 1000
|
||||
max_sessions_per_agent: int = 0 # 0 = unlimited
|
||||
@@ -204,7 +204,7 @@ class MultiChannelGatewayConfig:
|
||||
gateway_config = GatewayConfig(
|
||||
host=gw_data.get("host", "127.0.0.1"),
|
||||
port=gw_data.get("port", 8765),
|
||||
cors_origins=gw_data.get("cors_origins", ["*"]),
|
||||
cors_origins=gw_data.get("cors_origins", []),
|
||||
auth_token=gw_data.get("auth_token"),
|
||||
max_connections=gw_data.get("max_connections", 1000),
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ Manages registration and lookup of hooks for different events.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from praisonaiagents._logging import get_logger
|
||||
from typing import Dict, List, Optional, Callable, Union
|
||||
from functools import wraps
|
||||
@@ -319,18 +320,21 @@ class HookRegistry:
|
||||
|
||||
# Global default registry
|
||||
_default_registry: Optional[HookRegistry] = None
|
||||
_default_registry_lock = threading.Lock()
|
||||
|
||||
def get_default_registry() -> HookRegistry:
|
||||
"""Get the default global hook registry."""
|
||||
global _default_registry
|
||||
if _default_registry is None:
|
||||
_default_registry = HookRegistry()
|
||||
return _default_registry
|
||||
with _default_registry_lock:
|
||||
if _default_registry is None:
|
||||
_default_registry = HookRegistry()
|
||||
return _default_registry
|
||||
|
||||
def set_default_registry(registry: HookRegistry):
|
||||
"""Set the default global hook registry."""
|
||||
global _default_registry
|
||||
_default_registry = registry
|
||||
with _default_registry_lock:
|
||||
_default_registry = registry
|
||||
|
||||
# =============================================================================
|
||||
# Simplified API (beginner-friendly aliases)
|
||||
|
||||
@@ -4,7 +4,8 @@ Knowledge Store Adapters for PraisonAI Agents.
|
||||
Provides adapter implementations for various knowledge backends.
|
||||
All adapters implement KnowledgeStoreProtocol.
|
||||
|
||||
Adapters are lazy-loaded to avoid importing heavy dependencies.
|
||||
Adapters are now registered via the adapter registry to enable
|
||||
protocol-driven knowledge backend resolution.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -12,7 +13,33 @@ from typing import TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from .mem0_adapter import Mem0Adapter
|
||||
|
||||
# Lazy loading for adapters
|
||||
# Import registry functions
|
||||
from .registry import (
|
||||
register_knowledge_adapter,
|
||||
register_knowledge_factory,
|
||||
get_knowledge_adapter,
|
||||
list_knowledge_adapters,
|
||||
get_first_available_knowledge_adapter,
|
||||
has_knowledge_adapter,
|
||||
)
|
||||
|
||||
# Import factory functions for heavy adapters
|
||||
from .factories import (
|
||||
create_mem0_knowledge_adapter,
|
||||
create_mongodb_knowledge_adapter,
|
||||
create_chroma_knowledge_adapter,
|
||||
create_sqlite_knowledge_adapter,
|
||||
)
|
||||
|
||||
# Register core adapter factories (lightweight, no heavy dependencies)
|
||||
register_knowledge_factory("sqlite", create_sqlite_knowledge_adapter)
|
||||
|
||||
# Register heavy adapter factories (lazy-loaded via factories)
|
||||
register_knowledge_factory("mem0", create_mem0_knowledge_adapter)
|
||||
register_knowledge_factory("mongodb", create_mongodb_knowledge_adapter)
|
||||
register_knowledge_factory("chroma", create_chroma_knowledge_adapter)
|
||||
|
||||
# Lazy loading for adapters (backward compatibility)
|
||||
_LAZY_IMPORTS = {
|
||||
"Mem0Adapter": ("praisonaiagents.knowledge.adapters.mem0_adapter", "Mem0Adapter"),
|
||||
"MongoDBKnowledgeAdapter": ("praisonaiagents.knowledge.adapters.mongodb_adapter", "MongoDBKnowledgeAdapter"),
|
||||
@@ -31,10 +58,24 @@ def __getattr__(name: str):
|
||||
|
||||
def __dir__():
|
||||
"""List available attributes."""
|
||||
return list(_LAZY_IMPORTS.keys())
|
||||
return list(_LAZY_IMPORTS.keys()) + [
|
||||
"register_knowledge_adapter",
|
||||
"register_knowledge_factory",
|
||||
"get_knowledge_adapter",
|
||||
"list_knowledge_adapters",
|
||||
"get_first_available_knowledge_adapter",
|
||||
"has_knowledge_adapter",
|
||||
]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Mem0Adapter",
|
||||
"MongoDBKnowledgeAdapter",
|
||||
# Registry functions
|
||||
"register_knowledge_adapter",
|
||||
"register_knowledge_factory",
|
||||
"get_knowledge_adapter",
|
||||
"list_knowledge_adapters",
|
||||
"get_first_available_knowledge_adapter",
|
||||
"has_knowledge_adapter",
|
||||
]
|
||||
@@ -0,0 +1,603 @@
|
||||
"""
|
||||
Knowledge Adapter Factory Functions
|
||||
|
||||
Provides factory functions for heavy knowledge adapters that replace hardcoded imports
|
||||
in knowledge/knowledge.py. These enable lazy loading of heavy dependencies while maintaining
|
||||
the protocol-driven architecture.
|
||||
|
||||
Each factory function:
|
||||
1. Lazy imports the required dependencies
|
||||
2. Creates and returns an adapter instance that implements KnowledgeStoreProtocol
|
||||
3. Raises clear ImportError with installation instructions if dependencies missing
|
||||
|
||||
This approach follows the protocol-driven core principle by moving heavy implementations
|
||||
out of the core Knowledge class while preserving backward compatibility.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
from ..protocols import KnowledgeStoreProtocol
|
||||
|
||||
|
||||
def create_mem0_knowledge_adapter(**kwargs) -> KnowledgeStoreProtocol:
|
||||
"""
|
||||
Factory function to create Mem0 knowledge adapter.
|
||||
|
||||
Lazy imports mem0 and creates an adapter that wraps mem0.Memory
|
||||
to implement KnowledgeStoreProtocol.
|
||||
|
||||
Args:
|
||||
**kwargs: Configuration passed to mem0 Memory
|
||||
|
||||
Returns:
|
||||
KnowledgeStoreProtocol adapter instance
|
||||
|
||||
Raises:
|
||||
ImportError: If mem0 is not installed
|
||||
"""
|
||||
try:
|
||||
# Lazy import - only when actually needed
|
||||
from .mem0_adapter import Mem0Adapter
|
||||
return Mem0Adapter(**kwargs)
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"mem0 is required for mem0 knowledge adapter. Install with: pip install mem0ai"
|
||||
)
|
||||
|
||||
|
||||
def create_mongodb_knowledge_adapter(**kwargs) -> KnowledgeStoreProtocol:
|
||||
"""
|
||||
Factory function to create MongoDB knowledge adapter.
|
||||
|
||||
Lazy imports pymongo and creates an adapter that implements KnowledgeStoreProtocol
|
||||
using MongoDB as the document store backend.
|
||||
|
||||
Args:
|
||||
**kwargs: Configuration passed to MongoDB adapter
|
||||
|
||||
Returns:
|
||||
KnowledgeStoreProtocol adapter instance
|
||||
|
||||
Raises:
|
||||
ImportError: If pymongo is not installed
|
||||
"""
|
||||
try:
|
||||
# Lazy import - only when actually needed
|
||||
from .mongodb_adapter import MongoDBKnowledgeAdapter
|
||||
return MongoDBKnowledgeAdapter(**kwargs)
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"pymongo is required for mongodb knowledge adapter. Install with: pip install pymongo"
|
||||
)
|
||||
|
||||
|
||||
def create_chroma_knowledge_adapter(**kwargs) -> KnowledgeStoreProtocol:
|
||||
"""
|
||||
Factory function to create ChromaDB knowledge adapter.
|
||||
|
||||
Lazy imports chromadb and markitdown dependencies then creates an adapter
|
||||
that implements KnowledgeStoreProtocol using ChromaDB as the vector store backend.
|
||||
|
||||
Args:
|
||||
**kwargs: Configuration passed to ChromaDB adapter
|
||||
|
||||
Returns:
|
||||
KnowledgeStoreProtocol adapter instance
|
||||
|
||||
Raises:
|
||||
ImportError: If chromadb or markitdown are not installed
|
||||
"""
|
||||
try:
|
||||
import chromadb
|
||||
from markitdown import MarkItDown
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"chromadb and markitdown are required for chroma knowledge adapter. "
|
||||
"Install with: pip install chromadb markitdown"
|
||||
)
|
||||
|
||||
return ChromaKnowledgeAdapter(chromadb=chromadb, markitdown=MarkItDown(), **kwargs)
|
||||
|
||||
|
||||
class ChromaKnowledgeAdapter:
|
||||
"""
|
||||
Knowledge adapter that uses ChromaDB to implement KnowledgeStoreProtocol.
|
||||
|
||||
This adapter replaces the hardcoded chromadb import in knowledge.py (lines 77-78).
|
||||
"""
|
||||
|
||||
def __init__(self, chromadb, markitdown, **kwargs):
|
||||
"""Initialize ChromaDB knowledge adapter."""
|
||||
self.chromadb = chromadb
|
||||
self.markitdown = markitdown
|
||||
|
||||
# Configuration
|
||||
config = kwargs.get("config", {})
|
||||
vector_config = config.get("vector_store", {}).get("config", {})
|
||||
|
||||
collection_name = vector_config.get("collection_name", "praisonai_knowledge")
|
||||
persist_dir = vector_config.get("path", "knowledge_db")
|
||||
os.makedirs(persist_dir, exist_ok=True)
|
||||
|
||||
# Initialize ChromaDB client
|
||||
self.client = chromadb.PersistentClient(
|
||||
path=persist_dir,
|
||||
settings=chromadb.config.Settings(anonymized_telemetry=False)
|
||||
)
|
||||
|
||||
# Initialize collection
|
||||
try:
|
||||
self.collection = self.client.get_collection(name=collection_name)
|
||||
except Exception:
|
||||
self.collection = self.client.create_collection(
|
||||
name=collection_name,
|
||||
metadata={"hnsw:space": "cosine"}
|
||||
)
|
||||
|
||||
def search(self, query: str, *, user_id: Optional[str] = None, agent_id: Optional[str] = None,
|
||||
run_id: Optional[str] = None, limit: int = 10, filters: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any):
|
||||
"""Search for relevant content in ChromaDB."""
|
||||
from ..models import SearchResult, SearchResultItem
|
||||
|
||||
# Get embedding for query
|
||||
try:
|
||||
from praisonaiagents.embedding import embedding
|
||||
result = embedding(query, model="text-embedding-3-small")
|
||||
query_embedding = result.embeddings[0] if result.embeddings else None
|
||||
except Exception:
|
||||
query_embedding = None
|
||||
|
||||
if query_embedding is None:
|
||||
return SearchResult(results=[])
|
||||
|
||||
# Build where filters for scoped search
|
||||
where_filter = {}
|
||||
if user_id is not None:
|
||||
where_filter["user_id"] = user_id
|
||||
if agent_id is not None:
|
||||
where_filter["agent_id"] = agent_id
|
||||
if run_id is not None:
|
||||
where_filter["run_id"] = run_id
|
||||
|
||||
# Merge with additional filters
|
||||
if filters:
|
||||
where_filter.update(filters)
|
||||
|
||||
# Search ChromaDB
|
||||
try:
|
||||
query_kwargs = {
|
||||
"query_embeddings": [query_embedding],
|
||||
"n_results": limit,
|
||||
"include": ["documents", "metadatas", "distances"]
|
||||
}
|
||||
|
||||
# Add where filter if any conditions specified
|
||||
if where_filter:
|
||||
query_kwargs["where"] = where_filter
|
||||
|
||||
response = self.collection.query(**query_kwargs)
|
||||
|
||||
items = []
|
||||
if response["ids"]:
|
||||
for i in range(len(response["ids"][0])):
|
||||
metadata = response["metadatas"][0][i] if "metadatas" in response else {}
|
||||
# Ensure metadata is dict, never None (required by protocol)
|
||||
metadata = metadata or {}
|
||||
|
||||
score = 1.0 - (response["distances"][0][i] if "distances" in response else 0.0)
|
||||
|
||||
items.append(SearchResultItem(
|
||||
id=response["ids"][0][i],
|
||||
text=response["documents"][0][i],
|
||||
metadata=metadata,
|
||||
score=score
|
||||
))
|
||||
|
||||
return SearchResult(results=items)
|
||||
|
||||
except Exception as e:
|
||||
return SearchResult(results=[])
|
||||
|
||||
def add(self, content: Any, *, user_id: Optional[str] = None, agent_id: Optional[str] = None,
|
||||
run_id: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any):
|
||||
"""Add content to ChromaDB."""
|
||||
from ..models import AddResult
|
||||
|
||||
# Convert content to string
|
||||
content_str = str(content)
|
||||
|
||||
# Get embedding
|
||||
try:
|
||||
from praisonaiagents.embedding import embedding
|
||||
result = embedding(content_str, model="text-embedding-3-small")
|
||||
content_embedding = result.embeddings[0] if result.embeddings else None
|
||||
except Exception:
|
||||
content_embedding = None
|
||||
|
||||
if content_embedding is None:
|
||||
return AddResult(success=False, message="Failed to generate embedding")
|
||||
|
||||
# Generate ID and store
|
||||
import time
|
||||
doc_id = str(time.time_ns())
|
||||
|
||||
# Prepare metadata
|
||||
doc_metadata = metadata or {}
|
||||
if user_id:
|
||||
doc_metadata["user_id"] = user_id
|
||||
if agent_id:
|
||||
doc_metadata["agent_id"] = agent_id
|
||||
if run_id:
|
||||
doc_metadata["run_id"] = run_id
|
||||
|
||||
try:
|
||||
self.collection.add(
|
||||
documents=[content_str],
|
||||
metadatas=[doc_metadata],
|
||||
ids=[doc_id],
|
||||
embeddings=[content_embedding]
|
||||
)
|
||||
|
||||
return AddResult(success=True, id=doc_id)
|
||||
|
||||
except Exception as e:
|
||||
return AddResult(success=False, message=str(e))
|
||||
|
||||
def get(self, item_id: str, **kwargs: Any):
|
||||
"""Get a specific item by ID from ChromaDB."""
|
||||
from ..models import SearchResultItem
|
||||
|
||||
try:
|
||||
response = self.collection.get(
|
||||
ids=[item_id],
|
||||
include=["documents", "metadatas"]
|
||||
)
|
||||
|
||||
if response["ids"] and len(response["ids"]) > 0:
|
||||
metadata = response["metadatas"][0] if "metadatas" in response else {}
|
||||
# Ensure metadata is dict, never None
|
||||
metadata = metadata or {}
|
||||
|
||||
return SearchResultItem(
|
||||
id=response["ids"][0],
|
||||
text=response["documents"][0],
|
||||
metadata=metadata,
|
||||
score=1.0
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_all(self, *, user_id: Optional[str] = None, agent_id: Optional[str] = None,
|
||||
run_id: Optional[str] = None, limit: int = 100, **kwargs: Any):
|
||||
"""Get all items from ChromaDB."""
|
||||
from ..models import SearchResult, SearchResultItem
|
||||
|
||||
try:
|
||||
# ChromaDB doesn't support get_all with filters easily, so we'll use peek
|
||||
response = self.collection.peek(limit=limit)
|
||||
|
||||
items = []
|
||||
if response["ids"]:
|
||||
for i in range(len(response["ids"])):
|
||||
metadata = response["metadatas"][i] if "metadatas" in response else {}
|
||||
# Ensure metadata is dict, never None
|
||||
metadata = metadata or {}
|
||||
|
||||
# Apply filtering if specified
|
||||
if user_id and metadata.get("user_id") != user_id:
|
||||
continue
|
||||
if agent_id and metadata.get("agent_id") != agent_id:
|
||||
continue
|
||||
if run_id and metadata.get("run_id") != run_id:
|
||||
continue
|
||||
|
||||
items.append(SearchResultItem(
|
||||
id=response["ids"][i],
|
||||
text=response["documents"][i],
|
||||
metadata=metadata,
|
||||
score=1.0
|
||||
))
|
||||
|
||||
return SearchResult(results=items)
|
||||
|
||||
except Exception:
|
||||
return SearchResult(results=[])
|
||||
|
||||
def update(self, item_id: str, content: Any, **kwargs: Any):
|
||||
"""Update an existing item in ChromaDB."""
|
||||
from ..models import AddResult
|
||||
|
||||
# ChromaDB doesn't support direct updates, so we delete and re-add
|
||||
try:
|
||||
self.collection.delete(ids=[item_id])
|
||||
return self.add(content, **kwargs)
|
||||
except Exception as e:
|
||||
return AddResult(success=False, message=str(e))
|
||||
|
||||
def delete(self, item_id: str, **kwargs: Any) -> bool:
|
||||
"""Delete an item by ID from ChromaDB."""
|
||||
try:
|
||||
self.collection.delete(ids=[item_id])
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def delete_all(self, *, user_id: Optional[str] = None, agent_id: Optional[str] = None,
|
||||
run_id: Optional[str] = None, **kwargs: Any) -> bool:
|
||||
"""Delete all items matching scope from ChromaDB."""
|
||||
try:
|
||||
if not any([user_id, agent_id, run_id]):
|
||||
# Delete entire collection
|
||||
collection_name = self.collection.name
|
||||
self.client.delete_collection(collection_name)
|
||||
|
||||
# Recreate the collection to keep adapter usable
|
||||
self.collection = self.client.create_collection(
|
||||
name=collection_name,
|
||||
metadata={"hnsw:space": "cosine"}
|
||||
)
|
||||
return True
|
||||
else:
|
||||
# Get filtered items and delete them
|
||||
all_items = self.get_all(user_id=user_id, agent_id=agent_id, run_id=run_id)
|
||||
item_ids = [item.id for item in all_items.results]
|
||||
if item_ids:
|
||||
self.collection.delete(ids=item_ids)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def create_sqlite_knowledge_adapter(**kwargs) -> KnowledgeStoreProtocol:
|
||||
"""
|
||||
Factory function to create SQLite knowledge adapter.
|
||||
|
||||
Creates a lightweight SQLite-based knowledge adapter for core usage.
|
||||
|
||||
Args:
|
||||
**kwargs: Configuration passed to SQLite adapter
|
||||
|
||||
Returns:
|
||||
KnowledgeStoreProtocol adapter instance
|
||||
"""
|
||||
return SQLiteKnowledgeAdapter(**kwargs)
|
||||
|
||||
|
||||
class SQLiteKnowledgeAdapter:
|
||||
"""
|
||||
Lightweight SQLite-based knowledge adapter implementing KnowledgeStoreProtocol.
|
||||
|
||||
This provides a core adapter that doesn't require heavy dependencies.
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize SQLite knowledge adapter."""
|
||||
import sqlite3
|
||||
import json
|
||||
import threading
|
||||
|
||||
self.db_path = kwargs.get("db_path", "knowledge.db")
|
||||
self._local = threading.local()
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# Initialize database
|
||||
self._init_db()
|
||||
|
||||
def _init_db(self):
|
||||
"""Initialize SQLite database."""
|
||||
conn = self._get_conn()
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS knowledge (
|
||||
id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
metadata TEXT,
|
||||
user_id TEXT,
|
||||
agent_id TEXT,
|
||||
run_id TEXT,
|
||||
created_at REAL
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
def _get_conn(self):
|
||||
"""Get thread-local SQLite connection."""
|
||||
if not hasattr(self._local, 'conn'):
|
||||
import sqlite3
|
||||
self._local.conn = sqlite3.connect(self.db_path)
|
||||
return self._local.conn
|
||||
|
||||
def search(self, query: str, *, user_id: Optional[str] = None, agent_id: Optional[str] = None,
|
||||
run_id: Optional[str] = None, limit: int = 10, filters: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any):
|
||||
"""Search for relevant content in SQLite."""
|
||||
from ..models import SearchResult, SearchResultItem
|
||||
import json
|
||||
|
||||
conn = self._get_conn()
|
||||
|
||||
# Build query with filters
|
||||
sql = "SELECT id, content, metadata FROM knowledge WHERE content LIKE ?"
|
||||
params = [f"%{query}%"]
|
||||
|
||||
if user_id:
|
||||
sql += " AND user_id = ?"
|
||||
params.append(user_id)
|
||||
if agent_id:
|
||||
sql += " AND agent_id = ?"
|
||||
params.append(agent_id)
|
||||
if run_id:
|
||||
sql += " AND run_id = ?"
|
||||
params.append(run_id)
|
||||
|
||||
sql += " LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
cursor = conn.execute(sql, params)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
items = []
|
||||
for row in rows:
|
||||
metadata = json.loads(row[2] or "{}")
|
||||
items.append(SearchResultItem(
|
||||
id=row[0],
|
||||
text=row[1],
|
||||
metadata=metadata,
|
||||
score=1.0
|
||||
))
|
||||
|
||||
return SearchResult(results=items)
|
||||
|
||||
def add(self, content: Any, *, user_id: Optional[str] = None, agent_id: Optional[str] = None,
|
||||
run_id: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any):
|
||||
"""Add content to SQLite."""
|
||||
from ..models import AddResult
|
||||
import json
|
||||
import time
|
||||
|
||||
content_str = str(content)
|
||||
doc_id = str(time.time_ns())
|
||||
metadata_json = json.dumps(metadata or {})
|
||||
|
||||
try:
|
||||
conn = self._get_conn()
|
||||
conn.execute(
|
||||
"INSERT INTO knowledge (id, content, metadata, user_id, agent_id, run_id, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(doc_id, content_str, metadata_json, user_id, agent_id, run_id, time.time())
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return AddResult(success=True, id=doc_id)
|
||||
|
||||
except Exception as e:
|
||||
return AddResult(success=False, message=str(e))
|
||||
|
||||
def get(self, item_id: str, **kwargs: Any):
|
||||
"""Get a specific item by ID from SQLite."""
|
||||
from ..models import SearchResultItem
|
||||
import json
|
||||
|
||||
conn = self._get_conn()
|
||||
cursor = conn.execute("SELECT id, content, metadata FROM knowledge WHERE id = ?", (item_id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
metadata = json.loads(row[2] or "{}")
|
||||
return SearchResultItem(
|
||||
id=row[0],
|
||||
text=row[1],
|
||||
metadata=metadata,
|
||||
score=1.0
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def get_all(self, *, user_id: Optional[str] = None, agent_id: Optional[str] = None,
|
||||
run_id: Optional[str] = None, limit: int = 100, **kwargs: Any):
|
||||
"""Get all items from SQLite."""
|
||||
from ..models import SearchResult, SearchResultItem
|
||||
import json
|
||||
|
||||
conn = self._get_conn()
|
||||
|
||||
# Build query with filters
|
||||
sql = "SELECT id, content, metadata FROM knowledge WHERE 1=1"
|
||||
params = []
|
||||
|
||||
if user_id:
|
||||
sql += " AND user_id = ?"
|
||||
params.append(user_id)
|
||||
if agent_id:
|
||||
sql += " AND agent_id = ?"
|
||||
params.append(agent_id)
|
||||
if run_id:
|
||||
sql += " AND run_id = ?"
|
||||
params.append(run_id)
|
||||
|
||||
sql += " LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
cursor = conn.execute(sql, params)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
items = []
|
||||
for row in rows:
|
||||
metadata = json.loads(row[2] or "{}")
|
||||
items.append(SearchResultItem(
|
||||
id=row[0],
|
||||
text=row[1],
|
||||
metadata=metadata,
|
||||
score=1.0
|
||||
))
|
||||
|
||||
return SearchResult(results=items)
|
||||
|
||||
def update(self, item_id: str, content: Any, **kwargs: Any):
|
||||
"""Update an existing item in SQLite."""
|
||||
from ..models import AddResult
|
||||
import json
|
||||
|
||||
content_str = str(content)
|
||||
metadata = kwargs.get("metadata", {})
|
||||
metadata_json = json.dumps(metadata)
|
||||
|
||||
try:
|
||||
conn = self._get_conn()
|
||||
conn.execute(
|
||||
"UPDATE knowledge SET content = ?, metadata = ? WHERE id = ?",
|
||||
(content_str, metadata_json, item_id)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return AddResult(success=True, id=item_id)
|
||||
|
||||
except Exception as e:
|
||||
return AddResult(success=False, message=str(e))
|
||||
|
||||
def delete(self, item_id: str, **kwargs: Any) -> bool:
|
||||
"""Delete an item by ID from SQLite."""
|
||||
try:
|
||||
conn = self._get_conn()
|
||||
conn.execute("DELETE FROM knowledge WHERE id = ?", (item_id,))
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def delete_all(self, *, user_id: Optional[str] = None, agent_id: Optional[str] = None,
|
||||
run_id: Optional[str] = None, **kwargs: Any) -> bool:
|
||||
"""Delete all items matching scope from SQLite."""
|
||||
try:
|
||||
conn = self._get_conn()
|
||||
|
||||
if not any([user_id, agent_id, run_id]):
|
||||
# Delete all
|
||||
conn.execute("DELETE FROM knowledge")
|
||||
else:
|
||||
# Delete with filters
|
||||
sql = "DELETE FROM knowledge WHERE 1=1"
|
||||
params = []
|
||||
|
||||
if user_id:
|
||||
sql += " AND user_id = ?"
|
||||
params.append(user_id)
|
||||
if agent_id:
|
||||
sql += " AND agent_id = ?"
|
||||
params.append(agent_id)
|
||||
if run_id:
|
||||
sql += " AND run_id = ?"
|
||||
params.append(run_id)
|
||||
|
||||
conn.execute(sql, params)
|
||||
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
Knowledge Adapter Registry
|
||||
|
||||
Provides a registry system for knowledge adapters to replace hardcoded imports
|
||||
in knowledge/knowledge.py. This enables protocol-driven knowledge backends without
|
||||
core modifications.
|
||||
"""
|
||||
|
||||
from typing import Callable, List, Optional, Tuple, Type
|
||||
from ...utils.adapter_registry import AdapterRegistry
|
||||
from ..protocols import KnowledgeStoreProtocol
|
||||
|
||||
|
||||
class KnowledgeAdapterRegistry(AdapterRegistry[KnowledgeStoreProtocol]):
|
||||
"""
|
||||
Thread-safe registry for knowledge adapters.
|
||||
|
||||
Replaces hardcoded imports and configurations in knowledge.py
|
||||
with dynamic adapter discovery and instantiation.
|
||||
|
||||
Example:
|
||||
```python
|
||||
# Register adapter (typically done on import)
|
||||
register_knowledge_adapter("sqlite", SQLiteKnowledgeAdapter)
|
||||
register_knowledge_adapter("chroma", ChromaAdapter) # in wrapper
|
||||
|
||||
# Use in Knowledge class
|
||||
adapter = get_knowledge_adapter("chroma", config=config)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(adapter_type_name="Knowledge")
|
||||
|
||||
|
||||
# Global registry instance
|
||||
_knowledge_registry = KnowledgeAdapterRegistry()
|
||||
|
||||
|
||||
def register_knowledge_adapter(name: str, adapter_class: Type[KnowledgeStoreProtocol]) -> None:
|
||||
"""Register a knowledge adapter class."""
|
||||
_knowledge_registry.register_adapter(name, adapter_class)
|
||||
|
||||
|
||||
def register_knowledge_factory(name: str, factory_func: Callable[..., KnowledgeStoreProtocol]) -> None:
|
||||
"""Register a knowledge adapter factory function."""
|
||||
_knowledge_registry.register_factory(name, factory_func)
|
||||
|
||||
|
||||
def get_knowledge_adapter(name: str, **kwargs) -> Optional[KnowledgeStoreProtocol]:
|
||||
"""Get knowledge adapter instance by name."""
|
||||
return _knowledge_registry.get_adapter(name, **kwargs)
|
||||
|
||||
|
||||
def list_knowledge_adapters() -> List[str]:
|
||||
"""List all registered knowledge adapter names."""
|
||||
return _knowledge_registry.list_adapters()
|
||||
|
||||
|
||||
def get_first_available_knowledge_adapter(
|
||||
preferences: Optional[List[str]] = None, **kwargs
|
||||
) -> Optional[Tuple[str, KnowledgeStoreProtocol]]:
|
||||
"""
|
||||
Get first available knowledge adapter.
|
||||
|
||||
Args:
|
||||
preferences: List of adapter names to try (defaults to ["mem0", "mongodb"])
|
||||
**kwargs: Configuration passed to adapter constructor
|
||||
|
||||
Returns:
|
||||
(adapter_name, adapter_instance) tuple or None
|
||||
"""
|
||||
if preferences is None:
|
||||
# Default to actual available adapters per knowledge/adapters/__init__.py
|
||||
preferences = ["mem0", "mongodb"]
|
||||
|
||||
return _knowledge_registry.get_first_available(preferences, **kwargs)
|
||||
|
||||
|
||||
# Canonical aliases per AGENTS.md naming conventions
|
||||
def add_knowledge_adapter(name: str, adapter_class: Type[KnowledgeStoreProtocol]) -> None:
|
||||
"""Canonical alias for register_knowledge_adapter (preferred naming per AGENTS.md)."""
|
||||
return register_knowledge_adapter(name, adapter_class)
|
||||
|
||||
|
||||
def add_knowledge_factory(name: str, factory_func: Callable[..., KnowledgeStoreProtocol]) -> None:
|
||||
"""Canonical alias for register_knowledge_factory (preferred naming per AGENTS.md)."""
|
||||
return register_knowledge_factory(name, factory_func)
|
||||
|
||||
|
||||
def has_knowledge_adapter(name: str) -> bool:
|
||||
"""Canonical alias for is_available (preferred naming per AGENTS.md)."""
|
||||
return _knowledge_registry.is_available(name)
|
||||
|
||||
|
||||
__all__ = [
|
||||
'KnowledgeAdapterRegistry',
|
||||
'register_knowledge_adapter',
|
||||
'register_knowledge_factory',
|
||||
'get_knowledge_adapter',
|
||||
'list_knowledge_adapters',
|
||||
'get_first_available_knowledge_adapter',
|
||||
# Canonical aliases (preferred)
|
||||
'add_knowledge_adapter',
|
||||
'add_knowledge_factory',
|
||||
'has_knowledge_adapter',
|
||||
]
|
||||
@@ -355,7 +355,7 @@ class FileTracker:
|
||||
# Calculate hash for small files, use mtime+size for large files
|
||||
if stat.st_size < 1024 * 1024: # 1MB
|
||||
with open(filepath, "rb") as f:
|
||||
file_hash = hashlib.md5(f.read()).hexdigest()
|
||||
file_hash = hashlib.sha256(f.read()).hexdigest()
|
||||
else:
|
||||
# For large files, use mtime + size as proxy
|
||||
file_hash = f"{stat.st_mtime}:{stat.st_size}"
|
||||
|
||||
@@ -70,14 +70,18 @@ class Knowledge:
|
||||
|
||||
# Disable OpenAI API request logging
|
||||
get_logger('openai').setLevel(logging.WARNING)
|
||||
|
||||
# Initialize provider using protocol-driven approach
|
||||
self._provider = None
|
||||
|
||||
@cached_property
|
||||
@cached_property
|
||||
def _deps(self):
|
||||
"""Lazy load dependencies only when needed (backward compatibility)."""
|
||||
# This is kept for backward compatibility with existing code
|
||||
# The new protocol-driven approach uses adapters via registry
|
||||
try:
|
||||
from markitdown import MarkItDown
|
||||
import chromadb
|
||||
return {
|
||||
'chromadb': chromadb,
|
||||
'markdown': MarkItDown()
|
||||
}
|
||||
except ImportError:
|
||||
@@ -94,14 +98,13 @@ class Knowledge:
|
||||
from ..paths import get_project_data_dir
|
||||
persist_dir = str(get_project_data_dir())
|
||||
|
||||
# Create persistent client config
|
||||
# Create persistent client config (protocol-driven)
|
||||
base_config = {
|
||||
"vector_store": {
|
||||
"provider": "chroma",
|
||||
"config": {
|
||||
"collection_name": default_collection,
|
||||
"path": persist_dir,
|
||||
"client": self._deps['chromadb'].PersistentClient(path=persist_dir),
|
||||
"host": None,
|
||||
"port": None
|
||||
}
|
||||
@@ -190,16 +193,51 @@ class Knowledge:
|
||||
|
||||
@cached_property
|
||||
def memory(self):
|
||||
# Check if MongoDB provider is specified
|
||||
if (self.config.get("vector_store", {}).get("provider") == "mongodb"):
|
||||
try:
|
||||
from .adapters import MongoDBKnowledgeAdapter
|
||||
return MongoDBKnowledgeAdapter(self.config)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize MongoDB adapter: {e}")
|
||||
# Fall back to default memory
|
||||
pass
|
||||
"""Initialize knowledge adapter using protocol-driven approach."""
|
||||
# Import registry functions
|
||||
from .adapters import get_knowledge_adapter, get_first_available_knowledge_adapter
|
||||
|
||||
# Determine provider preference
|
||||
provider = self.config.get("vector_store", {}).get("provider", "mem0")
|
||||
self._log(f"Requested knowledge provider: {provider}")
|
||||
|
||||
# Map legacy provider names to adapter names
|
||||
provider_mapping = {
|
||||
"chroma": "chroma",
|
||||
"mongodb": "mongodb",
|
||||
"mem0": "mem0",
|
||||
"sqlite": "sqlite"
|
||||
}
|
||||
|
||||
adapter_name = provider_mapping.get(provider, "mem0")
|
||||
|
||||
# Try to get preferred adapter, fallback to available ones
|
||||
try:
|
||||
adapter = get_knowledge_adapter(adapter_name, config=self.config, verbose=self._verbose)
|
||||
except Exception as e:
|
||||
self._log(f"Failed to initialize '{adapter_name}': {e}")
|
||||
adapter = None
|
||||
|
||||
if adapter is None:
|
||||
# Fallback to first available adapter
|
||||
self._log(f"Provider '{adapter_name}' not available, trying fallbacks")
|
||||
fallback_result = get_first_available_knowledge_adapter(
|
||||
preferences=["sqlite", "mem0"],
|
||||
config=self.config,
|
||||
verbose=self._verbose
|
||||
)
|
||||
if fallback_result:
|
||||
adapter_name, adapter = fallback_result
|
||||
self._log(f"Using fallback knowledge adapter: {adapter_name}")
|
||||
else:
|
||||
# Final fallback: try legacy mem0 approach for backward compatibility
|
||||
return self._init_legacy_memory()
|
||||
|
||||
self._log(f"Initialized knowledge adapter: {adapter_name}")
|
||||
return adapter
|
||||
|
||||
def _init_legacy_memory(self):
|
||||
"""Legacy fallback memory initialization for backward compatibility."""
|
||||
# Prepare config for mem0 (strip PraisonAI-specific fields)
|
||||
mem0_config = self._prepare_mem0_config(self.config)
|
||||
|
||||
@@ -500,10 +538,16 @@ class Knowledge:
|
||||
elif isinstance(memory_result, list):
|
||||
all_results.extend(memory_result)
|
||||
else:
|
||||
# Log warning for unexpected types but don't break
|
||||
import logging
|
||||
logging.warning(f"Unexpected memory_result type: {type(memory_result)}, skipping")
|
||||
progress.advance(store_task)
|
||||
from .models import AddResult
|
||||
if isinstance(memory_result, AddResult):
|
||||
all_results.append(memory_result.id)
|
||||
elif hasattr(memory_result, 'results'):
|
||||
all_results.extend(memory_result.results)
|
||||
else:
|
||||
# Log warning for unexpected types but don't break
|
||||
import logging
|
||||
logging.warning(f"Unexpected memory_result type: {type(memory_result)}, skipping")
|
||||
progress.advance(store_task)
|
||||
|
||||
# Emit trace event for knowledge add
|
||||
self._emit_knowledge_event("add", source=input_path, chunk_count=len(memories),
|
||||
|
||||
@@ -153,15 +153,15 @@ def normalize_search_item(raw: Dict[str, Any]) -> SearchResultItem:
|
||||
if raw is None:
|
||||
return SearchResultItem()
|
||||
|
||||
# Handle text field (mem0 uses 'memory', others use 'text')
|
||||
text = raw.get("text") or raw.get("memory", "") or ""
|
||||
|
||||
# Handle metadata - CRITICAL: ensure never None
|
||||
metadata = raw.get("metadata")
|
||||
if metadata is None:
|
||||
metadata = {}
|
||||
elif not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
|
||||
# Handle text field (mem0 uses 'memory', others use 'text' or store in metadata['data'])
|
||||
text = raw.get("text") or raw.get("memory", "") or metadata.get("data", "") or ""
|
||||
|
||||
# Extract source/filename from metadata if not at top level
|
||||
source = raw.get("source") or metadata.get("source")
|
||||
|
||||
@@ -98,6 +98,58 @@ def __getattr__(name):
|
||||
from .llm import TokenUsage
|
||||
_lazy_cache[name] = TokenUsage
|
||||
return TokenUsage
|
||||
elif name == "LLMProviderProtocol":
|
||||
from .protocols import LLMProviderProtocol
|
||||
_lazy_cache[name] = LLMProviderProtocol
|
||||
return LLMProviderProtocol
|
||||
elif name == "ModelCapabilitiesProtocol":
|
||||
from .protocols import ModelCapabilitiesProtocol
|
||||
_lazy_cache[name] = ModelCapabilitiesProtocol
|
||||
return ModelCapabilitiesProtocol
|
||||
elif name == "LLMRateLimiterProtocol":
|
||||
from .protocols import LLMRateLimiterProtocol
|
||||
_lazy_cache[name] = LLMRateLimiterProtocol
|
||||
return LLMRateLimiterProtocol
|
||||
elif name == "LLMFailoverProtocol":
|
||||
from .protocols import LLMFailoverProtocol
|
||||
_lazy_cache[name] = LLMFailoverProtocol
|
||||
return LLMFailoverProtocol
|
||||
elif name == "LLMProviderError":
|
||||
from .protocols import LLMProviderError
|
||||
_lazy_cache[name] = LLMProviderError
|
||||
return LLMProviderError
|
||||
elif name == "RateLimitError":
|
||||
from .protocols import RateLimitError
|
||||
_lazy_cache[name] = RateLimitError
|
||||
return RateLimitError
|
||||
elif name == "ModelNotAvailableError":
|
||||
from .protocols import ModelNotAvailableError
|
||||
_lazy_cache[name] = ModelNotAvailableError
|
||||
return ModelNotAvailableError
|
||||
elif name == "ContextLengthExceededError":
|
||||
from .protocols import ContextLengthExceededError
|
||||
_lazy_cache[name] = ContextLengthExceededError
|
||||
return ContextLengthExceededError
|
||||
elif name == "UnifiedLLMProtocol":
|
||||
from .protocols import UnifiedLLMProtocol
|
||||
_lazy_cache[name] = UnifiedLLMProtocol
|
||||
return UnifiedLLMProtocol
|
||||
elif name == "LiteLLMAdapter":
|
||||
from .unified_adapters import LiteLLMAdapter
|
||||
_lazy_cache[name] = LiteLLMAdapter
|
||||
return LiteLLMAdapter
|
||||
elif name == "OpenAIAdapter":
|
||||
from .unified_adapters import OpenAIAdapter
|
||||
_lazy_cache[name] = OpenAIAdapter
|
||||
return OpenAIAdapter
|
||||
elif name == "UnifiedLLMDispatcher":
|
||||
from .unified_adapters import UnifiedLLMDispatcher
|
||||
_lazy_cache[name] = UnifiedLLMDispatcher
|
||||
return UnifiedLLMDispatcher
|
||||
elif name == "create_llm_dispatcher":
|
||||
from .unified_adapters import create_llm_dispatcher
|
||||
_lazy_cache[name] = create_llm_dispatcher
|
||||
return create_llm_dispatcher
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
@@ -122,5 +174,21 @@ __all__ = [
|
||||
"TaskComplexity",
|
||||
"create_routing_agent",
|
||||
"RateLimiter",
|
||||
"TokenUsage"
|
||||
"TokenUsage",
|
||||
# Protocols
|
||||
"LLMProviderProtocol",
|
||||
"ModelCapabilitiesProtocol",
|
||||
"LLMRateLimiterProtocol",
|
||||
"LLMFailoverProtocol",
|
||||
"UnifiedLLMProtocol",
|
||||
# Adapters
|
||||
"LiteLLMAdapter",
|
||||
"OpenAIAdapter",
|
||||
"UnifiedLLMDispatcher",
|
||||
"create_llm_dispatcher",
|
||||
# Exceptions
|
||||
"LLMProviderError",
|
||||
"RateLimitError",
|
||||
"ModelNotAvailableError",
|
||||
"ContextLengthExceededError"
|
||||
]
|
||||
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
LLM Provider Adapters
|
||||
|
||||
Concrete implementations of LLMProviderAdapter protocol that replace
|
||||
scattered provider dispatch logic throughout the core.
|
||||
|
||||
This demonstrates the protocol-driven approach for Gap 2.
|
||||
"""
|
||||
|
||||
from ..protocols import LLMProviderAdapterProtocol
|
||||
from ..model_capabilities import GEMINI_INTERNAL_TOOLS
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
|
||||
class DefaultAdapter:
|
||||
"""Default provider adapter with sensible fallbacks."""
|
||||
|
||||
def supports_prompt_caching(self) -> bool:
|
||||
return False
|
||||
|
||||
def should_summarize_tools(self, iter_count: int) -> bool:
|
||||
return iter_count >= 5 # Conservative default
|
||||
|
||||
def format_tools(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
return tools # No special formatting by default
|
||||
|
||||
def post_tool_iteration(self, state: Dict[str, Any]) -> None:
|
||||
pass # No post-processing by default
|
||||
|
||||
def supports_structured_output(self) -> bool:
|
||||
return False # Conservative default
|
||||
|
||||
def supports_streaming(self) -> bool:
|
||||
return True # Most providers support streaming
|
||||
|
||||
def supports_streaming_with_tools(self) -> bool:
|
||||
return True # Most providers support streaming with tools
|
||||
|
||||
def get_max_iteration_threshold(self) -> int:
|
||||
return 10 # Conservative default
|
||||
|
||||
def format_tool_result_message(self, function_name: str, tool_result: Any, tool_call_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
# Standard OpenAI-style tool result message
|
||||
message = {
|
||||
"role": "tool",
|
||||
"content": str(tool_result),
|
||||
}
|
||||
if tool_call_id is not None:
|
||||
message["tool_call_id"] = tool_call_id
|
||||
else:
|
||||
# Fallback for backward compatibility
|
||||
message["tool_call_id"] = f"call_{function_name}"
|
||||
return message
|
||||
|
||||
def handle_empty_response_with_tools(self, state: Dict[str, Any]) -> bool:
|
||||
return False # No special handling by default
|
||||
|
||||
def get_default_settings(self) -> Dict[str, Any]:
|
||||
return {} # No provider-specific defaults
|
||||
|
||||
|
||||
class OllamaAdapter(DefaultAdapter):
|
||||
"""
|
||||
Ollama-specific provider adapter.
|
||||
|
||||
Handles Ollama's specific quirks:
|
||||
- Doesn't support streaming with tools reliably
|
||||
- Needs tool summarization after iteration 1
|
||||
- Uses natural language tool result format
|
||||
- Handles empty responses after tool execution
|
||||
"""
|
||||
|
||||
def should_summarize_tools(self, iter_count: int) -> bool:
|
||||
# Replaces: OLLAMA_SUMMARY_ITERATION_THRESHOLD logic
|
||||
# Must match LLM.OLLAMA_SUMMARY_ITERATION_THRESHOLD = 1
|
||||
return iter_count >= 1
|
||||
|
||||
def supports_streaming_with_tools(self) -> bool:
|
||||
# Ollama doesn't reliably support streaming with tools
|
||||
return False
|
||||
|
||||
def get_max_iteration_threshold(self) -> int:
|
||||
return 1 # Ollama-specific threshold
|
||||
|
||||
def format_tool_result_message(self, function_name: str, tool_result: Any, tool_call_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
# Ollama uses natural language format for tool results
|
||||
return {
|
||||
"role": "user",
|
||||
"content": f"Tool '{function_name}' returned: {tool_result}"
|
||||
}
|
||||
|
||||
def handle_empty_response_with_tools(self, state: Dict[str, Any]) -> bool:
|
||||
# Handle Ollama's tendency to return empty responses after tool execution
|
||||
iteration_count = state.get('iteration_count', 0)
|
||||
has_tool_results = bool(state.get('accumulated_tool_results'))
|
||||
response_text = state.get('response_text', '').strip()
|
||||
|
||||
if iteration_count >= 1 and has_tool_results and not response_text:
|
||||
return True # Signal that special handling is needed
|
||||
return False
|
||||
|
||||
def post_tool_iteration(self, state: Dict[str, Any]) -> None:
|
||||
# Replaces: Ollama-specific post-tool summary branches
|
||||
if (not state.get('response_text', '').strip() and
|
||||
state.get('formatted_tools') and
|
||||
state.get('iteration_count') == 0):
|
||||
# Add Ollama-specific summary logic here
|
||||
state['needs_summary'] = True
|
||||
|
||||
def get_default_settings(self) -> Dict[str, Any]:
|
||||
return {
|
||||
'max_tool_repairs': 2,
|
||||
'force_tool_usage': 'auto'
|
||||
}
|
||||
|
||||
|
||||
class AnthropicAdapter(DefaultAdapter):
|
||||
"""Anthropic/Claude provider adapter."""
|
||||
|
||||
def supports_prompt_caching(self) -> bool:
|
||||
return True # Claude supports prompt caching
|
||||
|
||||
def supports_structured_output(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class GeminiAdapter(DefaultAdapter):
|
||||
"""
|
||||
Google Gemini provider adapter.
|
||||
|
||||
Handles Gemini's specific quirks:
|
||||
- Has internal tools that need special formatting
|
||||
- Doesn't support streaming with tools reliably
|
||||
- Supports structured output
|
||||
"""
|
||||
|
||||
def format_tools(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
# Replaces: gemini_internal_tools handling in llm.py
|
||||
# Internal tool names match GEMINI_INTERNAL_TOOLS: {'googleSearch', 'urlContext', 'codeExecution'}
|
||||
formatted = []
|
||||
for tool in tools:
|
||||
if tool.get('name') in GEMINI_INTERNAL_TOOLS:
|
||||
# Convert to Gemini internal tool format
|
||||
formatted.append({
|
||||
'type': 'function',
|
||||
'function': tool
|
||||
})
|
||||
else:
|
||||
formatted.append(tool)
|
||||
return formatted
|
||||
|
||||
def supports_streaming_with_tools(self) -> bool:
|
||||
# Gemini has issues with streaming + tools
|
||||
return False
|
||||
|
||||
def supports_structured_output(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
# Provider adapter registry - public for extension
|
||||
_provider_adapters: Dict[str, LLMProviderAdapterProtocol] = {}
|
||||
|
||||
# Register core adapters at import time
|
||||
_default_adapter = DefaultAdapter()
|
||||
_provider_adapters['default'] = _default_adapter
|
||||
_provider_adapters['ollama'] = OllamaAdapter()
|
||||
_provider_adapters['anthropic'] = AnthropicAdapter()
|
||||
_provider_adapters['claude'] = AnthropicAdapter() # Alias
|
||||
_provider_adapters['gemini'] = GeminiAdapter()
|
||||
|
||||
|
||||
def add_provider_adapter(name: str, adapter: LLMProviderAdapterProtocol) -> None:
|
||||
"""
|
||||
Register a provider adapter by name.
|
||||
|
||||
This enables new providers to be added without modifying core code.
|
||||
|
||||
Args:
|
||||
name: Provider name (e.g., "cohere", "huggingface")
|
||||
adapter: Provider adapter implementing LLMProviderProtocol
|
||||
"""
|
||||
_provider_adapters[name] = adapter
|
||||
|
||||
|
||||
def get_provider_adapter(name: str) -> LLMProviderAdapterProtocol:
|
||||
"""
|
||||
Get provider adapter by name with fallback to default.
|
||||
|
||||
Args:
|
||||
name: Provider name (e.g., "anthropic", "ollama", "gemini")
|
||||
|
||||
Returns:
|
||||
Provider adapter instance (default if name not found)
|
||||
"""
|
||||
name_lower = name.lower()
|
||||
|
||||
# Exact match first
|
||||
if name_lower in _provider_adapters:
|
||||
return _provider_adapters[name_lower]
|
||||
|
||||
# Provider prefixes or substrings
|
||||
if "ollama" in name_lower:
|
||||
return _provider_adapters["ollama"]
|
||||
if "claude" in name_lower or "anthropic" in name_lower:
|
||||
return _provider_adapters["anthropic"]
|
||||
if "gemini" in name_lower:
|
||||
return _provider_adapters["gemini"]
|
||||
|
||||
return _provider_adapters["default"]
|
||||
|
||||
|
||||
def list_provider_adapters() -> List[str]:
|
||||
"""List all registered provider adapter names."""
|
||||
return sorted(_provider_adapters.keys())
|
||||
|
||||
|
||||
def has_provider_adapter(name: str) -> bool:
|
||||
"""Check if a provider adapter is registered."""
|
||||
return name in _provider_adapters
|
||||
|
||||
|
||||
__all__ = [
|
||||
'DefaultAdapter',
|
||||
'OllamaAdapter',
|
||||
'AnthropicAdapter',
|
||||
'GeminiAdapter',
|
||||
'get_provider_adapter',
|
||||
'add_provider_adapter',
|
||||
'list_provider_adapters',
|
||||
'has_provider_adapter',
|
||||
]
|
||||
@@ -412,8 +412,11 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
self.max_tool_repairs = extra_settings.get('max_tool_repairs', 0) # Will be set to 2 for Ollama if not explicit
|
||||
self.force_tool_usage = extra_settings.get('force_tool_usage', 'never') # Will be set to 'auto' for Ollama if not explicit
|
||||
|
||||
# Apply Ollama-specific defaults after model is set
|
||||
self._apply_ollama_defaults()
|
||||
# Initialize provider adapter for dispatch logic
|
||||
self._provider_adapter = self._initialize_provider_adapter()
|
||||
|
||||
# Apply provider-specific defaults after adapter is initialized
|
||||
self._apply_provider_defaults()
|
||||
|
||||
# Token tracking
|
||||
self.last_token_metrics: Optional[TokenMetrics] = None
|
||||
@@ -474,15 +477,75 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
self._console = _get_console()()
|
||||
return self._console
|
||||
|
||||
def _apply_ollama_defaults(self):
|
||||
"""Apply Ollama-specific defaults for tool calling reliability."""
|
||||
def _detect_provider(self) -> str:
|
||||
"""
|
||||
Detect provider from model name.
|
||||
|
||||
Consolidates all provider detection logic into a single method
|
||||
that replaces scattered _is_X_provider() calls.
|
||||
|
||||
Returns:
|
||||
Provider name (e.g., "ollama", "anthropic", "gemini", "openai")
|
||||
"""
|
||||
if not self.model:
|
||||
return "default"
|
||||
|
||||
model_lower = self.model.lower()
|
||||
|
||||
# Parse route prefix for explicit provider routing
|
||||
provider_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else None
|
||||
|
||||
# Explicit provider prefixes take priority
|
||||
if provider_prefix == "ollama":
|
||||
return "ollama"
|
||||
if provider_prefix in {"anthropic", "claude"}:
|
||||
return "anthropic"
|
||||
if provider_prefix in {"gemini", "google"} and "gemini" in model_lower:
|
||||
return "gemini"
|
||||
|
||||
# Use existing robust Ollama detection logic first
|
||||
if self._is_ollama_provider():
|
||||
# Apply defaults only if not explicitly set by user
|
||||
if not self._max_tool_repairs_explicit:
|
||||
self.max_tool_repairs = 2
|
||||
if not self._force_tool_usage_explicit:
|
||||
self.force_tool_usage = 'auto'
|
||||
logging.debug(f"[OLLAMA_RELIABILITY] Applied Ollama defaults: max_tool_repairs={self.max_tool_repairs}, force_tool_usage={self.force_tool_usage}")
|
||||
return "ollama"
|
||||
|
||||
# Check for direct model name patterns (not substrings)
|
||||
if model_lower.startswith("claude"):
|
||||
return "anthropic"
|
||||
|
||||
if model_lower.startswith("gemini") or model_lower.startswith("google/gemini"):
|
||||
return "gemini"
|
||||
|
||||
# Check base_url for provider hints
|
||||
base_urls = [self.base_url, os.getenv("OPENAI_BASE_URL", ""), os.getenv("OPENAI_API_BASE", "")]
|
||||
if any(url and ("ollama" in url.lower() or ":11434" in url) for url in base_urls):
|
||||
return "ollama"
|
||||
|
||||
# Default fallback
|
||||
return "openai"
|
||||
|
||||
def _initialize_provider_adapter(self):
|
||||
"""Initialize provider adapter based on detected provider."""
|
||||
provider = self._detect_provider()
|
||||
|
||||
# Import here to avoid circular imports
|
||||
from .adapters import get_provider_adapter
|
||||
adapter = get_provider_adapter(provider)
|
||||
|
||||
logging.debug(f"[ADAPTER] Initialized {provider} adapter: {adapter.__class__.__name__}")
|
||||
return adapter
|
||||
|
||||
def _apply_provider_defaults(self):
|
||||
"""Apply provider-specific defaults via adapter pattern."""
|
||||
if hasattr(self, '_provider_adapter') and self._provider_adapter:
|
||||
defaults = self._provider_adapter.get_default_settings()
|
||||
if defaults:
|
||||
# Apply defaults only if not explicitly set by user
|
||||
if not self._max_tool_repairs_explicit and 'max_tool_repairs' in defaults:
|
||||
self.max_tool_repairs = defaults['max_tool_repairs']
|
||||
if not self._force_tool_usage_explicit and 'force_tool_usage' in defaults:
|
||||
self.force_tool_usage = defaults['force_tool_usage']
|
||||
|
||||
if defaults: # Only log if there were actual defaults
|
||||
logging.debug(f"[PROVIDER_DEFAULTS] Applied {self._provider_adapter.__class__.__name__} defaults: {defaults}")
|
||||
|
||||
def _is_ollama_provider(self) -> bool:
|
||||
"""Detect if this is an Ollama provider regardless of naming convention"""
|
||||
@@ -753,14 +816,22 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
|
||||
def _supports_prompt_caching(self) -> bool:
|
||||
"""
|
||||
Check if the current model supports prompt caching via LiteLLM.
|
||||
Check if the current model supports prompt caching.
|
||||
|
||||
Prompt caching allows caching parts of prompts to reduce costs and latency.
|
||||
Supported by OpenAI, Anthropic, Bedrock, and Deepseek.
|
||||
Uses provider adapter to eliminate scattered provider logic.
|
||||
|
||||
Returns:
|
||||
bool: True if the model supports prompt caching, False otherwise
|
||||
"""
|
||||
if hasattr(self, '_provider_adapter') and self._provider_adapter:
|
||||
adapter_support = self._provider_adapter.supports_prompt_caching()
|
||||
if adapter_support:
|
||||
return True
|
||||
# If adapter says False and is not the default, trust the adapter
|
||||
if self._provider_adapter.__class__.__name__ != 'DefaultAdapter':
|
||||
return False
|
||||
|
||||
# Fallback to model capabilities for DefaultAdapter or uninitialized
|
||||
from .model_capabilities import supports_prompt_caching
|
||||
return supports_prompt_caching(self.model)
|
||||
|
||||
@@ -1270,12 +1341,20 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
- final_response_text: Text to use as final response (None if continuing)
|
||||
- iteration_count: Updated iteration count
|
||||
"""
|
||||
if not (self._is_ollama_provider() and iteration_count >= self.OLLAMA_SUMMARY_ITERATION_THRESHOLD):
|
||||
# Use provider adapter to determine if tool summarization should occur
|
||||
# Adapter should always be initialized, but provide safe fallback
|
||||
if hasattr(self, '_provider_adapter') and self._provider_adapter:
|
||||
should_summarize = self._provider_adapter.should_summarize_tools(iteration_count)
|
||||
else:
|
||||
# Conservative fallback without provider detection
|
||||
should_summarize = iteration_count >= 5 # Conservative default
|
||||
|
||||
if not should_summarize:
|
||||
return False, None, iteration_count
|
||||
|
||||
# For Ollama: if we have meaningful tool results, generate summary immediately
|
||||
# Don't wait for more iterations as Ollama tends to repeat tool calls
|
||||
if accumulated_tool_results and iteration_count >= self.OLLAMA_SUMMARY_ITERATION_THRESHOLD:
|
||||
# If we should summarize: if we have meaningful tool results, generate summary immediately
|
||||
# Don't wait for more iterations as some providers tend to repeat tool calls
|
||||
if accumulated_tool_results and should_summarize:
|
||||
# Generate summary from tool results
|
||||
tool_summary = self._generate_ollama_tool_summary(accumulated_tool_results, response_text)
|
||||
if tool_summary:
|
||||
@@ -1301,9 +1380,7 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
"""
|
||||
Check if the current provider supports streaming with tools.
|
||||
|
||||
Most providers that support tool calling also support streaming with tools,
|
||||
but some providers (like Ollama and certain local models) require non-streaming
|
||||
calls when tools are involved.
|
||||
Uses provider adapter to eliminate scattered provider logic.
|
||||
|
||||
Returns:
|
||||
bool: True if provider supports streaming with tools, False otherwise
|
||||
@@ -1311,32 +1388,11 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
if not self.model:
|
||||
return False
|
||||
|
||||
# Ollama doesn't reliably support streaming with tools
|
||||
if self._is_ollama_provider():
|
||||
return False
|
||||
# Use provider adapter for streaming with tools support
|
||||
if hasattr(self, '_provider_adapter') and self._provider_adapter:
|
||||
return self._provider_adapter.supports_streaming_with_tools()
|
||||
|
||||
# Import the capability check function
|
||||
from .model_capabilities import supports_streaming_with_tools
|
||||
|
||||
# Check if this model supports streaming with tools
|
||||
if supports_streaming_with_tools(self.model):
|
||||
return True
|
||||
|
||||
# Anthropic Claude models support streaming with tools
|
||||
if self.model.startswith("claude-"):
|
||||
return True
|
||||
|
||||
# Google Gemini models support streaming with tools
|
||||
if any(self.model.startswith(prefix) for prefix in ["gemini-", "gemini/"]):
|
||||
return True
|
||||
|
||||
# Models with XML tool format support streaming with tools
|
||||
if self._supports_xml_tool_format():
|
||||
return True
|
||||
|
||||
# For other providers, default to False to be safe
|
||||
# This ensures we make a single non-streaming call rather than risk
|
||||
# missing tool calls or making duplicate calls
|
||||
# Fallback to conservative default if adapter not initialized
|
||||
return False
|
||||
|
||||
def _build_messages(self, prompt, system_prompt=None, chat_history=None, output_json=None, output_pydantic=None, tools=None):
|
||||
|
||||
@@ -11,6 +11,7 @@ from praisonaiagents._logging import get_logger
|
||||
import time
|
||||
import json
|
||||
import asyncio
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional, Union, AsyncIterator, Iterator, Callable, Tuple, TYPE_CHECKING
|
||||
from pydantic import BaseModel
|
||||
from dataclasses import dataclass
|
||||
@@ -2197,6 +2198,7 @@ class OpenAIClient:
|
||||
# Global client instance (similar to main.py pattern)
|
||||
_global_client = None
|
||||
_global_client_params = None
|
||||
_global_client_lock = threading.Lock()
|
||||
|
||||
def get_openai_client(api_key: Optional[str] = None, base_url: Optional[str] = None) -> OpenAIClient:
|
||||
"""
|
||||
@@ -2216,9 +2218,10 @@ def get_openai_client(api_key: Optional[str] = None, base_url: Optional[str] = N
|
||||
normalized_base_url = base_url
|
||||
current_params = (normalized_api_key, normalized_base_url)
|
||||
|
||||
# Only create new client if parameters changed or first time
|
||||
if _global_client is None or _global_client_params != current_params:
|
||||
_global_client = OpenAIClient(api_key=api_key, base_url=base_url)
|
||||
_global_client_params = current_params
|
||||
|
||||
return _global_client
|
||||
# Thread-safe client creation
|
||||
with _global_client_lock:
|
||||
# Only create new client if parameters changed or first time
|
||||
if _global_client is None or _global_client_params != current_params:
|
||||
_global_client = OpenAIClient(api_key=api_key, base_url=base_url)
|
||||
_global_client_params = current_params
|
||||
return _global_client
|
||||
@@ -0,0 +1,369 @@
|
||||
"""
|
||||
LLM Provider Protocols for PraisonAI Agents.
|
||||
|
||||
Defines minimal protocol interfaces for LLM providers to enable
|
||||
extensibility without vendor lock-in to litellm or any specific provider.
|
||||
|
||||
No heavy imports - only stdlib and typing.
|
||||
"""
|
||||
|
||||
from typing import Any, AsyncIterator, Dict, Iterator, List, Optional, Protocol, Union, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class LLMProviderProtocol(Protocol):
|
||||
"""
|
||||
Protocol defining the interface that LLM clients must implement.
|
||||
|
||||
This enables switching between different LLM backends (litellm, openai,
|
||||
anthropic, local models, etc.) without modifying core agent code.
|
||||
|
||||
Implementations must provide both sync and async variants for
|
||||
production flexibility.
|
||||
"""
|
||||
|
||||
model: str
|
||||
|
||||
def chat(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
*,
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
temperature: float = 0.0,
|
||||
max_tokens: Optional[int] = None,
|
||||
stream: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> Union[Dict[str, Any], Iterator[Dict[str, Any]]]:
|
||||
"""
|
||||
Generate chat completion.
|
||||
|
||||
Args:
|
||||
messages: List of message dicts with 'role' and 'content'
|
||||
tools: Optional list of tool schemas for function calling
|
||||
temperature: Sampling temperature (0.0-2.0)
|
||||
max_tokens: Maximum tokens to generate
|
||||
stream: Whether to return streaming iterator
|
||||
**kwargs: Provider-specific options
|
||||
|
||||
Returns:
|
||||
Single response dict or streaming iterator of response chunks
|
||||
"""
|
||||
...
|
||||
|
||||
async def achat(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
*,
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
temperature: float = 0.0,
|
||||
max_tokens: Optional[int] = None,
|
||||
stream: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> Union[Dict[str, Any], AsyncIterator[Dict[str, Any]]]:
|
||||
"""
|
||||
Async version of chat.
|
||||
|
||||
Args:
|
||||
messages: List of message dicts with 'role' and 'content'
|
||||
tools: Optional list of tool schemas for function calling
|
||||
temperature: Sampling temperature (0.0-2.0)
|
||||
max_tokens: Maximum tokens to generate
|
||||
stream: Whether to return async streaming iterator
|
||||
**kwargs: Provider-specific options
|
||||
|
||||
Returns:
|
||||
Single response dict or async streaming iterator of response chunks
|
||||
"""
|
||||
...
|
||||
|
||||
def get_token_count(self, text: str) -> int:
|
||||
"""
|
||||
Count tokens in text for the current model.
|
||||
|
||||
Args:
|
||||
text: Text to count tokens for
|
||||
|
||||
Returns:
|
||||
Number of tokens
|
||||
"""
|
||||
...
|
||||
|
||||
def get_context_length(self) -> int:
|
||||
"""
|
||||
Get maximum context length for the current model.
|
||||
|
||||
Returns:
|
||||
Maximum context length in tokens
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ModelCapabilitiesProtocol(Protocol):
|
||||
"""
|
||||
Protocol for querying model capabilities.
|
||||
|
||||
Enables runtime capability detection for different models
|
||||
without hardcoded capability tables.
|
||||
"""
|
||||
|
||||
def supports_streaming(self, model: str) -> bool:
|
||||
"""Check if model supports streaming responses."""
|
||||
...
|
||||
|
||||
def supports_function_calling(self, model: str) -> bool:
|
||||
"""Check if model supports function/tool calling."""
|
||||
...
|
||||
|
||||
def supports_structured_output(self, model: str) -> bool:
|
||||
"""Check if model supports structured JSON output."""
|
||||
...
|
||||
|
||||
def supports_vision(self, model: str) -> bool:
|
||||
"""Check if model supports vision/image inputs."""
|
||||
...
|
||||
|
||||
def get_max_tokens(self, model: str) -> Optional[int]:
|
||||
"""Get maximum context length for model."""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class LLMRateLimiterProtocol(Protocol):
|
||||
"""
|
||||
Protocol for LLM request rate limiting.
|
||||
|
||||
Enables different rate limiting strategies without
|
||||
coupling to specific implementations.
|
||||
"""
|
||||
|
||||
async def acquire(self, tokens: int = 1) -> None:
|
||||
"""
|
||||
Acquire permission to make request.
|
||||
|
||||
Args:
|
||||
tokens: Number of tokens in the request
|
||||
|
||||
Raises:
|
||||
RateLimitError: If rate limit would be exceeded
|
||||
"""
|
||||
...
|
||||
|
||||
def can_proceed(self, tokens: int = 1) -> bool:
|
||||
"""
|
||||
Check if request can proceed without blocking.
|
||||
|
||||
Args:
|
||||
tokens: Number of tokens in the request
|
||||
|
||||
Returns:
|
||||
True if request can proceed immediately
|
||||
"""
|
||||
...
|
||||
|
||||
def get_wait_time(self, tokens: int = 1) -> float:
|
||||
"""
|
||||
Get seconds to wait before request can proceed.
|
||||
|
||||
Args:
|
||||
tokens: Number of tokens in the request
|
||||
|
||||
Returns:
|
||||
Seconds to wait (0.0 if can proceed immediately)
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class LLMFailoverProtocol(Protocol):
|
||||
"""
|
||||
Protocol for LLM failover strategies.
|
||||
|
||||
Enables automatic fallback to alternative models
|
||||
when primary models fail or are unavailable.
|
||||
"""
|
||||
|
||||
def get_fallback_model(self, failed_model: str, error: Exception) -> Optional[str]:
|
||||
"""
|
||||
Get fallback model for failed request.
|
||||
|
||||
Args:
|
||||
failed_model: Model that failed
|
||||
error: Exception that occurred
|
||||
|
||||
Returns:
|
||||
Alternative model name or None if no fallback available
|
||||
"""
|
||||
...
|
||||
|
||||
def should_retry(self, error: Exception) -> bool:
|
||||
"""
|
||||
Check if error indicates retryable failure.
|
||||
|
||||
Args:
|
||||
error: Exception that occurred
|
||||
|
||||
Returns:
|
||||
True if should retry with fallback
|
||||
"""
|
||||
...
|
||||
|
||||
def get_retry_delay(self, attempt: int) -> float:
|
||||
"""
|
||||
Get delay before retry attempt.
|
||||
|
||||
Args:
|
||||
attempt: Retry attempt number (1-based)
|
||||
|
||||
Returns:
|
||||
Seconds to wait before retry
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class LLMProviderError(Exception):
|
||||
"""Base exception for LLM provider errors."""
|
||||
|
||||
def __init__(self, message: str, provider: Optional[str] = None, model: Optional[str] = None):
|
||||
self.provider = provider
|
||||
self.model = model
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class RateLimitError(LLMProviderError):
|
||||
"""Raised when rate limit is exceeded."""
|
||||
|
||||
def __init__(self, message: Optional[str] = None, retry_after: Optional[float] = None, provider: Optional[str] = None, model: Optional[str] = None):
|
||||
self.retry_after = retry_after
|
||||
super().__init__(message or "Rate limit exceeded", provider=provider, model=model)
|
||||
|
||||
|
||||
class ModelNotAvailableError(LLMProviderError):
|
||||
"""Raised when requested model is not available."""
|
||||
|
||||
def __init__(self, model: str, provider: Optional[str] = None):
|
||||
message = f"Model '{model}' is not available{f' from provider {provider}' if provider else ''}"
|
||||
super().__init__(message, provider=provider, model=model)
|
||||
|
||||
|
||||
class ContextLengthExceededError(LLMProviderError):
|
||||
"""Raised when input exceeds model's context length."""
|
||||
|
||||
def __init__(self, tokens: int, max_tokens: int, provider: Optional[str] = None, model: Optional[str] = None):
|
||||
self.tokens = tokens
|
||||
self.max_tokens = max_tokens
|
||||
super().__init__(f"Input length ({tokens} tokens) exceeds model limit ({max_tokens} tokens)", provider=provider, model=model)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class LLMProviderAdapterProtocol(Protocol):
|
||||
"""
|
||||
Protocol for provider-specific LLM adaptations.
|
||||
|
||||
This replaces scattered provider dispatch logic throughout the core.
|
||||
Each provider implements these hooks to handle provider-specific quirks
|
||||
without requiring if/elif branching in the core chat loop.
|
||||
|
||||
Example:
|
||||
```python
|
||||
class OllamaAdapter:
|
||||
def supports_prompt_caching(self) -> bool:
|
||||
return False
|
||||
|
||||
def should_summarize_tools(self, iter_count: int) -> bool:
|
||||
return iter_count >= 3 # Ollama-specific threshold
|
||||
|
||||
def format_tools(self, tools) -> list:
|
||||
return tools # No special formatting needed
|
||||
|
||||
def post_tool_iteration(self, state) -> None:
|
||||
if state.get('empty_response') and state.get('tools'):
|
||||
# Add Ollama-specific tool summary
|
||||
state['summary'] = "Tool results processed"
|
||||
```
|
||||
"""
|
||||
|
||||
def supports_prompt_caching(self) -> bool:
|
||||
"""Check if this provider supports prompt caching."""
|
||||
...
|
||||
|
||||
def should_summarize_tools(self, iter_count: int) -> bool:
|
||||
"""Check if tools should be summarized at this iteration count."""
|
||||
...
|
||||
|
||||
def format_tools(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Format tools for this provider's requirements."""
|
||||
...
|
||||
|
||||
def post_tool_iteration(self, state: Dict[str, Any]) -> None:
|
||||
"""Handle provider-specific post-tool processing."""
|
||||
...
|
||||
|
||||
def supports_structured_output(self) -> bool:
|
||||
"""Check if provider supports structured JSON output."""
|
||||
...
|
||||
|
||||
def supports_streaming(self) -> bool:
|
||||
"""Check if provider supports streaming responses."""
|
||||
...
|
||||
|
||||
def supports_streaming_with_tools(self) -> bool:
|
||||
"""Check if provider supports streaming with tools enabled."""
|
||||
...
|
||||
|
||||
def get_max_iteration_threshold(self) -> int:
|
||||
"""Get provider-specific maximum iteration count."""
|
||||
...
|
||||
|
||||
def format_tool_result_message(self, function_name: str, tool_result: Any, tool_call_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Format tool result message for this provider's requirements."""
|
||||
...
|
||||
|
||||
def handle_empty_response_with_tools(self, state: Dict[str, Any]) -> bool:
|
||||
"""Handle provider-specific empty response with tools logic."""
|
||||
...
|
||||
|
||||
def get_default_settings(self) -> Dict[str, Any]:
|
||||
"""Get provider-specific default settings."""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class UnifiedLLMProtocol(Protocol):
|
||||
"""
|
||||
Unified protocol for LLM dispatch that consolidates the dual execution paths.
|
||||
|
||||
This replaces the separate custom-LLM path (LLM.get_response) and OpenAI path
|
||||
(OpenAIClient) with a single async-first protocol that all providers implement.
|
||||
|
||||
Sync implementations must not call asyncio.run() from library internals.
|
||||
Provide native sync implementations or convert streaming responses
|
||||
to real Iterator[Dict[str, Any]] objects.
|
||||
"""
|
||||
|
||||
async def achat_completion(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
*,
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
temperature: float = 0.0,
|
||||
max_tokens: Optional[int] = None,
|
||||
stream: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> Union[Dict[str, Any], AsyncIterator[Dict[str, Any]]]:
|
||||
"""
|
||||
Primary async chat completion method.
|
||||
All LLM interactions go through this single path.
|
||||
"""
|
||||
...
|
||||
|
||||
def chat_completion(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
**kwargs: Any
|
||||
) -> Union[Dict[str, Any], Iterator[Dict[str, Any]]]:
|
||||
"""
|
||||
Synchronous chat completion entry point.
|
||||
For streaming, implementations must return a real Iterator[Dict[str, Any]].
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,348 @@
|
||||
"""
|
||||
LLM Provider Adapters for Unified Protocol Dispatch.
|
||||
|
||||
Implements the adapter pattern to consolidate the dual execution paths
|
||||
into a single async-first protocol-driven architecture.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Union, Iterator, AsyncIterator, Callable, TYPE_CHECKING
|
||||
from .protocols import UnifiedLLMProtocol
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .llm import LLM
|
||||
from .openai_client import OpenAIClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LiteLLMAdapter:
|
||||
"""
|
||||
Adapter for LiteLLM-backed LLM instances.
|
||||
|
||||
Wraps the existing LLM class to conform to the UnifiedLLMProtocol,
|
||||
ensuring all provider interactions go through a single async dispatch.
|
||||
"""
|
||||
|
||||
def __init__(self, llm_instance: 'LLM'):
|
||||
"""Initialize adapter with existing LLM instance."""
|
||||
self.llm = llm_instance
|
||||
self.model = llm_instance.model
|
||||
|
||||
async def achat_completion(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
*,
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
temperature: float = 0.0,
|
||||
max_tokens: Optional[int] = None,
|
||||
stream: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> Union[Dict[str, Any], AsyncIterator[Dict[str, Any]]]:
|
||||
"""
|
||||
Async chat completion using existing LLM.get_response_async.
|
||||
|
||||
This consolidates the custom-LLM path into the unified protocol.
|
||||
"""
|
||||
# Extract system prompt, chat history, and current prompt from messages
|
||||
system_prompt = None
|
||||
conversation_messages = messages
|
||||
|
||||
if messages and messages[0].get('role') == 'system':
|
||||
system_prompt = messages[0].get('content')
|
||||
conversation_messages = messages[1:]
|
||||
|
||||
prompt = ""
|
||||
chat_history = None
|
||||
|
||||
if conversation_messages:
|
||||
prompt = conversation_messages[-1].get('content', '')
|
||||
if len(conversation_messages) > 1:
|
||||
chat_history = conversation_messages[:-1]
|
||||
|
||||
# Extract verbose before passing kwargs to avoid duplicate parameter
|
||||
verbose = kwargs.pop('verbose', False)
|
||||
max_tokens = kwargs.pop('max_tokens', None)
|
||||
|
||||
# Call existing async method
|
||||
response = await self.llm.get_response_async(
|
||||
prompt=prompt,
|
||||
system_prompt=system_prompt,
|
||||
chat_history=chat_history,
|
||||
temperature=temperature,
|
||||
tools=tools,
|
||||
stream=stream,
|
||||
verbose=verbose,
|
||||
max_tokens=max_tokens,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
# Convert response to standard format
|
||||
if stream:
|
||||
# For streaming, wrap in async iterator that yields standard chunks
|
||||
return self._convert_stream_response(response)
|
||||
else:
|
||||
return self._convert_response_to_standard_format(response)
|
||||
|
||||
def chat_completion(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
**kwargs: Any
|
||||
) -> Union[Dict[str, Any], Iterator[Dict[str, Any]]]:
|
||||
"""
|
||||
Sync chat completion using existing sync LLM methods.
|
||||
|
||||
Implements a native sync path without calling asyncio.run() to comply
|
||||
with UnifiedLLMProtocol requirements.
|
||||
"""
|
||||
# Extract system prompt, chat history, and current prompt from messages
|
||||
system_prompt = None
|
||||
conversation_messages = messages
|
||||
|
||||
if messages and messages[0].get('role') == 'system':
|
||||
system_prompt = messages[0].get('content')
|
||||
conversation_messages = messages[1:]
|
||||
|
||||
prompt = ""
|
||||
chat_history = None
|
||||
|
||||
if conversation_messages:
|
||||
prompt = conversation_messages[-1].get('content', '')
|
||||
if len(conversation_messages) > 1:
|
||||
chat_history = conversation_messages[:-1]
|
||||
|
||||
# Extract parameters to avoid kwargs conflicts
|
||||
tools = kwargs.get('tools')
|
||||
temperature = kwargs.get('temperature', 0.0)
|
||||
stream = kwargs.get('stream', False)
|
||||
verbose = kwargs.pop('verbose', False) if 'verbose' in kwargs else False
|
||||
max_tokens = kwargs.pop('max_tokens', None) if 'max_tokens' in kwargs else None
|
||||
|
||||
# For streaming, explicitly disallow as it violates protocol sync contract
|
||||
if stream:
|
||||
raise ValueError(
|
||||
"Streaming is not supported in sync LiteLLMAdapter. "
|
||||
"Use achat_completion() for streaming support."
|
||||
)
|
||||
|
||||
# Use existing sync method
|
||||
response = self.llm.get_response(
|
||||
prompt=prompt,
|
||||
system_prompt=system_prompt,
|
||||
chat_history=chat_history,
|
||||
temperature=temperature,
|
||||
tools=tools,
|
||||
stream=False,
|
||||
verbose=verbose,
|
||||
max_tokens=max_tokens,
|
||||
**{k: v for k, v in kwargs.items() if k not in ['tools', 'temperature', 'stream', 'verbose', 'max_tokens']}
|
||||
)
|
||||
|
||||
return self._convert_response_to_standard_format(response)
|
||||
|
||||
def _convert_messages_to_prompt(self, messages: List[Dict[str, Any]]) -> str:
|
||||
"""Convert OpenAI-style messages to prompt string."""
|
||||
if not messages:
|
||||
return ""
|
||||
|
||||
# If single message, return its content
|
||||
if len(messages) == 1:
|
||||
return messages[0].get('content', '')
|
||||
|
||||
# For multiple messages, use the last user message as prompt
|
||||
for msg in reversed(messages):
|
||||
if msg.get('role') == 'user':
|
||||
return msg.get('content', '')
|
||||
|
||||
return messages[-1].get('content', '') if messages else ''
|
||||
|
||||
async def _convert_stream_response(self, response_stream) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""Convert LLM stream response to standard format."""
|
||||
# Implementation depends on the actual stream format from LLM.get_response_async
|
||||
# This is a placeholder - would need to be implemented based on actual stream format
|
||||
if hasattr(response_stream, '__aiter__'):
|
||||
async for chunk in response_stream:
|
||||
yield self._convert_chunk_to_standard_format(chunk)
|
||||
else:
|
||||
# If not async iterable, yield the full response as a single chunk
|
||||
yield self._convert_response_to_standard_format(response_stream)
|
||||
|
||||
def _convert_response_to_standard_format(self, response: Any) -> Dict[str, Any]:
|
||||
"""Convert LLM response to standard OpenAI-like format."""
|
||||
if isinstance(response, str):
|
||||
return {
|
||||
"choices": [{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": response
|
||||
},
|
||||
"finish_reason": "stop"
|
||||
}]
|
||||
}
|
||||
return response
|
||||
|
||||
def _convert_chunk_to_standard_format(self, chunk: Any) -> Dict[str, Any]:
|
||||
"""Convert LLM stream chunk to standard format."""
|
||||
if isinstance(chunk, str):
|
||||
return {
|
||||
"choices": [{
|
||||
"delta": {
|
||||
"content": chunk
|
||||
}
|
||||
}]
|
||||
}
|
||||
return chunk
|
||||
|
||||
|
||||
class OpenAIAdapter:
|
||||
"""
|
||||
Adapter for direct OpenAI client interactions.
|
||||
|
||||
Wraps the existing OpenAIClient to conform to the UnifiedLLMProtocol,
|
||||
consolidating the OpenAI-specific path into the unified dispatch.
|
||||
"""
|
||||
|
||||
def __init__(self, client: 'OpenAIClient', model: str = "gpt-4o-mini"):
|
||||
"""Initialize adapter with OpenAI client."""
|
||||
self.client = client
|
||||
self.model = model
|
||||
|
||||
async def achat_completion(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
*,
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
temperature: float = 0.0,
|
||||
max_tokens: Optional[int] = None,
|
||||
stream: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> Union[Dict[str, Any], AsyncIterator[Dict[str, Any]]]:
|
||||
"""
|
||||
Async chat completion using OpenAI client.
|
||||
|
||||
This consolidates the OpenAI path into the unified protocol.
|
||||
"""
|
||||
# Delegate to the existing OpenAI client's async method with tools
|
||||
return await self.client.achat_completion_with_tools(
|
||||
messages=messages,
|
||||
model=self.model,
|
||||
tools=tools,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
stream=stream,
|
||||
execute_tool_fn=kwargs.get('execute_tool_fn'),
|
||||
console=kwargs.get('console'),
|
||||
display_fn=kwargs.get('display_fn'),
|
||||
reasoning_steps=kwargs.get('reasoning_steps', False),
|
||||
verbose=kwargs.get('verbose', True),
|
||||
max_iterations=kwargs.get('max_iterations', 10),
|
||||
**{k: v for k, v in kwargs.items() if k not in ['max_tokens', 'execute_tool_fn', 'console', 'display_fn', 'reasoning_steps', 'verbose', 'max_iterations']}
|
||||
)
|
||||
|
||||
def chat_completion(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
**kwargs: Any
|
||||
) -> Union[Dict[str, Any], Iterator[Dict[str, Any]]]:
|
||||
"""
|
||||
Sync chat completion as thin wrapper around async implementation.
|
||||
|
||||
Delegates to async method following protocol requirements for
|
||||
sync methods to be wrappers around the canonical async implementation.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
# For streaming, explicitly disallow as it violates sync protocol contract
|
||||
if kwargs.get('stream', False):
|
||||
raise ValueError(
|
||||
"Streaming is not supported in sync OpenAIAdapter. "
|
||||
"Use achat_completion() for streaming support."
|
||||
)
|
||||
|
||||
# Check if we're already in an event loop
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
# In event loop - use thread pool to avoid nesting
|
||||
import concurrent.futures
|
||||
|
||||
def run_in_thread():
|
||||
return asyncio.run(self.achat_completion(messages, **kwargs))
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(run_in_thread)
|
||||
return future.result()
|
||||
except RuntimeError:
|
||||
# No running loop, safe to use asyncio.run
|
||||
return asyncio.run(self.achat_completion(messages, **kwargs))
|
||||
|
||||
|
||||
class UnifiedLLMDispatcher:
|
||||
"""
|
||||
Unified LLM dispatcher that implements UnifiedLLMProtocol.
|
||||
|
||||
This is the single entry point for all LLM interactions,
|
||||
routing to the appropriate adapter based on configuration.
|
||||
"""
|
||||
|
||||
def __init__(self, adapter: Union[LiteLLMAdapter, OpenAIAdapter]):
|
||||
"""Initialize with a specific adapter."""
|
||||
self.adapter = adapter
|
||||
self.model = adapter.model
|
||||
|
||||
async def achat_completion(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
*,
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
temperature: float = 0.0,
|
||||
max_tokens: Optional[int] = None,
|
||||
stream: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> Union[Dict[str, Any], AsyncIterator[Dict[str, Any]]]:
|
||||
"""Primary async chat completion method."""
|
||||
return await self.adapter.achat_completion(
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
stream=stream,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def chat_completion(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
**kwargs: Any
|
||||
) -> Union[Dict[str, Any], Iterator[Dict[str, Any]]]:
|
||||
"""Sync wrapper around async method."""
|
||||
return self.adapter.chat_completion(messages=messages, **kwargs)
|
||||
|
||||
|
||||
def create_llm_dispatcher(
|
||||
llm_instance: Optional['LLM'] = None,
|
||||
openai_client: Optional['OpenAIClient'] = None,
|
||||
model: str = "gpt-4o-mini"
|
||||
) -> UnifiedLLMDispatcher:
|
||||
"""
|
||||
Factory function to create appropriate LLM dispatcher.
|
||||
|
||||
Args:
|
||||
llm_instance: Existing LLM instance (for LiteLLM path)
|
||||
openai_client: Existing OpenAI client (for OpenAI path)
|
||||
model: Model name to use
|
||||
|
||||
Returns:
|
||||
UnifiedLLMDispatcher configured with appropriate adapter
|
||||
|
||||
Raises:
|
||||
ValueError: If neither llm_instance nor openai_client provided
|
||||
"""
|
||||
if llm_instance is not None:
|
||||
adapter = LiteLLMAdapter(llm_instance)
|
||||
elif openai_client is not None:
|
||||
adapter = OpenAIAdapter(openai_client, model)
|
||||
else:
|
||||
raise ValueError("Either llm_instance or openai_client must be provided")
|
||||
|
||||
return UnifiedLLMDispatcher(adapter)
|
||||
@@ -2,6 +2,7 @@ import os
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from praisonaiagents._logging import get_logger
|
||||
from typing import List, Optional, Dict, Any, Union, Literal, Type
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
@@ -23,15 +24,20 @@ except ImportError:
|
||||
|
||||
# Logging is already configured in _logging.py via __init__.py
|
||||
|
||||
# Global list to store error logs
|
||||
error_logs = []
|
||||
# Global list to store error logs - with bounded size to prevent memory leaks
|
||||
from collections import deque
|
||||
MAX_ERROR_LOGS = 1000 # Maximum number of error logs to keep in memory
|
||||
error_logs = deque(maxlen=MAX_ERROR_LOGS) # Ring buffer that auto-evicts old errors
|
||||
_error_logs_lock = threading.Lock()
|
||||
|
||||
# Separate registries for sync and async callbacks
|
||||
sync_display_callbacks = {}
|
||||
async_display_callbacks = {}
|
||||
_callbacks_lock = threading.Lock()
|
||||
|
||||
# Global approval callback registry
|
||||
approval_callback = None
|
||||
_approval_callback_lock = threading.Lock()
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# PraisonAI Unique Color Palette: "Elegant Intelligence"
|
||||
@@ -128,10 +134,11 @@ def register_display_callback(display_type: str, callback_fn, is_async: bool = F
|
||||
callback_fn: The callback function to register
|
||||
is_async (bool): Whether the callback is asynchronous
|
||||
"""
|
||||
if is_async:
|
||||
async_display_callbacks[display_type] = callback_fn
|
||||
else:
|
||||
sync_display_callbacks[display_type] = callback_fn
|
||||
with _callbacks_lock:
|
||||
if is_async:
|
||||
async_display_callbacks[display_type] = callback_fn
|
||||
else:
|
||||
sync_display_callbacks[display_type] = callback_fn
|
||||
|
||||
def register_approval_callback(callback_fn):
|
||||
"""Register a global approval callback function for dangerous tool operations.
|
||||
@@ -140,7 +147,8 @@ def register_approval_callback(callback_fn):
|
||||
callback_fn: Function that takes (function_name, arguments, risk_level) and returns ApprovalDecision
|
||||
"""
|
||||
global approval_callback
|
||||
approval_callback = callback_fn
|
||||
with _approval_callback_lock:
|
||||
approval_callback = callback_fn
|
||||
|
||||
# Simplified aliases (consistent naming convention)
|
||||
add_display_callback = register_display_callback
|
||||
@@ -155,8 +163,10 @@ def execute_sync_callback(display_type: str, **kwargs):
|
||||
display_type (str): Type of display event
|
||||
**kwargs: Arguments to pass to the callback function
|
||||
"""
|
||||
if display_type in sync_display_callbacks:
|
||||
callback = sync_display_callbacks[display_type]
|
||||
with _callbacks_lock:
|
||||
callback = sync_display_callbacks.get(display_type)
|
||||
|
||||
if callback:
|
||||
import inspect
|
||||
sig = inspect.signature(callback)
|
||||
|
||||
@@ -180,9 +190,12 @@ async def execute_callback(display_type: str, **kwargs):
|
||||
import inspect
|
||||
|
||||
# Execute synchronous callback if registered
|
||||
if display_type in sync_display_callbacks:
|
||||
callback = sync_display_callbacks[display_type]
|
||||
sig = inspect.signature(callback)
|
||||
with _callbacks_lock:
|
||||
sync_callback = sync_display_callbacks.get(display_type)
|
||||
async_callback = async_display_callbacks.get(display_type)
|
||||
|
||||
if sync_callback:
|
||||
sig = inspect.signature(sync_callback)
|
||||
|
||||
# Filter kwargs to what the callback accepts to maintain backward compatibility
|
||||
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()):
|
||||
@@ -193,11 +206,11 @@ async def execute_callback(display_type: str, **kwargs):
|
||||
supported_kwargs = {k: v for k, v in kwargs.items() if k in sig.parameters}
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, lambda: callback(**supported_kwargs))
|
||||
await loop.run_in_executor(None, lambda: sync_callback(**supported_kwargs))
|
||||
|
||||
# Execute asynchronous callback if registered
|
||||
if display_type in async_display_callbacks:
|
||||
callback = async_display_callbacks[display_type]
|
||||
if async_callback:
|
||||
callback = async_callback
|
||||
sig = inspect.signature(callback)
|
||||
|
||||
# Filter kwargs to what the callback accepts to maintain backward compatibility
|
||||
@@ -418,7 +431,8 @@ def display_error(message: str, console=None):
|
||||
title="⚠ Error",
|
||||
border_style=PRAISON_COLORS["error"]
|
||||
))
|
||||
error_logs.append(message)
|
||||
with _error_logs_lock:
|
||||
error_logs.append(message)
|
||||
|
||||
def display_generating(content: str = "", start_time: Optional[float] = None):
|
||||
if not content or not str(content).strip():
|
||||
@@ -615,7 +629,8 @@ async def adisplay_error(message: str, console=None):
|
||||
await execute_callback('error', message=message)
|
||||
|
||||
console.print(Panel.fit(Text(message, style="bold red"), title="Error", border_style="red"))
|
||||
error_logs.append(message)
|
||||
with _error_logs_lock:
|
||||
error_logs.append(message)
|
||||
|
||||
async def adisplay_generating(content: str = "", start_time: Optional[float] = None):
|
||||
"""Async version of display_generating."""
|
||||
|
||||
@@ -106,6 +106,16 @@ def __getattr__(name):
|
||||
if name == "repeat":
|
||||
from ..workflows import repeat
|
||||
return repeat
|
||||
# Structured result types for better error handling
|
||||
if name == "MemoryResult":
|
||||
from .results import MemoryResult
|
||||
return MemoryResult
|
||||
if name == "MemoryResultStatus":
|
||||
from .results import MemoryResultStatus
|
||||
return MemoryResultStatus
|
||||
if name == "SearchResult":
|
||||
from .results import SearchResult
|
||||
return SearchResult
|
||||
# Backward compatibility aliases
|
||||
if name == "StepInput":
|
||||
from ..workflows import WorkflowContext
|
||||
|
||||
@@ -5,15 +5,49 @@ This module provides concrete implementations of MemoryProtocol
|
||||
for different backends, following the adapter pattern used in knowledge/.
|
||||
|
||||
Adapters:
|
||||
- sqlite_adapter: SQLite-based memory storage
|
||||
- chromadb_adapter: ChromaDB vector memory storage
|
||||
- mongodb_adapter: MongoDB document storage
|
||||
- redis_adapter: Redis in-memory storage
|
||||
- postgres_adapter: PostgreSQL storage
|
||||
- sqlite_adapter: SQLite-based memory storage (core)
|
||||
- in_memory_adapter: Lightweight in-memory storage (core, for testing/dev)
|
||||
- chromadb_adapter: ChromaDB vector memory storage (wrapper)
|
||||
- mongodb_adapter: MongoDB document storage (wrapper)
|
||||
- redis_adapter: Redis in-memory storage (wrapper)
|
||||
- postgres_adapter: PostgreSQL storage (wrapper)
|
||||
|
||||
Heavy adapters (chromadb, mongodb, etc.) are moved to wrapper package
|
||||
to keep core lightweight and protocol-driven.
|
||||
"""
|
||||
|
||||
from .sqlite_adapter import SqliteMemoryAdapter
|
||||
from .in_memory_adapter import InMemoryAdapter
|
||||
from .registry import (
|
||||
register_memory_adapter,
|
||||
register_memory_factory,
|
||||
get_memory_adapter,
|
||||
list_memory_adapters,
|
||||
get_first_available_memory_adapter,
|
||||
has_memory_adapter,
|
||||
)
|
||||
from .factories import (
|
||||
create_mem0_memory_adapter,
|
||||
create_chroma_memory_adapter,
|
||||
create_mongodb_memory_adapter,
|
||||
)
|
||||
|
||||
# Register core adapters
|
||||
register_memory_adapter("sqlite", SqliteMemoryAdapter)
|
||||
register_memory_adapter("in_memory", InMemoryAdapter)
|
||||
|
||||
# Register heavy adapter factories (lazy-loaded)
|
||||
register_memory_factory("mem0", create_mem0_memory_adapter)
|
||||
register_memory_factory("chroma", create_chroma_memory_adapter)
|
||||
register_memory_factory("mongodb", create_mongodb_memory_adapter)
|
||||
|
||||
__all__ = [
|
||||
'SqliteMemoryAdapter',
|
||||
]
|
||||
'InMemoryAdapter',
|
||||
'register_memory_adapter',
|
||||
'register_memory_factory',
|
||||
'get_memory_adapter',
|
||||
'list_memory_adapters',
|
||||
'get_first_available_memory_adapter',
|
||||
'has_memory_adapter',
|
||||
]
|
||||
@@ -0,0 +1,495 @@
|
||||
"""
|
||||
Memory Adapter Factory Functions
|
||||
|
||||
Provides factory functions for heavy memory adapters that replace hardcoded imports
|
||||
in memory/memory.py. These enable lazy loading of heavy dependencies while maintaining
|
||||
the protocol-driven architecture.
|
||||
|
||||
Each factory function:
|
||||
1. Lazy imports the required dependencies
|
||||
2. Creates and returns an adapter instance that implements MemoryProtocol
|
||||
3. Raises clear ImportError with installation instructions if dependencies missing
|
||||
|
||||
This approach follows the protocol-driven core principle by moving heavy implementations
|
||||
out of the core Memory class while preserving backward compatibility.
|
||||
"""
|
||||
|
||||
import os
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional
|
||||
from ..protocols import MemoryProtocol
|
||||
|
||||
|
||||
def create_mem0_memory_adapter(**kwargs) -> MemoryProtocol:
|
||||
"""
|
||||
Factory function to create Mem0 memory adapter.
|
||||
|
||||
Lazy imports mem0 and creates an adapter that wraps mem0.Memory
|
||||
to implement MemoryProtocol.
|
||||
|
||||
Args:
|
||||
**kwargs: Configuration passed to mem0 Memory
|
||||
|
||||
Returns:
|
||||
MemoryProtocol adapter instance
|
||||
|
||||
Raises:
|
||||
ImportError: If mem0 is not installed
|
||||
"""
|
||||
try:
|
||||
import mem0
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"mem0 is required for mem0 adapter. Install with: pip install mem0ai"
|
||||
)
|
||||
|
||||
return Mem0MemoryAdapter(mem0_config=kwargs)
|
||||
|
||||
|
||||
def create_chroma_memory_adapter(**kwargs) -> MemoryProtocol:
|
||||
"""
|
||||
Factory function to create ChromaDB memory adapter.
|
||||
|
||||
Lazy imports chromadb and creates an adapter that implements MemoryProtocol
|
||||
using ChromaDB as the vector store backend.
|
||||
|
||||
Args:
|
||||
**kwargs: Configuration passed to ChromaDB
|
||||
|
||||
Returns:
|
||||
MemoryProtocol adapter instance
|
||||
|
||||
Raises:
|
||||
ImportError: If chromadb is not installed
|
||||
"""
|
||||
try:
|
||||
import chromadb
|
||||
from chromadb.config import Settings as ChromaSettings
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"chromadb is required for chroma adapter. Install with: pip install chromadb"
|
||||
)
|
||||
|
||||
return ChromaMemoryAdapter(chromadb=chromadb, chroma_settings=ChromaSettings, **kwargs)
|
||||
|
||||
|
||||
def create_mongodb_memory_adapter(**kwargs) -> MemoryProtocol:
|
||||
"""
|
||||
Factory function to create MongoDB memory adapter.
|
||||
|
||||
Lazy imports pymongo and creates an adapter that implements MemoryProtocol
|
||||
using MongoDB as the document store backend.
|
||||
|
||||
Args:
|
||||
**kwargs: Configuration passed to MongoDB client
|
||||
|
||||
Returns:
|
||||
MemoryProtocol adapter instance
|
||||
|
||||
Raises:
|
||||
ImportError: If pymongo is not installed
|
||||
"""
|
||||
try:
|
||||
import pymongo
|
||||
from pymongo import MongoClient
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"pymongo is required for mongodb adapter. Install with: pip install pymongo"
|
||||
)
|
||||
|
||||
return MongoDBMemoryAdapter(pymongo=pymongo, mongo_client=MongoClient, **kwargs)
|
||||
|
||||
|
||||
class Mem0MemoryAdapter:
|
||||
"""
|
||||
Memory adapter that wraps mem0.Memory to implement MemoryProtocol.
|
||||
|
||||
This adapter handles the integration between PraisonAI's memory interface
|
||||
and mem0's memory system.
|
||||
"""
|
||||
|
||||
def __init__(self, mem0_config: Dict[str, Any]):
|
||||
"""Initialize mem0 memory adapter."""
|
||||
import mem0
|
||||
|
||||
config = mem0_config.get("config", {})
|
||||
api_key = config.get("api_key", os.getenv("MEM0_API_KEY"))
|
||||
org_id = config.get("org_id")
|
||||
proj_id = config.get("project_id")
|
||||
|
||||
# Check if graph memory is enabled
|
||||
graph_config = config.get("graph_store")
|
||||
use_graph = graph_config is not None
|
||||
|
||||
if use_graph:
|
||||
# Initialize with graph memory support
|
||||
mem0_config_dict = {"graph_store": graph_config}
|
||||
|
||||
# Add other configurations if provided
|
||||
for key in ["vector_store", "llm", "embedder"]:
|
||||
if key in config:
|
||||
mem0_config_dict[key] = config[key]
|
||||
|
||||
self.mem0_client = mem0.Memory.from_config(config_dict=mem0_config_dict)
|
||||
self.graph_enabled = True
|
||||
else:
|
||||
# Use traditional MemoryClient
|
||||
if org_id and proj_id:
|
||||
self.mem0_client = mem0.MemoryClient(api_key=api_key, org_id=org_id, project_id=proj_id)
|
||||
else:
|
||||
self.mem0_client = mem0.MemoryClient(api_key=api_key)
|
||||
self.graph_enabled = False
|
||||
|
||||
def store_short_term(self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> str:
|
||||
"""Store in mem0 (treated as long-term since mem0 doesn't distinguish)."""
|
||||
return self.store_long_term(text, metadata, **kwargs)
|
||||
|
||||
def search_short_term(self, query: str, limit: int = 5, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""Search mem0 (treated as long-term since mem0 doesn't distinguish)."""
|
||||
return self.search_long_term(query, limit, **kwargs)
|
||||
|
||||
def store_long_term(self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> str:
|
||||
"""Store text in mem0."""
|
||||
result = self.mem0_client.add(text, metadata=metadata or {})
|
||||
return str(result[0]["id"]) if result else ""
|
||||
|
||||
def search_long_term(self, query: str, limit: int = 5, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""Search mem0 for relevant memories."""
|
||||
search_params = {"query": query, "limit": limit}
|
||||
search_params.update(kwargs)
|
||||
|
||||
try:
|
||||
return self.mem0_client.search(**search_params)
|
||||
except TypeError as e:
|
||||
# Handle known mem0 MongoDB compatibility issue
|
||||
if "unexpected keyword argument" in str(e).lower() and "vectors" in str(e).lower():
|
||||
# Return empty results rather than crashing
|
||||
return []
|
||||
raise
|
||||
|
||||
def get_all_memories(self, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""Get all memories from mem0."""
|
||||
return self.mem0_client.get_all(**kwargs)
|
||||
|
||||
|
||||
class ChromaMemoryAdapter:
|
||||
"""
|
||||
Memory adapter that uses ChromaDB to implement MemoryProtocol.
|
||||
"""
|
||||
|
||||
def __init__(self, chromadb, chroma_settings, **kwargs):
|
||||
"""Initialize ChromaDB memory adapter."""
|
||||
self.chromadb = chromadb
|
||||
self.chroma_settings = chroma_settings
|
||||
|
||||
# Configuration
|
||||
rag_path = kwargs.get("rag_db_path", "chroma_db")
|
||||
os.makedirs(rag_path, exist_ok=True)
|
||||
|
||||
# Initialize ChromaDB client
|
||||
self.client = chromadb.PersistentClient(
|
||||
path=rag_path,
|
||||
settings=chroma_settings(
|
||||
anonymized_telemetry=False,
|
||||
allow_reset=True
|
||||
)
|
||||
)
|
||||
|
||||
# Initialize collection
|
||||
collection_name = "memory_store"
|
||||
try:
|
||||
self.collection = self.client.get_collection(name=collection_name)
|
||||
except Exception:
|
||||
self.collection = self.client.create_collection(
|
||||
name=collection_name,
|
||||
metadata={"hnsw:space": "cosine"}
|
||||
)
|
||||
|
||||
def store_short_term(self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> str:
|
||||
"""Store in ChromaDB (short-term and long-term treated the same)."""
|
||||
return self.store_long_term(text, metadata, **kwargs)
|
||||
|
||||
def search_short_term(self, query: str, limit: int = 5, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""Search ChromaDB."""
|
||||
return self.search_long_term(query, limit, **kwargs)
|
||||
|
||||
def store_long_term(self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> str:
|
||||
"""Store text in ChromaDB with embedding."""
|
||||
# Get embedding
|
||||
from praisonaiagents.embedding import embedding
|
||||
embedding_model = kwargs.get("embedding_model", "text-embedding-3-small")
|
||||
|
||||
result = embedding(text, model=embedding_model)
|
||||
text_embedding = result.embeddings[0] if result.embeddings else None
|
||||
|
||||
if text_embedding is None:
|
||||
raise RuntimeError("Failed to generate embedding for text")
|
||||
|
||||
# Generate ID and store
|
||||
import time
|
||||
doc_id = str(time.time_ns())
|
||||
|
||||
# Sanitize metadata for ChromaDB
|
||||
sanitized_metadata = self._sanitize_metadata(metadata or {})
|
||||
|
||||
self.collection.add(
|
||||
documents=[text],
|
||||
metadatas=[sanitized_metadata],
|
||||
ids=[doc_id],
|
||||
embeddings=[text_embedding]
|
||||
)
|
||||
|
||||
return doc_id
|
||||
|
||||
def search_long_term(self, query: str, limit: int = 5, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""Search ChromaDB using vector similarity."""
|
||||
from praisonaiagents.embedding import embedding
|
||||
embedding_model = kwargs.get("embedding_model", "text-embedding-3-small")
|
||||
|
||||
result = embedding(query, model=embedding_model)
|
||||
query_embedding = result.embeddings[0] if result.embeddings else None
|
||||
|
||||
if query_embedding is None:
|
||||
return []
|
||||
|
||||
# Search ChromaDB
|
||||
response = self.collection.query(
|
||||
query_embeddings=[query_embedding],
|
||||
n_results=limit,
|
||||
include=["documents", "metadatas", "distances"]
|
||||
)
|
||||
|
||||
results = []
|
||||
if response["ids"]:
|
||||
for i in range(len(response["ids"][0])):
|
||||
metadata = response["metadatas"][0][i] if "metadatas" in response else {}
|
||||
text = response["documents"][0][i]
|
||||
score = 1.0 - (response["distances"][0][i] if "distances" in response else 0.0)
|
||||
|
||||
results.append({
|
||||
"id": response["ids"][0][i],
|
||||
"text": text,
|
||||
"metadata": metadata,
|
||||
"score": score
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
def get_all_memories(self, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""Get all memories from ChromaDB."""
|
||||
response = self.collection.get(include=["documents", "metadatas"])
|
||||
|
||||
results = []
|
||||
if response["ids"]:
|
||||
for i, doc_id in enumerate(response["ids"]):
|
||||
results.append({
|
||||
"id": doc_id,
|
||||
"text": response["documents"][i],
|
||||
"metadata": response["metadatas"][i] if "metadatas" in response else {},
|
||||
"type": "long_term"
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
def _sanitize_metadata(self, metadata: Dict) -> Dict:
|
||||
"""Sanitize metadata for ChromaDB - convert to acceptable types."""
|
||||
sanitized = {}
|
||||
for k, v in metadata.items():
|
||||
if v is None:
|
||||
continue
|
||||
if isinstance(v, (str, int, float, bool)):
|
||||
sanitized[k] = v
|
||||
elif isinstance(v, dict):
|
||||
sanitized[k] = str(v)
|
||||
else:
|
||||
sanitized[k] = str(v)
|
||||
return sanitized
|
||||
|
||||
|
||||
class MongoDBMemoryAdapter:
|
||||
"""
|
||||
Memory adapter that uses MongoDB to implement MemoryProtocol.
|
||||
"""
|
||||
|
||||
def __init__(self, pymongo, mongo_client, **kwargs):
|
||||
"""Initialize MongoDB memory adapter."""
|
||||
self.pymongo = pymongo
|
||||
|
||||
config = kwargs.get("config", {})
|
||||
connection_string = config.get("connection_string", "mongodb://localhost:27017/")
|
||||
database_name = config.get("database", "praisonai")
|
||||
self.use_vector_search = config.get("use_vector_search", False)
|
||||
|
||||
# Initialize MongoDB client
|
||||
self.client = mongo_client(
|
||||
connection_string,
|
||||
maxPoolSize=config.get("max_pool_size", 50),
|
||||
minPoolSize=config.get("min_pool_size", 10),
|
||||
maxIdleTimeMS=config.get("max_idle_time", 30000),
|
||||
serverSelectionTimeoutMS=config.get("server_selection_timeout", 5000),
|
||||
retryWrites=True,
|
||||
retryReads=True
|
||||
)
|
||||
|
||||
# Test connection
|
||||
self.client.admin.command('ping')
|
||||
|
||||
# Setup database and collections
|
||||
self.db = self.client[database_name]
|
||||
self.short_collection = self.db.short_term_memory
|
||||
self.long_collection = self.db.long_term_memory
|
||||
|
||||
# Create indexes for better performance
|
||||
self._create_indexes()
|
||||
|
||||
def _create_indexes(self):
|
||||
"""Create MongoDB indexes for better performance."""
|
||||
try:
|
||||
# Text search indexes
|
||||
self.short_collection.create_index([("content", "text")])
|
||||
self.long_collection.create_index([("content", "text")])
|
||||
|
||||
# Compound indexes for filtering
|
||||
self.short_collection.create_index([("created_at", -1), ("metadata.quality", -1)])
|
||||
self.long_collection.create_index([("created_at", -1), ("metadata.quality", -1)])
|
||||
except Exception:
|
||||
pass # Indexes might already exist
|
||||
|
||||
def store_short_term(self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> str:
|
||||
"""Store in MongoDB short-term collection."""
|
||||
from datetime import datetime
|
||||
import time
|
||||
|
||||
doc_id = str(time.time_ns())
|
||||
doc = {
|
||||
"_id": doc_id,
|
||||
"content": text,
|
||||
"metadata": metadata or {},
|
||||
"created_at": datetime.now(datetime.timezone.utc),
|
||||
"memory_type": "short_term"
|
||||
}
|
||||
|
||||
self.short_collection.insert_one(doc)
|
||||
return doc_id
|
||||
|
||||
def search_short_term(self, query: str, limit: int = 5, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""Search MongoDB short-term collection."""
|
||||
search_filter = {"$text": {"$search": query}}
|
||||
|
||||
results = []
|
||||
for doc in self.short_collection.find(search_filter).limit(limit):
|
||||
results.append({
|
||||
"id": str(doc["_id"]),
|
||||
"text": doc["content"],
|
||||
"metadata": doc.get("metadata", {}),
|
||||
"score": 1.0
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
def store_long_term(self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> str:
|
||||
"""Store in MongoDB long-term collection."""
|
||||
from datetime import datetime
|
||||
import time
|
||||
|
||||
doc_id = str(time.time_ns())
|
||||
doc = {
|
||||
"_id": doc_id,
|
||||
"content": text,
|
||||
"metadata": metadata or {},
|
||||
"created_at": datetime.now(datetime.timezone.utc),
|
||||
"memory_type": "long_term"
|
||||
}
|
||||
|
||||
# Add embedding if vector search is enabled
|
||||
if self.use_vector_search:
|
||||
embedding = self._get_embedding(text)
|
||||
if embedding:
|
||||
doc["embedding"] = embedding
|
||||
|
||||
self.long_collection.insert_one(doc)
|
||||
return doc_id
|
||||
|
||||
def search_long_term(self, query: str, limit: int = 5, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""Search MongoDB long-term collection."""
|
||||
results = []
|
||||
|
||||
# Try vector search first if enabled
|
||||
if self.use_vector_search:
|
||||
embedding = self._get_embedding(query)
|
||||
if embedding:
|
||||
pipeline = [
|
||||
{
|
||||
"$vectorSearch": {
|
||||
"index": "vector_index",
|
||||
"path": "embedding",
|
||||
"queryVector": embedding,
|
||||
"numCandidates": limit * 10,
|
||||
"limit": limit
|
||||
}
|
||||
},
|
||||
{
|
||||
"$addFields": {
|
||||
"score": {"$meta": "vectorSearchScore"}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
try:
|
||||
for doc in self.long_collection.aggregate(pipeline):
|
||||
results.append({
|
||||
"id": str(doc["_id"]),
|
||||
"text": doc["content"],
|
||||
"metadata": doc.get("metadata", {}),
|
||||
"score": doc.get("score", 1.0)
|
||||
})
|
||||
except Exception:
|
||||
pass # Fall back to text search
|
||||
|
||||
# Fallback to text search
|
||||
if not results:
|
||||
search_filter = {"$text": {"$search": query}}
|
||||
for doc in self.long_collection.find(search_filter).limit(limit):
|
||||
results.append({
|
||||
"id": str(doc["_id"]),
|
||||
"text": doc["content"],
|
||||
"metadata": doc.get("metadata", {}),
|
||||
"score": 1.0
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
def get_all_memories(self, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""Get all memories from both collections."""
|
||||
results = []
|
||||
|
||||
# Get short-term memories
|
||||
for doc in self.short_collection.find():
|
||||
results.append({
|
||||
"id": str(doc["_id"]),
|
||||
"text": doc["content"],
|
||||
"metadata": doc.get("metadata", {}),
|
||||
"created_at": doc.get("created_at"),
|
||||
"type": "short_term"
|
||||
})
|
||||
|
||||
# Get long-term memories
|
||||
for doc in self.long_collection.find():
|
||||
results.append({
|
||||
"id": str(doc["_id"]),
|
||||
"text": doc["content"],
|
||||
"metadata": doc.get("metadata", {}),
|
||||
"created_at": doc.get("created_at"),
|
||||
"type": "long_term"
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
def _get_embedding(self, text: str) -> Optional[List[float]]:
|
||||
"""Get embedding for text."""
|
||||
try:
|
||||
from praisonaiagents.embedding import embedding
|
||||
result = embedding(text, model="text-embedding-3-small")
|
||||
return result.embeddings[0] if result.embeddings else None
|
||||
except Exception:
|
||||
return None
|
||||
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
In-Memory Memory Adapter for PraisonAI.
|
||||
|
||||
Lightweight in-memory adapter for testing and development.
|
||||
No dependencies required - stores data as plain Python lists.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
class InMemoryAdapter:
|
||||
"""
|
||||
Lightweight in-memory adapter for testing and development.
|
||||
|
||||
Stores short-term and long-term memories as plain Python lists.
|
||||
Data is not persisted across process restarts.
|
||||
|
||||
Usage:
|
||||
adapter = InMemoryAdapter()
|
||||
adapter.store_short_term("hello", {"source": "test"})
|
||||
results = adapter.search_short_term("hello")
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
self._data: List[Dict[str, Any]] = []
|
||||
|
||||
def store_short_term(
|
||||
self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs
|
||||
) -> str:
|
||||
entry: Dict[str, Any] = {
|
||||
"id": str(len(self._data)),
|
||||
"text": text,
|
||||
"type": "short",
|
||||
"metadata": metadata,
|
||||
}
|
||||
self._data.append(entry)
|
||||
return entry["id"]
|
||||
|
||||
def search_short_term(
|
||||
self, query: str, limit: int = 5, **kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
results = [
|
||||
e
|
||||
for e in self._data
|
||||
if e["type"] == "short" and query.lower() in e["text"].lower()
|
||||
]
|
||||
return results[:limit]
|
||||
|
||||
def store_long_term(
|
||||
self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs
|
||||
) -> str:
|
||||
entry: Dict[str, Any] = {
|
||||
"id": str(len(self._data)),
|
||||
"text": text,
|
||||
"type": "long",
|
||||
"metadata": metadata,
|
||||
}
|
||||
self._data.append(entry)
|
||||
return entry["id"]
|
||||
|
||||
def search_long_term(
|
||||
self, query: str, limit: int = 5, **kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
results = [
|
||||
e
|
||||
for e in self._data
|
||||
if e["type"] == "long" and query.lower() in e["text"].lower()
|
||||
]
|
||||
return results[:limit]
|
||||
|
||||
def get_all_memories(self, **kwargs) -> List[Dict[str, Any]]:
|
||||
# Return defensive copy to prevent external mutation of internal state
|
||||
return [dict(entry) for entry in self._data]
|
||||
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
Legacy Memory Adapter
|
||||
|
||||
This adapter wraps the existing Memory class to provide backward compatibility
|
||||
while demonstrating the protocol-driven approach. This allows the core Memory
|
||||
class to be gradually refactored while maintaining all existing functionality.
|
||||
|
||||
This approach follows the Strangler Fig pattern:
|
||||
1. Create adapters that wrap existing implementations
|
||||
2. Register them in the adapter registry
|
||||
3. Core classes use adapters via registry instead of direct imports
|
||||
4. Gradually move logic from legacy class to clean adapters
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
from ..protocols import MemoryProtocol
|
||||
|
||||
|
||||
class LegacyMemoryAdapter:
|
||||
"""
|
||||
Adapter that wraps the existing Memory class to implement MemoryProtocol.
|
||||
|
||||
This enables the existing Memory implementation to work through the
|
||||
adapter registry while we gradually refactor it to be more protocol-driven.
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize legacy memory adapter by wrapping existing Memory class."""
|
||||
# Import the original Memory class here to avoid circular imports
|
||||
from .. import memory as memory_module
|
||||
|
||||
# Create instance of the original Memory class
|
||||
config = kwargs.get("config", kwargs)
|
||||
verbose = kwargs.get("verbose", 0)
|
||||
|
||||
self._memory = memory_module.Memory(config=config, verbose=verbose)
|
||||
|
||||
def store_short_term(self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> str:
|
||||
"""Store in short-term memory via legacy Memory class."""
|
||||
self._memory.store_short_term(text, metadata=metadata, **kwargs)
|
||||
# Generate a stable UUID instead of using unstable id(text)
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def search_short_term(self, query: str, limit: int = 5, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""Search short-term memory via legacy Memory class."""
|
||||
return self._memory.search_short_term(query, limit=limit, **kwargs)
|
||||
|
||||
def store_long_term(self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> str:
|
||||
"""Store in long-term memory via legacy Memory class."""
|
||||
self._memory.store_long_term(text, metadata=metadata, **kwargs)
|
||||
# Generate a stable UUID instead of using unstable id(text)
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def search_long_term(self, query: str, limit: int = 5, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""Search long-term memory via legacy Memory class."""
|
||||
return self._memory.search_long_term(query, limit=limit, **kwargs)
|
||||
|
||||
def get_all_memories(self, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""Get all memories via legacy Memory class."""
|
||||
return self._memory.get_all_memories(**kwargs)
|
||||
|
||||
|
||||
def create_legacy_memory_adapter(**kwargs) -> MemoryProtocol:
|
||||
"""
|
||||
Factory function to create legacy memory adapter.
|
||||
|
||||
This factory enables the existing Memory class to work through the
|
||||
adapter registry without requiring immediate refactoring.
|
||||
|
||||
Args:
|
||||
**kwargs: Configuration passed to legacy Memory class
|
||||
|
||||
Returns:
|
||||
MemoryProtocol adapter instance wrapping legacy Memory
|
||||
"""
|
||||
return LegacyMemoryAdapter(**kwargs)
|
||||
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
Memory Adapter Registry
|
||||
|
||||
Provides a registry system for memory adapters to replace hardcoded imports
|
||||
in memory/memory.py. This enables protocol-driven memory backends without
|
||||
core modifications.
|
||||
"""
|
||||
|
||||
from typing import Callable, List, Optional, Tuple, Type
|
||||
from ...utils.adapter_registry import AdapterRegistry
|
||||
from ..protocols import MemoryProtocol
|
||||
|
||||
|
||||
class MemoryAdapterRegistry(AdapterRegistry[MemoryProtocol]):
|
||||
"""
|
||||
Thread-safe registry for memory adapters.
|
||||
|
||||
Replaces hardcoded _check_chromadb, _check_mem0, etc. in memory.py
|
||||
with dynamic adapter discovery and instantiation.
|
||||
|
||||
Example:
|
||||
```python
|
||||
# Register adapter (typically done on import)
|
||||
register_memory_adapter("sqlite", SQLiteAdapter)
|
||||
register_memory_adapter("chroma", ChromaAdapter) # in wrapper
|
||||
|
||||
# Use in Memory class
|
||||
adapter = get_memory_adapter("chroma", config=config)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(adapter_type_name="Memory")
|
||||
|
||||
|
||||
# Global registry instance
|
||||
_memory_registry = MemoryAdapterRegistry()
|
||||
|
||||
|
||||
def register_memory_adapter(name: str, adapter_class: Type[MemoryProtocol]) -> None:
|
||||
"""Register a memory adapter class."""
|
||||
_memory_registry.register_adapter(name, adapter_class)
|
||||
|
||||
|
||||
def register_memory_factory(name: str, factory_func: Callable[..., MemoryProtocol]) -> None:
|
||||
"""Register a memory adapter factory function."""
|
||||
_memory_registry.register_factory(name, factory_func)
|
||||
|
||||
|
||||
def get_memory_adapter(name: str, **kwargs) -> Optional[MemoryProtocol]:
|
||||
"""Get memory adapter instance by name."""
|
||||
return _memory_registry.get_adapter(name, **kwargs)
|
||||
|
||||
|
||||
def list_memory_adapters() -> List[str]:
|
||||
"""List all registered memory adapter names."""
|
||||
return _memory_registry.list_adapters()
|
||||
|
||||
|
||||
# Canonical aliases per AGENTS.md naming conventions
|
||||
def add_memory_adapter(name: str, adapter_class: Type[MemoryProtocol]) -> None:
|
||||
"""Canonical alias for register_memory_adapter (preferred naming per AGENTS.md)."""
|
||||
return register_memory_adapter(name, adapter_class)
|
||||
|
||||
|
||||
def add_memory_factory(name: str, factory_func: Callable[..., MemoryProtocol]) -> None:
|
||||
"""Canonical alias for register_memory_factory (preferred naming per AGENTS.md)."""
|
||||
return register_memory_factory(name, factory_func)
|
||||
|
||||
|
||||
def has_memory_adapter(name: str) -> bool:
|
||||
"""Canonical alias for is_available (preferred naming per AGENTS.md)."""
|
||||
return _memory_registry.is_available(name)
|
||||
|
||||
|
||||
def get_first_available_memory_adapter(
|
||||
preferences: Optional[List[str]] = None, **kwargs
|
||||
) -> Optional[Tuple[str, MemoryProtocol]]:
|
||||
"""
|
||||
Get first available memory adapter.
|
||||
|
||||
Args:
|
||||
preferences: List of adapter names to try (defaults to ["sqlite", "in_memory"])
|
||||
**kwargs: Configuration passed to adapter constructor
|
||||
|
||||
Returns:
|
||||
(adapter_name, adapter_instance) tuple or None
|
||||
"""
|
||||
if preferences is None:
|
||||
preferences = ["sqlite", "in_memory"]
|
||||
|
||||
return _memory_registry.get_first_available(preferences, **kwargs)
|
||||
|
||||
|
||||
__all__ = [
|
||||
'MemoryAdapterRegistry',
|
||||
'register_memory_adapter',
|
||||
'register_memory_factory',
|
||||
'get_memory_adapter',
|
||||
'list_memory_adapters',
|
||||
'get_first_available_memory_adapter',
|
||||
# Canonical aliases (preferred)
|
||||
'add_memory_adapter',
|
||||
'add_memory_factory',
|
||||
'has_memory_adapter',
|
||||
]
|
||||
@@ -300,7 +300,7 @@ class AutoMemory:
|
||||
|
||||
# Check if already processed
|
||||
import hashlib
|
||||
text_hash = hashlib.md5(text.encode()).hexdigest()[:16]
|
||||
text_hash = hashlib.sha256(text.encode()).hexdigest()[:16]
|
||||
if text_hash in self._processed_hashes:
|
||||
return []
|
||||
self._processed_hashes.add(text_hash)
|
||||
|
||||
@@ -8,17 +8,19 @@ and quality scoring logic. Split from the main memory.py file for better maintai
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import asyncio
|
||||
from typing import Any, Dict, List, Optional
|
||||
from datetime import datetime
|
||||
from .results import MemoryResult, MemoryResultStatus, SearchResult
|
||||
|
||||
|
||||
class MemoryCoreMixin:
|
||||
"""Mixin class containing core memory functionality for the Memory class."""
|
||||
|
||||
def store_short_term(self, content: str, metadata: Optional[Dict] = None, quality_score: Optional[float] = None,
|
||||
user_id: Optional[str] = None, auto_promote: bool = True) -> str:
|
||||
user_id: Optional[str] = None, auto_promote: bool = True, **kwargs) -> str:
|
||||
"""
|
||||
Store content in short-term memory.
|
||||
Store content in short-term memory using protocol-driven approach.
|
||||
|
||||
Args:
|
||||
content: The content to store
|
||||
@@ -26,6 +28,7 @@ class MemoryCoreMixin:
|
||||
quality_score: Optional pre-calculated quality score
|
||||
user_id: Optional user identifier
|
||||
auto_promote: Whether to automatically promote to LTM if quality is high
|
||||
**kwargs: Additional provider-specific arguments
|
||||
|
||||
Returns:
|
||||
The memory ID of the stored content
|
||||
@@ -45,37 +48,35 @@ class MemoryCoreMixin:
|
||||
metadata['user_id'] = user_id
|
||||
|
||||
metadata['timestamp'] = datetime.now().isoformat()
|
||||
metadata['quality'] = quality_score
|
||||
|
||||
# Sanitize metadata for storage
|
||||
clean_metadata = self._sanitize_metadata(metadata)
|
||||
|
||||
# Protocol-driven storage: Delegate to adapter first
|
||||
memory_id = None
|
||||
|
||||
# Store in vector store if available
|
||||
if self.use_rag and hasattr(self, 'stm_collection'):
|
||||
try:
|
||||
memory_id = self._store_vector_stm(content, clean_metadata, quality_score)
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to store in vector STM: {e}")
|
||||
|
||||
if self.use_mongodb and hasattr(self, 'stm_collection'):
|
||||
try:
|
||||
memory_id = self._store_mongodb_stm(content, clean_metadata, quality_score)
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to store in MongoDB STM: {e}")
|
||||
|
||||
# Always store in SQLite as fallback
|
||||
try:
|
||||
if not memory_id:
|
||||
memory_id = self._store_sqlite_stm(content, clean_metadata, quality_score)
|
||||
if hasattr(self, 'memory_adapter') and self.memory_adapter:
|
||||
memory_id = self.memory_adapter.store_short_term(content, metadata=clean_metadata, **kwargs)
|
||||
self._log_verbose(f"Stored in {self.provider} STM via adapter: {content[:100]}...")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to store in SQLite STM: {e}")
|
||||
return ""
|
||||
self._log_verbose(f"Failed to store in {self.provider} STM: {e}", logging.WARNING)
|
||||
|
||||
# Backward compatibility: Also store in SQLite if not using SQLite adapter
|
||||
if hasattr(self, '_sqlite_adapter') and self._sqlite_adapter != getattr(self, 'memory_adapter', None):
|
||||
try:
|
||||
fallback_id = self._sqlite_adapter.store_short_term(content, metadata=clean_metadata, **kwargs)
|
||||
if not memory_id:
|
||||
memory_id = fallback_id
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to store in SQLite STM fallback: {e}")
|
||||
if not memory_id:
|
||||
return ""
|
||||
|
||||
# Auto-promote to long-term memory if quality is high
|
||||
if auto_promote and quality_score >= 7.5: # High quality threshold
|
||||
try:
|
||||
self.store_long_term(content, clean_metadata, quality_score, user_id)
|
||||
self.store_long_term(content, clean_metadata, quality_score, user_id, **kwargs)
|
||||
self._log_verbose(f"Auto-promoted STM content to LTM (score: {quality_score:.2f})")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to auto-promote to LTM: {e}")
|
||||
@@ -83,20 +84,119 @@ class MemoryCoreMixin:
|
||||
# Emit memory event
|
||||
self._emit_memory_event("store", "short_term", content, clean_metadata)
|
||||
|
||||
self._log_verbose(f"Stored in STM: {content[:100]}... (quality: {quality_score:.2f})")
|
||||
|
||||
return memory_id or ""
|
||||
|
||||
def store_long_term(self, content: str, metadata: Optional[Dict] = None, quality_score: Optional[float] = None,
|
||||
user_id: Optional[str] = None) -> str:
|
||||
def store_short_term_structured(self, content: str, metadata: Optional[Dict] = None,
|
||||
quality_score: Optional[float] = None,
|
||||
user_id: Optional[str] = None,
|
||||
auto_promote: bool = True, **kwargs) -> MemoryResult:
|
||||
"""
|
||||
Store content in long-term memory.
|
||||
Store content in short-term memory with structured error handling.
|
||||
|
||||
Returns a MemoryResult that distinguishes between success, fallback,
|
||||
and failure scenarios instead of silently returning empty strings.
|
||||
|
||||
Args:
|
||||
content: The content to store
|
||||
metadata: Optional metadata dictionary
|
||||
quality_score: Optional pre-calculated quality score
|
||||
user_id: Optional user identifier
|
||||
auto_promote: Whether to auto-promote high-quality content to long-term memory
|
||||
**kwargs: Additional provider-specific arguments
|
||||
|
||||
Returns:
|
||||
MemoryResult with status, memory_id, and error context
|
||||
"""
|
||||
if not content.strip():
|
||||
return MemoryResult.failed_result("Empty content cannot be stored")
|
||||
|
||||
# Calculate quality score if not provided
|
||||
if quality_score is None:
|
||||
quality_score = self.compute_quality_score(content, metadata)
|
||||
|
||||
# Prepare metadata with quality score
|
||||
metadata = metadata or {}
|
||||
metadata['quality'] = quality_score
|
||||
|
||||
# Sanitize metadata for storage
|
||||
clean_metadata = self._sanitize_metadata(metadata)
|
||||
|
||||
# Protocol-driven storage: Try primary adapter first
|
||||
primary_error = None
|
||||
memory_id = None
|
||||
|
||||
try:
|
||||
if hasattr(self, 'memory_adapter') and self.memory_adapter:
|
||||
memory_id = self.memory_adapter.store_short_term(content, metadata=clean_metadata, **kwargs)
|
||||
self._log_verbose(f"Stored in {self.provider} STM via adapter: {content[:100]}...")
|
||||
|
||||
# Auto-promote to long-term memory if quality is high
|
||||
if auto_promote and quality_score >= 7.5:
|
||||
try:
|
||||
self.store_long_term(content, clean_metadata, quality_score, user_id, **kwargs)
|
||||
self._log_verbose(f"Auto-promoted STM content to LTM (score: {quality_score:.2f})")
|
||||
except Exception as e:
|
||||
# Auto-promotion failure doesn't affect the primary storage result
|
||||
logging.warning(f"Failed to auto-promote to LTM: {e}")
|
||||
|
||||
# Emit memory event for successful storage
|
||||
self._emit_memory_event("store", "short_term", content, clean_metadata)
|
||||
|
||||
return MemoryResult.success_result(
|
||||
memory_id=memory_id,
|
||||
adapter_used=self.provider,
|
||||
context={
|
||||
"quality_score": quality_score,
|
||||
"auto_promoted": auto_promote and quality_score >= 7.5
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
primary_error = str(e)
|
||||
self._log_verbose(f"Failed to store in {self.provider} STM: {e}", logging.WARNING)
|
||||
|
||||
# Fallback to SQLite if available and different from primary adapter
|
||||
fallback_error = None
|
||||
if hasattr(self, '_sqlite_adapter') and self._sqlite_adapter != getattr(self, 'memory_adapter', None):
|
||||
try:
|
||||
fallback_id = self._sqlite_adapter.store_short_term(content, metadata=clean_metadata, **kwargs)
|
||||
|
||||
# Emit memory event for fallback storage
|
||||
self._emit_memory_event("store", "short_term", content, clean_metadata)
|
||||
|
||||
return MemoryResult.fallback_result(
|
||||
memory_id=fallback_id,
|
||||
fallback_reason=f"Primary adapter failed: {primary_error}",
|
||||
adapter_used="sqlite",
|
||||
context={
|
||||
"quality_score": quality_score,
|
||||
"primary_error": primary_error
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
fallback_error = str(e)
|
||||
logging.error(f"Failed to store in SQLite STM fallback: {e}")
|
||||
|
||||
# Complete failure - both primary and fallback failed
|
||||
return MemoryResult.failed_result(
|
||||
error_message=f"All storage adapters failed. Primary: {primary_error}, Fallback: {fallback_error}",
|
||||
context={
|
||||
"primary_error": primary_error,
|
||||
"fallback_error": fallback_error,
|
||||
"quality_score": quality_score
|
||||
}
|
||||
)
|
||||
|
||||
def store_long_term(self, content: str, metadata: Optional[Dict] = None, quality_score: Optional[float] = None,
|
||||
user_id: Optional[str] = None, **kwargs) -> str:
|
||||
"""
|
||||
Store content in long-term memory using protocol-driven approach.
|
||||
|
||||
Args:
|
||||
content: The content to store
|
||||
metadata: Optional metadata dictionary
|
||||
quality_score: Optional pre-calculated quality score
|
||||
user_id: Optional user identifier
|
||||
**kwargs: Additional provider-specific arguments
|
||||
|
||||
Returns:
|
||||
The memory ID of the stored content
|
||||
@@ -122,38 +222,34 @@ class MemoryCoreMixin:
|
||||
|
||||
metadata['timestamp'] = datetime.now().isoformat()
|
||||
metadata['promoted_at'] = datetime.now().isoformat()
|
||||
metadata['quality'] = quality_score
|
||||
|
||||
# Sanitize metadata for storage
|
||||
clean_metadata = self._sanitize_metadata(metadata)
|
||||
|
||||
# Protocol-driven storage: Delegate to adapter first
|
||||
memory_id = None
|
||||
|
||||
# Store in vector store if available
|
||||
if self.use_rag and hasattr(self, 'ltm_collection'):
|
||||
try:
|
||||
memory_id = self._store_vector_ltm(content, clean_metadata, quality_score)
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to store in vector LTM: {e}")
|
||||
|
||||
if self.use_mongodb and hasattr(self, 'ltm_collection'):
|
||||
try:
|
||||
memory_id = self._store_mongodb_ltm(content, clean_metadata, quality_score)
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to store in MongoDB LTM: {e}")
|
||||
|
||||
# Always store in SQLite as fallback
|
||||
try:
|
||||
if not memory_id:
|
||||
memory_id = self._store_sqlite_ltm(content, clean_metadata, quality_score)
|
||||
if hasattr(self, 'memory_adapter') and self.memory_adapter:
|
||||
memory_id = self.memory_adapter.store_long_term(content, metadata=clean_metadata, **kwargs)
|
||||
self._log_verbose(f"Stored in {self.provider} LTM via adapter: {content[:100]}...")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to store in SQLite LTM: {e}")
|
||||
return ""
|
||||
self._log_verbose(f"Failed to store in {self.provider} LTM: {e}", logging.WARNING)
|
||||
|
||||
# Backward compatibility: Also store in SQLite if not using SQLite adapter
|
||||
if hasattr(self, '_sqlite_adapter') and self._sqlite_adapter != getattr(self, 'memory_adapter', None):
|
||||
try:
|
||||
fallback_id = self._sqlite_adapter.store_long_term(content, metadata=clean_metadata, **kwargs)
|
||||
if not memory_id:
|
||||
memory_id = fallback_id
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to store in SQLite LTM fallback: {e}")
|
||||
if not memory_id:
|
||||
return ""
|
||||
|
||||
# Emit memory event
|
||||
self._emit_memory_event("store", "long_term", content, clean_metadata)
|
||||
|
||||
self._log_verbose(f"Stored in LTM: {content[:100]}... (quality: {quality_score:.2f})")
|
||||
|
||||
return memory_id or ""
|
||||
|
||||
def compute_quality_score(self, content: str, metadata: Optional[Dict] = None,
|
||||
@@ -283,15 +379,28 @@ class MemoryCoreMixin:
|
||||
def _emit_memory_event(self, event_type: str, memory_type: str, content: str, metadata: Dict):
|
||||
"""Emit a memory-related event for monitoring/hooks."""
|
||||
try:
|
||||
# This would integrate with the event system
|
||||
# Integrate with the existing event system
|
||||
event_data = {
|
||||
'type': event_type,
|
||||
'memory_type': memory_type,
|
||||
'content_preview': content[:100] + ('...' if len(content) > 100 else ''),
|
||||
'metadata': metadata,
|
||||
'timestamp': datetime.now().isoformat()
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'provider': getattr(self, 'provider', 'unknown')
|
||||
}
|
||||
# Event emission logic would go here
|
||||
|
||||
# Try to emit to the EventBus if available
|
||||
try:
|
||||
from ..bus import get_default_bus
|
||||
bus = get_default_bus()
|
||||
bus.publish(f"memory.{event_type}.{memory_type}", event_data)
|
||||
except ImportError:
|
||||
# EventBus not available, fall back to logging
|
||||
logging.debug(f"Memory event: {event_type}.{memory_type} - {event_data}")
|
||||
except Exception as bus_error:
|
||||
# EventBus failed, log the event instead
|
||||
logging.debug(f"Memory event (bus failed): {event_type}.{memory_type} - {event_data}")
|
||||
logging.debug(f"EventBus error: {bus_error}")
|
||||
except Exception as e:
|
||||
logging.debug(f"Failed to emit memory event: {e}")
|
||||
|
||||
@@ -304,4 +413,86 @@ class MemoryCoreMixin:
|
||||
self._learn_manager = LearnManager(config=self._learn_config)
|
||||
except ImportError:
|
||||
logging.warning("Learn manager not available - install learn dependencies")
|
||||
return self._learn_manager
|
||||
return self._learn_manager
|
||||
|
||||
# Async variants to prevent event loop blocking
|
||||
async def store_short_term_async(self, content: str, metadata: Optional[Dict] = None, quality_score: Optional[float] = None,
|
||||
user_id: Optional[str] = None, auto_promote: bool = True) -> str:
|
||||
"""
|
||||
Async version of store_short_term to prevent event loop blocking.
|
||||
|
||||
Args:
|
||||
content: The content to store
|
||||
metadata: Optional metadata dictionary
|
||||
quality_score: Optional pre-calculated quality score
|
||||
user_id: Optional user identifier
|
||||
auto_promote: Whether to automatically promote to LTM if quality is high
|
||||
|
||||
Returns:
|
||||
The memory ID of the stored content
|
||||
"""
|
||||
if not content.strip():
|
||||
return ""
|
||||
|
||||
# Calculate quality score if not provided
|
||||
if quality_score is None:
|
||||
quality_score = self.compute_quality_score(content, metadata)
|
||||
|
||||
# Prepare metadata (mirror sync version's metadata construction including sanitization
|
||||
# to ensure only JSON-serializable values are stored, preventing crashes across backends)
|
||||
raw_metadata = metadata.copy() if metadata else {}
|
||||
raw_metadata.update({
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"quality_score": quality_score,
|
||||
"memory_type": "short_term"
|
||||
})
|
||||
if user_id:
|
||||
raw_metadata["user_id"] = user_id
|
||||
clean_metadata = self._sanitize_metadata(raw_metadata)
|
||||
|
||||
# Store in SQLite STM
|
||||
memory_id = ""
|
||||
try:
|
||||
memory_id = await asyncio.to_thread(self._store_sqlite_stm, content, clean_metadata, quality_score)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to store in SQLite STM: {e}")
|
||||
return ""
|
||||
|
||||
# Auto-promote to long-term memory if quality is high (async)
|
||||
if auto_promote and quality_score >= 7.5: # High quality threshold
|
||||
try:
|
||||
await self.store_long_term_async(content, clean_metadata, quality_score, user_id)
|
||||
self._log_verbose(f"Auto-promoted STM content to LTM (score: {quality_score:.2f})")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to auto-promote to LTM: {e}")
|
||||
|
||||
# Emit memory event
|
||||
self._emit_memory_event("store", "short_term", content, clean_metadata)
|
||||
|
||||
self._log_verbose(f"Stored in STM: {content[:100]}... (quality: {quality_score:.2f})")
|
||||
|
||||
return memory_id or ""
|
||||
|
||||
async def store_long_term_async(self, content: str, metadata: Optional[Dict] = None, quality_score: Optional[float] = None,
|
||||
user_id: Optional[str] = None) -> str:
|
||||
"""
|
||||
Async version of store_long_term to prevent event loop blocking.
|
||||
|
||||
Args:
|
||||
content: The content to store
|
||||
metadata: Optional metadata dictionary
|
||||
quality_score: Optional pre-calculated quality score
|
||||
user_id: Optional user identifier
|
||||
|
||||
Returns:
|
||||
The memory ID of the stored content
|
||||
"""
|
||||
if not content.strip():
|
||||
return ""
|
||||
|
||||
# Calculate quality score if not provided
|
||||
if quality_score is None:
|
||||
quality_score = self.compute_quality_score(content, metadata)
|
||||
|
||||
# Use sync version in thread to avoid blocking event loop
|
||||
return await asyncio.to_thread(self.store_long_term, content, metadata, quality_score, user_id)
|
||||
@@ -37,6 +37,7 @@ Hook Events:
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import shlex
|
||||
from praisonaiagents._logging import get_logger
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
@@ -300,9 +301,11 @@ class HooksManager:
|
||||
command = str(self.workspace_path / command)
|
||||
|
||||
# Execute
|
||||
# Use shell=False with shlex.split for safer execution
|
||||
args = shlex.split(command)
|
||||
result = subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
args,
|
||||
shell=False, # Use shell=False for security
|
||||
cwd=str(self.workspace_path),
|
||||
env=env,
|
||||
capture_output=True,
|
||||
|
||||
@@ -14,166 +14,21 @@ from .storage import StorageMixin
|
||||
from .search import SearchMixin
|
||||
from .core import MemoryCoreMixin
|
||||
|
||||
# Protocol-driven imports (AGENTS.md compliant)
|
||||
from .protocols import MemoryProtocol
|
||||
from .adapters import get_memory_adapter, get_first_available_memory_adapter
|
||||
from .adapters.sqlite_adapter import SqliteMemoryAdapter
|
||||
|
||||
# Disable litellm telemetry before any imports
|
||||
os.environ["LITELLM_TELEMETRY"] = "False"
|
||||
|
||||
# Set up logger using centralized logging utility
|
||||
# Keep existing custom TRACE level functionality for this module
|
||||
from .._logging import get_logger
|
||||
logger = get_logger(__name__, extra_data={"subsystem": "memory"})
|
||||
|
||||
# Add custom TRACE level (below DEBUG)
|
||||
TRACE_LEVEL = 5
|
||||
logging.addLevelName(TRACE_LEVEL, 'TRACE')
|
||||
|
||||
def trace(self, message, *args, **kwargs):
|
||||
if self.isEnabledFor(TRACE_LEVEL):
|
||||
self._log(TRACE_LEVEL, message, args, **kwargs)
|
||||
|
||||
logging.Logger.trace = trace
|
||||
|
||||
# Thread-safe lazy imports using proper thread synchronization
|
||||
import threading
|
||||
|
||||
# Thread-safe import cache
|
||||
_import_lock = threading.Lock()
|
||||
_module_cache = {}
|
||||
|
||||
def _check_chromadb():
|
||||
"""Thread-safe lazy check for chromadb availability."""
|
||||
if "chromadb" in _module_cache:
|
||||
return _module_cache["chromadb"]["available"]
|
||||
|
||||
with _import_lock:
|
||||
if "chromadb" in _module_cache:
|
||||
return _module_cache["chromadb"]["available"]
|
||||
|
||||
try:
|
||||
import chromadb
|
||||
from chromadb.config import Settings as ChromaSettings
|
||||
_module_cache["chromadb"] = {
|
||||
"available": True,
|
||||
"module": chromadb,
|
||||
"settings": ChromaSettings
|
||||
}
|
||||
except ImportError:
|
||||
_module_cache["chromadb"] = {"available": False}
|
||||
|
||||
return _module_cache["chromadb"]["available"]
|
||||
|
||||
def _get_chromadb():
|
||||
"""Get chromadb module and settings (thread-safe lazy load)."""
|
||||
if not _check_chromadb():
|
||||
raise ImportError("chromadb is required. Install with: pip install chromadb")
|
||||
return _module_cache["chromadb"]["module"], _module_cache["chromadb"]["settings"]
|
||||
|
||||
def _check_mem0():
|
||||
"""Thread-safe lazy check for mem0 availability."""
|
||||
if "mem0" in _module_cache:
|
||||
return _module_cache["mem0"]["available"]
|
||||
|
||||
with _import_lock:
|
||||
if "mem0" in _module_cache:
|
||||
return _module_cache["mem0"]["available"]
|
||||
|
||||
try:
|
||||
import mem0
|
||||
_module_cache["mem0"] = {
|
||||
"available": True,
|
||||
"module": mem0
|
||||
}
|
||||
except ImportError:
|
||||
_module_cache["mem0"] = {"available": False}
|
||||
|
||||
return _module_cache["mem0"]["available"]
|
||||
|
||||
def _get_mem0():
|
||||
"""Get mem0 module (thread-safe lazy load)."""
|
||||
if not _check_mem0():
|
||||
raise ImportError("mem0 is required. Install with: pip install mem0ai")
|
||||
return _module_cache["mem0"]["module"]
|
||||
|
||||
def _check_openai():
|
||||
"""Thread-safe lazy check for openai availability."""
|
||||
if "openai" in _module_cache:
|
||||
return _module_cache["openai"]["available"]
|
||||
|
||||
with _import_lock:
|
||||
if "openai" in _module_cache:
|
||||
return _module_cache["openai"]["available"]
|
||||
|
||||
try:
|
||||
import openai
|
||||
_module_cache["openai"] = {
|
||||
"available": True,
|
||||
"module": openai
|
||||
}
|
||||
except ImportError:
|
||||
_module_cache["openai"] = {"available": False}
|
||||
|
||||
return _module_cache["openai"]["available"]
|
||||
|
||||
def _get_openai():
|
||||
"""Get openai module (thread-safe lazy load)."""
|
||||
if not _check_openai():
|
||||
raise ImportError("openai is required. Install with: pip install openai")
|
||||
return _module_cache["openai"]["module"]
|
||||
|
||||
def _check_litellm():
|
||||
"""Thread-safe lazy check for litellm availability."""
|
||||
if "litellm" in _module_cache:
|
||||
return _module_cache["litellm"]["available"]
|
||||
|
||||
with _import_lock:
|
||||
if "litellm" in _module_cache:
|
||||
return _module_cache["litellm"]["available"]
|
||||
|
||||
try:
|
||||
import litellm
|
||||
litellm.telemetry = False
|
||||
_module_cache["litellm"] = {
|
||||
"available": True,
|
||||
"module": litellm
|
||||
}
|
||||
except ImportError:
|
||||
_module_cache["litellm"] = {"available": False}
|
||||
|
||||
return _module_cache["litellm"]["available"]
|
||||
|
||||
def _get_litellm():
|
||||
"""Get litellm module (thread-safe lazy load)."""
|
||||
if not _check_litellm():
|
||||
raise ImportError("litellm is required. Install with: pip install litellm")
|
||||
return _module_cache["litellm"]["module"]
|
||||
|
||||
def _check_pymongo():
|
||||
"""Thread-safe lazy check for pymongo availability."""
|
||||
if "pymongo" in _module_cache:
|
||||
return _module_cache["pymongo"]["available"]
|
||||
|
||||
with _import_lock:
|
||||
if "pymongo" in _module_cache:
|
||||
return _module_cache["pymongo"]["available"]
|
||||
|
||||
try:
|
||||
import pymongo
|
||||
from pymongo import MongoClient
|
||||
_module_cache["pymongo"] = {
|
||||
"available": True,
|
||||
"module": pymongo,
|
||||
"client": MongoClient
|
||||
}
|
||||
except ImportError:
|
||||
_module_cache["pymongo"] = {"available": False}
|
||||
|
||||
return _module_cache["pymongo"]["available"]
|
||||
|
||||
def _get_pymongo():
|
||||
"""Get pymongo module and MongoClient (thread-safe lazy load)."""
|
||||
if not _check_pymongo():
|
||||
raise ImportError("pymongo is required. Install with: pip install pymongo")
|
||||
return _module_cache["pymongo"]["module"], _module_cache["pymongo"]["client"]
|
||||
|
||||
|
||||
|
||||
class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
@@ -242,41 +97,96 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
self.cfg = config or {}
|
||||
self.verbose = verbose
|
||||
|
||||
# Thread-local storage for SQLite connections (thread-safe)
|
||||
self._local = threading.local()
|
||||
|
||||
# Write lock for serializing database modifications (thread-safe)
|
||||
self._write_lock = threading.Lock()
|
||||
|
||||
# Connection registry for cleanup across threads (use regular set with careful cleanup)
|
||||
self._all_connections = set()
|
||||
self._connection_lock = threading.Lock() # Protect the connection registry
|
||||
|
||||
# Set logger level based on verbose
|
||||
if verbose >= 5:
|
||||
logger.setLevel(10) # DEBUG
|
||||
else:
|
||||
logger.setLevel(30) # WARNING
|
||||
|
||||
# Also set ChromaDB and OpenAI client loggers to WARNING
|
||||
import logging as _logging
|
||||
get_logger('chromadb').setLevel(_logging.WARNING)
|
||||
get_logger('openai').setLevel(_logging.WARNING)
|
||||
get_logger('httpx').setLevel(_logging.WARNING)
|
||||
get_logger('httpcore').setLevel(_logging.WARNING)
|
||||
get_logger('chromadb.segment.impl.vector.local_persistent_hnsw').setLevel(_logging.ERROR)
|
||||
get_logger('utils').setLevel(_logging.WARNING)
|
||||
get_logger('litellm.utils').setLevel(logging.WARNING)
|
||||
|
||||
self.provider = self.cfg.get("provider", "rag")
|
||||
self.use_mem0 = (self.provider.lower() == "mem0") and _check_mem0()
|
||||
self.use_rag = (self.provider.lower() == "rag") and _check_chromadb() and self.cfg.get("use_embedding", False)
|
||||
self.use_mongodb = (self.provider.lower() == "mongodb") and _check_pymongo()
|
||||
self.graph_enabled = False # Initialize graph support flag
|
||||
# Suppress noisy loggers from dependencies
|
||||
self._configure_dependency_logging()
|
||||
|
||||
# Initialize core protocol-driven memory system
|
||||
self._init_protocol_driven_memory()
|
||||
|
||||
# Backward compatibility: Initialize SQLite-based STM/LTM for legacy API
|
||||
self._init_legacy_compatibility()
|
||||
|
||||
# Initialize learning manager if configured
|
||||
self._learn_manager = None # Lazy-loaded LearnManager
|
||||
self._learn_config = self.cfg.get("learn", None) # Learn configuration
|
||||
|
||||
def _configure_dependency_logging(self):
|
||||
"""Suppress verbose logging from heavy dependencies."""
|
||||
import logging as _logging
|
||||
for logger_name in [
|
||||
'chromadb', 'openai', 'httpx', 'httpcore', 'litellm.utils',
|
||||
'chromadb.segment.impl.vector.local_persistent_hnsw', 'utils'
|
||||
]:
|
||||
get_logger(logger_name).setLevel(_logging.WARNING)
|
||||
|
||||
def _init_protocol_driven_memory(self):
|
||||
"""Initialize memory using adapter registry (protocol-driven approach)."""
|
||||
# Determine provider preference
|
||||
provider = self.cfg.get("provider", "rag").lower()
|
||||
self._log_verbose(f"Requested memory provider: {provider}")
|
||||
|
||||
# Extract embedding model from config
|
||||
# Map legacy provider names to adapter names
|
||||
provider_mapping = {
|
||||
"rag": "chroma", # Legacy "rag" provider uses ChromaDB
|
||||
"mem0": "mem0",
|
||||
"mongodb": "mongodb",
|
||||
"sqlite": "sqlite",
|
||||
"none": "in_memory"
|
||||
}
|
||||
|
||||
adapter_name = provider_mapping.get(provider, provider)
|
||||
|
||||
# Try to get preferred adapter, fallback to available ones
|
||||
try:
|
||||
adapter_config = self._get_adapter_config_for_provider(adapter_name)
|
||||
adapter = get_memory_adapter(adapter_name, **adapter_config)
|
||||
except RuntimeError as exc:
|
||||
self._log_verbose(
|
||||
f"Failed to initialize '{adapter_name}': {exc}",
|
||||
logging.WARNING,
|
||||
)
|
||||
adapter = None
|
||||
|
||||
if adapter is None:
|
||||
# Fallback to first available adapter
|
||||
self._log_verbose(f"Provider '{adapter_name}' not available, trying fallbacks")
|
||||
# Try each fallback preference individually
|
||||
for fallback_provider in ["sqlite", "in_memory"]:
|
||||
try:
|
||||
fallback_config = self._get_adapter_config_for_provider(fallback_provider)
|
||||
adapter = get_memory_adapter(fallback_provider, **fallback_config)
|
||||
if adapter:
|
||||
adapter_name = fallback_provider
|
||||
self._log_verbose(f"Using fallback adapter: {adapter_name}")
|
||||
break
|
||||
except Exception as e:
|
||||
self._log_verbose(f"Fallback {fallback_provider} failed: {e}")
|
||||
continue
|
||||
|
||||
if not adapter:
|
||||
raise RuntimeError("No memory adapters available")
|
||||
|
||||
# Store adapter and metadata
|
||||
self.memory_adapter = adapter
|
||||
self.provider = adapter_name
|
||||
self._log_verbose(f"Initialized memory adapter: {adapter_name}")
|
||||
|
||||
# Set legacy flags for backward compatibility
|
||||
self.use_mem0 = (adapter_name == "mem0")
|
||||
self.use_rag = (adapter_name == "chroma")
|
||||
self.use_mongodb = (adapter_name == "mongodb")
|
||||
self.use_embedding = adapter_name in ['chroma', 'mongodb']
|
||||
|
||||
# Check if adapter supports advanced features
|
||||
self.graph_enabled = hasattr(adapter, 'graph_enabled') and adapter.graph_enabled
|
||||
|
||||
# Extract embedding model for legacy compatibility
|
||||
self.embedder_config = self.cfg.get("embedder", {})
|
||||
if isinstance(self.embedder_config, dict):
|
||||
embedder_model_config = self.embedder_config.get("config", {})
|
||||
@@ -284,32 +194,86 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
else:
|
||||
self.embedding_model = "text-embedding-3-small"
|
||||
|
||||
self._log_verbose(f"Using embedding model: {self.embedding_model}")
|
||||
|
||||
# Determine embedding dimensions based on model
|
||||
# Determine embedding dimensions for legacy compatibility
|
||||
self.embedding_dimensions = self._get_embedding_dimensions(self.embedding_model)
|
||||
self._log_verbose(f"Using embedding dimensions: {self.embedding_dimensions}")
|
||||
|
||||
# Create project data directory if it doesn't exist
|
||||
def _get_adapter_config(self) -> Dict[str, Any]:
|
||||
"""Get configuration for adapter initialization."""
|
||||
# Only include adapter-relevant configuration
|
||||
config = {}
|
||||
|
||||
# Add embedding model configuration
|
||||
embedder_config = self.cfg.get("embedder", {})
|
||||
if isinstance(embedder_config, dict):
|
||||
embedder_model_config = embedder_config.get("config", {})
|
||||
config["embedding_model"] = embedder_model_config.get("model", "text-embedding-3-small")
|
||||
else:
|
||||
config["embedding_model"] = "text-embedding-3-small"
|
||||
|
||||
# Add project data directory paths for file-based adapters
|
||||
from ..paths import get_project_data_dir
|
||||
_project_data = str(get_project_data_dir())
|
||||
os.makedirs(_project_data, exist_ok=True)
|
||||
project_data = str(get_project_data_dir())
|
||||
os.makedirs(project_data, exist_ok=True)
|
||||
|
||||
config["short_db"] = self.cfg.get("short_db", os.path.join(project_data, "short_term.db"))
|
||||
config["long_db"] = self.cfg.get("long_db", os.path.join(project_data, "long_term.db"))
|
||||
config["rag_db_path"] = self.cfg.get("rag_db_path", os.path.join(project_data, "chroma_db"))
|
||||
config["verbose"] = self.verbose
|
||||
|
||||
# Add specific configurations for different adapters
|
||||
if "config" in self.cfg:
|
||||
config["config"] = self.cfg["config"]
|
||||
|
||||
return config
|
||||
|
||||
# Short-term DB
|
||||
self.short_db = self.cfg.get("short_db", os.path.join(_project_data, "short_term.db"))
|
||||
self._init_stm()
|
||||
def _get_adapter_config_for_provider(self, provider_name: str) -> Dict[str, Any]:
|
||||
"""Get configuration tailored for specific provider."""
|
||||
base_config = self._get_adapter_config()
|
||||
|
||||
# Filter configuration based on provider requirements
|
||||
if provider_name == "sqlite":
|
||||
return {
|
||||
"short_db": base_config["short_db"],
|
||||
"long_db": base_config["long_db"],
|
||||
"verbose": base_config["verbose"]
|
||||
}
|
||||
elif provider_name == "in_memory":
|
||||
return {
|
||||
"verbose": base_config.get("verbose", 0)
|
||||
}
|
||||
elif provider_name in ["mem0", "chroma", "mongodb"]:
|
||||
# Factory-based adapters accept full config
|
||||
return base_config
|
||||
else:
|
||||
# Default: return base config and let adapter handle filtering
|
||||
return base_config
|
||||
|
||||
# Long-term DB
|
||||
self.long_db = self.cfg.get("long_db", os.path.join(_project_data, "long_term.db"))
|
||||
self._init_ltm()
|
||||
|
||||
# Conditionally init Mem0, MongoDB, or local RAG
|
||||
if self.use_mem0:
|
||||
self._init_mem0()
|
||||
elif self.use_mongodb:
|
||||
self._init_mongodb()
|
||||
elif self.use_rag:
|
||||
self._init_chroma()
|
||||
def _init_legacy_compatibility(self):
|
||||
"""Initialize legacy compatibility layer for direct SQLite access."""
|
||||
# For backward compatibility, maintain direct SQLite connections
|
||||
# This ensures existing code that accesses _get_stm_conn() still works
|
||||
from ..paths import get_project_data_dir
|
||||
project_data = str(get_project_data_dir())
|
||||
|
||||
self.short_db = self.cfg.get("short_db", os.path.join(project_data, "short_term.db"))
|
||||
self.long_db = self.cfg.get("long_db", os.path.join(project_data, "long_term.db"))
|
||||
|
||||
# Only create separate SQLite adapter if primary adapter is not SQLite
|
||||
if self.provider != "sqlite":
|
||||
self._sqlite_adapter = SqliteMemoryAdapter(
|
||||
short_db=self.short_db,
|
||||
long_db=self.long_db,
|
||||
verbose=self.verbose
|
||||
)
|
||||
else:
|
||||
# Reuse the primary adapter
|
||||
self._sqlite_adapter = self.memory_adapter
|
||||
|
||||
# Thread-local storage and locks (for legacy compatibility)
|
||||
self._local = self._sqlite_adapter._local
|
||||
self._write_lock = self._sqlite_adapter._write_lock
|
||||
self._all_connections = self._sqlite_adapter._all_connections
|
||||
self._connection_lock = self._sqlite_adapter._connection_lock
|
||||
|
||||
def _get_stm_conn(self):
|
||||
"""Get thread-local short-term memory SQLite connection."""
|
||||
@@ -942,7 +906,7 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
from praisonaiagents.embedding import embedding as get_embedding
|
||||
|
||||
logger.info("Getting embeddings...")
|
||||
logger.trace(f"Embedding input text: {text}")
|
||||
logger.log(TRACE_LEVEL, f"Embedding input text: {text}")
|
||||
|
||||
result = get_embedding(text, model=self.embedding_model)
|
||||
embedding = result.embeddings[0] if result.embeddings else None
|
||||
@@ -952,7 +916,7 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
return
|
||||
|
||||
logger.info("Successfully got embeddings")
|
||||
logger.trace(f"Received embedding of length: {len(embedding)}")
|
||||
logger.log(TRACE_LEVEL, f"Received embedding of length: {len(embedding)}")
|
||||
|
||||
# Sanitize metadata for ChromaDB
|
||||
sanitized_metadata = self._sanitize_metadata(metadata)
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
Structured result types for memory operations.
|
||||
|
||||
Provides type-safe return values that distinguish between success, failure,
|
||||
and fallback scenarios for memory storage and retrieval operations.
|
||||
Enables proper error handling and observability in multi-agent workflows.
|
||||
"""
|
||||
|
||||
from typing import Optional, Any, Dict, Literal, Union
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class MemoryResultStatus(Enum):
|
||||
"""Status of a memory operation."""
|
||||
SUCCESS = "success"
|
||||
FALLBACK = "fallback"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryResult:
|
||||
"""
|
||||
Structured result for memory operations.
|
||||
|
||||
Replaces silent failures and empty string returns with explicit
|
||||
status information and error context for better debugging and
|
||||
observability in production deployments.
|
||||
|
||||
Example:
|
||||
```python
|
||||
# Instead of silent failure returning ""
|
||||
result = memory.store_short_term("content")
|
||||
if result.status == MemoryResultStatus.FAILED:
|
||||
handle_failure(result.error_message)
|
||||
elif result.status == MemoryResultStatus.FALLBACK:
|
||||
log_fallback_warning(result.fallback_reason)
|
||||
```
|
||||
"""
|
||||
|
||||
status: MemoryResultStatus
|
||||
memory_id: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
fallback_reason: Optional[str] = None
|
||||
adapter_used: Optional[str] = None
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
"""Returns True if the operation succeeded (including fallback)."""
|
||||
return self.status in (MemoryResultStatus.SUCCESS, MemoryResultStatus.FALLBACK)
|
||||
|
||||
@property
|
||||
def failed(self) -> bool:
|
||||
"""Returns True if the operation completely failed."""
|
||||
return self.status == MemoryResultStatus.FAILED
|
||||
|
||||
@property
|
||||
def used_fallback(self) -> bool:
|
||||
"""Returns True if a fallback adapter was used."""
|
||||
return self.status == MemoryResultStatus.FALLBACK
|
||||
|
||||
@classmethod
|
||||
def success_result(cls, memory_id: str, adapter_used: str = "primary",
|
||||
context: Optional[Dict[str, Any]] = None) -> "MemoryResult":
|
||||
"""Create a successful memory result."""
|
||||
return cls(
|
||||
status=MemoryResultStatus.SUCCESS,
|
||||
memory_id=memory_id,
|
||||
adapter_used=adapter_used,
|
||||
context=context or {}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def fallback_result(cls, memory_id: str, fallback_reason: str,
|
||||
adapter_used: str = "fallback",
|
||||
context: Optional[Dict[str, Any]] = None) -> "MemoryResult":
|
||||
"""Create a fallback memory result."""
|
||||
return cls(
|
||||
status=MemoryResultStatus.FALLBACK,
|
||||
memory_id=memory_id,
|
||||
fallback_reason=fallback_reason,
|
||||
adapter_used=adapter_used,
|
||||
context=context or {}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def failed_result(cls, error_message: str,
|
||||
context: Optional[Dict[str, Any]] = None) -> "MemoryResult":
|
||||
"""Create a failed memory result."""
|
||||
return cls(
|
||||
status=MemoryResultStatus.FAILED,
|
||||
error_message=error_message,
|
||||
context=context or {}
|
||||
)
|
||||
|
||||
def to_legacy_string(self) -> str:
|
||||
"""
|
||||
Convert to legacy empty string return format for backward compatibility.
|
||||
|
||||
Returns the memory_id or empty string to maintain compatibility
|
||||
with existing code that expects string returns.
|
||||
"""
|
||||
return self.memory_id or ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchResult:
|
||||
"""
|
||||
Structured result for memory search operations.
|
||||
"""
|
||||
|
||||
status: MemoryResultStatus
|
||||
results: list = None
|
||||
total_count: int = 0
|
||||
query: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
adapter_used: Optional[str] = None
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.results is None:
|
||||
self.results = []
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
"""Returns True if the search succeeded."""
|
||||
return self.status == MemoryResultStatus.SUCCESS
|
||||
|
||||
@property
|
||||
def failed(self) -> bool:
|
||||
"""Returns True if the search failed."""
|
||||
return self.status == MemoryResultStatus.FAILED
|
||||
|
||||
@property
|
||||
def has_results(self) -> bool:
|
||||
"""Returns True if the search returned any results."""
|
||||
return len(self.results) > 0
|
||||
|
||||
@classmethod
|
||||
def success_result(cls, results: list, query: str = "",
|
||||
adapter_used: str = "primary",
|
||||
context: Optional[Dict[str, Any]] = None) -> "SearchResult":
|
||||
"""Create a successful search result."""
|
||||
return cls(
|
||||
status=MemoryResultStatus.SUCCESS,
|
||||
results=results,
|
||||
total_count=len(results),
|
||||
query=query,
|
||||
adapter_used=adapter_used,
|
||||
context=context or {}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def failed_result(cls, error_message: str, query: str = "",
|
||||
context: Optional[Dict[str, Any]] = None) -> "SearchResult":
|
||||
"""Create a failed search result."""
|
||||
return cls(
|
||||
status=MemoryResultStatus.FAILED,
|
||||
results=[],
|
||||
total_count=0,
|
||||
query=query,
|
||||
error_message=error_message,
|
||||
context=context or {}
|
||||
)
|
||||
|
||||
def to_legacy_list(self) -> list:
|
||||
"""
|
||||
Convert to legacy list return format for backward compatibility.
|
||||
|
||||
Returns the results list or empty list to maintain compatibility
|
||||
with existing code that expects list returns.
|
||||
"""
|
||||
return self.results
|
||||
|
||||
|
||||
# Export main types
|
||||
__all__ = [
|
||||
"MemoryResultStatus",
|
||||
"MemoryResult",
|
||||
"SearchResult"
|
||||
]
|
||||
@@ -15,9 +15,9 @@ class SearchMixin:
|
||||
"""Mixin class containing search and retrieval methods for the Memory class."""
|
||||
|
||||
def search_short_term(self, query: str, limit: int = 5, metadata_filter: Optional[Dict] = None,
|
||||
min_quality: Optional[float] = None, user_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
min_quality: Optional[float] = None, user_id: Optional[str] = None, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search short-term memory for relevant content.
|
||||
Search short-term memory for relevant content using protocol-driven approach.
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
@@ -25,38 +25,58 @@ class SearchMixin:
|
||||
metadata_filter: Optional metadata filter
|
||||
min_quality: Minimum quality score threshold
|
||||
user_id: Optional user ID for filtering
|
||||
**kwargs: Additional provider-specific arguments
|
||||
|
||||
Returns:
|
||||
List of relevant memory entries
|
||||
"""
|
||||
results = []
|
||||
|
||||
# Search in vector store (ChromaDB/MongoDB) if available
|
||||
if self.use_rag and hasattr(self, 'stm_collection'):
|
||||
try:
|
||||
results.extend(self._search_vector_stm(query, limit))
|
||||
except Exception as e:
|
||||
logging.warning(f"Vector STM search failed: {e}")
|
||||
|
||||
if self.use_mongodb and hasattr(self, 'stm_collection'):
|
||||
try:
|
||||
results.extend(self._search_mongodb_stm(query, limit, metadata_filter, min_quality, user_id))
|
||||
except Exception as e:
|
||||
logging.warning(f"MongoDB STM search failed: {e}")
|
||||
|
||||
# Search in SQLite as fallback
|
||||
# Protocol-driven search: Delegate to adapter first
|
||||
try:
|
||||
sqlite_results = self._search_sqlite_stm(query, limit, metadata_filter, min_quality, user_id)
|
||||
results.extend(sqlite_results)
|
||||
if hasattr(self, 'memory_adapter') and self.memory_adapter:
|
||||
adapter_results = self.memory_adapter.search_short_term(
|
||||
query, limit=limit, user_id=user_id, agent_id=kwargs.get('agent_id'),
|
||||
run_id=kwargs.get('run_id'), **kwargs
|
||||
)
|
||||
# Convert adapter results to legacy format
|
||||
if isinstance(adapter_results, list):
|
||||
for result in adapter_results:
|
||||
if hasattr(result, 'text') and hasattr(result, 'metadata'):
|
||||
# SearchResult from protocol
|
||||
results.append({
|
||||
"id": getattr(result, 'id', ''),
|
||||
"text": result.text,
|
||||
"metadata": result.metadata or {},
|
||||
"score": getattr(result, 'score', 1.0)
|
||||
})
|
||||
elif isinstance(result, dict):
|
||||
# Already in legacy format
|
||||
results.append(result)
|
||||
|
||||
self._log_verbose(f"Found {len(results)} results in {self.provider} STM via adapter")
|
||||
except Exception as e:
|
||||
logging.warning(f"SQLite STM search failed: {e}")
|
||||
self._log_verbose(f"Failed to search {self.provider} STM: {e}", logging.WARNING)
|
||||
|
||||
# Apply quality filtering if specified
|
||||
if min_quality is not None:
|
||||
results = [r for r in results if r.get("metadata", {}).get("quality", 0.0) >= min_quality]
|
||||
|
||||
# Apply metadata filtering if specified
|
||||
if metadata_filter:
|
||||
filtered_results = []
|
||||
for result in results:
|
||||
metadata = result.get("metadata", {})
|
||||
if all(metadata.get(k) == v for k, v in metadata_filter.items()):
|
||||
filtered_results.append(result)
|
||||
results = filtered_results
|
||||
|
||||
return self._deduplicate_and_rank_results(results, limit)
|
||||
|
||||
def search_long_term(self, query: str, limit: int = 10, metadata_filter: Optional[Dict] = None,
|
||||
min_quality: Optional[float] = None, user_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
min_quality: Optional[float] = None, user_id: Optional[str] = None, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search long-term memory for relevant content.
|
||||
Search long-term memory for relevant content using protocol-driven approach.
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
@@ -64,31 +84,51 @@ class SearchMixin:
|
||||
metadata_filter: Optional metadata filter
|
||||
min_quality: Minimum quality score threshold
|
||||
user_id: Optional user ID for filtering
|
||||
**kwargs: Additional provider-specific arguments
|
||||
|
||||
Returns:
|
||||
List of relevant memory entries
|
||||
"""
|
||||
results = []
|
||||
|
||||
# Search in vector store (ChromaDB/MongoDB) if available
|
||||
if self.use_rag and hasattr(self, 'ltm_collection'):
|
||||
try:
|
||||
results.extend(self._search_vector_ltm(query, limit))
|
||||
except Exception as e:
|
||||
logging.warning(f"Vector LTM search failed: {e}")
|
||||
|
||||
if self.use_mongodb and hasattr(self, 'ltm_collection'):
|
||||
try:
|
||||
results.extend(self._search_mongodb_ltm(query, limit, metadata_filter, min_quality, user_id))
|
||||
except Exception as e:
|
||||
logging.warning(f"MongoDB LTM search failed: {e}")
|
||||
|
||||
# Search in SQLite as fallback
|
||||
# Protocol-driven search: Delegate to adapter first
|
||||
try:
|
||||
sqlite_results = self._search_sqlite_ltm(query, limit, metadata_filter, min_quality, user_id)
|
||||
results.extend(sqlite_results)
|
||||
if hasattr(self, 'memory_adapter') and self.memory_adapter:
|
||||
adapter_results = self.memory_adapter.search_long_term(
|
||||
query, limit=limit, user_id=user_id, agent_id=kwargs.get('agent_id'),
|
||||
run_id=kwargs.get('run_id'), **kwargs
|
||||
)
|
||||
# Convert adapter results to legacy format
|
||||
if isinstance(adapter_results, list):
|
||||
for result in adapter_results:
|
||||
if hasattr(result, 'text') and hasattr(result, 'metadata'):
|
||||
# SearchResult from protocol
|
||||
results.append({
|
||||
"id": getattr(result, 'id', ''),
|
||||
"text": result.text,
|
||||
"metadata": result.metadata or {},
|
||||
"score": getattr(result, 'score', 1.0)
|
||||
})
|
||||
elif isinstance(result, dict):
|
||||
# Already in legacy format
|
||||
results.append(result)
|
||||
|
||||
self._log_verbose(f"Found {len(results)} results in {self.provider} LTM via adapter")
|
||||
except Exception as e:
|
||||
logging.warning(f"SQLite LTM search failed: {e}")
|
||||
self._log_verbose(f"Failed to search {self.provider} LTM: {e}", logging.WARNING)
|
||||
|
||||
# Apply quality filtering if specified
|
||||
if min_quality is not None:
|
||||
results = [r for r in results if r.get("metadata", {}).get("quality", 0.0) >= min_quality]
|
||||
|
||||
# Apply metadata filtering if specified
|
||||
if metadata_filter:
|
||||
filtered_results = []
|
||||
for result in results:
|
||||
metadata = result.get("metadata", {})
|
||||
if all(metadata.get(k) == v for k, v in metadata_filter.items()):
|
||||
filtered_results.append(result)
|
||||
results = filtered_results
|
||||
|
||||
return self._deduplicate_and_rank_results(results, limit)
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from ..task.task import Task
|
||||
from ..workflows.results import StepResult, StepError, WorkflowResult, StepStatus, ErrorStrategy
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ class DoomLoopDetector:
|
||||
|
||||
try:
|
||||
arg_str = json.dumps(arguments, sort_keys=True, default=str)
|
||||
return hashlib.md5(arg_str.encode()).hexdigest()[:16]
|
||||
return hashlib.sha256(arg_str.encode()).hexdigest()[:16]
|
||||
except (TypeError, ValueError):
|
||||
return "unhashable"
|
||||
|
||||
|
||||
@@ -115,8 +115,9 @@ def enable(plugins: list = None) -> None:
|
||||
from .manager import get_plugin_manager
|
||||
manager = get_plugin_manager()
|
||||
|
||||
# Auto-discover plugins from default directories
|
||||
# Auto-discover plugins from default directories and entry points
|
||||
manager.auto_discover_plugins()
|
||||
manager.discover_entry_points()
|
||||
|
||||
# Enable specific plugins or all
|
||||
if plugins is not None:
|
||||
|
||||
@@ -57,23 +57,24 @@ class PluginManager:
|
||||
Returns:
|
||||
True if registered successfully
|
||||
"""
|
||||
try:
|
||||
info = plugin.info
|
||||
if info.name in self._plugins:
|
||||
logger.warning(f"Plugin '{info.name}' already registered")
|
||||
with self._lock:
|
||||
try:
|
||||
info = plugin.info
|
||||
if info.name in self._plugins:
|
||||
logger.warning(f"Plugin '{info.name}' already registered")
|
||||
return False
|
||||
|
||||
self._plugins[info.name] = plugin
|
||||
self._enabled[info.name] = True
|
||||
|
||||
# Initialize plugin
|
||||
plugin.on_init({})
|
||||
|
||||
logger.info(f"Registered plugin: {info.name} v{info.version}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to register plugin: {e}")
|
||||
return False
|
||||
|
||||
self._plugins[info.name] = plugin
|
||||
self._enabled[info.name] = True
|
||||
|
||||
# Initialize plugin
|
||||
plugin.on_init({})
|
||||
|
||||
logger.info(f"Registered plugin: {info.name} v{info.version}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to register plugin: {e}")
|
||||
return False
|
||||
|
||||
def unregister(self, name: str) -> bool:
|
||||
"""
|
||||
@@ -423,6 +424,99 @@ class PluginManager:
|
||||
def get_single_file_plugin(self, name: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get a single-file plugin by name."""
|
||||
return self._single_file_plugins.get(name)
|
||||
|
||||
def discover_entry_points(self) -> int:
|
||||
"""
|
||||
Discover plugins registered via pip entry_points.
|
||||
|
||||
Looks for entry_points in the "praisonaiagents.plugins" group.
|
||||
Compatible with Python 3.8+ using importlib.metadata backport.
|
||||
|
||||
Returns:
|
||||
Number of plugins loaded
|
||||
"""
|
||||
# Lazy import to reduce startup time
|
||||
def _get_entry_points():
|
||||
try:
|
||||
from importlib.metadata import entry_points as _ep
|
||||
return _ep
|
||||
except ImportError:
|
||||
logger.warning("importlib.metadata not available - cannot discover entry_points plugins")
|
||||
return None
|
||||
|
||||
get_ep = _get_entry_points()
|
||||
if get_ep is None:
|
||||
return 0
|
||||
|
||||
try:
|
||||
# Python 3.10+ style - try group= first
|
||||
eps = get_ep(group="praisonaiagents.plugins")
|
||||
except TypeError:
|
||||
# Python 3.9 fallback - call then get group
|
||||
try:
|
||||
all_eps = get_ep()
|
||||
eps = all_eps.get("praisonaiagents.plugins", [])
|
||||
except Exception:
|
||||
eps = []
|
||||
|
||||
loaded = 0
|
||||
# Use lock to ensure thread-safe discovery of multiple entry points
|
||||
with self._lock:
|
||||
for ep in eps:
|
||||
try:
|
||||
plugin_class = ep.load()
|
||||
plugin = plugin_class()
|
||||
# register() also acquires lock but that's fine (RLock allows re-entry)
|
||||
if self.register(plugin):
|
||||
loaded += 1
|
||||
logger.info(f"Loaded entry_point plugin: {ep.name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load entry_point plugin {ep.name}: {e}")
|
||||
|
||||
return loaded
|
||||
|
||||
# =========================================================================
|
||||
# Entry Point Discovery Support (External Packages)
|
||||
# =========================================================================
|
||||
|
||||
def discover_entry_points(self) -> int:
|
||||
"""
|
||||
Auto-discover and load protocol-driven plugins installed via pip
|
||||
that register in the 'praisonai.plugins' entry-point group.
|
||||
|
||||
Returns:
|
||||
Number of plugins loaded successfully.
|
||||
"""
|
||||
loaded = 0
|
||||
try:
|
||||
import importlib.metadata
|
||||
# Python 3.10+
|
||||
entry_points = importlib.metadata.entry_points(group="praisonai.plugins")
|
||||
except ImportError:
|
||||
logger.debug("importlib.metadata not available. Cannot discover plugins.")
|
||||
return 0
|
||||
except TypeError:
|
||||
# Python 3.8/3.9 fallback
|
||||
try:
|
||||
entry_points = importlib.metadata.entry_points().get("praisonai.plugins", [])
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get entry points: {e}")
|
||||
return 0
|
||||
|
||||
for ep in entry_points:
|
||||
try:
|
||||
plugin_cls = ep.load()
|
||||
# Duck-type check or inheritance check isn't strictly necessary if it conforms to Plugin interface at runtime
|
||||
if callable(plugin_cls):
|
||||
plugin = plugin_cls()
|
||||
if self.register(plugin):
|
||||
loaded += 1
|
||||
else:
|
||||
logger.warning(f"Entry point {ep.name} is not callable")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load plugin from entry point {ep.name}: {e}")
|
||||
|
||||
return loaded
|
||||
|
||||
# Global plugin manager instance
|
||||
_default_manager: Optional[PluginManager] = None
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import logging
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
import json
|
||||
from typing import Dict, Optional, List, Any, AsyncGenerator
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
@@ -25,6 +27,8 @@ class Process:
|
||||
manager_llm: Optional[str] = None,
|
||||
verbose: bool = False,
|
||||
max_iter: int = 10,
|
||||
max_retries: int = 3,
|
||||
workflow_timeout: Optional[int] = None, # seconds, None = no timeout
|
||||
output: Optional[str] = None,
|
||||
):
|
||||
logging.debug(f"=== Initializing Process ===")
|
||||
@@ -37,9 +41,11 @@ class Process:
|
||||
self.agents = agents
|
||||
self.manager_llm = manager_llm
|
||||
self.max_iter = max_iter
|
||||
self.max_retries = max_retries
|
||||
self.workflow_timeout = workflow_timeout
|
||||
self.task_retry_counter: Dict[str, int] = {} # Initialize retry counter
|
||||
self.workflow_finished = False # ADDED: Workflow finished flag
|
||||
self._state_lock = asyncio.Lock() # Async lock for shared state protection
|
||||
self._state_lock = None # Lazy-initialized async lock for shared state protection
|
||||
|
||||
# Resolve verbose from output= param (takes precedence) or legacy verbose= param
|
||||
if output is not None:
|
||||
@@ -233,7 +239,7 @@ class Process:
|
||||
if hasattr(task, 'description') and 'Input data from previous tasks:' in task.description:
|
||||
task.description = task.description.split('Input data from previous tasks:')[0].strip()
|
||||
|
||||
while fallback_attempts < Process.DEFAULT_RETRY_LIMIT and not temp_current_task:
|
||||
while fallback_attempts < self.max_retries and not temp_current_task:
|
||||
fallback_attempts += 1
|
||||
logging.debug(f"Fallback attempt {fallback_attempts}: Trying to find next 'not started' task.")
|
||||
for task_candidate in self.tasks.values():
|
||||
@@ -248,13 +254,13 @@ class Process:
|
||||
if not leads_to_task and not task_candidate.next_tasks:
|
||||
continue # Skip if no valid path exists
|
||||
|
||||
if self.task_retry_counter.get(task_candidate.id, 0) < Process.DEFAULT_RETRY_LIMIT:
|
||||
if self.task_retry_counter.get(task_candidate.id, 0) < self.max_retries:
|
||||
self.task_retry_counter[task_candidate.id] = self.task_retry_counter.get(task_candidate.id, 0) + 1
|
||||
temp_current_task = task_candidate
|
||||
logging.debug(f"Fallback attempt {fallback_attempts}: Found 'not started' task: {temp_current_task.name}, retry count: {self.task_retry_counter[temp_current_task.id]}")
|
||||
return temp_current_task # Return the found task immediately
|
||||
else:
|
||||
logging.debug(f"Max retries reached for task {task_candidate.name} in fallback mode, marking as failed.")
|
||||
logging.debug(f"Max retries ({self.max_retries}) reached for task {task_candidate.name} in fallback mode, marking as failed.")
|
||||
task_candidate.status = "failed"
|
||||
if not temp_current_task:
|
||||
logging.debug(f"Fallback attempt {fallback_attempts}: No 'not started' task found within retry limit.")
|
||||
@@ -383,6 +389,7 @@ class Process:
|
||||
"""Async version of workflow method"""
|
||||
logging.debug("=== Starting Async Workflow ===")
|
||||
current_iter = 0 # Track how many times we've looped
|
||||
workflow_start = time.monotonic() # For timeout enforcement
|
||||
# Build workflow relationships first
|
||||
logging.debug("Building workflow relationships...")
|
||||
for task in self.tasks.values():
|
||||
@@ -418,6 +425,13 @@ class Process:
|
||||
logging.info(f"Max iteration limit {self.max_iter} reached, ending workflow.")
|
||||
break
|
||||
|
||||
# Enforce workflow timeout if set
|
||||
if self.workflow_timeout is not None:
|
||||
elapsed = time.monotonic() - workflow_start
|
||||
if elapsed > self.workflow_timeout:
|
||||
logging.warning(f"Workflow timeout ({self.workflow_timeout}s) exceeded after {elapsed:.1f}s, stopping.")
|
||||
break
|
||||
|
||||
# ADDED: Check workflow finished flag at the start of each cycle
|
||||
if self.workflow_finished:
|
||||
logging.info("Workflow finished early as all tasks are completed.")
|
||||
@@ -455,11 +469,13 @@ Context tasks: {[t.name for t in current_task.context] if current_task.context e
|
||||
Description length: {len(current_task.description)}
|
||||
""")
|
||||
|
||||
# Add context from previous tasks to description
|
||||
# Build context and set description for this execution pass only
|
||||
context = self._build_task_context(current_task)
|
||||
if context:
|
||||
# Update task description with context
|
||||
current_task.description = current_task.description + context
|
||||
# Store original description if not already stored
|
||||
if not hasattr(current_task, '_original_description'):
|
||||
current_task._original_description = current_task.description
|
||||
# Set description with context for execution; reset after yield to prevent accumulation
|
||||
current_task.description = current_task._original_description + (context if context else "")
|
||||
|
||||
# Skip execution for loop tasks, only process their subtasks
|
||||
if current_task.task_type == "loop":
|
||||
@@ -566,6 +582,9 @@ Subtask: {st.name}
|
||||
logging.debug(f"Task next_tasks: {current_task.next_tasks}")
|
||||
yield task_id
|
||||
visited_tasks.add(task_id)
|
||||
# Reset description to original after execution to prevent context accumulation
|
||||
if hasattr(current_task, '_original_description'):
|
||||
current_task.description = current_task._original_description
|
||||
|
||||
# Only end workflow if no next_tasks AND no conditions
|
||||
if not current_task.next_tasks and not current_task.condition and not any(
|
||||
@@ -577,28 +596,31 @@ Subtask: {st.name}
|
||||
current_task = None
|
||||
break
|
||||
|
||||
# Reset completed task to "not started" so it can run again
|
||||
if self.tasks[task_id].status == "completed":
|
||||
# Never reset loop tasks, decision tasks, or their subtasks if rerun is False
|
||||
subtask_name = self.tasks[task_id].name
|
||||
task_to_check = self.tasks[task_id]
|
||||
logging.debug(f"=== Checking reset for completed task: {subtask_name} ===")
|
||||
logging.debug(f"Task type: {task_to_check.task_type}")
|
||||
logging.debug(f"Task status before reset check: {task_to_check.status}")
|
||||
logging.debug(f"Task rerun: {getattr(task_to_check, 'rerun', True)}") # default to True if not set
|
||||
logging.debug(f"Task async_execution: {task_to_check.async_execution}")
|
||||
# Reset completed task to "not started" so it can run again (atomic operation)
|
||||
if self._state_lock is None:
|
||||
self._state_lock = asyncio.Lock()
|
||||
async with self._state_lock:
|
||||
if self.tasks[task_id].status == "completed":
|
||||
# Never reset loop tasks, decision tasks, or their subtasks if rerun is False
|
||||
subtask_name = self.tasks[task_id].name
|
||||
task_to_check = self.tasks[task_id]
|
||||
logging.debug(f"=== Checking reset for completed task: {subtask_name} ===")
|
||||
logging.debug(f"Task type: {task_to_check.task_type}")
|
||||
logging.debug(f"Task status before reset check: {task_to_check.status}")
|
||||
logging.debug(f"Task rerun: {getattr(task_to_check, 'rerun', True)}") # default to True if not set
|
||||
logging.debug(f"Task async_execution: {task_to_check.async_execution}")
|
||||
|
||||
if (getattr(task_to_check, 'rerun', True) and # Corrected condition - reset only if rerun is True (or default True)
|
||||
task_to_check.task_type != "loop" and # Removed "decision" from exclusion
|
||||
not any(t.task_type == "loop" and subtask_name.startswith(t.name + "_")
|
||||
for t in self.tasks.values()) and
|
||||
not task_to_check.async_execution): # Don't reset async parallel tasks
|
||||
logging.debug(f"=== Resetting non-loop, non-decision, non-parallel task {subtask_name} to 'not started' ===")
|
||||
self.tasks[task_id].status = "not started"
|
||||
logging.debug(f"Task status after reset: {self.tasks[task_id].status}")
|
||||
else:
|
||||
logging.debug(f"=== Skipping reset for loop/decision/subtask/parallel or rerun=False: {subtask_name} ===")
|
||||
logging.debug(f"Keeping status as: {self.tasks[task_id].status}")
|
||||
if (getattr(task_to_check, 'rerun', True) and # Corrected condition - reset only if rerun is True (or default True)
|
||||
task_to_check.task_type != "loop" and # Removed "decision" from exclusion
|
||||
not any(t.task_type == "loop" and subtask_name.startswith(t.name + "_")
|
||||
for t in self.tasks.values()) and
|
||||
not task_to_check.async_execution): # Don't reset async parallel tasks
|
||||
logging.debug(f"=== Resetting non-loop, non-decision, non-parallel task {subtask_name} to 'not started' ===")
|
||||
self.tasks[task_id].status = "not started"
|
||||
logging.debug(f"Task status after reset: {self.tasks[task_id].status}")
|
||||
else:
|
||||
logging.debug(f"=== Skipping reset for loop/decision/subtask/parallel or rerun=False: {subtask_name} ===")
|
||||
logging.debug(f"Keeping status as: {self.tasks[task_id].status}")
|
||||
|
||||
# Handle loop progression
|
||||
if current_task.task_type == "loop":
|
||||
@@ -1116,11 +1138,13 @@ Context tasks: {[t.name for t in current_task.context] if current_task.context e
|
||||
Description length: {len(current_task.description)}
|
||||
""")
|
||||
|
||||
# Add context from previous tasks to description
|
||||
# Build context and set description for this execution pass only
|
||||
context = self._build_task_context(current_task)
|
||||
if context:
|
||||
# Update task description with context
|
||||
current_task.description = current_task.description + context
|
||||
# Store original description if not already stored
|
||||
if not hasattr(current_task, '_original_description'):
|
||||
current_task._original_description = current_task.description
|
||||
# Set description with context for execution; reset after yield to prevent accumulation
|
||||
current_task.description = current_task._original_description + (context if context else "")
|
||||
|
||||
# Skip execution for loop tasks, only process their subtasks
|
||||
if current_task.task_type == "loop":
|
||||
@@ -1227,6 +1251,9 @@ Subtask: {st.name}
|
||||
logging.debug(f"Task next_tasks: {current_task.next_tasks}")
|
||||
yield task_id
|
||||
visited_tasks.add(task_id)
|
||||
# Reset description to original after execution to prevent context accumulation
|
||||
if hasattr(current_task, '_original_description'):
|
||||
current_task.description = current_task._original_description
|
||||
|
||||
# Only end workflow if no next_tasks AND no conditions
|
||||
if not current_task.next_tasks and not current_task.condition and not any(
|
||||
|
||||
@@ -31,7 +31,7 @@ def _chunk_hash(text: str, source: Optional[str] = None) -> str:
|
||||
Hash string for deduplication
|
||||
"""
|
||||
content = f"{source or ''}:{text}"
|
||||
return hashlib.md5(content.encode()).hexdigest()[:16]
|
||||
return hashlib.sha256(content.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def deduplicate_chunks(
|
||||
|
||||
@@ -31,16 +31,34 @@ class DefaultCitationFormatter:
|
||||
citations = []
|
||||
|
||||
for i, result in enumerate(results):
|
||||
# Handle different result formats
|
||||
text = result.get("text") or result.get("memory", "")
|
||||
# CRITICAL: Handle metadata=None from mem0 - ensure always dict
|
||||
metadata = result.get("metadata") or {}
|
||||
score = result.get("score", 0.0)
|
||||
# Handle both dict and dataclass formats
|
||||
is_dict = isinstance(result, dict)
|
||||
|
||||
# Extract text field
|
||||
if is_dict:
|
||||
text = result.get("text") or result.get("memory", "")
|
||||
else:
|
||||
text = getattr(result, "text", None) or getattr(result, "memory", None) or ""
|
||||
|
||||
# CRITICAL: Handle metadata=None - ensure always dict
|
||||
if is_dict:
|
||||
metadata = result.get("metadata") or {}
|
||||
else:
|
||||
metadata = getattr(result, "metadata", None)
|
||||
if metadata is None:
|
||||
metadata = {}
|
||||
|
||||
score = result.get("score", 0.0) if is_dict else getattr(result, "score", 0.0)
|
||||
|
||||
# Extract source info
|
||||
source = metadata.get("source", "")
|
||||
filename = metadata.get("filename", "")
|
||||
doc_id = result.get("id") or metadata.get("doc_id", "")
|
||||
|
||||
if is_dict:
|
||||
doc_id = result.get("id") or metadata.get("doc_id", "")
|
||||
else:
|
||||
doc_id = getattr(result, "id", None) or metadata.get("doc_id", "")
|
||||
|
||||
chunk_id = metadata.get("chunk_id", "")
|
||||
|
||||
citation = Citation(
|
||||
@@ -163,13 +181,18 @@ class RAG:
|
||||
# Handle different result formats
|
||||
if isinstance(results, dict):
|
||||
results = results.get("results", [])
|
||||
|
||||
elif hasattr(results, "results") and isinstance(getattr(results, "results"), list):
|
||||
results = getattr(results, "results")
|
||||
|
||||
# Filter by minimum score
|
||||
if self.config.min_score > 0:
|
||||
results = [
|
||||
r for r in results
|
||||
if r.get("score", 0) >= self.config.min_score
|
||||
]
|
||||
filtered = []
|
||||
for r in results:
|
||||
# Handle both dict and dataclass/pydantic models
|
||||
score = r.get("score", 0) if isinstance(r, dict) else getattr(r, "score", 0)
|
||||
if score >= self.config.min_score:
|
||||
filtered.append(r)
|
||||
results = filtered
|
||||
|
||||
# Limit to top_k
|
||||
results = results[:self.config.top_k]
|
||||
|
||||
@@ -118,6 +118,67 @@ class RAGProtocol(Protocol):
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RetrievalStrategyProtocol(Protocol):
|
||||
"""
|
||||
Protocol for pluggable retrieval strategies.
|
||||
|
||||
Enables custom retrieval algorithms without modifying core RAG pipeline.
|
||||
Implementations can provide different retrieval approaches (semantic,
|
||||
keyword, hybrid, graph-based, etc.) through a unified interface.
|
||||
"""
|
||||
|
||||
name: str
|
||||
|
||||
def retrieve(
|
||||
self,
|
||||
query: str,
|
||||
knowledge_store: Any, # KnowledgeStoreProtocol
|
||||
*,
|
||||
limit: int = 10,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Retrieve relevant documents for query.
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
knowledge_store: Knowledge store to search
|
||||
limit: Maximum number of results
|
||||
filters: Optional metadata filters
|
||||
**kwargs: Strategy-specific options
|
||||
|
||||
Returns:
|
||||
List of retrieved documents with metadata
|
||||
"""
|
||||
...
|
||||
|
||||
async def aretrieve(
|
||||
self,
|
||||
query: str,
|
||||
knowledge_store: Any, # KnowledgeStoreProtocol
|
||||
*,
|
||||
limit: int = 10,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Async version of retrieve.
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
knowledge_store: Knowledge store to search
|
||||
limit: Maximum number of results
|
||||
filters: Optional metadata filters
|
||||
**kwargs: Strategy-specific options
|
||||
|
||||
Returns:
|
||||
List of retrieved documents with metadata
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class GraphHookProtocol(Protocol):
|
||||
"""
|
||||
|
||||
@@ -27,7 +27,7 @@ class ServerConfig:
|
||||
|
||||
host: str = DEFAULT_HOST
|
||||
port: int = DEFAULT_PORT
|
||||
cors_origins: List[str] = field(default_factory=lambda: ["*"])
|
||||
cors_origins: List[str] = field(default_factory=lambda: [])
|
||||
auth_token: Optional[str] = None
|
||||
max_connections: int = 100
|
||||
|
||||
@@ -200,8 +200,8 @@ class AgentServer:
|
||||
app = CORSMiddleware(
|
||||
app,
|
||||
allow_origins=self.config.cors_origins,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_methods=["GET", "POST", "OPTIONS"],
|
||||
allow_headers=["Authorization", "Content-Type", "Origin", "Accept"],
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
@@ -129,9 +129,17 @@ class FileSnapshot:
|
||||
self.session_id = session_id
|
||||
|
||||
# Create unique shadow repo path based on project path hash
|
||||
project_hash = hashlib.md5(self.project_path.encode()).hexdigest()[:12]
|
||||
base_dir = snapshot_dir or DEFAULT_SNAPSHOT_DIR
|
||||
self.shadow_path = os.path.join(base_dir, project_hash)
|
||||
project_hash = hashlib.sha256(self.project_path.encode()).hexdigest()[:12]
|
||||
new_shadow_path = os.path.join(base_dir, project_hash)
|
||||
# Backward compat: adopt legacy snapshot directory if present
|
||||
legacy_hash = hashlib.md5(self.project_path.encode()).hexdigest()[:12] # noqa: S324
|
||||
legacy_shadow_path = os.path.join(base_dir, legacy_hash)
|
||||
self.shadow_path = (
|
||||
legacy_shadow_path
|
||||
if (not os.path.exists(new_shadow_path) and os.path.exists(legacy_shadow_path))
|
||||
else new_shadow_path
|
||||
)
|
||||
|
||||
self._initialized = False
|
||||
|
||||
|
||||
@@ -640,6 +640,7 @@ class MinimalTelemetry:
|
||||
|
||||
# Global telemetry instance
|
||||
_telemetry_instance = None
|
||||
_telemetry_instance_lock = threading.Lock()
|
||||
|
||||
def get_telemetry() -> MinimalTelemetry:
|
||||
"""
|
||||
@@ -649,17 +650,19 @@ def get_telemetry() -> MinimalTelemetry:
|
||||
The global MinimalTelemetry instance
|
||||
"""
|
||||
global _telemetry_instance
|
||||
if _telemetry_instance is None:
|
||||
_telemetry_instance = MinimalTelemetry()
|
||||
return _telemetry_instance
|
||||
with _telemetry_instance_lock:
|
||||
if _telemetry_instance is None:
|
||||
_telemetry_instance = MinimalTelemetry()
|
||||
return _telemetry_instance
|
||||
|
||||
def disable_telemetry():
|
||||
"""Programmatically disable telemetry."""
|
||||
global _telemetry_instance
|
||||
if _telemetry_instance:
|
||||
_telemetry_instance.enabled = False
|
||||
else:
|
||||
_telemetry_instance = MinimalTelemetry(enabled=False)
|
||||
with _telemetry_instance_lock:
|
||||
if _telemetry_instance:
|
||||
_telemetry_instance.enabled = False
|
||||
else:
|
||||
_telemetry_instance = MinimalTelemetry(enabled=False)
|
||||
|
||||
def force_shutdown_telemetry():
|
||||
"""
|
||||
@@ -667,8 +670,9 @@ def force_shutdown_telemetry():
|
||||
This function ensures proper termination of all background threads.
|
||||
"""
|
||||
global _telemetry_instance
|
||||
if _telemetry_instance:
|
||||
_telemetry_instance.shutdown()
|
||||
with _telemetry_instance_lock:
|
||||
if _telemetry_instance:
|
||||
_telemetry_instance.shutdown()
|
||||
|
||||
# Additional cleanup - wait for all threads to finish
|
||||
import threading
|
||||
@@ -698,11 +702,12 @@ def force_shutdown_telemetry():
|
||||
def enable_telemetry():
|
||||
"""Programmatically enable telemetry (if not disabled by environment)."""
|
||||
global _telemetry_instance
|
||||
if not _is_telemetry_disabled():
|
||||
if _telemetry_instance:
|
||||
_telemetry_instance.enabled = True
|
||||
else:
|
||||
_telemetry_instance = MinimalTelemetry(enabled=True)
|
||||
with _telemetry_instance_lock:
|
||||
if not _is_telemetry_disabled():
|
||||
if _telemetry_instance:
|
||||
_telemetry_instance.enabled = True
|
||||
else:
|
||||
_telemetry_instance = MinimalTelemetry(enabled=True)
|
||||
|
||||
# For backward compatibility with existing code
|
||||
class TelemetryCollector:
|
||||
|
||||
@@ -165,6 +165,12 @@ TOOL_MAPPINGS = {
|
||||
'SkillTools': ('.skill_tools', 'SkillTools'),
|
||||
'skill_tools': ('.skill_tools', None),
|
||||
|
||||
# Github Tools
|
||||
'github_create_branch': ('.github_tools', None),
|
||||
'github_commit_and_push': ('.github_tools', None),
|
||||
'github_create_pull_request': ('.github_tools', None),
|
||||
'github_tools': ('.github_tools', None),
|
||||
|
||||
# Schedule Tools (agent-centric scheduling)
|
||||
'schedule_add': ('.schedule_tools', None),
|
||||
'schedule_list': ('.schedule_tools', None),
|
||||
@@ -200,7 +206,16 @@ TOOL_MAPPINGS = {
|
||||
'email_tools': ('.email_tools', None),
|
||||
}
|
||||
|
||||
_instances = {} # Cache for class instances (protected by _tools_lock)
|
||||
# Tool factory functions - caches classes but creates fresh instances
|
||||
# This prevents state leakage between concurrent agents while optimizing import performance
|
||||
_loaded_classes = {} # Cache the Class, NOT the instance
|
||||
|
||||
def _create_tool_instance(class_name: str, module_path: str):
|
||||
"""Create a new tool instance. Caches the class but returns fresh instances to prevent state sharing."""
|
||||
if class_name not in _loaded_classes:
|
||||
module = import_module(module_path, __package__)
|
||||
_loaded_classes[class_name] = getattr(module, class_name)
|
||||
return _loaded_classes[class_name]() # Fresh instance safe for multi-agent
|
||||
|
||||
# Profile exports (lazy loaded)
|
||||
_PROFILE_EXPORTS = frozenset({
|
||||
@@ -313,6 +328,7 @@ def __getattr__(name: str) -> Any:
|
||||
'search_web', 'web_search', 'get_available_providers',
|
||||
'web_crawl', 'crawl_web', 'get_available_crawl_providers',
|
||||
'run_skill_script', 'read_skill_file', 'list_skill_scripts', 'create_skill_tools',
|
||||
'github_create_branch', 'github_commit_and_push', 'github_create_pull_request',
|
||||
'schedule_add', 'schedule_list', 'schedule_remove',
|
||||
'ast_grep_search', 'ast_grep_rewrite', 'ast_grep_scan', 'is_ast_grep_available', 'get_ast_grep_tools',
|
||||
'store_memory', 'search_memory',
|
||||
@@ -321,21 +337,14 @@ def __getattr__(name: str) -> Any:
|
||||
'smtp_send_email', 'smtp_read_inbox'
|
||||
]:
|
||||
return getattr(module, name)
|
||||
if name in ['file_tools', 'spider_tools', 'python_tools', 'shell_tools', 'cot_tools', 'tavily_tools', 'youdotcom_tools', 'exa_tools', 'crawl4ai_tools', 'skill_tools', 'schedule_tools', 'ast_grep_tools', 'email_tools']:
|
||||
if name in ['file_tools', 'spider_tools', 'python_tools', 'shell_tools', 'cot_tools', 'tavily_tools', 'youdotcom_tools', 'exa_tools', 'crawl4ai_tools', 'skill_tools', 'github_tools', 'schedule_tools', 'ast_grep_tools', 'email_tools']:
|
||||
return module # Returns the callable module
|
||||
return getattr(module, name)
|
||||
else:
|
||||
# Class method import (thread-safe)
|
||||
if class_name not in _instances:
|
||||
with _tools_lock:
|
||||
# Double-check pattern to avoid race conditions
|
||||
if class_name not in _instances:
|
||||
module = import_module(module_path, __package__)
|
||||
class_ = getattr(module, class_name)
|
||||
_instances[class_name] = class_()
|
||||
|
||||
# Get the method and bind it to the instance
|
||||
method = getattr(_instances[class_name], name)
|
||||
# Create a fresh tool instance for each agent/session to prevent state leakage
|
||||
# This factory pattern ensures multi-agent safety by avoiding shared mutable state
|
||||
instance = _create_tool_instance(class_name, module_path)
|
||||
method = getattr(instance, name)
|
||||
return method
|
||||
|
||||
__all__ = list(TOOL_MAPPINGS.keys()) + [
|
||||
|
||||
Loaded 100 of 214 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user