mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-11 03:31:27 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1260ede088 | ||
|
|
2a463d6b39 | ||
|
|
10068583bb | ||
|
|
beb3e0a644 | ||
|
|
6a90cc07ee | ||
|
|
ffb9bd157e | ||
|
|
dd3ccb8b5f | ||
|
|
da8786520d | ||
|
|
ed02fc660f | ||
|
|
3c45e6f474 | ||
|
|
9b85a22b4c | ||
|
|
9a79f2a15d | ||
|
|
11e449bf43 | ||
|
|
d9581f39f8 | ||
|
|
88806ce469 | ||
|
|
468dffa99d | ||
|
|
c1947b9e4b | ||
|
|
21a3774b63 | ||
|
|
2c9b4a5c34 | ||
|
|
bc0a2a3765 | ||
|
|
b6c10e4040 | ||
|
|
f5220973f8 | ||
|
|
a924a55245 | ||
|
|
dcd072dc0c | ||
|
|
8173350f79 | ||
|
|
5b9a046d4b | ||
|
|
27aafddaa4 | ||
|
|
4a0b34ea20 | ||
|
|
19f5a16766 | ||
|
|
20cb053181 | ||
|
|
41ce1de079 | ||
|
|
36920ff8fe | ||
|
|
e428393e0a | ||
|
|
95fd857212 | ||
|
|
c260519bf1 | ||
|
|
2921103ddd | ||
|
|
73800f3509 | ||
|
|
7371ed7048 | ||
|
|
129174257f | ||
|
|
efcdc42fbb | ||
|
|
df79c5acb9 | ||
|
|
d9da7cc59c | ||
|
|
11d9f290c7 | ||
|
|
f796d12908 | ||
|
|
9c4adcee89 | ||
|
|
1d0ffed030 | ||
|
|
3be6df4de8 | ||
|
|
b65de3bf06 | ||
|
|
f949814af8 | ||
|
|
7449ccac76 | ||
|
|
ae26edd3f8 | ||
|
|
6349c88ad9 | ||
|
|
1d4721d039 | ||
|
|
90915fe790 | ||
|
|
29facc8f89 | ||
|
|
401521848f | ||
|
|
ab97eae954 | ||
|
|
5dda0643ca | ||
|
|
1b8d7a02d9 | ||
|
|
dbcd36edce | ||
|
|
9ef5391c83 | ||
|
|
7385adbd60 | ||
|
|
78d7883fb7 | ||
|
|
6122531a6a | ||
|
|
d704cf1349 | ||
|
|
8523ef076e | ||
|
|
72889d2f73 | ||
|
|
f17bd7369c | ||
|
|
ed31e9b280 | ||
|
|
69e9e712c7 | ||
|
|
7617b10c70 | ||
|
|
7939a4f12e | ||
|
|
8cbad0c997 | ||
|
|
48ed131577 |
No files matched your search
@@ -22,6 +22,12 @@ inputs:
|
||||
To enable observability with Google Cloud, include:
|
||||
{"telemetry": {"enabled": true, "target": "gcp"}, "sandbox": false}
|
||||
required: false
|
||||
model:
|
||||
description: |
|
||||
The Gemini model to use. Options: gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-lite.
|
||||
Use flash-lite for highest free-tier quota (1,000 RPD).
|
||||
required: false
|
||||
default: 'gemini-3.1-flash-lite-preview'
|
||||
version:
|
||||
description: |
|
||||
The version of @google/gemini-cli to execute.
|
||||
@@ -70,10 +76,11 @@ runs:
|
||||
|
||||
- name: Run Gemini CLI
|
||||
run: |
|
||||
gemini --yolo --prompt "$PROMPT"
|
||||
gemini --yolo --model "$GEMINI_MODEL" --prompt "$PROMPT"
|
||||
shell: bash
|
||||
env:
|
||||
GEMINI_API_KEY: ${{ inputs.GEMINI_API_KEY }}
|
||||
GEMINI_MODEL: ${{ inputs.model }}
|
||||
PROMPT: ${{ inputs.prompt }}
|
||||
|
||||
branding:
|
||||
|
||||
@@ -1,26 +1,45 @@
|
||||
name: Auto Issue Comment with PR Creation
|
||||
name: Auto Issue Comment
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
add-comment:
|
||||
# Trigger Claude by adding the 'claude' label (only if not already labeled)
|
||||
# Note: @claude in comments only works from human users (not bots)
|
||||
# The claude.yml workflow listens for issues:labeled event with 'claude' label
|
||||
trigger-claude:
|
||||
# Skip if issue was already created with 'claude' label to avoid double-trigger
|
||||
if: |
|
||||
!contains(toJSON(github.event.issue.labels), 'claude')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
actions: read
|
||||
steps:
|
||||
- name: Add automated comment
|
||||
- name: Add claude label to trigger analysis
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
// Check if 'claude' label already exists on this issue
|
||||
const labels = context.payload.issue.labels.map(l => l.name);
|
||||
if (labels.includes('claude')) {
|
||||
console.log('Issue already has claude label, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add label to trigger claude.yml via issues:labeled event
|
||||
await github.rest.issues.addLabels({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude review this issue and do a detailed analysis and fix this if the existing code doesn\'t have the solution implemented. Making sure it has backward compatibility, no existing features removed. After making those changes, again review the applied changes. Use @web to search if you dont know any information or to find the latest documentation or to find the latest version. Run the code if you think you need to run it to test it. Minimal code change to start with if required any changes. MAINLY IT SHOULD NOT IMPACT ON THE CURRENT SPEED OF EXECUTION of existing features, not increasing the overhead. **Please create a PR using gh tool with your changes**.'
|
||||
})
|
||||
labels: ['claude']
|
||||
});
|
||||
|
||||
// Also post instructions as a comment for context
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '🤖 Auto-triaging with Claude. Analyzing issue and implementing a fix...\n\nClaude will:\n1. Classify this issue\n2. Search codebase for root cause\n3. Implement a minimal fix following AGENTS.md\n4. Create a PR with the changes'
|
||||
});
|
||||
@@ -1,23 +1,138 @@
|
||||
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
|
||||
# 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).
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
pull_request:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
add-comment:
|
||||
# ================================================================
|
||||
# HUMAN PRs: Trigger @copilot AFTER CodeRabbit posts its summary
|
||||
# ================================================================
|
||||
copilot-after-coderabbit:
|
||||
if: |
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
github.event.comment.user.login == 'coderabbitai[bot]' &&
|
||||
contains(github.event.comment.body, 'summary by coderabbit')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Add automated comment
|
||||
- name: Post Copilot review request (as user via PAT)
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
github-token: ${{ secrets.PAT_TOKEN }}
|
||||
script: |
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
// Check for duplicates — posted by MervinPraison (PAT) or github-actions[bot] (legacy)
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@copilot') &&
|
||||
(c.user.login === 'MervinPraison' || c.user.login === 'github-actions[bot]')
|
||||
);
|
||||
if (alreadyPosted) {
|
||||
console.log('Copilot already triggered, skipping');
|
||||
return;
|
||||
}
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude review this pull request and do a detailed analysis and fix this if the existing code doesn\'t have the solution implemented. Making sure it has backward compatibility, no existing features removed. After making those changes, again review the applied changes. Use @web to search if you dont know any information or to find the latest documentation or to find the latest version. Run the code if you think you need to run it to test it. Minimal code change to start with if required any changes. MAINLY IT SHOULD NOT IMPACT ON THE CURRENT SPEED OF EXECUTION of existing features, not increasing the overhead.'
|
||||
})
|
||||
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'
|
||||
})
|
||||
|
||||
# Fallback: Trigger @copilot after Qodo review (if Coderabbit doesn't fire)
|
||||
copilot-after-qodo:
|
||||
if: |
|
||||
github.event_name == 'pull_request_review' &&
|
||||
github.event.review.user.login == 'qodo-code-review[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Post Copilot review request (as user via PAT)
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.PAT_TOKEN }}
|
||||
script: |
|
||||
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('@copilot') &&
|
||||
(c.user.login === 'MervinPraison' || c.user.login === 'github-actions[bot]')
|
||||
);
|
||||
if (alreadyPosted) {
|
||||
console.log('Copilot already triggered, skipping');
|
||||
return;
|
||||
}
|
||||
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'
|
||||
})
|
||||
|
||||
# ================================================================
|
||||
# BOT PRs: CodeRabbit/Qodo skip bot-authored PRs by default.
|
||||
# Trigger them explicitly. Do NOT trigger Copilot here — it will
|
||||
# be triggered by copilot-after-coderabbit/qodo when they finish.
|
||||
# ================================================================
|
||||
bot-pr-trigger-reviews:
|
||||
if: |
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.action == 'opened' &&
|
||||
github.event.pull_request.user.login == 'github-actions[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Trigger CodeRabbit, Qodo, and Gemini reviews
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.PAT_TOKEN }}
|
||||
script: |
|
||||
const pr = context.payload.pull_request.number;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
// 1. Trigger CodeRabbit review
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr, owner, repo,
|
||||
body: '@coderabbitai review'
|
||||
});
|
||||
console.log(`Triggered CodeRabbit for bot PR #${pr}`);
|
||||
|
||||
// 2. Trigger Qodo review
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr, owner, repo,
|
||||
body: '/review'
|
||||
});
|
||||
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}`);
|
||||
|
||||
// NOTE: @copilot is NOT triggered here.
|
||||
// It will be triggered by copilot-after-coderabbit or copilot-after-qodo
|
||||
// once those bots finish, ensuring Copilot always reviews LAST.
|
||||
console.log('Copilot will be triggered after CodeRabbit/Qodo complete.');
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Chain Claude After Reviewers
|
||||
|
||||
# Event-driven: triggers when Copilot finishes reviewing a PR
|
||||
on:
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
# Future: re-enable issue_comment trigger for Gemini→Claude chain
|
||||
# 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' &&
|
||||
github.event.review.user.login == 'copilot-pull-request-reviewer[bot]'
|
||||
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.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 Qodo, Coderabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: heavy code in wrapper, not core SDK?\n2. Backward compatible: no public API removed without deprecation?\n3. Performance: no hot-path regressions, lazy imports for optional deps?\n4. DRY: no duplication, reuses existing abstractions?\n5. Async-safe and multi-agent safe?\n\n**Phase 2: Fix valid issues from other reviewers**\n6. Read Qodo, Coderabbit, and Copilot reviews above\n7. For each VALID suggestion they raised: implement the fix\n8. Push all fixes to THIS branch (do NOT create a new PR)\n9. Comment a summary of what you fixed and what you intentionally skipped (with reasoning)\n\n**Phase 3: Final verdict**\n10. If all issues are resolved and tests pass, approve the PR\n11. If blocking issues remain, request changes with clear action items'
|
||||
})
|
||||
|
||||
# Future: Trigger Claude after Gemini finishes on an Issue (commented out)
|
||||
# claude-after-gemini-on-issue:
|
||||
# if: |
|
||||
# github.event_name == 'issue_comment' &&
|
||||
# !github.event.issue.pull_request &&
|
||||
# github.event.comment.user.login == 'gemini-code-assist[bot]'
|
||||
# runs-on: ubuntu-latest
|
||||
# permissions:
|
||||
# issues: write
|
||||
# steps:
|
||||
# - name: Post Claude analysis request (final reviewer)
|
||||
# uses: actions/github-script@v7
|
||||
# with:
|
||||
# github-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 Gemini\'s analysis above first.\n\nAnalyze this issue and implement a fix following AGENTS.md:\n1. Ensure backward compatibility — no features removed\n2. Zero performance regression on hot paths\n3. Minimal, focused changes only\n4. Run tests to verify the fix\n5. Create a PR with your changes using gh'
|
||||
# })
|
||||
+18
-108
@@ -6,18 +6,22 @@ on:
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned, labeled]
|
||||
types: [assigned, labeled]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude-response:
|
||||
# Prevent certain bots from triggering this action (but allow github-actions[bot] for auto-comments)
|
||||
# Allow: human users, github-actions[bot] (auto-comments/labels)
|
||||
# Block: dependabot, cursor, renovate, other bots
|
||||
if: |
|
||||
(
|
||||
!contains(github.actor, '[bot]') ||
|
||||
github.actor == 'github-actions[bot]'
|
||||
) &&
|
||||
github.actor != 'dependabot[bot]' &&
|
||||
github.actor != 'cursor[bot]' &&
|
||||
github.actor != 'renovate[bot]' &&
|
||||
!(contains(github.actor, '[bot]') && github.actor != 'github-actions[bot]')
|
||||
github.actor != 'renovate[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -32,9 +36,18 @@ jobs:
|
||||
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_ACCESS_TOKEN }}
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
trigger_phrase: "@claude"
|
||||
label_trigger: "claude"
|
||||
direct_prompt: |
|
||||
Analyze this issue and implement a fix following AGENTS.md:
|
||||
1. Read AGENTS.md for repo guidelines
|
||||
2. Search the codebase and identify the root cause
|
||||
3. Create a fix branch and implement a minimal, focused fix
|
||||
4. Run tests to verify
|
||||
5. Commit, push, and create a PR using `gh pr create`
|
||||
CRITICAL: You MUST create the PR automatically using `gh pr create`. Do NOT just provide a link.
|
||||
allowed_tools: |
|
||||
Bash(git:*)
|
||||
Bash(python:*)
|
||||
@@ -55,106 +68,3 @@ jobs:
|
||||
mcp__github__get_issue_comments
|
||||
mcp__github__update_issue
|
||||
timeout_minutes: 30
|
||||
|
||||
post-review-comment:
|
||||
needs: claude-response
|
||||
if: success() && needs.claude-response.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
steps:
|
||||
- name: Check for existing review comment
|
||||
id: check-comment
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const reviewText = '@claude review if the changes made in the above branch are as per the requirements and make changes to the above branch if issues still exist.';
|
||||
let comments = [];
|
||||
let issueNumber;
|
||||
|
||||
try {
|
||||
// Determine issue/PR number based on event type
|
||||
if (context.eventName === 'pull_request_review_comment' || context.eventName === 'pull_request_review') {
|
||||
issueNumber = context.payload.pull_request?.number || context.issue.number;
|
||||
} else if (context.eventName === 'issue_comment' && context.payload.issue.pull_request) {
|
||||
// This is a comment on a PR (not an issue)
|
||||
issueNumber = context.payload.issue.number;
|
||||
} else {
|
||||
// This is a regular issue
|
||||
issueNumber = context.issue.number;
|
||||
}
|
||||
|
||||
comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber
|
||||
});
|
||||
|
||||
// Look for Claude's completed review comments
|
||||
const claudeReviewComments = comments.data.filter(comment =>
|
||||
(comment.user.login === 'claude[bot]' || comment.body.includes('✅') || comment.body.includes('## Summary')) &&
|
||||
!comment.body.includes('⏳') &&
|
||||
!comment.body.includes('🔄') &&
|
||||
!comment.body.includes('Loading...')
|
||||
);
|
||||
|
||||
// Check if our review request comment already exists
|
||||
const existingReviewRequest = comments.data.find(comment =>
|
||||
comment.body.includes(reviewText)
|
||||
);
|
||||
|
||||
// Post if Claude has completed a review AND we haven't posted the review request yet
|
||||
const shouldPost = claudeReviewComments.length > 0 && !existingReviewRequest;
|
||||
core.setOutput('should-post', shouldPost);
|
||||
console.log(`Claude review completed: ${claudeReviewComments.length > 0}`);
|
||||
console.log(`Review request exists: ${!!existingReviewRequest}`);
|
||||
console.log(`Should post comment: ${shouldPost}`);
|
||||
|
||||
} catch (error) {
|
||||
console.log('Error checking comments:', error);
|
||||
core.setOutput('should-post', false); // If we can't check, don't post
|
||||
}
|
||||
|
||||
- name: Wait 30 seconds before posting comment
|
||||
if: steps.check-comment.outputs.should-post == 'true'
|
||||
run: sleep 30
|
||||
|
||||
- name: Post review comment
|
||||
if: steps.check-comment.outputs.should-post == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
let issueNumber;
|
||||
let isPR = false;
|
||||
|
||||
// Determine issue/PR number and type based on event type
|
||||
if (context.eventName === 'pull_request_review_comment' || context.eventName === 'pull_request_review') {
|
||||
issueNumber = context.payload.pull_request?.number || context.issue.number;
|
||||
isPR = true;
|
||||
} else if (context.eventName === 'issue_comment' && context.payload.issue.pull_request) {
|
||||
// This is a comment on a PR (not an issue)
|
||||
issueNumber = context.payload.issue.number;
|
||||
isPR = true;
|
||||
} else {
|
||||
// This is a regular issue - don't post review comment
|
||||
issueNumber = context.issue.number;
|
||||
isPR = false;
|
||||
}
|
||||
|
||||
// Only post review comment on PRs, not regular issues
|
||||
if (isPR) {
|
||||
console.log(`Posting review comment on PR #${issueNumber}`);
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: issueNumber,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude review if the changes made in the above branch are as per the requirements and make changes to the above branch if issues still exist. MAINLY IT SHOULD NOT IMPACT ON THE CURRENT SPEED OF EXECUTION of existing features, not increasing the overhead.'
|
||||
});
|
||||
} else {
|
||||
console.log(`Skipping review comment for regular issue #${issueNumber}`);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
name: Run tests and upload coverage
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
pull_request:
|
||||
branches: [ main, develop ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run tests and collect coverage
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.11
|
||||
|
||||
- name: Install UV
|
||||
run: |
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd src/praisonai
|
||||
uv pip install --system ."[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
|
||||
uv pip install --system duckduckgo_search
|
||||
uv pip install --system pytest pytest-cov pytest-asyncio
|
||||
# Install knowledge dependencies from praisonai-agents
|
||||
uv pip install --system "praisonaiagents[knowledge]"
|
||||
|
||||
- name: Set environment variables
|
||||
run: |
|
||||
echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY || 'sk-test-key-for-github-actions-testing-only-not-real' }}" >> $GITHUB_ENV
|
||||
echo "OPENAI_API_BASE=${{ secrets.OPENAI_API_BASE || 'https://api.openai.com/v1' }}" >> $GITHUB_ENV
|
||||
echo "OPENAI_MODEL_NAME=${{ secrets.OPENAI_MODEL_NAME || 'gpt-5-nano' }}" >> $GITHUB_ENV
|
||||
echo "LOGLEVEL=DEBUG" >> $GITHUB_ENV
|
||||
echo "PYTHONPATH=${{ github.workspace }}/src/praisonai-agents:$PYTHONPATH" >> $GITHUB_ENV
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
cd src/praisonai
|
||||
# Run tests - allow some failures for now while we fix remaining issues
|
||||
pytest --cov=praisonai --cov-branch --cov-report=xml tests/unit/ --tb=no -q || echo "Some tests failed, but coverage was collected"
|
||||
|
||||
- name: Upload results to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
file: src/praisonai/coverage.xml
|
||||
flags: unit-tests
|
||||
name: unit-tests-coverage
|
||||
fail_ci_if_error: false
|
||||
verbose: true
|
||||
@@ -20,7 +20,8 @@ jobs:
|
||||
!contains(github.event.comment.body, '@gemini triage') &&
|
||||
(github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR'))
|
||||
github.event.comment.author_association == 'COLLABORATOR' ||
|
||||
github.event.comment.author_association == 'CONTRIBUTOR'))
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
@@ -39,8 +40,8 @@ jobs:
|
||||
|
||||
- name: Set up Git
|
||||
run: |
|
||||
git config --local user.email "gemini-bot@github.com"
|
||||
git config --local user.name "Gemini Bot"
|
||||
git config --local user.email "praison.ms@gmail.com"
|
||||
git config --local user.name "MervinPraison"
|
||||
|
||||
- name: Get issue data
|
||||
id: issue_data
|
||||
@@ -75,16 +76,16 @@ jobs:
|
||||
echo "ADDITIONAL_INSTRUCTIONS=$ADDITIONAL_INSTRUCTIONS" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Run Gemini CLI
|
||||
uses: ./.github/actions/gemini
|
||||
uses: google-github-actions/run-gemini-cli@v0
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||
ISSUE_NUMBER: ${{ steps.issue_data.outputs.ISSUE_NUMBER }}
|
||||
ADDITIONAL_INSTRUCTIONS: ${{ steps.issue_data.outputs.ADDITIONAL_INSTRUCTIONS }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
with:
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
OTLP_GCP_WIF_PROVIDER: ${{ secrets.OTLP_GCP_WIF_PROVIDER }}
|
||||
OTLP_GOOGLE_CLOUD_PROJECT: ${{ secrets.OTLP_GOOGLE_CLOUD_PROJECT }}
|
||||
gemini_api_key: ${{ secrets.GEMINI_API_KEY }}
|
||||
gemini_model: 'gemini-2.5-flash'
|
||||
prompt: |
|
||||
You are a helpful GitHub issue assistant. Your task is to analyze and help with GitHub issues.
|
||||
|
||||
@@ -161,7 +162,7 @@ jobs:
|
||||
|
||||
Please proceed with helping resolve this issue following this exact workflow.
|
||||
|
||||
settings_json: |
|
||||
settings: |
|
||||
{
|
||||
"coreTools": [
|
||||
"run_shell_command(echo)",
|
||||
@@ -193,10 +194,5 @@ jobs:
|
||||
"web_fetch",
|
||||
"google_web_search",
|
||||
"save_memory"
|
||||
],
|
||||
"telemetry": {
|
||||
"enabled": false,
|
||||
"otlpGcpWifProvider": "${{ secrets.OTLP_GCP_WIF_PROVIDER }}",
|
||||
"googleCloudProject": "${{ secrets.OTLP_GOOGLE_CLOUD_PROJECT }}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,25 +1,21 @@
|
||||
# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
|
||||
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
|
||||
|
||||
name: Python package
|
||||
name: Python Package (Multi-Version)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "develop" ]
|
||||
pull_request:
|
||||
branches: [ "develop" ]
|
||||
schedule:
|
||||
- cron: '0 4 * * 1' # Weekly Monday 4am UTC
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11"]
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
@@ -30,39 +26,16 @@ jobs:
|
||||
echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY || 'sk-test-key-for-github-actions-testing-only-not-real' }}" >> $GITHUB_ENV
|
||||
echo "OPENAI_API_BASE=${{ secrets.OPENAI_API_BASE || 'https://api.openai.com/v1' }}" >> $GITHUB_ENV
|
||||
echo "OPENAI_MODEL_NAME=${{ secrets.OPENAI_MODEL_NAME || 'gpt-5-nano' }}" >> $GITHUB_ENV
|
||||
echo "LOGLEVEL=DEBUG" >> $GITHUB_ENV
|
||||
echo "PYTHONPATH=${{ github.workspace }}/src/praisonai-agents:$PYTHONPATH" >> $GITHUB_ENV
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd src/praisonai
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install flake8 pytest
|
||||
python -m pip install ."[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
|
||||
python -m pip install duckduckgo_search
|
||||
python -m pip install pytest-asyncio pytest-cov
|
||||
# Install knowledge dependencies from praisonai-agents
|
||||
python -m pip install duckduckgo_search pytest pytest-asyncio pytest-cov
|
||||
python -m pip install "praisonaiagents[knowledge]"
|
||||
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
|
||||
|
||||
- name: Debug API Key Status
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
echo "🔍 Checking API key availability..."
|
||||
if [ -n "${{ secrets.OPENAI_API_KEY }}" ]; then
|
||||
echo "✅ GitHub secret OPENAI_API_KEY is available"
|
||||
echo "🔑 API key starts with: $(echo "$OPENAI_API_KEY" | cut -c1-7)..."
|
||||
else
|
||||
echo "⚠️ GitHub secret OPENAI_API_KEY is NOT set - using fallback"
|
||||
echo "🔑 Using fallback key: sk-test-key..."
|
||||
fi
|
||||
echo "🌐 API Base: $OPENAI_API_BASE"
|
||||
echo "🤖 Model: $OPENAI_MODEL_NAME"
|
||||
|
||||
# - name: Lint with flake8
|
||||
# run: |
|
||||
# # stop the build if there are Python syntax errors or undefined names
|
||||
# flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||
# # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
|
||||
# flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
||||
- name: Test with pytest
|
||||
run: |
|
||||
cd src/praisonai && python tests/test_runner.py --pattern fast
|
||||
cd src/praisonai && python -m pytest tests/unit/ -v --tb=short --disable-warnings --timeout=60 || echo "Some tests failed"
|
||||
@@ -51,8 +51,8 @@ jobs:
|
||||
# Sync with upstream commit
|
||||
- name: Sync with upstream
|
||||
run: |
|
||||
git config --global user.name "GitHub Actions"
|
||||
git config --global user.email "actions@github.com"
|
||||
git config --global user.name "MervinPraison"
|
||||
git config --global user.email "praison.ms@gmail.com"
|
||||
|
||||
# Add upstream remote
|
||||
git remote add upstream https://github.com/MervinPraison/PraisonAI.git
|
||||
@@ -473,8 +473,8 @@ jobs:
|
||||
# Create tag at the same commit
|
||||
- name: Create tag at commit
|
||||
run: |
|
||||
git config --global user.name "GitHub Actions"
|
||||
git config --global user.email "actions@github.com"
|
||||
git config --global user.name "MervinPraison"
|
||||
git config --global user.email "praison.ms@gmail.com"
|
||||
|
||||
# Remove tag if it exists locally
|
||||
git tag -d ${{ github.event.inputs.tag }} || true
|
||||
|
||||
+27
-243
@@ -1,41 +1,44 @@
|
||||
name: Core Tests
|
||||
|
||||
on:
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/test-core.yml'
|
||||
pull_request:
|
||||
branches: [ main, develop ]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/test-core.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test-core:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.11]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
timeout-minutes: 10
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install UV
|
||||
run: |
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd src/praisonai
|
||||
uv pip install --system ."[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
|
||||
uv pip install --system duckduckgo_search
|
||||
uv pip install --system pytest pytest-asyncio pytest-cov pytest-timeout
|
||||
# Install knowledge dependencies from praisonai-agents
|
||||
uv pip install --system "praisonaiagents[knowledge]"
|
||||
|
||||
- name: Set environment variables
|
||||
@@ -43,246 +46,27 @@ jobs:
|
||||
echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY || 'sk-test-key-for-github-actions-testing-only-not-real' }}" >> $GITHUB_ENV
|
||||
echo "OPENAI_API_BASE=${{ secrets.OPENAI_API_BASE || 'https://api.openai.com/v1' }}" >> $GITHUB_ENV
|
||||
echo "OPENAI_MODEL_NAME=${{ secrets.OPENAI_MODEL_NAME || 'gpt-5-nano' }}" >> $GITHUB_ENV
|
||||
echo "LOGLEVEL=DEBUG" >> $GITHUB_ENV
|
||||
echo "PYTHONPATH=${{ github.workspace }}/src/praisonai-agents:$PYTHONPATH" >> $GITHUB_ENV
|
||||
# Also export to current shell session for immediate availability
|
||||
export OPENAI_API_KEY="${{ secrets.OPENAI_API_KEY || 'sk-test-key-for-github-actions-testing-only-not-real' }}"
|
||||
export OPENAI_API_BASE="${{ secrets.OPENAI_API_BASE || 'https://api.openai.com/v1' }}"
|
||||
export OPENAI_MODEL_NAME="${{ secrets.OPENAI_MODEL_NAME || 'gpt-5-nano' }}"
|
||||
export LOGLEVEL=DEBUG
|
||||
# Verify immediate availability
|
||||
echo "🔧 Immediate verification in same step:"
|
||||
echo " OPENAI_API_KEY length in current session: ${#OPENAI_API_KEY}"
|
||||
echo " OPENAI_API_KEY starts with sk-: $(echo "$OPENAI_API_KEY" | grep -q '^sk-' && echo 'YES' || echo 'NO')"
|
||||
|
||||
- name: Debug API Key Status
|
||||
run: |
|
||||
echo "🔍 Checking API key availability..."
|
||||
if [ -n "${{ secrets.OPENAI_API_KEY }}" ]; then
|
||||
echo "✅ GitHub secret OPENAI_API_KEY is available"
|
||||
echo "🔑 API key starts with: $(echo "$OPENAI_API_KEY" | cut -c1-7)..."
|
||||
else
|
||||
echo "⚠️ GitHub secret OPENAI_API_KEY is NOT set - using fallback"
|
||||
echo "🔑 Using fallback key: sk-test-key..."
|
||||
fi
|
||||
echo "🌐 API Base: $OPENAI_API_BASE"
|
||||
echo "🤖 Model: $OPENAI_MODEL_NAME"
|
||||
echo "🐛 Log Level: $LOGLEVEL"
|
||||
echo "📊 Environment Check:"
|
||||
echo " - OPENAI_API_KEY length: ${#OPENAI_API_KEY}"
|
||||
echo " - OPENAI_API_BASE: $OPENAI_API_BASE"
|
||||
echo " - OPENAI_MODEL_NAME: $OPENAI_MODEL_NAME"
|
||||
echo " - LOGLEVEL: $LOGLEVEL"
|
||||
echo "🔍 Is API key actually set?"
|
||||
echo " - API key starts with sk-: $(echo "$OPENAI_API_KEY" | grep -q '^sk-' && echo 'YES' || echo 'NO')"
|
||||
echo " - API key is not test key: $([ "$OPENAI_API_KEY" != 'sk-test-key-for-github-actions-testing-only-not-real' ] && echo 'YES' || echo 'NO')"
|
||||
|
||||
- name: Debug Environment Variables Raw
|
||||
run: |
|
||||
echo "🔧 Raw environment variable check:"
|
||||
echo "OPENAI_API_KEY set: $(if [ -n "$OPENAI_API_KEY" ]; then echo 'YES'; else echo 'NO'; fi)"
|
||||
echo "OPENAI_API_KEY length: ${#OPENAI_API_KEY}"
|
||||
echo "OPENAI_API_KEY first 10 chars: ${OPENAI_API_KEY:0:10}"
|
||||
echo "OPENAI_API_KEY last 5 chars: ${OPENAI_API_KEY: -5}"
|
||||
printenv | grep OPENAI || echo "No OPENAI env vars found"
|
||||
|
||||
- name: Debug Python Environment Variables
|
||||
run: |
|
||||
python -c "
|
||||
import os
|
||||
print('🐍 Python Environment Variable Check:')
|
||||
api_key = os.environ.get('OPENAI_API_KEY', 'NOT_SET')
|
||||
if api_key != 'NOT_SET':
|
||||
print(f' ✅ OPENAI_API_KEY: {api_key[:7]}... (length: {len(api_key)})')
|
||||
else:
|
||||
print(' ❌ OPENAI_API_KEY: NOT_SET')
|
||||
print(f' 🌐 OPENAI_API_BASE: {os.environ.get(\"OPENAI_API_BASE\", \"NOT_SET\")}')
|
||||
print(f' 🤖 OPENAI_MODEL_NAME: {os.environ.get(\"OPENAI_MODEL_NAME\", \"NOT_SET\")}')
|
||||
print(f' 📋 All OPENAI env vars:')
|
||||
for key, value in os.environ.items():
|
||||
if key.startswith('OPENAI'):
|
||||
print(f' {key}: {value[:10] if len(value) > 10 else value}...')
|
||||
"
|
||||
|
||||
- name: Validate API Key
|
||||
run: |
|
||||
echo "🔑 Testing API key validity with minimal OpenAI call..."
|
||||
python -c "
|
||||
import os
|
||||
try:
|
||||
from openai import OpenAI
|
||||
client = OpenAI(api_key=os.environ.get('OPENAI_API_KEY'))
|
||||
response = client.models.list()
|
||||
print('✅ API Key is VALID - OpenAI responded successfully')
|
||||
print(f'📊 Available models: {len(list(response.data))} models found')
|
||||
except Exception as e:
|
||||
print(f'❌ API Key is INVALID - Error: {e}')
|
||||
print('🔍 This explains why all API-dependent tests are failing')
|
||||
print('💡 The GitHub secret OPENAI_API_KEY needs to be updated with a valid key')
|
||||
"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Debug PraisonAI API Key Usage
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY || 'sk-test-key-for-github-actions-testing-only-not-real' }}
|
||||
OPENAI_API_BASE: ${{ secrets.OPENAI_API_BASE || 'https://api.openai.com/v1' }}
|
||||
OPENAI_MODEL_NAME: ${{ secrets.OPENAI_MODEL_NAME || 'gpt-5-nano' }}
|
||||
LOGLEVEL: DEBUG
|
||||
run: |
|
||||
echo "🔍 Testing PraisonAI API key usage directly..."
|
||||
cd src/praisonai
|
||||
python -c "
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, '.')
|
||||
|
||||
# Attempt to import SecretStr, otherwise use a dummy class
|
||||
try:
|
||||
from pydantic.types import SecretStr
|
||||
except ImportError:
|
||||
class SecretStr: # Dummy class if pydantic is not available in this minimal context
|
||||
def __init__(self, value): self._value = value
|
||||
def get_secret_value(self): return self._value
|
||||
|
||||
def get_key_display_value(key_value):
|
||||
if isinstance(key_value, SecretStr):
|
||||
return key_value.get_secret_value()[:10] if key_value.get_secret_value() else 'EMPTY_SECRET'
|
||||
elif isinstance(key_value, str):
|
||||
return key_value[:10] if key_value != 'nokey' and key_value != 'NOT_SET' else key_value
|
||||
return 'INVALID_TYPE'
|
||||
|
||||
print('🔧 Direct PraisonAI API Key Check:')
|
||||
env_api_key = os.environ.get(\"OPENAI_API_KEY\", \"NOT_SET\")
|
||||
print(f'Environment OPENAI_API_KEY: {get_key_display_value(env_api_key)}...')
|
||||
|
||||
from praisonai import PraisonAI
|
||||
praisonai = PraisonAI()
|
||||
|
||||
print(f'PraisonAI config_list: {praisonai.config_list}')
|
||||
api_key_from_config = praisonai.config_list[0].get('api_key', 'NOT_SET')
|
||||
print(f'API key from PraisonAI config: {get_key_display_value(api_key_from_config)}...')
|
||||
|
||||
from praisonai.inc.models import PraisonAIModel
|
||||
|
||||
print('\\n🧪 Testing PraisonAIModel with explicit API key (CrewAI method):')
|
||||
model_with_explicit_key = PraisonAIModel(
|
||||
model='openai/gpt-5-nano',
|
||||
base_url=praisonai.config_list[0].get('base_url'),
|
||||
api_key=api_key_from_config # This will be a string from praisonai.config_list
|
||||
)
|
||||
print(f' Model: {model_with_explicit_key.model}')
|
||||
print(f' Model name: {model_with_explicit_key.model_name}')
|
||||
print(f' API key var: {model_with_explicit_key.api_key_var}')
|
||||
# model_with_explicit_key.api_key is now a string, or 'nokey'
|
||||
print(f' API key (explicitly passed to PraisonAIModel): {get_key_display_value(model_with_explicit_key.api_key)}...')
|
||||
print(f' Base URL: {model_with_explicit_key.base_url}')
|
||||
|
||||
try:
|
||||
llm_instance = model_with_explicit_key.get_model()
|
||||
print(f' ✅ LLM instance created successfully: {type(llm_instance).__name__}')
|
||||
|
||||
# langchain_openai.ChatOpenAI stores the key in openai_api_key as SecretStr
|
||||
llm_api_key_attr = getattr(llm_instance, 'openai_api_key', 'NOT_FOUND')
|
||||
if llm_api_key_attr != 'NOT_FOUND':
|
||||
print(f' LLM instance API key: {get_key_display_value(llm_api_key_attr)}...')
|
||||
else:
|
||||
print(f' LLM instance API key attribute not found.')
|
||||
except Exception as e:
|
||||
print(f' ❌ Failed to create LLM instance: {e}')
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run Unit Tests
|
||||
run: |
|
||||
cd src/praisonai && python -m pytest tests/unit/ -v --tb=short --disable-warnings --cov=praisonai --cov-report=term-missing --cov-report=xml --cov-branch || echo "Some tests failed - see output above"
|
||||
cd src/praisonai && python -m pytest tests/unit/ -v --tb=short --disable-warnings \
|
||||
--cov=praisonai --cov-report=xml --cov-branch \
|
||||
--timeout=60 || echo "Some unit tests failed"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run Integration Tests
|
||||
- name: Run Integration Tests
|
||||
run: |
|
||||
cd src/praisonai && python -m pytest tests/integration/ -v --tb=short --disable-warnings
|
||||
cd src/praisonai && python -m pytest tests/integration/ -v --tb=short --disable-warnings \
|
||||
--timeout=60 \
|
||||
--ignore=tests/integration/test_tracker_complex_tasks.py \
|
||||
--ignore=tests/integration/test_serve_integration.py \
|
||||
|| echo "Some integration tests failed"
|
||||
|
||||
- name: Debug Directory Structure
|
||||
run: |
|
||||
echo "🔍 Debugging directory structure for CrewAI tests..."
|
||||
cd src/praisonai
|
||||
echo "Current working directory: $(pwd)"
|
||||
echo "📁 Contents of current directory:"
|
||||
ls -la
|
||||
echo ""
|
||||
echo "📁 Contents of tests directory:"
|
||||
ls -la tests/ || echo "❌ tests/ directory not found"
|
||||
echo ""
|
||||
echo "📁 Contents of tests/integration:"
|
||||
ls -la tests/integration/ || echo "❌ tests/integration/ directory not found"
|
||||
echo ""
|
||||
echo "📁 Looking for crewai directory:"
|
||||
find . -name "crewai" -type d 2>/dev/null || echo "❌ No crewai directories found"
|
||||
echo ""
|
||||
echo "📁 Full directory tree of tests:"
|
||||
tree tests/ || find tests/ -type d 2>/dev/null || echo "❌ Cannot explore tests directory"
|
||||
|
||||
- name: Test AutoGen Framework
|
||||
run: |
|
||||
echo "🤖 Testing AutoGen Framework Integration..."
|
||||
cd src/praisonai && python tests/test_runner.py --pattern autogen --verbose
|
||||
|
||||
- name: Test CrewAI Framework
|
||||
run: |
|
||||
echo "⛵ Testing CrewAI Framework Integration..."
|
||||
cd src/praisonai
|
||||
echo "🔍 Trying test runner first..."
|
||||
python tests/test_runner.py --pattern crewai --verbose || {
|
||||
echo "❌ Test runner failed, trying direct pytest..."
|
||||
echo "📁 Current directory: $(pwd)"
|
||||
echo "📁 Looking for CrewAI tests..."
|
||||
find . -name "*crewai*" -type f 2>/dev/null
|
||||
echo "🧪 Trying direct pytest on integration/crewai..."
|
||||
python -m pytest tests/integration/crewai/ -v --tb=short --disable-warnings || {
|
||||
echo "❌ Direct path failed, trying relative path..."
|
||||
python -m pytest integration/crewai/ -v --tb=short --disable-warnings || {
|
||||
echo "❌ All CrewAI test attempts failed"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- name: Run Legacy Tests with API Key
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY || 'sk-test-key-for-github-actions-testing-only-not-real' }}
|
||||
OPENAI_API_BASE: ${{ secrets.OPENAI_API_BASE || 'https://api.openai.com/v1' }}
|
||||
OPENAI_MODEL_NAME: ${{ secrets.OPENAI_MODEL_NAME || 'gpt-5-nano' }}
|
||||
LOGLEVEL: DEBUG
|
||||
run: |
|
||||
echo "🧪 Running legacy tests with real API key..."
|
||||
echo "🔧 Final environment check before pytest:"
|
||||
echo " OPENAI_API_KEY set: $([ -n "$OPENAI_API_KEY" ] && echo 'YES' || echo 'NO')"
|
||||
echo " OPENAI_API_KEY length: ${#OPENAI_API_KEY}"
|
||||
echo " OPENAI_API_KEY starts with sk-: $(echo "$OPENAI_API_KEY" | grep -q '^sk-' && echo 'YES' || echo 'NO')"
|
||||
export OPENAI_API_KEY="$OPENAI_API_KEY"
|
||||
export OPENAI_API_BASE="$OPENAI_API_BASE"
|
||||
export OPENAI_MODEL_NAME="$OPENAI_MODEL_NAME"
|
||||
cd src/praisonai && python -m pytest tests/test.py -v --tb=short --disable-warnings -m "not real" --timeout=60
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.python-version == '3.11'
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
file: src/praisonai/coverage.xml
|
||||
flags: core-tests
|
||||
name: core-tests-coverage
|
||||
fail_ci_if_error: false
|
||||
verbose: true
|
||||
|
||||
- name: Upload Coverage Reports (Artifacts)
|
||||
if: matrix.python-version == '3.11'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-reports
|
||||
path: |
|
||||
src/praisonai/.coverage
|
||||
src/praisonai/htmlcov/
|
||||
src/praisonai/coverage.xml
|
||||
retention-days: 7
|
||||
fail_ci_if_error: false
|
||||
@@ -1,14 +1,9 @@
|
||||
name: Extended Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
schedule:
|
||||
# Run nightly at 2 AM UTC
|
||||
- cron: '0 2 * * *'
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test-examples:
|
||||
|
||||
@@ -3,8 +3,16 @@ name: Optimized Test Suite
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/test-optimized.yml'
|
||||
pull_request:
|
||||
branches: [ main, develop ]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/test-optimized.yml'
|
||||
schedule:
|
||||
# Nightly extended tests at 3 AM UTC
|
||||
- cron: '0 3 * * *'
|
||||
|
||||
@@ -3,8 +3,16 @@ name: Windows Tests
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/test-windows.yml'
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/test-windows.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -14,6 +22,7 @@ jobs:
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_MODEL_NAME: gpt-4o-mini
|
||||
PYTHONUTF8: 1
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -27,13 +36,13 @@ jobs:
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
cd src/praisonai-agents
|
||||
pip install .
|
||||
pip install -e .
|
||||
shell: bash
|
||||
|
||||
- name: Install praisonai wrapper
|
||||
run: |
|
||||
cd src/praisonai
|
||||
pip install .[dev]
|
||||
pip install -e .
|
||||
shell: bash
|
||||
|
||||
# ==================== CORE IMPORTS ====================
|
||||
@@ -41,7 +50,7 @@ jobs:
|
||||
run: |
|
||||
python -c "from praisonaiagents import Agent, Agents, Workflow; print('Core imports OK')"
|
||||
python -c "from praisonaiagents import tool, Tools; print('Tool imports OK')"
|
||||
python -c "from praisonaiagents import Task, WorkflowStep; print('Task/Step imports OK')"
|
||||
python -c "from praisonaiagents import Task; print('Task imports OK')"
|
||||
|
||||
# ==================== AGENT FEATURES ====================
|
||||
- name: Test Agent instantiation
|
||||
@@ -66,21 +75,13 @@ jobs:
|
||||
"
|
||||
|
||||
# ==================== WORKFLOW FEATURES ====================
|
||||
- name: Test Workflow and WorkflowStep
|
||||
- name: Test Workflow
|
||||
run: |
|
||||
python -c "
|
||||
from praisonaiagents import Workflow, WorkflowStep
|
||||
from praisonaiagents.workflows import WorkflowOutputConfig
|
||||
from praisonaiagents import Workflow
|
||||
|
||||
w = Workflow(name='test')
|
||||
s1 = WorkflowStep(name='step1')
|
||||
s2 = WorkflowStep(name='step2', context=['step1'])
|
||||
print('Workflow:', w.name)
|
||||
print('Steps:', s1.name, s2.name)
|
||||
|
||||
cfg = WorkflowOutputConfig(verbose=True, stream=False)
|
||||
w2 = Workflow(name='configured', output=cfg)
|
||||
print('Configured workflow verbose:', w2.verbose)
|
||||
"
|
||||
|
||||
# ==================== MEMORY FEATURES ====================
|
||||
|
||||
@@ -1,516 +0,0 @@
|
||||
name: Quick Validation Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
quick-test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.11
|
||||
|
||||
- name: Install UV
|
||||
run: |
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd src/praisonai
|
||||
uv pip install --system ."[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
|
||||
uv pip install --system duckduckgo_search
|
||||
uv pip install --system pytest pytest-asyncio pytest-cov pytest-timeout
|
||||
# Install knowledge dependencies from praisonai-agents
|
||||
uv pip install --system "praisonaiagents[knowledge]"
|
||||
|
||||
- name: Set environment variables
|
||||
run: |
|
||||
echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY || 'sk-test-key-for-github-actions-testing-only-not-real' }}" >> $GITHUB_ENV
|
||||
echo "OPENAI_API_BASE=${{ secrets.OPENAI_API_BASE || 'https://api.openai.com/v1' }}" >> $GITHUB_ENV
|
||||
echo "OPENAI_MODEL_NAME=${{ secrets.OPENAI_MODEL_NAME || 'gpt-5-nano' }}" >> $GITHUB_ENV
|
||||
echo "LOGLEVEL=DEBUG" >> $GITHUB_ENV
|
||||
echo "PYTHONPATH=${{ github.workspace }}/src/praisonai-agents:$PYTHONPATH" >> $GITHUB_ENV
|
||||
|
||||
- name: Backup Root Config Files
|
||||
run: |
|
||||
echo "🔄 Backing up root configuration files to prevent default file resolution interference..."
|
||||
echo "ℹ️ PraisonAI automatically uses 'agents.yaml' in working directory when no file specified"
|
||||
echo "ℹ️ This can interfere with test files that specify their own YAML files"
|
||||
if [ -f "agents.yaml" ]; then
|
||||
mv agents.yaml agents.yaml.backup
|
||||
echo "✅ Moved root agents.yaml to agents.yaml.backup"
|
||||
echo " - This prevents default file fallback during tests"
|
||||
fi
|
||||
if [ -f "tools.py" ]; then
|
||||
mv tools.py tools.py.backup
|
||||
echo "✅ Moved tools.py to tools.py.backup"
|
||||
fi
|
||||
|
||||
- name: Debug API Key Status
|
||||
run: |
|
||||
echo "🔍 Checking API key availability..."
|
||||
if [ -n "${{ secrets.OPENAI_API_KEY }}" ]; then
|
||||
echo "✅ GitHub secret OPENAI_API_KEY is available"
|
||||
echo "🔑 API key starts with: $(echo "$OPENAI_API_KEY" | cut -c1-7)..."
|
||||
else
|
||||
echo "⚠️ GitHub secret OPENAI_API_KEY is NOT set - using fallback"
|
||||
echo "🔑 Using fallback key: sk-test-key..."
|
||||
fi
|
||||
echo "🌐 API Base: $OPENAI_API_BASE"
|
||||
echo "🤖 Model: $OPENAI_MODEL_NAME"
|
||||
echo "🐛 Log Level: $LOGLEVEL"
|
||||
echo "📊 Environment Check:"
|
||||
echo " - OPENAI_API_KEY length: ${#OPENAI_API_KEY}"
|
||||
echo " - OPENAI_API_BASE: $OPENAI_API_BASE"
|
||||
echo " - OPENAI_MODEL_NAME: $OPENAI_MODEL_NAME"
|
||||
echo " - LOGLEVEL: $LOGLEVEL"
|
||||
|
||||
- name: Debug Python Environment Variables
|
||||
run: |
|
||||
python -c "
|
||||
import os
|
||||
print('🐍 Python Environment Variable Check:')
|
||||
api_key = os.environ.get('OPENAI_API_KEY', 'NOT_SET')
|
||||
if api_key != 'NOT_SET':
|
||||
print(f' ✅ OPENAI_API_KEY: {api_key[:7]}... (length: {len(api_key)})')
|
||||
else:
|
||||
print(' ❌ OPENAI_API_KEY: NOT_SET')
|
||||
print(f' 🌐 OPENAI_API_BASE: {os.environ.get(\"OPENAI_API_BASE\", \"NOT_SET\")}')
|
||||
print(f' 🤖 OPENAI_MODEL_NAME: {os.environ.get(\"OPENAI_MODEL_NAME\", \"NOT_SET\")}')
|
||||
print(f' 📋 All OPENAI env vars:')
|
||||
for key, value in os.environ.items():
|
||||
if key.startswith('OPENAI'):
|
||||
print(f' {key}: {value[:10] if len(value) > 10 else value}...')
|
||||
"
|
||||
|
||||
- name: Find Researcher Role Source
|
||||
run: |
|
||||
echo "🔍 Hunting for the mysterious 'Researcher' role..."
|
||||
cd src/praisonai
|
||||
python -c "
|
||||
import os
|
||||
import yaml
|
||||
import glob
|
||||
|
||||
print('📋 Searching for Researcher role in all YAML files:')
|
||||
yaml_files = glob.glob('src/praisonai/tests/*.yaml')
|
||||
|
||||
for yaml_file in yaml_files:
|
||||
try:
|
||||
with open(yaml_file, 'r') as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
# Check if any role contains 'researcher'
|
||||
roles = config.get('roles', {})
|
||||
for role_key, role_data in roles.items():
|
||||
role_name = role_data.get('role', '')
|
||||
if 'researcher' in role_key.lower() or 'researcher' in role_name.lower():
|
||||
print(f' 🎯 FOUND in {yaml_file}:')
|
||||
print(f' Framework: {config.get(\"framework\", \"NOT_SET\")}')
|
||||
print(f' Role key: {role_key}')
|
||||
print(f' Role name: {role_name}')
|
||||
print(f' All roles: {list(roles.keys())}')
|
||||
print()
|
||||
except Exception as e:
|
||||
print(f' ❌ Error reading {yaml_file}: {e}')
|
||||
|
||||
print('🔍 Checking for default configurations...')
|
||||
# Check if there are any default configs or hardcoded roles
|
||||
try:
|
||||
import praisonai
|
||||
print(f' PraisonAI package location: {praisonai.__file__}')
|
||||
|
||||
# Check if there are any example YAML files in the package
|
||||
package_dir = os.path.dirname(praisonai.__file__)
|
||||
for root, dirs, files in os.walk(package_dir):
|
||||
for file in files:
|
||||
if file.endswith(('.yaml', '.yml')):
|
||||
file_path = os.path.join(root, file)
|
||||
print(f' 📁 Found YAML in package: {file_path}')
|
||||
except Exception as e:
|
||||
print(f' ❌ Error checking package: {e}')
|
||||
"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Trace AutoGen Execution Path
|
||||
run: |
|
||||
echo "🔍 Tracing AutoGen execution to find where it diverges..."
|
||||
cd src/praisonai
|
||||
python -c "
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, '.')
|
||||
|
||||
try:
|
||||
from praisonai import PraisonAI
|
||||
from praisonai.agents_generator import AgentsGenerator
|
||||
|
||||
# Test the exact execution path
|
||||
print('🎯 Testing AutoGen execution path:')
|
||||
|
||||
praisonai = PraisonAI(agent_file='tests/autogen-agents.yaml')
|
||||
print(f' 1. PraisonAI framework: \"{praisonai.framework}\"')
|
||||
|
||||
agents_gen = AgentsGenerator(
|
||||
agent_file='tests/autogen-agents.yaml',
|
||||
framework=praisonai.framework,
|
||||
config_list=praisonai.config_list
|
||||
)
|
||||
print(f' 2. AgentsGenerator framework: \"{agents_gen.framework}\"')
|
||||
|
||||
# Load the YAML to check what it contains
|
||||
import yaml
|
||||
with open('src/praisonai/tests/autogen-agents.yaml', 'r') as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
framework = agents_gen.framework or config.get('framework')
|
||||
print(f' 3. Final framework decision: \"{framework}\"')
|
||||
print(f' 4. Available frameworks:')
|
||||
|
||||
# Check framework availability
|
||||
try:
|
||||
import autogen
|
||||
print(f' ✅ AutoGen available')
|
||||
except ImportError:
|
||||
print(f' ❌ AutoGen NOT available')
|
||||
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
print(f' ✅ PraisonAI agents available')
|
||||
except ImportError:
|
||||
print(f' ❌ PraisonAI agents NOT available')
|
||||
|
||||
try:
|
||||
from crewai import Agent
|
||||
print(f' ✅ CrewAI available')
|
||||
except ImportError:
|
||||
print(f' ❌ CrewAI NOT available')
|
||||
|
||||
print(f' 5. Roles in YAML: {list(config.get(\"roles\", {}).keys())}')
|
||||
|
||||
# Now test the actual framework execution
|
||||
if framework == 'autogen':
|
||||
print(f' 6. ✅ Should execute _run_autogen')
|
||||
elif framework == 'praisonai':
|
||||
print(f' 6. ❌ Would execute _run_praisonai (WRONG!)')
|
||||
else:
|
||||
print(f' 6. ❌ Would execute _run_crewai (DEFAULT FALLBACK)')
|
||||
|
||||
except Exception as e:
|
||||
print(f'❌ Error tracing execution: {e}')
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Debug YAML Loading and Roles
|
||||
run: |
|
||||
echo "🔍 Tracing YAML file loading and role creation..."
|
||||
cd src/praisonai
|
||||
python -c "
|
||||
import os
|
||||
import sys
|
||||
import yaml
|
||||
sys.path.insert(0, '.')
|
||||
|
||||
print('📁 Available YAML files in src/praisonai/tests/:')
|
||||
import glob
|
||||
yaml_files = glob.glob('tests/*.yaml')
|
||||
for f in yaml_files:
|
||||
print(f' {f}')
|
||||
|
||||
print()
|
||||
print('📋 Content of autogen-agents.yaml:')
|
||||
with open('tests/autogen-agents.yaml', 'r') as f:
|
||||
config = yaml.safe_load(f)
|
||||
print(f' Framework: {config.get(\"framework\")}')
|
||||
print(f' Topic: {config.get(\"topic\")}')
|
||||
print(f' Roles: {list(config.get(\"roles\", {}).keys())}')
|
||||
for role_key, role_data in config.get('roles', {}).items():
|
||||
print(f' {role_key} -> {role_data.get(\"role\", \"NO_ROLE\")}')
|
||||
|
||||
print()
|
||||
print('🔍 Checking if execution uses a different YAML:')
|
||||
|
||||
# Check other YAML files for 'Researcher' role
|
||||
for yaml_file in yaml_files:
|
||||
try:
|
||||
with open(yaml_file, 'r') as f:
|
||||
test_config = yaml.safe_load(f)
|
||||
roles = test_config.get('roles', {})
|
||||
for role_key, role_data in roles.items():
|
||||
if 'researcher' in role_key.lower() or 'researcher' in role_data.get('role', '').lower():
|
||||
print(f' 🎯 FOUND Researcher in {yaml_file}!')
|
||||
print(f' Framework: {test_config.get(\"framework\")}')
|
||||
print(f' Role key: {role_key} -> {role_data.get(\"role\")}')
|
||||
except:
|
||||
pass
|
||||
"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Debug Framework Detection
|
||||
run: |
|
||||
echo "🔍 Testing framework detection and config flow..."
|
||||
python -c "
|
||||
import os
|
||||
import sys
|
||||
import yaml
|
||||
sys.path.insert(0, '.')
|
||||
|
||||
print('🔧 Testing framework detection:')
|
||||
|
||||
# Load the YAML file
|
||||
with open('tests/autogen-agents.yaml', 'r') as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
print(f' 📋 YAML framework: {config.get(\"framework\", \"NOT_SET\")}')
|
||||
print(f' 📋 YAML topic: {config.get(\"topic\", \"NOT_SET\")}')
|
||||
|
||||
try:
|
||||
from praisonai import PraisonAI
|
||||
from praisonai.agents_generator import AgentsGenerator
|
||||
|
||||
# Test PraisonAI initialization
|
||||
praisonai = PraisonAI(agent_file='tests/autogen-agents.yaml')
|
||||
print(f' 🎯 PraisonAI framework: {praisonai.framework}')
|
||||
|
||||
# Test AgentsGenerator initialization
|
||||
agents_gen = AgentsGenerator(
|
||||
agent_file='tests/autogen-agents.yaml',
|
||||
framework=praisonai.framework,
|
||||
config_list=praisonai.config_list
|
||||
)
|
||||
print(f' ⚙️ AgentsGenerator framework: {agents_gen.framework}')
|
||||
print(f' ⚙️ Final framework decision: {agents_gen.framework or config.get(\"framework\")}')
|
||||
|
||||
# Check config_list
|
||||
print(f' 🔑 Config list model: {praisonai.config_list[0].get(\"model\")}')
|
||||
print(f' 🔑 Config list API key: {praisonai.config_list[0].get(\"api_key\", \"NOT_SET\")[:10]}...')
|
||||
|
||||
except Exception as e:
|
||||
print(f'❌ Error in framework detection: {e}')
|
||||
"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Debug PraisonAIModel API Key Flow
|
||||
run: |
|
||||
echo "🔍 Testing PraisonAIModel API key handling..."
|
||||
cd src/praisonai
|
||||
python -c "
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, '.')
|
||||
|
||||
print('🔑 Environment API Key Check:')
|
||||
env_key = os.environ.get('OPENAI_API_KEY', 'NOT_FOUND')
|
||||
print(f' OPENAI_API_KEY: {env_key[:10] if env_key != \"NOT_FOUND\" else \"NOT_FOUND\"}...')
|
||||
|
||||
try:
|
||||
from praisonai.inc.models import PraisonAIModel
|
||||
|
||||
# Test PraisonAIModel with openai/gpt-5-nano (from YAML)
|
||||
model = PraisonAIModel(model='openai/gpt-5-nano')
|
||||
|
||||
print('🤖 PraisonAIModel Configuration:')
|
||||
print(f' model: {model.model}')
|
||||
print(f' model_name: {model.model_name}')
|
||||
print(f' api_key_var: {model.api_key_var}')
|
||||
print(f' api_key: {model.api_key[:10] if model.api_key != \"nokey\" else \"DEFAULT_NOKEY\"}...')
|
||||
print(f' base_url: {model.base_url}')
|
||||
|
||||
if model.api_key == 'nokey':
|
||||
print('❌ FOUND THE ISSUE: PraisonAIModel is using default \"nokey\" instead of environment variable!')
|
||||
else:
|
||||
print('✅ PraisonAIModel has valid API key from environment')
|
||||
|
||||
except Exception as e:
|
||||
print(f'❌ Error testing PraisonAIModel: {e}')
|
||||
"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Validate API Key
|
||||
run: |
|
||||
echo "🔑 Testing API key validity with minimal OpenAI call..."
|
||||
python -c "
|
||||
import os
|
||||
try:
|
||||
from openai import OpenAI
|
||||
client = OpenAI(api_key=os.environ.get('OPENAI_API_KEY'))
|
||||
# Make a minimal API call to test key validity
|
||||
response = client.models.list()
|
||||
print('✅ API Key is VALID - OpenAI responded successfully')
|
||||
print(f'📊 Available models: {len(list(response.data))} models found')
|
||||
except Exception as e:
|
||||
print(f'❌ API Key is INVALID - Error: {e}')
|
||||
print('🔍 This explains why all API-dependent tests are failing')
|
||||
print('💡 The GitHub secret OPENAI_API_KEY needs to be updated with a valid key')
|
||||
"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Test Direct PraisonAI Execution
|
||||
run: |
|
||||
echo "🧪 Testing direct PraisonAI execution (what works locally)..."
|
||||
python -m praisonai src/praisonai/tests/autogen-agents.yaml
|
||||
continue-on-error: true
|
||||
|
||||
- name: Comprehensive Execution Debug
|
||||
run: |
|
||||
echo "🔍 Comprehensive debugging of PraisonAI execution path..."
|
||||
cd src/praisonai
|
||||
python -c "
|
||||
import os
|
||||
import sys
|
||||
import yaml
|
||||
sys.path.insert(0, '.')
|
||||
|
||||
print('=' * 60)
|
||||
print('🔍 COMPREHENSIVE EXECUTION DEBUG')
|
||||
print('=' * 60)
|
||||
|
||||
# Check current working directory
|
||||
print(f'📁 Current working directory: {os.getcwd()}')
|
||||
|
||||
# List files in current directory
|
||||
print('📋 Files in current directory:')
|
||||
for f in os.listdir('.'):
|
||||
print(f' {f}')
|
||||
|
||||
print()
|
||||
print('📋 Files in src/praisonai/tests/ directory:')
|
||||
for f in os.listdir('tests'):
|
||||
if f.endswith('.yaml'):
|
||||
print(f' src/praisonai/tests/{f}')
|
||||
|
||||
# Check if root agents.yaml exists
|
||||
print()
|
||||
if os.path.exists('agents.yaml'):
|
||||
print('❌ ROOT agents.yaml EXISTS (this is the problem!)')
|
||||
with open('agents.yaml', 'r') as f:
|
||||
root_config = yaml.safe_load(f)
|
||||
print(f' Root framework: {root_config.get(\"framework\")}')
|
||||
print(f' Root roles: {list(root_config.get(\"roles\", {}).keys())}')
|
||||
else:
|
||||
print('✅ ROOT agents.yaml does NOT exist (good!)')
|
||||
|
||||
# Test the actual execution path
|
||||
print()
|
||||
print('🎯 Testing EXACT execution path:')
|
||||
|
||||
try:
|
||||
from praisonai import PraisonAI
|
||||
from praisonai.agents_generator import AgentsGenerator
|
||||
|
||||
# Test with full path
|
||||
test_file = 'src/praisonai/tests/autogen-agents.yaml'
|
||||
print(f' Using test file: {test_file}')
|
||||
print(f' File exists: {os.path.exists(test_file)}')
|
||||
|
||||
# Load the test file directly
|
||||
with open(test_file, 'r') as f:
|
||||
test_config = yaml.safe_load(f)
|
||||
print(f' Test file framework: {test_config.get(\"framework\")}')
|
||||
print(f' Test file roles: {list(test_config.get(\"roles\", {}).keys())}')
|
||||
|
||||
# Create PraisonAI instance
|
||||
praisonai = PraisonAI(agent_file=test_file)
|
||||
print(f' PraisonAI.agent_file: {praisonai.agent_file}')
|
||||
print(f' PraisonAI.framework: {praisonai.framework}')
|
||||
|
||||
# Create AgentsGenerator
|
||||
agents_gen = AgentsGenerator(
|
||||
agent_file=test_file,
|
||||
framework=praisonai.framework,
|
||||
config_list=praisonai.config_list
|
||||
)
|
||||
print(f' AgentsGenerator.agent_file: {agents_gen.agent_file}')
|
||||
print(f' AgentsGenerator.framework: {agents_gen.framework}')
|
||||
|
||||
# Test what would happen in generate_crew_and_kickoff
|
||||
print()
|
||||
print('🔧 Testing generate_crew_and_kickoff logic:')
|
||||
|
||||
# Simulate the loading logic
|
||||
if agents_gen.agent_yaml:
|
||||
loaded_config = yaml.safe_load(agents_gen.agent_yaml)
|
||||
print(' Would load from agent_yaml')
|
||||
else:
|
||||
if agents_gen.agent_file == '/app/api:app' or agents_gen.agent_file == 'api:app':
|
||||
agents_gen.agent_file = 'agents.yaml'
|
||||
print(f' Would change agent_file to: {agents_gen.agent_file}')
|
||||
try:
|
||||
with open(agents_gen.agent_file, 'r') as f:
|
||||
loaded_config = yaml.safe_load(f)
|
||||
print(f' Successfully loaded: {agents_gen.agent_file}')
|
||||
except FileNotFoundError:
|
||||
print(f' FileNotFoundError: {agents_gen.agent_file}')
|
||||
loaded_config = None
|
||||
|
||||
if loaded_config:
|
||||
final_framework = agents_gen.framework or loaded_config.get('framework')
|
||||
print(f' Final framework decision: {final_framework}')
|
||||
print(f' Loaded roles: {list(loaded_config.get(\"roles\", {}).keys())}')
|
||||
|
||||
if 'researcher' in loaded_config.get('roles', {}):
|
||||
print(' ❌ FOUND Researcher role in loaded config!')
|
||||
else:
|
||||
print(' ✅ No Researcher role in loaded config')
|
||||
|
||||
except Exception as e:
|
||||
print(f'❌ Error during execution debug: {e}')
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run Fast Tests
|
||||
run: |
|
||||
# Run the fastest, most essential tests with coverage
|
||||
cd src/praisonai && python -m pytest tests/unit/test_core_agents.py -v --tb=short --disable-warnings --cov=praisonai --cov-report=xml --cov-branch
|
||||
|
||||
- name: Run Real API Tests
|
||||
run: |
|
||||
echo "🔑 Running real API tests with actual OpenAI API key..."
|
||||
cd src/praisonai && python -m pytest tests/test_agents_playbook.py -v --tb=short --disable-warnings -m real
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run E2E Real Tests
|
||||
run: |
|
||||
echo "🧪 Running E2E real tests..."
|
||||
cd src/praisonai && python -m pytest tests/e2e/ -v --tb=short --disable-warnings -m real
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run Legacy Example Tests
|
||||
run: |
|
||||
cd src/praisonai && python -m pytest tests/test.py -v --tb=short --disable-warnings -m "not real" --timeout=60
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
file: src/praisonai/coverage.xml
|
||||
flags: quick-validation
|
||||
name: quick-validation-coverage
|
||||
fail_ci_if_error: false
|
||||
verbose: true
|
||||
|
||||
- name: Restore Root Config Files
|
||||
run: |
|
||||
echo "🔄 Restoring root configuration files..."
|
||||
if [ -f "agents.yaml.backup" ]; then
|
||||
mv agents.yaml.backup agents.yaml
|
||||
echo "✅ Restored agents.yaml"
|
||||
fi
|
||||
if [ -f "tools.py.backup" ]; then
|
||||
mv tools.py.backup tools.py
|
||||
echo "✅ Restored tools.py"
|
||||
fi
|
||||
if: always() # Always run this step, even if previous steps failed
|
||||
@@ -52,6 +52,6 @@ jobs:
|
||||
with:
|
||||
commit_message: "docs: auto-update api.md [skip ci]"
|
||||
file_pattern: 'api.md'
|
||||
commit_user_name: "github-actions[bot]"
|
||||
commit_user_email: "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
commit_author: "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>"
|
||||
commit_user_name: "MervinPraison"
|
||||
commit_user_email: "praison.ms@gmail.com"
|
||||
commit_author: "MervinPraison <praison.ms@gmail.com>"
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
with:
|
||||
repository: MervinPraison/PraisonAIDocs
|
||||
path: PraisonAIDocs
|
||||
token: ${{ secrets.DOCS_PAT }}
|
||||
token: ${{ secrets.DOCS_PAT || secrets.PAT_TOKEN }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
@@ -65,16 +65,16 @@ jobs:
|
||||
with:
|
||||
commit_message: "docs: auto-update documentation parity trackers [skip ci]"
|
||||
file_pattern: 'src/praisonai-agents/DOCS_PARITY.md src/praisonai-ts/DOCS_PARITY.md src/praisonai-rust/DOCS_PARITY.md'
|
||||
commit_user_name: "github-actions[bot]"
|
||||
commit_user_email: "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
commit_author: "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>"
|
||||
commit_user_name: "MervinPraison"
|
||||
commit_user_email: "praison.ms@gmail.com"
|
||||
commit_author: "MervinPraison <praison.ms@gmail.com>"
|
||||
|
||||
- name: Commit and push changes to PraisonAIDocs
|
||||
if: steps.check_changes.outputs.changes == 'true'
|
||||
run: |
|
||||
cd PraisonAIDocs
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git config user.name "MervinPraison"
|
||||
git config user.email "praison.ms@gmail.com"
|
||||
git add docs/features/DOCS_PARITY.md docs/js/DOCS_PARITY.md docs/rust/DOCS_PARITY.md
|
||||
git diff --staged --quiet || git commit -m "docs: auto-update documentation parity trackers [skip ci]"
|
||||
git push
|
||||
@@ -29,6 +29,9 @@ jobs:
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install pyyaml
|
||||
|
||||
- name: Generate parity trackers
|
||||
run: |
|
||||
cd $GITHUB_WORKSPACE
|
||||
@@ -52,7 +55,7 @@ jobs:
|
||||
with:
|
||||
commit_message: "docs: auto-update feature parity trackers [skip ci]"
|
||||
file_pattern: 'src/praisonai-ts/FEATURE_PARITY_TRACKER.json src/praisonai-ts/PARITY.md src/praisonai-rust/FEATURE_PARITY_TRACKER.json src/praisonai-rust/PARITY.md'
|
||||
commit_user_name: "github-actions[bot]"
|
||||
commit_user_email: "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
commit_author: "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>"
|
||||
commit_user_name: "MervinPraison"
|
||||
commit_user_email: "praison.ms@gmail.com"
|
||||
commit_author: "MervinPraison <praison.ms@gmail.com>"
|
||||
|
||||
@@ -21,6 +21,7 @@ docs.sh
|
||||
other
|
||||
output
|
||||
!src/praisonai/praisonai/cli/output/
|
||||
!src/praisonai-agents/praisonaiagents/output/
|
||||
|
||||
.files
|
||||
threads.db
|
||||
|
||||
@@ -43,8 +43,9 @@ PraisonAI 🦞 — Automate and solve complex challenges with AI agent teams tha
|
||||
> - 🆕 **New here?** → [Quick Start](#-quick-start) *(1 minute to first agent)*
|
||||
> - 📦 **Installing?** → [Installation](#-installation)
|
||||
> - 🐍 **Python SDK?** → [Python Examples](#-using-python-code)
|
||||
> - 🎯 **CLI user?** → [CLI Quick Reference](#cli-quick-reference)
|
||||
> - 🤝 **Contributing?** → [Development](#-development)
|
||||
> - 📄 **YAML/No-Code?** → [YAML Examples](#-using-yaml-no-code)
|
||||
> - 🎯 **CLI user?** → [CLI Quick Reference](#-cli-quick-reference)
|
||||
> - 🤝 **Contributing?** → [Contributing](#-contributing)
|
||||
|
||||
---
|
||||
|
||||
@@ -328,6 +329,81 @@ Open **http://localhost:8082** — the dashboard comes with 13 built-in pages: C
|
||||
|
||||
---
|
||||
|
||||
## 📄 Using YAML (No Code)
|
||||
|
||||
### Example 1: Two Agents Working Together
|
||||
|
||||
Create `agents.yaml`:
|
||||
|
||||
```yaml
|
||||
framework: praisonai
|
||||
topic: "Write a blog post about AI"
|
||||
|
||||
agents:
|
||||
researcher:
|
||||
role: Research Analyst
|
||||
goal: Research AI trends and gather information
|
||||
instructions: "Find accurate information about AI trends"
|
||||
|
||||
writer:
|
||||
role: Content Writer
|
||||
goal: Write engaging blog posts
|
||||
instructions: "Write clear, engaging content based on research"
|
||||
```
|
||||
|
||||
Run with:
|
||||
```bash
|
||||
praisonai agents.yaml
|
||||
```
|
||||
|
||||
> The agents automatically work together sequentially
|
||||
|
||||
### Example 2: Agent with Custom Tool
|
||||
|
||||
Create two files in the same folder:
|
||||
|
||||
**agents.yaml:**
|
||||
```yaml
|
||||
framework: praisonai
|
||||
topic: "Calculate the sum of 25 and 15"
|
||||
|
||||
agents:
|
||||
calculator_agent:
|
||||
role: Calculator
|
||||
goal: Perform calculations
|
||||
instructions: "Use the add_numbers tool to help with calculations"
|
||||
tools:
|
||||
- add_numbers
|
||||
```
|
||||
|
||||
**tools.py:**
|
||||
```python
|
||||
def add_numbers(a: float, b: float) -> float:
|
||||
"""
|
||||
Add two numbers together.
|
||||
|
||||
Args:
|
||||
a: First number
|
||||
b: Second number
|
||||
|
||||
Returns:
|
||||
The sum of a and b
|
||||
"""
|
||||
return a + b
|
||||
```
|
||||
|
||||
Run with:
|
||||
```bash
|
||||
praisonai agents.yaml
|
||||
```
|
||||
|
||||
> 💡 **Tips:**
|
||||
> - Use the function name (e.g., `add_numbers`) in the tools list, not the file name
|
||||
> - Tools in `tools.py` are automatically discovered
|
||||
> - The function's docstring helps the AI understand how to use it
|
||||
|
||||
---
|
||||
|
||||
## 🎯 CLI Quick Reference
|
||||
|
||||
| Category | Commands |
|
||||
|
||||
@@ -49,6 +49,7 @@ Methods:
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">console</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">context_manager</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">context_manager</a>(value)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">cost_summary</a>() -> dict</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">delete_history</a>(index: int) -> bool</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">delete_history_matching</a>(pattern: str) -> int</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">diff</a>(from_hash: Optional[str] = None)</code>
|
||||
@@ -99,6 +100,7 @@ Methods:
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">switch_model</a>(new_model: str) -> None</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">thinking_budget</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">thinking_budget</a>(value)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">total_cost</a>() -> float</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">undo</a>() -> bool</code>
|
||||
* <code title="class AutoAgents">AutoAgents.<a href="./src/praisonai-agents/praisonaiagents/agents/autoagents.py">astart</a>()</code>
|
||||
* <code title="class AutoAgents">AutoAgents.<a href="./src/praisonai-agents/praisonaiagents/agents/autoagents.py">start</a>()</code>
|
||||
@@ -250,6 +252,7 @@ Methods:
|
||||
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">build_context_for_task</a>(task_descr: str, user_id: Optional[str] = None, additional: str = '', max_items: int = 3, include_in_output: Optional[bool] = None) -> str</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">calculate_quality_metrics</a>(output: str, expected_output: str, llm: Optional[str] = None, custom_prompt: Optional[str] = None) -> Dict[str, float]</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">close_connections</a>()</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">compute_quality_score</a>(completeness: float, relevance: float, clarity: float, accuracy: float, weights: Dict[str, float] = None) -> float</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">delete_long_term</a>(memory_id: str) -> bool</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">delete_memories</a>(memory_ids: List[str]) -> int</code>
|
||||
@@ -750,8 +753,6 @@ Methods:
|
||||
* <code title="class AutoApproveBackend">AutoApproveBackend.<a href="./src/praisonai-agents/praisonaiagents/approval/backends.py">request_approval_sync</a>(request: ApprovalRequest) -> ApprovalDecision</code>
|
||||
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/embedding/embed.py">aembedding</a>(input: Union[str, List[str]], model: str = 'text-embedding-3-small', dimensions: Optional[int] = None, encoding_format: str = 'float', timeout: float = 600.0, api_key: Optional[str] = None, api_base: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> EmbeddingResult</code>
|
||||
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/embedding/embed.py">aembeddings</a>(input: Union[str, List[str]], model: str = 'text-embedding-3-small', dimensions: Optional[int] = None, encoding_format: str = 'float', timeout: float = 600.0, api_key: Optional[str] = None, api_base: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> EmbeddingResult</code>
|
||||
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/embedding/embed.py">embedding</a>(input: Union[str, List[str]], model: str = 'text-embedding-3-small', dimensions: Optional[int] = None, encoding_format: str = 'float', timeout: float = 600.0, api_key: Optional[str] = None, api_base: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> EmbeddingResult</code>
|
||||
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/embedding/embed.py">embeddings</a>(input: Union[str, List[str]], model: str = 'text-embedding-3-small', dimensions: Optional[int] = None, encoding_format: str = 'float', timeout: float = 600.0, api_key: Optional[str] = None, api_base: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> EmbeddingResult</code>
|
||||
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/embedding/dimensions.py">get_dimensions</a>(model_name: str) -> int</code>
|
||||
|
||||
# Wrapper (praisonai)
|
||||
|
||||
@@ -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.80" \
|
||||
"praisonai>=4.5.87" \
|
||||
"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.80" \
|
||||
"praisonai>=4.5.87" \
|
||||
"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.80" \
|
||||
"praisonai>=4.5.87" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
framework: ag2
|
||||
topic: "Research the latest developments in AI agents"
|
||||
|
||||
# Install: pip install "praisonai[ag2]"
|
||||
# Run: praisonai --framework ag2 examples/ag2/ag2_basic.yaml
|
||||
# or praisonai run examples/ag2/ag2_basic.yaml --framework ag2
|
||||
|
||||
roles:
|
||||
research_agent:
|
||||
role: "AI Research Specialist"
|
||||
goal: "Research and summarise the latest developments in AI agent frameworks"
|
||||
backstory: |
|
||||
You are an experienced AI researcher with deep knowledge of multi-agent
|
||||
systems, large language models, and the latest trends in AI tooling.
|
||||
You excel at synthesising complex technical topics into clear summaries.
|
||||
tasks:
|
||||
research_task:
|
||||
description: |
|
||||
Research and summarise the latest developments in AI agent frameworks
|
||||
for the topic: {topic}
|
||||
|
||||
Focus on:
|
||||
1. Key frameworks and their unique capabilities
|
||||
2. Recent innovations and improvements
|
||||
3. Community adoption and ecosystem growth
|
||||
4. Practical use cases and success stories
|
||||
expected_output: |
|
||||
A concise research summary covering the key developments,
|
||||
major frameworks, and practical insights. Include 3-5 bullet
|
||||
points of the most important findings.
|
||||
@@ -0,0 +1,42 @@
|
||||
framework: ag2
|
||||
topic: "Cloud-native AI deployment strategies on AWS"
|
||||
|
||||
# AG2 exclusive feature: native AWS Bedrock support via LLMConfig(api_type="bedrock")
|
||||
#
|
||||
# Prerequisites:
|
||||
# pip install "praisonai[ag2]"
|
||||
# aws configure (or set AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION)
|
||||
#
|
||||
# Run:
|
||||
# praisonai --framework ag2 examples/ag2/ag2_bedrock.yaml
|
||||
#
|
||||
# The AG2 adapter detects api_type="bedrock" from the llm config and uses
|
||||
# LLMConfig(api_type="bedrock", model=...) — no OPENAI_API_KEY required.
|
||||
# AWS credentials are sourced from boto3 (env vars, ~/.aws/credentials, IAM role).
|
||||
|
||||
roles:
|
||||
cloud_architect:
|
||||
role: "AWS Cloud Architect"
|
||||
goal: "Design and explain cloud-native AI deployment strategies on AWS"
|
||||
backstory: |
|
||||
You are an AWS Solutions Architect specialising in AI/ML workloads.
|
||||
You have deep expertise in Amazon Bedrock, SageMaker, ECS, and Lambda,
|
||||
and you help organisations deploy AI agents at scale securely and cost-effectively.
|
||||
llm:
|
||||
model: "bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0"
|
||||
api_type: "bedrock"
|
||||
aws_region: "us-east-1"
|
||||
tasks:
|
||||
architecture_task:
|
||||
description: |
|
||||
Design a cloud-native deployment strategy for AI agents on AWS for: {topic}
|
||||
|
||||
Cover:
|
||||
1. Recommended AWS services (Bedrock, ECS, Lambda, etc.)
|
||||
2. Scalability and cost optimisation patterns
|
||||
3. Security and compliance considerations
|
||||
4. A simple reference architecture overview
|
||||
expected_output: |
|
||||
A concise architecture guide with service recommendations,
|
||||
a high-level deployment diagram description, and key
|
||||
best practices for production AI agent deployments on AWS.
|
||||
@@ -0,0 +1,54 @@
|
||||
framework: ag2
|
||||
topic: "The impact of open-source AI on enterprise software development"
|
||||
|
||||
# Install: pip install "praisonai[ag2]"
|
||||
# Run: praisonai --framework ag2 examples/ag2/ag2_multi_agent.yaml
|
||||
# or praisonai run examples/ag2/ag2_multi_agent.yaml --framework ag2
|
||||
#
|
||||
# This example demonstrates AG2's GroupChat multi-agent coordination.
|
||||
# Both agents participate in a collaborative conversation managed by
|
||||
# a GroupChatManager until the task is complete.
|
||||
|
||||
roles:
|
||||
researcher:
|
||||
role: "Research Specialist"
|
||||
goal: "Gather and analyse information on the given topic"
|
||||
backstory: |
|
||||
You are a meticulous researcher who excels at finding relevant
|
||||
information, analysing trends, and presenting data-backed insights.
|
||||
You always cite your reasoning and structure your findings clearly.
|
||||
tasks:
|
||||
research_task:
|
||||
description: |
|
||||
Research the topic: {topic}
|
||||
|
||||
Investigate:
|
||||
1. Current state and adoption rates
|
||||
2. Key players and projects driving the trend
|
||||
3. Technical advantages and challenges
|
||||
4. Business impact and cost implications
|
||||
expected_output: |
|
||||
A structured research briefing with findings on the topic,
|
||||
including key data points, trends, and technical observations.
|
||||
|
||||
writer:
|
||||
role: "Technical Content Writer"
|
||||
goal: "Transform research findings into clear, engaging written content"
|
||||
backstory: |
|
||||
You are a skilled technical writer who turns complex research into
|
||||
accessible, well-structured articles. You focus on clarity, logical
|
||||
flow, and actionable takeaways for a professional audience.
|
||||
tasks:
|
||||
writing_task:
|
||||
description: |
|
||||
Using the research findings provided by the Research Specialist,
|
||||
write a concise article on: {topic}
|
||||
|
||||
The article should:
|
||||
1. Open with a compelling hook
|
||||
2. Present key findings logically
|
||||
3. Include practical implications for developers
|
||||
4. Close with a forward-looking conclusion
|
||||
expected_output: |
|
||||
A 400-500 word article suitable for a technical blog,
|
||||
with clear sections, professional tone, and concrete takeaways.
|
||||
@@ -19,7 +19,15 @@ async function main() {
|
||||
},
|
||||
execute: async ({ expression }) => {
|
||||
try {
|
||||
const result = eval(expression);
|
||||
// Only allow safe numeric math characters
|
||||
if (!/^[0-9+\-*/.() ]+$/.test(expression)) {
|
||||
return 'Error: Only basic math expressions are allowed';
|
||||
}
|
||||
// Use Function constructor scoped to math only, no globals
|
||||
const result = new Function(`"use strict"; return (${expression})`)();
|
||||
if (typeof result !== 'number' || !isFinite(result)) {
|
||||
return 'Error: Invalid result';
|
||||
}
|
||||
return `Result: ${result}`;
|
||||
} catch (e) {
|
||||
return 'Error: Invalid expression';
|
||||
|
||||
@@ -38,7 +38,13 @@ const calculatorTool = createTool({
|
||||
},
|
||||
run: (params: { expression: string }) => {
|
||||
try {
|
||||
return eval(params.expression);
|
||||
// Only allow safe numeric math characters
|
||||
if (!/^[0-9+\-*/.() ]+$/.test(params.expression)) {
|
||||
return 'Error: Only basic math expressions are allowed';
|
||||
}
|
||||
const result = new Function(`"use strict"; return (${params.expression})`)();
|
||||
if (typeof result !== 'number' || !isFinite(result)) return 'Error: Invalid result';
|
||||
return result;
|
||||
} catch {
|
||||
return 'Error: Invalid expression';
|
||||
}
|
||||
|
||||
@@ -134,10 +134,28 @@ def main():
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
# Safe AST-based math evaluator (no eval on arbitrary expressions)
|
||||
def _safe_calculate(expression: str) -> str:
|
||||
import ast
|
||||
try:
|
||||
allowed = set("0123456789+-*/.() ")
|
||||
if not all(c in allowed for c in expression):
|
||||
return "Error: Invalid characters in expression"
|
||||
tree = ast.parse(expression, mode="eval")
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, (ast.Expression, ast.BinOp, ast.UnaryOp,
|
||||
ast.Constant, ast.Add, ast.Sub, ast.Mult,
|
||||
ast.Div, ast.USub, ast.UAdd)):
|
||||
return "Error: Only basic math operations are allowed"
|
||||
result = eval(compile(tree, "<expr>", "eval"), {"__builtins__": {}})
|
||||
return f"Result: {result}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
# MCP endpoints
|
||||
mcp_tools = {
|
||||
"search": {"description": "Search the web", "func": lambda q: f"Results for: {q}"},
|
||||
"calculate": {"description": "Calculate expression", "func": lambda e: str(eval(e)) if e.replace(" ", "").replace("+", "").replace("-", "").replace("*", "").replace("/", "").replace(".", "").isdigit() or True else "Error"},
|
||||
"calculate": {"description": "Calculate expression", "func": _safe_calculate},
|
||||
}
|
||||
|
||||
@app.get("/mcp/tools")
|
||||
|
||||
@@ -21,9 +21,11 @@ Simpler than competitors • More extensible • Faster • Agent-centric
|
||||
| **Minimal API** | Fewer parameters, sensible defaults, explicit overrides |
|
||||
| **Performance-First** | Lazy loading, optional dependencies, no hot-path regressions |
|
||||
| **Production-Ready** | Safe by default, multi-agent safe, async-safe |
|
||||
Powerful, lightweight, highly reliable and robust
|
||||
Easy and developer frienldy for non developers
|
||||
Easy and user friendly
|
||||
| **Open Core** | Core free and open source; clear paid upgrade path (support/cloud/services) |
|
||||
|
||||
Powerful, lightweight, highly reliable and robust.
|
||||
Easy and developer-friendly for non-developers.
|
||||
Each feature runs 3 ways: **CLI, YAML, Python**.
|
||||
---
|
||||
|
||||
## 2. Repository Structure
|
||||
@@ -96,22 +98,33 @@ Easy and user friendly
|
||||
|
||||
| Module | Size | Purpose |
|
||||
|--------|------|---------|
|
||||
| `agent/` | 1.4M | Agent class, protocols, handoff, autonomy |
|
||||
| `memory/` | 984K | Memory protocols, adapters, file memory |
|
||||
| `context/` | 960K | Context management, artifacts, fast context |
|
||||
| `tools/` | 732K | Tool SDK, decorators, registry, protocols |
|
||||
| `llm/` | 620K | LLM client, model router |
|
||||
| `workflows/` | 456K | Workflow engine, patterns (Route, Parallel, Loop) |
|
||||
| `knowledge/` | 436K | Knowledge/RAG protocols, vector stores |
|
||||
| `hooks/` | 260K | Hook system, middleware, events |
|
||||
| `eval/` | 240K | Evaluation framework |
|
||||
| `agent/` | 1.7M | Agent class, protocols, handoff, autonomy |
|
||||
| `memory/` | 784K | Memory protocols, adapters, file memory |
|
||||
| `context/` | 840K | Context management, artifacts, fast context |
|
||||
| `tools/` | 816K | Tool SDK, decorators, registry, protocols |
|
||||
| `llm/` | 756K | LLM client, model router |
|
||||
| `workflows/` | 472K | Workflow engine, patterns (Route, Parallel, Loop) |
|
||||
| `mcp/` | 404K | Model Context Protocol integration |
|
||||
| `eval/` | 384K | Evaluation framework |
|
||||
| `agents/` | 376K | Multi-agent orchestration (AgentTeam, AgentFlow) |
|
||||
| `knowledge/` | 300K | Knowledge/RAG protocols, vector stores |
|
||||
| `rag/` | 264K | RAG protocols, retriever, reranker |
|
||||
| `plugins/` | 56K | Plugin manager, registry |
|
||||
| `policy/` | 64K | Policy engine |
|
||||
| `config/` | 252K | Feature configs (ExecutionConfig, OutputConfig, etc.) |
|
||||
| `session/` | 224K | Session management, persistence |
|
||||
| `plugins/` | 204K | Plugin manager, registry |
|
||||
| `hooks/` | 184K | Hook system, middleware, events |
|
||||
| `storage/` | 136K | Storage protocols, adapters |
|
||||
| `scheduler/` | 132K | Schedule models, store, parser, runner |
|
||||
| `trace/` | 116K | Trace protocols, context events |
|
||||
| `approval/` | 92K | Human-in-the-loop approval protocols |
|
||||
| `bots/` | 80K | BotOS protocols, config |
|
||||
| `policy/` | 76K | Policy engine |
|
||||
| `gateway/` | 60K | Gateway protocols |
|
||||
| `sandbox/` | 56K | Code execution sandbox |
|
||||
| `bus/` | 48K | Event bus |
|
||||
| `streaming/` | 56K | Streaming events, callbacks |
|
||||
| `trace/` | 152K | Trace protocols, context events |
|
||||
| `scheduler/` | 48K | Schedule models, store, parser, runner |
|
||||
| `streaming/` | 44K | Streaming events, callbacks |
|
||||
| `conditions/` | 40K | Conditional logic protocols |
|
||||
| `db/` | 32K | Database adapter protocols |
|
||||
|
||||
### 3.2 Protocol-First Design
|
||||
|
||||
@@ -512,9 +525,9 @@ class MyDbAdapter(DbAdapter):
|
||||
|
||||
---
|
||||
|
||||
## 7. CLI Representation
|
||||
## 7. CLI & YAML Representation
|
||||
|
||||
Every feature must have CLI parity:
|
||||
Every feature must run 3 ways: **Python, CLI, and YAML**.
|
||||
|
||||
```bash
|
||||
# Basic agent
|
||||
@@ -636,6 +649,30 @@ pytest tests/unit/
|
||||
pytest --cov=praisonaiagents tests/
|
||||
```
|
||||
|
||||
### 9.4 Real Agentic Test (MANDATORY)
|
||||
|
||||
Every feature MUST include a real agentic test — not just smoke tests.
|
||||
|
||||
A **real agentic test** means the agent actually runs and calls the LLM:
|
||||
|
||||
```python
|
||||
# ✅ REAL agentic test — agent runs end-to-end
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="test", instructions="You are a helpful assistant")
|
||||
result = agent.start("Say hello in one sentence")
|
||||
print(result) # Must produce actual LLM output
|
||||
|
||||
# ❌ SMOKE test only — NOT sufficient alone
|
||||
agent = Agent(name="test") # Just object construction
|
||||
assert agent.name == "test" # No LLM call
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Both smoke AND real agentic tests are required
|
||||
- Agent MUST call `agent.start()` with a real prompt
|
||||
- Agent MUST call the LLM and produce a text response
|
||||
- Print full output so developers can verify end-to-end behavior
|
||||
|
||||
---
|
||||
|
||||
## 10. Implementation Checklist
|
||||
@@ -646,8 +683,10 @@ For every feature/change:
|
||||
- [ ] **No new deps**: Use optional dependencies only
|
||||
- [ ] **Lazy imports**: Heavy deps imported inside functions
|
||||
- [ ] **Naming**: Follow conventions (add_*, get_*, XConfig)
|
||||
- [ ] **Tests**: TDD - write failing tests first
|
||||
- [ ] **Tests**: TDD — write failing tests first
|
||||
- [ ] **Real agentic test**: Agent must call LLM end-to-end (§9.4)
|
||||
- [ ] **CLI**: Add corresponding CLI command/option
|
||||
- [ ] **YAML**: Ensure YAML config parity with Python API
|
||||
- [ ] **Docs**: Update Mintlify documentation
|
||||
- [ ] **Examples**: Add to examples/ directory
|
||||
- [ ] **Multi-agent safe**: No shared mutable state
|
||||
@@ -853,7 +892,7 @@ platforms:
|
||||
|
||||
---
|
||||
|
||||
## 13. Paid Value Path
|
||||
## 14. Paid Value Path
|
||||
|
||||
The core remains **free and open source**. Clear upgrade paths to paid value:
|
||||
|
||||
|
||||
@@ -59,18 +59,7 @@ _logging.configure_root_logger()
|
||||
# Import configuration (lightweight, no heavy deps)
|
||||
from . import _config
|
||||
|
||||
# Lightweight imports that don't trigger heavy dependency chains
|
||||
from .tools.tools import Tools
|
||||
from .tools.base import BaseTool, ToolResult, ToolValidationError, validate_tool
|
||||
from .tools.decorator import tool, FunctionTool
|
||||
from .tools.registry import get_registry, register_tool, get_tool, ToolRegistry
|
||||
# db and obs are lazy-loaded via __getattr__ for performance
|
||||
|
||||
# Sub-packages for organized imports (pa.config, pa.tools, etc.)
|
||||
# These enable: import praisonaiagents as pa; pa.config.MemoryConfig
|
||||
from . import config
|
||||
from . import tools
|
||||
# Note: db, obs, knowledge and mcp are lazy-loaded via __getattr__ due to heavy deps
|
||||
# Note: tools, config, memory, workflows, db, obs, knowledge and mcp are lazy-loaded via __getattr__ due to heavy deps
|
||||
|
||||
# Embedding API - LAZY LOADED via __getattr__ for performance
|
||||
# Supports: embedding, embeddings, aembedding, aembeddings, EmbeddingResult, get_dimensions
|
||||
@@ -125,6 +114,19 @@ def _get_lazy_cache():
|
||||
# ============================================================================
|
||||
|
||||
_LAZY_IMPORTS = {
|
||||
# Tools (moved from eager imports for lazy loading)
|
||||
'Tools': ('praisonaiagents.tools.tools', 'Tools'),
|
||||
'BaseTool': ('praisonaiagents.tools.base', 'BaseTool'),
|
||||
'ToolResult': ('praisonaiagents.tools.base', 'ToolResult'),
|
||||
'ToolValidationError': ('praisonaiagents.tools.base', 'ToolValidationError'),
|
||||
'validate_tool': ('praisonaiagents.tools.base', 'validate_tool'),
|
||||
'tool': ('praisonaiagents.tools.decorator', 'tool'),
|
||||
'FunctionTool': ('praisonaiagents.tools.decorator', 'FunctionTool'),
|
||||
'get_registry': ('praisonaiagents.tools.registry', 'get_registry'),
|
||||
'register_tool': ('praisonaiagents.tools.registry', 'register_tool'),
|
||||
'get_tool': ('praisonaiagents.tools.registry', 'get_tool'),
|
||||
'ToolRegistry': ('praisonaiagents.tools.registry', 'ToolRegistry'),
|
||||
|
||||
# Main display utilities (imports rich)
|
||||
'TaskOutput': ('praisonaiagents.main', 'TaskOutput'),
|
||||
'ReflectionOutput': ('praisonaiagents.main', 'ReflectionOutput'),
|
||||
@@ -179,9 +181,7 @@ _LAZY_IMPORTS = {
|
||||
'HandoffDepthError': ('praisonaiagents.agent.handoff', 'HandoffDepthError'),
|
||||
'HandoffTimeoutError': ('praisonaiagents.agent.handoff', 'HandoffTimeoutError'),
|
||||
|
||||
# Embedding API
|
||||
'embedding': ('praisonaiagents.embedding.embed', 'embedding'),
|
||||
'embeddings': ('praisonaiagents.embedding.embed', 'embedding'),
|
||||
# Embedding API (Note: embedding/embeddings handled in custom_handler to override subpackage)
|
||||
'aembedding': ('praisonaiagents.embedding.embed', 'aembedding'),
|
||||
'aembeddings': ('praisonaiagents.embedding.embed', 'aembedding'),
|
||||
'embed': ('praisonaiagents.embedding.embed', 'embed'),
|
||||
@@ -203,6 +203,9 @@ _LAZY_IMPORTS = {
|
||||
|
||||
# Agent classes
|
||||
'Agent': ('praisonaiagents.agent.agent', 'Agent'),
|
||||
'BudgetExceededError': ('praisonaiagents.agent.agent', 'BudgetExceededError'),
|
||||
'Heartbeat': ('praisonaiagents.agent.heartbeat', 'Heartbeat'),
|
||||
'HeartbeatConfig': ('praisonaiagents.agent.heartbeat', 'HeartbeatConfig'),
|
||||
'ImageAgent': ('praisonaiagents.agent.image_agent', 'ImageAgent'),
|
||||
'VideoAgent': ('praisonaiagents.agent.video_agent', 'VideoAgent'),
|
||||
'VideoConfig': ('praisonaiagents.agent.video_agent', 'VideoConfig'),
|
||||
@@ -503,21 +506,27 @@ def _custom_handler(name, cache):
|
||||
cache['Agents'] = value
|
||||
return value
|
||||
|
||||
# Task removed in v4.0.0 - use Task instead
|
||||
if name == "Task":
|
||||
raise ImportError(
|
||||
"Task has been removed in v4.0.0. Use Task instead.\n"
|
||||
"Migration: Replace 'from praisonaiagents import Task' with 'from praisonaiagents import Task'\n"
|
||||
"Task supports all Task features including action, handler, loop_over, etc."
|
||||
)
|
||||
|
||||
# Module imports (return the module itself)
|
||||
if name == 'tools':
|
||||
import importlib
|
||||
mod = importlib.import_module('.tools', 'praisonaiagents')
|
||||
cache['tools'] = mod
|
||||
return mod
|
||||
if name == 'config':
|
||||
import importlib
|
||||
mod = importlib.import_module('.config', 'praisonaiagents')
|
||||
cache['config'] = mod
|
||||
return mod
|
||||
if name == 'memory':
|
||||
import importlib
|
||||
return importlib.import_module('.memory', 'praisonaiagents')
|
||||
mod = importlib.import_module('.memory', 'praisonaiagents')
|
||||
cache['memory'] = mod
|
||||
return mod
|
||||
if name == 'workflows':
|
||||
import importlib
|
||||
return importlib.import_module('.workflows', 'praisonaiagents')
|
||||
mod = importlib.import_module('.workflows', 'praisonaiagents')
|
||||
cache['workflows'] = mod
|
||||
return mod
|
||||
|
||||
raise AttributeError(f"Not handled by custom_handler: {name}")
|
||||
|
||||
@@ -529,12 +538,49 @@ def _custom_handler(name, cache):
|
||||
# Override them here to return the function instead of the module.
|
||||
# ============================================================================
|
||||
|
||||
# Override 'embedding' to return the function, not the subpackage
|
||||
from .embedding.embed import embedding as _embedding_func
|
||||
embedding = _embedding_func
|
||||
# Override 'embedding' and 'embeddings' at module level to prevent subpackage import
|
||||
# These need to be set after _LAZY_IMPORTS is defined but before __getattr__ is created
|
||||
def _get_embedding_func():
|
||||
"""Lazy getter for embedding function."""
|
||||
# Import with alias to avoid overwriting the module proxy
|
||||
from .embedding.embed import embedding as _embedding_func
|
||||
return _embedding_func
|
||||
|
||||
# Also provide embeddings alias
|
||||
embeddings = _embedding_func
|
||||
# Create lazy properties that override the submodule
|
||||
class _EmbeddingProxy:
|
||||
"""Proxy object that loads embedding function on first access."""
|
||||
def __init__(self):
|
||||
self._func = None
|
||||
|
||||
def _load(self):
|
||||
"""Load the actual embedding function if not already loaded."""
|
||||
if self._func is None:
|
||||
self._func = _get_embedding_func()
|
||||
return self._func
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
return self._load()(*args, **kwargs)
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._load(), name)
|
||||
|
||||
@property
|
||||
def __wrapped__(self):
|
||||
"""Support for inspect.signature() and functools.wraps."""
|
||||
return self._load()
|
||||
|
||||
@property
|
||||
def __signature__(self):
|
||||
"""Support for inspect.signature()."""
|
||||
import inspect
|
||||
return inspect.signature(self._load())
|
||||
|
||||
def __repr__(self):
|
||||
return f"<lazy proxy for {self._load()!r}>"
|
||||
|
||||
# Override the submodule with our function proxy
|
||||
embedding = _EmbeddingProxy()
|
||||
embeddings = embedding # embeddings is an alias
|
||||
|
||||
|
||||
# Create the __getattr__ function using centralized utility
|
||||
@@ -542,7 +588,7 @@ __getattr__ = create_lazy_getattr_with_fallback(
|
||||
mapping=_LAZY_IMPORTS,
|
||||
module_name=__name__,
|
||||
cache=_lazy_cache,
|
||||
fallback_modules=['tools', 'memory', 'config', 'workflows'],
|
||||
fallback_modules=[], # Note: 'embedding' excluded to avoid conflict with embedding() function
|
||||
custom_handler=_custom_handler
|
||||
)
|
||||
|
||||
|
||||
@@ -13,6 +13,18 @@ def __getattr__(name):
|
||||
from .agent import Agent
|
||||
_lazy_cache[name] = Agent
|
||||
return Agent
|
||||
if name == 'BudgetExceededError':
|
||||
from .agent import BudgetExceededError
|
||||
_lazy_cache[name] = BudgetExceededError
|
||||
return BudgetExceededError
|
||||
if name == 'Heartbeat':
|
||||
from .heartbeat import Heartbeat
|
||||
_lazy_cache[name] = Heartbeat
|
||||
return Heartbeat
|
||||
if name == 'HeartbeatConfig':
|
||||
from .heartbeat import HeartbeatConfig
|
||||
_lazy_cache[name] = HeartbeatConfig
|
||||
return HeartbeatConfig
|
||||
|
||||
# Specialized agents - lazy loaded (import rich)
|
||||
if name == 'ImageAgent':
|
||||
@@ -145,6 +157,9 @@ def __getattr__(name):
|
||||
|
||||
__all__ = [
|
||||
'Agent',
|
||||
'BudgetExceededError',
|
||||
'Heartbeat',
|
||||
'HeartbeatConfig',
|
||||
'ImageAgent',
|
||||
'VideoAgent',
|
||||
'VideoConfig',
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,138 @@
|
||||
"""Cost persistence — saves agent cost_summary to disk after runs.
|
||||
|
||||
Writes JSON files to ``~/.praisonai/costs/`` for CLI dashboard consumption.
|
||||
Zero overhead when not used (this module is never imported unless explicitly called).
|
||||
|
||||
Usage::
|
||||
|
||||
from praisonaiagents.agent.cost_persistence import save_cost_report, load_cost_report
|
||||
|
||||
save_cost_report(agent, session_name="my_run")
|
||||
report = load_cost_report("my_run")
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
COST_DIR = Path.home() / ".praisonai" / "costs"
|
||||
|
||||
|
||||
def save_cost_report(
|
||||
agent,
|
||||
session_name: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Persist an agent's cost_summary to disk.
|
||||
|
||||
Args:
|
||||
agent: Agent instance with cost_summary property.
|
||||
session_name: Optional session identifier. Auto-generated if None.
|
||||
|
||||
Returns:
|
||||
Path to the saved JSON file.
|
||||
"""
|
||||
COST_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
name = session_name or f"{agent.name}_{int(time.time())}"
|
||||
safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in name)
|
||||
|
||||
summary = getattr(agent, "cost_summary", None)
|
||||
if summary is None:
|
||||
summary = {
|
||||
"tokens_in": 0,
|
||||
"tokens_out": 0,
|
||||
"cost": 0.0,
|
||||
"llm_calls": 0,
|
||||
}
|
||||
|
||||
report = {
|
||||
"session": name,
|
||||
"agent_name": getattr(agent, "name", "unknown"),
|
||||
"timestamp": time.time(),
|
||||
"iso_time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
**summary,
|
||||
}
|
||||
|
||||
filepath = COST_DIR / f"{safe_name}.json"
|
||||
with open(filepath, "w") as f:
|
||||
json.dump(report, f, indent=2)
|
||||
|
||||
logger.info(f"[costs] Saved cost report to {filepath}")
|
||||
return str(filepath)
|
||||
|
||||
|
||||
def load_cost_report(session_name: str) -> Optional[Dict[str, Any]]:
|
||||
"""Load a cost report by session name.
|
||||
|
||||
Args:
|
||||
session_name: Session identifier.
|
||||
|
||||
Returns:
|
||||
Parsed JSON dict, or None if not found.
|
||||
"""
|
||||
safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in session_name)
|
||||
filepath = COST_DIR / f"{safe_name}.json"
|
||||
if filepath.exists():
|
||||
with open(filepath) as f:
|
||||
return json.load(f)
|
||||
return None
|
||||
|
||||
|
||||
def list_cost_reports(limit: int = 20) -> List[Dict[str, Any]]:
|
||||
"""List recent cost reports, sorted by timestamp (newest first).
|
||||
|
||||
Args:
|
||||
limit: Max number of reports to return.
|
||||
|
||||
Returns:
|
||||
List of parsed JSON report dicts.
|
||||
"""
|
||||
if not COST_DIR.exists():
|
||||
return []
|
||||
|
||||
reports = []
|
||||
for fpath in sorted(COST_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
|
||||
try:
|
||||
with open(fpath) as f:
|
||||
reports.append(json.load(f))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
if len(reports) >= limit:
|
||||
break
|
||||
return reports
|
||||
|
||||
|
||||
def format_cost_table(reports: List[Dict[str, Any]], as_json: bool = False) -> str:
|
||||
"""Format cost reports for terminal display.
|
||||
|
||||
Args:
|
||||
reports: List of report dicts from list_cost_reports().
|
||||
as_json: If True, returns JSON string instead of table.
|
||||
|
||||
Returns:
|
||||
Formatted string for terminal output.
|
||||
"""
|
||||
if as_json:
|
||||
return json.dumps(reports, indent=2)
|
||||
|
||||
if not reports:
|
||||
return "No cost reports found."
|
||||
|
||||
lines = []
|
||||
lines.append(f"{'Session':<30} {'Agent':<20} {'Cost ($)':<12} {'Tokens In':<12} {'Tokens Out':<12} {'LLM Calls':<10}")
|
||||
lines.append("-" * 96)
|
||||
for r in reports:
|
||||
lines.append(
|
||||
f"{r.get('session', '?'):<30} "
|
||||
f"{r.get('agent_name', '?'):<20} "
|
||||
f"${r.get('cost', 0):<11.4f} "
|
||||
f"{r.get('tokens_in', 0):<12} "
|
||||
f"{r.get('tokens_out', 0):<12} "
|
||||
f"{r.get('llm_calls', 0):<10}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Heartbeat — runs an agent on a schedule, delivers results via callback.
|
||||
|
||||
Standalone wrapper class. Does NOT modify the Agent class.
|
||||
|
||||
Usage::
|
||||
|
||||
from praisonaiagents import Agent, Heartbeat
|
||||
|
||||
agent = Agent(instructions="Check server status")
|
||||
hb = Heartbeat(agent, schedule="every 30m", prompt="Report status")
|
||||
hb.start() # blocking loop
|
||||
# hb.start(blocking=False) # background thread
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Optional, Union
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HeartbeatConfig:
|
||||
"""Configuration for Heartbeat scheduler.
|
||||
|
||||
Attributes:
|
||||
schedule: Human-friendly schedule expression. Supports:
|
||||
- Keywords: "hourly", "daily", "weekly"
|
||||
- Intervals: "every 30m", "every 6h", "*/30m", "*/10s"
|
||||
- Cron: "cron:0 7 * * *"
|
||||
- One-shot: "at:2026-03-01T09:00:00"
|
||||
prompt: Override prompt sent to agent each tick. None = use agent's default.
|
||||
on_result: Callback receiving (result_text: str). Default: log to stdout.
|
||||
on_error: "retry" | "skip" | callable(error). Default: "retry".
|
||||
max_retries: Max consecutive retries before skipping. Default: 3.
|
||||
"""
|
||||
schedule: str = "hourly"
|
||||
prompt: Optional[str] = None
|
||||
on_result: Optional[Callable] = None
|
||||
on_error: Union[str, Callable] = "retry"
|
||||
max_retries: int = 3
|
||||
|
||||
|
||||
class Heartbeat:
|
||||
"""Standalone heartbeat coordinator.
|
||||
|
||||
Runs an Agent on a schedule and delivers results via callback.
|
||||
Does NOT add any params to the Agent class.
|
||||
|
||||
Usage::
|
||||
|
||||
from praisonaiagents import Agent, Heartbeat
|
||||
|
||||
agent = Agent(instructions="Monitor server health")
|
||||
hb = Heartbeat(agent, schedule="hourly")
|
||||
hb.start()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent,
|
||||
schedule: str = "hourly",
|
||||
prompt: Optional[str] = None,
|
||||
on_result: Optional[Callable] = None,
|
||||
on_error: Union[str, Callable] = "retry",
|
||||
max_retries: int = 3,
|
||||
):
|
||||
self.agent = agent
|
||||
self.config = HeartbeatConfig(
|
||||
schedule=schedule,
|
||||
prompt=prompt,
|
||||
on_result=on_result,
|
||||
on_error=on_error,
|
||||
max_retries=max_retries,
|
||||
)
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._interval_seconds = self._resolve_interval(schedule)
|
||||
|
||||
def start(self, blocking: bool = True) -> None:
|
||||
"""Start the heartbeat loop.
|
||||
|
||||
Args:
|
||||
blocking: If True, blocks the calling thread. If False,
|
||||
runs in a background daemon thread.
|
||||
"""
|
||||
self._running = True
|
||||
if blocking:
|
||||
self._loop()
|
||||
else:
|
||||
self._thread = threading.Thread(target=self._loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the heartbeat loop."""
|
||||
self._running = False
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._running
|
||||
|
||||
# ── internals ────────────────────────────────────────────────────
|
||||
|
||||
def _loop(self) -> None:
|
||||
"""Main polling loop."""
|
||||
retries = 0
|
||||
while self._running:
|
||||
try:
|
||||
result = self._tick()
|
||||
retries = 0 # Reset on success
|
||||
self._deliver(result)
|
||||
except Exception as e:
|
||||
retries += 1
|
||||
logger.error(f"[heartbeat] {self.agent.name}: error on tick #{retries}: {e}")
|
||||
if callable(self.config.on_error):
|
||||
self.config.on_error(e)
|
||||
elif self.config.on_error == "skip":
|
||||
logger.warning(f"[heartbeat] Skipping error: {e}")
|
||||
else: # "retry"
|
||||
if retries >= self.config.max_retries:
|
||||
logger.error(
|
||||
f"[heartbeat] {self.agent.name}: max retries "
|
||||
f"({self.config.max_retries}) reached, skipping."
|
||||
)
|
||||
retries = 0
|
||||
time.sleep(self._interval_seconds)
|
||||
|
||||
def _tick(self) -> str:
|
||||
"""Execute one heartbeat tick — run the agent and return result."""
|
||||
prompt = self.config.prompt or "Run your scheduled check."
|
||||
result = self.agent.start(prompt)
|
||||
return str(result) if result else ""
|
||||
|
||||
def _deliver(self, result: str) -> None:
|
||||
"""Deliver result via callback or log."""
|
||||
if self.config.on_result:
|
||||
self.config.on_result(result)
|
||||
else:
|
||||
logger.info(f"[heartbeat] {self.agent.name}: {result[:200]}")
|
||||
|
||||
@staticmethod
|
||||
def _resolve_interval(schedule: str) -> float:
|
||||
"""Convert schedule string to interval in seconds.
|
||||
|
||||
Uses the scheduler parser if available, otherwise falls back to
|
||||
simple keyword parsing.
|
||||
"""
|
||||
try:
|
||||
from ..scheduler.parser import parse_schedule
|
||||
sched = parse_schedule(schedule)
|
||||
if sched.every_seconds:
|
||||
return float(sched.every_seconds)
|
||||
except (ImportError, Exception):
|
||||
pass
|
||||
|
||||
# Fallback keyword resolution
|
||||
keywords = {
|
||||
"hourly": 3600,
|
||||
"daily": 86400,
|
||||
"weekly": 604800,
|
||||
}
|
||||
lower = schedule.lower().strip()
|
||||
if lower in keywords:
|
||||
return float(keywords[lower])
|
||||
|
||||
# Try parsing "every Xm/h/s" patterns
|
||||
import re
|
||||
match = re.match(r"(?:every\s+)?(\d+)\s*(s|m|h)", lower)
|
||||
if match:
|
||||
val, unit = int(match.group(1)), match.group(2)
|
||||
multipliers = {"s": 1, "m": 60, "h": 3600}
|
||||
return float(val * multipliers.get(unit, 60))
|
||||
|
||||
return 3600.0 # Default to hourly
|
||||
@@ -10,9 +10,6 @@ import logging
|
||||
import warnings
|
||||
# Filter out Pydantic warning about fields
|
||||
warnings.filterwarnings("ignore", "Valid config keys have changed in V2", UserWarning)
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn
|
||||
class ImageGenerationConfig(BaseModel):
|
||||
"""Configuration for image generation settings."""
|
||||
style: str = Field(default="natural", description="Style of the generated image")
|
||||
@@ -155,6 +152,7 @@ class ImageAgent(Agent):
|
||||
# Gemini provider doesn't support response_format parameter
|
||||
# Apply this filter if provider is explicitly 'gemini' or as fallback for gemini models
|
||||
config.pop('response_format', None)
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
"""
|
||||
Tool Loop Detection for PraisonAI Agents.
|
||||
|
||||
Detects when an agent is stuck calling the same tool repeatedly with
|
||||
identical arguments and no progress.
|
||||
|
||||
Inspired by OpenClaw's tool-loop-detection.ts (624 lines, 4 detectors).
|
||||
Adapted to Python with PraisonAI's protocol-driven, zero-dep philosophy.
|
||||
|
||||
Zero Performance Impact:
|
||||
- Disabled by default (enabled=False)
|
||||
- Uses stdlib only: hashlib, json
|
||||
- History is per-agent-instance (no global mutable state)
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.agent.loop_detection import (
|
||||
LoopDetectionConfig, detect_tool_loop, record_tool_call, record_tool_outcome
|
||||
)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Literal, Optional, TypedDict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class LoopDetectionConfig:
|
||||
"""
|
||||
Configuration for tool loop detection.
|
||||
|
||||
Attributes:
|
||||
enabled: Whether loop detection is active. Default False (opt-in).
|
||||
history_size: Sliding window size for tool call history.
|
||||
warn_threshold: Number of identical (tool, args) calls before warning.
|
||||
critical_threshold: Number before critical/circuit-break. Must > warn.
|
||||
detectors: Which detectors are active.
|
||||
"""
|
||||
enabled: bool = False
|
||||
history_size: int = 30
|
||||
warn_threshold: int = 10
|
||||
critical_threshold: int = 20
|
||||
detectors: Dict[str, bool] = field(default_factory=lambda: {
|
||||
"generic_repeat": True,
|
||||
"poll_no_progress": True,
|
||||
"ping_pong": True,
|
||||
})
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Auto-correct: critical must always be > warn
|
||||
if self.critical_threshold <= self.warn_threshold:
|
||||
self.critical_threshold = self.warn_threshold + 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Result type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LoopDetectionResult(TypedDict, total=False):
|
||||
stuck: bool
|
||||
level: Optional[Literal["warning", "critical"]]
|
||||
detector: Optional[str]
|
||||
message: Optional[str]
|
||||
count: int
|
||||
|
||||
|
||||
_NOT_STUCK: LoopDetectionResult = {"stuck": False}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# History record
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ToolCallRecord(TypedDict, total=False):
|
||||
tool_name: str
|
||||
args_hash: str
|
||||
result_hash: Optional[str]
|
||||
timestamp: float
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hashing helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _stable_json(value: Any) -> str:
|
||||
"""Deterministic JSON serialization (sorted keys, str fallback)."""
|
||||
if value is None or isinstance(value, (bool, int, float, str)):
|
||||
return json.dumps(value)
|
||||
if isinstance(value, (list, tuple)):
|
||||
return "[" + ",".join(_stable_json(v) for v in value) + "]"
|
||||
if isinstance(value, dict):
|
||||
keys = sorted(value.keys())
|
||||
parts = [json.dumps(k) + ":" + _stable_json(value[k]) for k in keys]
|
||||
return "{" + ",".join(parts) + "}"
|
||||
# Fallback for non-serializable objects
|
||||
try:
|
||||
return json.dumps(str(value))
|
||||
except Exception:
|
||||
return '"<unserializable>"'
|
||||
|
||||
|
||||
def _sha256_hex(text: str, prefix_len: int = 16) -> str:
|
||||
return hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()[:prefix_len]
|
||||
|
||||
|
||||
def hash_tool_call(tool_name: str, args: Any) -> str:
|
||||
"""
|
||||
Compute a stable hash for a (tool_name, args) pair.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool being called
|
||||
args: Arguments dict or any serializable value
|
||||
|
||||
Returns:
|
||||
16-character hex string
|
||||
"""
|
||||
stable = _stable_json({"t": tool_name, "a": args})
|
||||
return _sha256_hex(stable)
|
||||
|
||||
|
||||
def _hash_result(result: Any) -> Optional[str]:
|
||||
"""Hash a tool result for no-progress detection."""
|
||||
if result is None:
|
||||
return None
|
||||
try:
|
||||
stable = _stable_json(result)
|
||||
return _sha256_hex(stable)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Record tool call / outcome
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def record_tool_call(
|
||||
history: List[ToolCallRecord],
|
||||
tool_name: str,
|
||||
args: Any,
|
||||
config: Optional[LoopDetectionConfig] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Append a tool call to the history (before execution).
|
||||
|
||||
Args:
|
||||
history: Mutable list of ToolCallRecord
|
||||
tool_name: Tool being called
|
||||
args: Tool arguments
|
||||
config: Loop detection config (for history_size)
|
||||
"""
|
||||
max_size = config.history_size if config else 30
|
||||
history.append(ToolCallRecord(
|
||||
tool_name=tool_name,
|
||||
args_hash=hash_tool_call(tool_name, args),
|
||||
result_hash=None,
|
||||
timestamp=time.time(),
|
||||
))
|
||||
# Trim to sliding window
|
||||
if len(history) > max_size:
|
||||
del history[: len(history) - max_size]
|
||||
|
||||
|
||||
def record_tool_outcome(
|
||||
history: List[ToolCallRecord],
|
||||
tool_name: str,
|
||||
args: Any,
|
||||
result: Any,
|
||||
config: Optional[LoopDetectionConfig] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Update the most recent matching record with result hash (after execution).
|
||||
|
||||
Args:
|
||||
history: Mutable list of ToolCallRecord
|
||||
tool_name: Tool that was called
|
||||
args: Arguments that were passed
|
||||
result: The return value of the tool
|
||||
config: Loop detection config
|
||||
"""
|
||||
args_hash = hash_tool_call(tool_name, args)
|
||||
result_hash = _hash_result(result)
|
||||
if result_hash is None:
|
||||
return
|
||||
|
||||
# Find the latest matching record without a result_hash
|
||||
for i in range(len(history) - 1, -1, -1):
|
||||
rec = history[i]
|
||||
if rec["tool_name"] == tool_name and rec["args_hash"] == args_hash and rec.get("result_hash") is None:
|
||||
history[i] = ToolCallRecord(
|
||||
tool_name=rec["tool_name"],
|
||||
args_hash=rec["args_hash"],
|
||||
result_hash=result_hash,
|
||||
timestamp=rec["timestamp"],
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Detectors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _known_poll_tool(tool_name: str) -> bool:
|
||||
"""Heuristic: tools whose names suggest polling/waiting."""
|
||||
_poll_keywords = ("status", "poll", "check", "wait", "ping", "health", "command_status")
|
||||
name_lower = tool_name.lower()
|
||||
return any(kw in name_lower for kw in _poll_keywords)
|
||||
|
||||
|
||||
def _count_generic_repeat(
|
||||
history: List[ToolCallRecord],
|
||||
args_hash: str,
|
||||
tool_name: str,
|
||||
) -> int:
|
||||
"""Count how many times this exact (tool, args) appears in history."""
|
||||
return sum(
|
||||
1 for rec in history
|
||||
if rec["tool_name"] == tool_name and rec["args_hash"] == args_hash
|
||||
)
|
||||
|
||||
|
||||
def _no_progress_streak(
|
||||
history: List[ToolCallRecord],
|
||||
args_hash: str,
|
||||
tool_name: str,
|
||||
) -> int:
|
||||
"""
|
||||
Count consecutive tail records with identical (args_hash, result_hash).
|
||||
Returns 0 if any record has no result_hash or a different result_hash.
|
||||
"""
|
||||
streak = 0
|
||||
first_result_hash: Optional[str] = None
|
||||
|
||||
for rec in reversed(history):
|
||||
if rec["tool_name"] != tool_name or rec["args_hash"] != args_hash:
|
||||
continue
|
||||
rh = rec.get("result_hash")
|
||||
if rh is None:
|
||||
continue
|
||||
if first_result_hash is None:
|
||||
first_result_hash = rh
|
||||
streak = 1
|
||||
elif rh == first_result_hash:
|
||||
streak += 1
|
||||
else:
|
||||
break # result changed — no longer a streak
|
||||
|
||||
return streak
|
||||
|
||||
|
||||
def _ping_pong_streak(
|
||||
history: List[ToolCallRecord],
|
||||
current_hash: str,
|
||||
) -> int:
|
||||
"""
|
||||
Detect alternating pattern: A, B, A, B, ...
|
||||
Returns count of consecutive alternating tail entries, 0 if not ping-pong.
|
||||
"""
|
||||
if len(history) < 2:
|
||||
return 0
|
||||
|
||||
last = history[-1]
|
||||
# Find the "other" hash in history (most recent != last)
|
||||
other_hash: Optional[str] = None
|
||||
for rec in reversed(history[:-1]):
|
||||
if rec["args_hash"] != last["args_hash"]:
|
||||
other_hash = rec["args_hash"]
|
||||
break
|
||||
|
||||
if other_hash is None:
|
||||
return 0
|
||||
|
||||
# Count tail alternation
|
||||
count = 0
|
||||
for i, rec in enumerate(reversed(history)):
|
||||
expected = last["args_hash"] if i % 2 == 0 else other_hash
|
||||
if rec["args_hash"] != expected:
|
||||
break
|
||||
count += 1
|
||||
|
||||
# Current call must continue the ping-pong pattern
|
||||
if current_hash != (other_hash if count % 2 == 1 else last["args_hash"]):
|
||||
return 0
|
||||
|
||||
return count + 1 # include current call
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main detection function
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def detect_tool_loop(
|
||||
history: List[ToolCallRecord],
|
||||
tool_name: str,
|
||||
args: Any,
|
||||
config: Optional[LoopDetectionConfig] = None,
|
||||
) -> LoopDetectionResult:
|
||||
"""
|
||||
Detect if agent is stuck in a tool call loop.
|
||||
|
||||
Args:
|
||||
history: Mutable sliding window of ToolCallRecord
|
||||
tool_name: Tool being called now
|
||||
args: Arguments for this call
|
||||
config: Loop detection configuration
|
||||
|
||||
Returns:
|
||||
LoopDetectionResult with stuck=False (safe) or stuck=True (warning/critical)
|
||||
"""
|
||||
if config is None or not config.enabled:
|
||||
return _NOT_STUCK
|
||||
|
||||
args_hash = hash_tool_call(tool_name, args)
|
||||
detectors = config.detectors or {}
|
||||
warn = config.warn_threshold
|
||||
critical = config.critical_threshold
|
||||
|
||||
# --- poll_no_progress detector ---
|
||||
if detectors.get("poll_no_progress", True) and _known_poll_tool(tool_name):
|
||||
streak = _no_progress_streak(history, args_hash, tool_name)
|
||||
if streak >= critical:
|
||||
msg = (
|
||||
f"CRITICAL: '{tool_name}' called {streak} times with identical args "
|
||||
f"and identical result. Detected stuck polling loop. Stop and report failure."
|
||||
)
|
||||
logger.error(f"[loop_detection] {msg}")
|
||||
return LoopDetectionResult(
|
||||
stuck=True, level="critical",
|
||||
detector="poll_no_progress", message=msg, count=streak,
|
||||
)
|
||||
if streak >= warn:
|
||||
msg = (
|
||||
f"WARNING: '{tool_name}' has returned identical results {streak} times. "
|
||||
f"This looks like a stuck poll. Increase wait time or report failure."
|
||||
)
|
||||
logger.warning(f"[loop_detection] {msg}")
|
||||
return LoopDetectionResult(
|
||||
stuck=True, level="warning",
|
||||
detector="poll_no_progress", message=msg, count=streak,
|
||||
)
|
||||
|
||||
# --- ping_pong detector ---
|
||||
if detectors.get("ping_pong", True):
|
||||
pp_count = _ping_pong_streak(history, args_hash)
|
||||
if pp_count >= critical:
|
||||
msg = (
|
||||
f"CRITICAL: Ping-pong loop detected ({pp_count} alternating calls). "
|
||||
f"Agent is oscillating between two tool states. Stop and report failure."
|
||||
)
|
||||
logger.error(f"[loop_detection] {msg}")
|
||||
return LoopDetectionResult(
|
||||
stuck=True, level="critical",
|
||||
detector="ping_pong", message=msg, count=pp_count,
|
||||
)
|
||||
if pp_count >= warn:
|
||||
msg = (
|
||||
f"WARNING: Alternating tool pattern detected ({pp_count} calls). "
|
||||
f"Stop ping-pong and try a different approach."
|
||||
)
|
||||
logger.warning(f"[loop_detection] {msg}")
|
||||
return LoopDetectionResult(
|
||||
stuck=True, level="warning",
|
||||
detector="ping_pong", message=msg, count=pp_count,
|
||||
)
|
||||
|
||||
# --- generic_repeat detector ---
|
||||
if detectors.get("generic_repeat", True) and not _known_poll_tool(tool_name):
|
||||
count = _count_generic_repeat(history, args_hash, tool_name)
|
||||
if count >= critical:
|
||||
msg = (
|
||||
f"CRITICAL: '{tool_name}' called {count} times with identical args. "
|
||||
f"Agent is stuck. Execution blocked."
|
||||
)
|
||||
logger.error(f"[loop_detection] {msg}")
|
||||
return LoopDetectionResult(
|
||||
stuck=True, level="critical",
|
||||
detector="generic_repeat", message=msg, count=count,
|
||||
)
|
||||
if count >= warn:
|
||||
msg = (
|
||||
f"WARNING: '{tool_name}' called {count} times with identical args. "
|
||||
f"If not making progress, stop and report failure."
|
||||
)
|
||||
logger.warning(f"[loop_detection] {msg}")
|
||||
return LoopDetectionResult(
|
||||
stuck=True, level="warning",
|
||||
detector="generic_repeat", message=msg, count=count,
|
||||
)
|
||||
|
||||
return _NOT_STUCK
|
||||
@@ -16,8 +16,6 @@ import asyncio
|
||||
import warnings
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Any, Dict, Union, List, Generator
|
||||
from rich.console import Console
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn
|
||||
|
||||
# Filter out Pydantic warning about fields
|
||||
warnings.filterwarnings("ignore", "Valid config keys have changed in V2", UserWarning)
|
||||
@@ -212,9 +210,10 @@ class VideoAgent:
|
||||
return VideoConfig()
|
||||
|
||||
@property
|
||||
def console(self) -> Console:
|
||||
def console(self):
|
||||
"""Lazily initialize Rich Console."""
|
||||
if self._console is None:
|
||||
from rich.console import Console
|
||||
self._console = Console()
|
||||
return self._console
|
||||
|
||||
@@ -342,6 +341,7 @@ class VideoAgent:
|
||||
params.pop("poll_interval", None)
|
||||
params.pop("max_wait_time", None)
|
||||
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
@@ -491,7 +491,8 @@ class VideoAgent:
|
||||
max_wait_time = max_wait_time or self._video_config.max_wait_time
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
|
||||
@@ -3,7 +3,6 @@ import time
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, Optional, List
|
||||
from rich.console import Console
|
||||
from ..main import display_error, TaskOutput
|
||||
from ..agent.agent import Agent
|
||||
from ..task.task import Task
|
||||
@@ -1287,6 +1286,7 @@ Context:
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
if show_verbose and is_tty:
|
||||
from rich.panel import Panel
|
||||
from rich.console import Console
|
||||
console = Console()
|
||||
import time as time_module
|
||||
|
||||
|
||||
@@ -237,18 +237,8 @@ class AgentApproval:
|
||||
|
||||
def request_approval_sync(self, request: ApprovalRequest) -> ApprovalDecision:
|
||||
"""Synchronous wrapper."""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
|
||||
if loop and loop.is_running():
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
future = pool.submit(asyncio.run, self.request_approval(request))
|
||||
return future.result(timeout=60)
|
||||
else:
|
||||
return asyncio.run(self.request_approval(request))
|
||||
from .utils import run_coroutine_safely
|
||||
return run_coroutine_safely(self.request_approval(request), timeout=60)
|
||||
|
||||
|
||||
class CallbackBackend:
|
||||
|
||||
@@ -45,6 +45,19 @@ DEFAULT_DANGEROUS_TOOLS: Dict[str, str] = {
|
||||
"scrape_page": "medium",
|
||||
}
|
||||
|
||||
# Permission presets — resolved to deny frozensets at Agent.__init__ time.
|
||||
# Usage: Agent(approval="safe")
|
||||
PERMISSION_PRESETS = {
|
||||
# "safe" — blocks all dangerous tools (file writes, shell exec, etc.)
|
||||
"safe": frozenset(DEFAULT_DANGEROUS_TOOLS.keys()),
|
||||
# "read_only" — blocks dangerous tools + write operations
|
||||
"read_only": frozenset(DEFAULT_DANGEROUS_TOOLS.keys()) | frozenset({
|
||||
"write_file", "copy_file", "move_file",
|
||||
}),
|
||||
# "full" — no restrictions
|
||||
"full": frozenset(),
|
||||
}
|
||||
|
||||
|
||||
class ApprovalRegistry:
|
||||
"""Per-agent approval configuration.
|
||||
@@ -197,15 +210,12 @@ class ApprovalRegistry:
|
||||
if hasattr(backend, "request_approval_sync"):
|
||||
decision = backend.request_approval_sync(request)
|
||||
else:
|
||||
# Fallback: run async method
|
||||
try:
|
||||
decision = asyncio.run(backend.request_approval(request))
|
||||
except RuntimeError:
|
||||
# Already in an event loop — fall back to thread
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
future = pool.submit(asyncio.run, backend.request_approval(request))
|
||||
decision = future.result(timeout=self.timeout)
|
||||
# Use shared utility for consistent async-to-sync bridging
|
||||
from .utils import run_coroutine_safely
|
||||
decision = run_coroutine_safely(
|
||||
backend.request_approval(request),
|
||||
timeout=self.timeout
|
||||
)
|
||||
|
||||
if decision.approved:
|
||||
self.mark_approved(tool_name)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Utility functions for approval handling.
|
||||
|
||||
Provides reusable async-to-sync bridging logic to prevent code duplication
|
||||
across the approval system.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
from typing import Any, Awaitable, Callable, Optional, TypeVar
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
def run_coroutine_safely(
|
||||
coro: Awaitable[T],
|
||||
timeout: Optional[float] = None
|
||||
) -> T:
|
||||
"""
|
||||
Run a coroutine safely, handling both sync and async contexts.
|
||||
|
||||
This function detects if an event loop is already running and uses a
|
||||
ThreadPoolExecutor as a fallback to avoid RuntimeError. It respects
|
||||
timeout semantics consistently across both code paths.
|
||||
|
||||
Args:
|
||||
coro: The coroutine to execute
|
||||
timeout: Timeout in seconds. None means indefinite wait.
|
||||
|
||||
Returns:
|
||||
The result of the coroutine
|
||||
|
||||
Raises:
|
||||
TimeoutError: If the operation times out
|
||||
Any exception raised by the coroutine
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
|
||||
if loop and loop.is_running():
|
||||
# We're in an async context - use thread pool to avoid RuntimeError
|
||||
pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
||||
|
||||
# Wrap the coroutine with timeout handling inside the thread
|
||||
def run_with_timeout():
|
||||
if timeout is not None and timeout > 0:
|
||||
return asyncio.run(asyncio.wait_for(coro, timeout=timeout))
|
||||
else:
|
||||
return asyncio.run(coro)
|
||||
|
||||
future = pool.submit(run_with_timeout)
|
||||
try:
|
||||
# Don't use timeout on Future.result() since we handle timeout
|
||||
# inside the coroutine via asyncio.wait_for
|
||||
result = future.result(timeout=None if timeout is None or timeout == 0 else timeout)
|
||||
return result
|
||||
finally:
|
||||
# Properly shut down the executor without waiting for threads
|
||||
pool.shutdown(wait=False, cancel_futures=True)
|
||||
else:
|
||||
# No running event loop - use asyncio.run directly
|
||||
if timeout is not None and timeout > 0:
|
||||
return asyncio.run(asyncio.wait_for(coro, timeout=timeout))
|
||||
else:
|
||||
return asyncio.run(coro)
|
||||
@@ -679,6 +679,12 @@ class ExecutionConfig:
|
||||
code_mode="safe",
|
||||
rate_limiter=my_limiter,
|
||||
))
|
||||
|
||||
# With token budget guard
|
||||
Agent(execution=ExecutionConfig(
|
||||
max_budget=0.50, # Hard USD limit per agent run
|
||||
on_budget_exceeded="stop", # "stop" | "warn" | callable
|
||||
))
|
||||
"""
|
||||
# Iteration limits
|
||||
max_iter: int = 20
|
||||
@@ -698,7 +704,23 @@ class ExecutionConfig:
|
||||
|
||||
# Rate limiter instance (consolidated from standalone rate_limiter param)
|
||||
rate_limiter: Optional[Any] = None
|
||||
|
||||
|
||||
# Context compaction: automatically compact chat_history when approaching token limit.
|
||||
# Opt-in only (safe default: False). Uses existing praisonaiagents.compaction module.
|
||||
# Usage: Agent(execution=ExecutionConfig(context_compaction=True, max_context_tokens=8000))
|
||||
context_compaction: bool = False
|
||||
|
||||
# Token limit before compaction triggers. None = auto-detect from model metadata.
|
||||
max_context_tokens: Optional[int] = None
|
||||
|
||||
# Token budget guard — hard dollar limit per agent run.
|
||||
# None = disabled (zero overhead). Float = max USD spend.
|
||||
max_budget: Optional[float] = None
|
||||
|
||||
# Action when budget exceeded: "stop" (default) raises BudgetExceededError,
|
||||
# "warn" logs warning but continues, or callable(total_cost, max_budget).
|
||||
on_budget_exceeded: Any = "stop"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
@@ -708,6 +730,9 @@ class ExecutionConfig:
|
||||
"max_retry_limit": self.max_retry_limit,
|
||||
"code_execution": self.code_execution,
|
||||
"code_mode": self.code_mode,
|
||||
"context_compaction": self.context_compaction,
|
||||
"max_context_tokens": self.max_context_tokens,
|
||||
"max_budget": self.max_budget,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import logging
|
||||
from datetime import datetime
|
||||
from .chunking import Chunking
|
||||
from functools import cached_property
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
|
||||
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -786,6 +787,7 @@ class Knowledge:
|
||||
memories = [self.normalize_content(input_path)]
|
||||
|
||||
# Create progress display
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
|
||||
progress = Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
|
||||
@@ -71,7 +71,9 @@ except ImportError:
|
||||
# Logging is already configured in _logging.py via __init__.py
|
||||
|
||||
# TODO: Include in-build tool calling in LLM class
|
||||
# TODO: Restructure so that duplicate calls are not made (Sync with agent.py)
|
||||
# NOTE: The custom-LLM path (Agent.chat → get_response) and OpenAI path
|
||||
# (Agent.chat → _chat_completion) are separate code paths, not duplicate
|
||||
# API calls per request. This is a DRY/maintenance concern, not a billing issue.
|
||||
class LLMContextLengthExceededException(Exception):
|
||||
"""Raised when LLM context length is exceeded"""
|
||||
def __init__(self, message: str):
|
||||
|
||||
@@ -4,14 +4,17 @@ import json
|
||||
import logging
|
||||
from typing import List, Optional, Dict, Any, Union, Literal, Type
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from rich import print
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
from rich.markdown import Markdown
|
||||
from rich.live import Live
|
||||
import asyncio
|
||||
|
||||
|
||||
def _rich():
|
||||
"""Lazy-load Rich display classes (cached by sys.modules after first call)."""
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
from rich.markdown import Markdown
|
||||
return Console, Panel, Text, Markdown
|
||||
|
||||
# Import token metrics if available
|
||||
try:
|
||||
from .telemetry.token_collector import TokenMetrics
|
||||
@@ -239,16 +242,17 @@ def display_interaction(message, response, markdown=True, generation_time=None,
|
||||
Args:
|
||||
metrics: Optional dict with token_in, token_out, cost, model for footer display
|
||||
"""
|
||||
Console, Panel, Text, Markdown = _rich()
|
||||
if console is None:
|
||||
console = Console()
|
||||
|
||||
|
||||
if isinstance(message, list):
|
||||
text_content = next((item["text"] for item in message if item["type"] == "text"), "")
|
||||
message = text_content
|
||||
|
||||
message = _clean_display_content(str(message))
|
||||
response = _clean_display_content(str(response))
|
||||
|
||||
|
||||
# Skip display if response is empty (common with Gemini tool calls)
|
||||
if not response or not response.strip():
|
||||
return
|
||||
@@ -268,12 +272,12 @@ def display_interaction(message, response, markdown=True, generation_time=None,
|
||||
task_id=task_id,
|
||||
metrics=metrics
|
||||
)
|
||||
|
||||
|
||||
# Build response title with time
|
||||
response_title = "Response"
|
||||
if generation_time:
|
||||
response_title = f"Response ({generation_time:.1f}s)"
|
||||
|
||||
|
||||
# Build response content with optional metrics footer
|
||||
response_content = response
|
||||
if metrics and isinstance(metrics, dict):
|
||||
@@ -282,7 +286,7 @@ def display_interaction(message, response, markdown=True, generation_time=None,
|
||||
tokens_out = metrics.get('tokens_out', 0)
|
||||
cost = metrics.get('cost', 0)
|
||||
model = metrics.get('model', '')
|
||||
|
||||
|
||||
metrics_line = f"\n\n─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─\n📊 {tokens_in} tokens in · {tokens_out} out"
|
||||
if cost > 0:
|
||||
metrics_line += f" · ${cost:.4f}"
|
||||
@@ -303,25 +307,27 @@ def display_interaction(message, response, markdown=True, generation_time=None,
|
||||
def display_self_reflection(message: str, console=None):
|
||||
if not message or not message.strip():
|
||||
return
|
||||
Console, Panel, Text, _Md = _rich()
|
||||
if console is None:
|
||||
console = Console()
|
||||
message = _clean_display_content(str(message))
|
||||
|
||||
|
||||
# Execute synchronous callbacks
|
||||
execute_sync_callback('self_reflection', message=message)
|
||||
|
||||
|
||||
console.print(Panel.fit(Text(message, style="bold yellow"), title="Self Reflection", border_style="magenta"))
|
||||
|
||||
def display_instruction(message: str, console=None, agent_name: str = None, agent_role: str = None, agent_tools: List[str] = None):
|
||||
if not message or not message.strip():
|
||||
return
|
||||
Console, Panel, Text, _Md = _rich()
|
||||
if console is None:
|
||||
console = Console()
|
||||
message = _clean_display_content(str(message))
|
||||
|
||||
|
||||
# Execute synchronous callbacks
|
||||
execute_sync_callback('instruction', message=message, agent_name=agent_name, agent_role=agent_role, agent_tools=agent_tools)
|
||||
|
||||
|
||||
# Display agent info if available
|
||||
if agent_name:
|
||||
agent_info = f"[bold #FF9B9B]👤 Agent:[/] [#FFE5E5]{agent_name}[/]"
|
||||
@@ -331,7 +337,7 @@ def display_instruction(message: str, console=None, agent_name: str = None, agen
|
||||
tools_str = ", ".join(f"[italic #B4D4FF]{tool}[/]" for tool in agent_tools)
|
||||
agent_info += f"\n[bold #86A789]Tools:[/] {tools_str}"
|
||||
console.print(Panel(agent_info, border_style="#D2E3C8", title="[bold]Agent Info[/]", title_align="left", padding=(1, 2)))
|
||||
|
||||
|
||||
# Only print if log level is DEBUG
|
||||
if logging.getLogger().getEffectiveLevel() == logging.DEBUG:
|
||||
console.print(Panel.fit(Text(message, style="bold blue"), title="Instruction", border_style="cyan"))
|
||||
@@ -400,17 +406,18 @@ def display_tool_call(message: str, console=None, tool_name: str = None, tool_in
|
||||
def display_error(message: str, console=None):
|
||||
if not message or not message.strip():
|
||||
return
|
||||
Console, Panel, Text, _Md = _rich()
|
||||
if console is None:
|
||||
console = Console()
|
||||
message = _clean_display_content(str(message))
|
||||
|
||||
|
||||
# Execute synchronous callbacks
|
||||
execute_sync_callback('error', message=message)
|
||||
|
||||
|
||||
# Use semantic error color
|
||||
console.print(Panel.fit(
|
||||
Text(message, style=f"bold {PRAISON_COLORS['error_text']}"),
|
||||
title="⚠ Error",
|
||||
Text(message, style=f"bold {PRAISON_COLORS['error_text']}"),
|
||||
title="⚠ Error",
|
||||
border_style=PRAISON_COLORS["error"]
|
||||
))
|
||||
error_logs.append(message)
|
||||
@@ -419,32 +426,33 @@ def display_generating(content: str = "", start_time: Optional[float] = None):
|
||||
if not content or not str(content).strip():
|
||||
logging.debug("Empty content in display_generating, returning early")
|
||||
return None
|
||||
|
||||
|
||||
elapsed_str = ""
|
||||
if start_time is not None:
|
||||
elapsed = time.time() - start_time
|
||||
elapsed_str = f" {elapsed:.1f}s"
|
||||
|
||||
|
||||
content = _clean_display_content(str(content))
|
||||
|
||||
|
||||
# Execute synchronous callbacks
|
||||
execute_sync_callback('generating', content=content, elapsed_time=elapsed_str.strip() if elapsed_str else None)
|
||||
|
||||
# Use semantic response color
|
||||
|
||||
_Console, Panel, _Text, Markdown = _rich()
|
||||
return Panel(Markdown(content), title=f"Generating...{elapsed_str}", border_style=PRAISON_COLORS["response"])
|
||||
|
||||
def display_reasoning_steps(steps: List[str], console=None):
|
||||
"""Display reasoning steps with unique numbered circles.
|
||||
|
||||
|
||||
Uses ①②③ numbered circles for a distinctive, scannable format
|
||||
that shows the agent's thought process.
|
||||
|
||||
|
||||
Args:
|
||||
steps: List of reasoning step descriptions
|
||||
console: Rich console for output
|
||||
"""
|
||||
if not steps:
|
||||
return
|
||||
Console, Panel, Text, _Md = _rich()
|
||||
if console is None:
|
||||
console = Console()
|
||||
|
||||
@@ -470,18 +478,19 @@ def display_reasoning_steps(steps: List[str], console=None):
|
||||
|
||||
def display_working_status(phase: int = 0, status_text: str = None, console=None):
|
||||
"""Display animated working status with pulsing dots.
|
||||
|
||||
|
||||
Shows a unique "Working ●○○" indicator with phase-specific status.
|
||||
This is PraisonAI's distinctive approach to showing processing status.
|
||||
|
||||
|
||||
Args:
|
||||
phase: Current animation phase (0-3)
|
||||
status_text: Optional status description
|
||||
console: Rich console for output
|
||||
|
||||
|
||||
Returns:
|
||||
Panel object for use with Rich.Live
|
||||
"""
|
||||
Console, Panel, Text, _Md = _rich()
|
||||
if console is None:
|
||||
console = Console()
|
||||
|
||||
@@ -501,6 +510,7 @@ def display_working_status(phase: int = 0, status_text: str = None, console=None
|
||||
# Async versions with 'a' prefix
|
||||
async def adisplay_interaction(message, response, markdown=True, generation_time=None, console=None, agent_name=None, agent_role=None, agent_tools=None, task_name=None, task_description=None, task_id=None):
|
||||
"""Async version of display_interaction."""
|
||||
Console, Panel, Text, Markdown = _rich()
|
||||
if console is None:
|
||||
console = Console()
|
||||
|
||||
@@ -541,6 +551,7 @@ async def adisplay_self_reflection(message: str, console=None):
|
||||
"""Async version of display_self_reflection."""
|
||||
if not message or not message.strip():
|
||||
return
|
||||
Console, Panel, Text, _Md = _rich()
|
||||
if console is None:
|
||||
console = Console()
|
||||
message = _clean_display_content(str(message))
|
||||
@@ -554,6 +565,7 @@ async def adisplay_instruction(message: str, console=None, agent_name: str = Non
|
||||
"""Async version of display_instruction."""
|
||||
if not message or not message.strip():
|
||||
return
|
||||
Console, Panel, Text, _Md = _rich()
|
||||
if console is None:
|
||||
console = Console()
|
||||
message = _clean_display_content(str(message))
|
||||
@@ -581,6 +593,7 @@ async def adisplay_tool_call(message: str, console=None):
|
||||
if not message or not message.strip():
|
||||
logging.debug("Empty message in adisplay_tool_call, returning early")
|
||||
return
|
||||
Console, Panel, Text, _Md = _rich()
|
||||
if console is None:
|
||||
console = Console()
|
||||
message = _clean_display_content(str(message))
|
||||
@@ -595,6 +608,7 @@ async def adisplay_error(message: str, console=None):
|
||||
"""Async version of display_error."""
|
||||
if not message or not message.strip():
|
||||
return
|
||||
Console, Panel, Text, _Md = _rich()
|
||||
if console is None:
|
||||
console = Console()
|
||||
message = _clean_display_content(str(message))
|
||||
@@ -610,17 +624,18 @@ async def adisplay_generating(content: str = "", start_time: Optional[float] = N
|
||||
if not content or not str(content).strip():
|
||||
logging.debug("Empty content in adisplay_generating, returning early")
|
||||
return None
|
||||
|
||||
|
||||
elapsed_str = ""
|
||||
if start_time is not None:
|
||||
elapsed = time.time() - start_time
|
||||
elapsed_str = f" {elapsed:.1f}s"
|
||||
|
||||
|
||||
content = _clean_display_content(str(content))
|
||||
|
||||
|
||||
# Execute callbacks
|
||||
await execute_callback('generating', content=content, elapsed_time=elapsed_str.strip() if elapsed_str else None)
|
||||
|
||||
|
||||
_Console, Panel, _Text, Markdown = _rich()
|
||||
return Panel(Markdown(content), title=f"Generating...{elapsed_str}", border_style="green")
|
||||
|
||||
def clean_triple_backticks(text: str) -> str:
|
||||
|
||||
@@ -21,6 +21,7 @@ import time
|
||||
import sys
|
||||
import hashlib
|
||||
import logging
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
@@ -160,7 +161,8 @@ class FileMemory:
|
||||
# Load or create config
|
||||
self.config = self._load_config(config)
|
||||
|
||||
# Initialize memory stores
|
||||
# Initialize thread-safe memory stores with lock for in-memory data protection
|
||||
self._lock = threading.RLock() # Reentrant lock for nested operations
|
||||
self._short_term: List[MemoryItem] = []
|
||||
self._long_term: List[MemoryItem] = []
|
||||
self._entities: Dict[str, EntityItem] = {}
|
||||
@@ -318,42 +320,45 @@ class FileMemory:
|
||||
Returns:
|
||||
The generated memory ID
|
||||
"""
|
||||
item = MemoryItem(
|
||||
id=self._generate_id(content),
|
||||
content=content,
|
||||
metadata=metadata or {},
|
||||
importance=importance
|
||||
)
|
||||
|
||||
self._short_term.append(item)
|
||||
|
||||
# Enforce limit (rolling buffer)
|
||||
limit = self.config["short_term_limit"]
|
||||
if len(self._short_term) > limit:
|
||||
# Before removing, check if should promote to long-term
|
||||
if self.config["auto_promote"]:
|
||||
self._auto_promote_to_long_term()
|
||||
with self._lock:
|
||||
item = MemoryItem(
|
||||
id=self._generate_id(content),
|
||||
content=content,
|
||||
metadata=metadata or {},
|
||||
importance=importance
|
||||
)
|
||||
|
||||
# Remove oldest items
|
||||
self._short_term = self._short_term[-limit:]
|
||||
|
||||
self._save_short_term()
|
||||
self._log(f"Added short-term memory: {content[:50]}...")
|
||||
|
||||
# Emit trace event
|
||||
self._emit_memory_event("store", "short_term", len(content), metadata=metadata)
|
||||
|
||||
return item.id
|
||||
self._short_term.append(item)
|
||||
|
||||
# Enforce limit (rolling buffer)
|
||||
limit = self.config["short_term_limit"]
|
||||
if len(self._short_term) > limit:
|
||||
# Before removing, check if should promote to long-term
|
||||
if self.config["auto_promote"]:
|
||||
self._auto_promote_to_long_term()
|
||||
|
||||
# Remove oldest items
|
||||
self._short_term = self._short_term[-limit:]
|
||||
|
||||
self._save_short_term()
|
||||
self._log(f"Added short-term memory: {content[:50]}...")
|
||||
|
||||
# Emit trace event
|
||||
self._emit_memory_event("store", "short_term", len(content), metadata=metadata)
|
||||
|
||||
return item.id
|
||||
|
||||
def get_short_term(self, limit: Optional[int] = None) -> List[MemoryItem]:
|
||||
"""Get short-term memories, most recent first."""
|
||||
items = list(reversed(self._short_term))
|
||||
if limit:
|
||||
items = items[:limit]
|
||||
return items
|
||||
with self._lock:
|
||||
items = list(reversed(self._short_term))
|
||||
if limit:
|
||||
items = items[:limit]
|
||||
return items
|
||||
|
||||
def _auto_promote_to_long_term(self):
|
||||
"""Promote high-importance short-term memories to long-term."""
|
||||
# Note: This method is called within _lock context from add_short_term
|
||||
threshold = self.config["importance_threshold"]
|
||||
promoted = []
|
||||
|
||||
@@ -390,36 +395,38 @@ class FileMemory:
|
||||
Returns:
|
||||
The generated memory ID
|
||||
"""
|
||||
item = MemoryItem(
|
||||
id=self._generate_id(content),
|
||||
content=content,
|
||||
metadata=metadata or {},
|
||||
importance=importance
|
||||
)
|
||||
|
||||
self._long_term.append(item)
|
||||
|
||||
# Enforce limit
|
||||
limit = self.config["long_term_limit"]
|
||||
if len(self._long_term) > limit:
|
||||
# Remove lowest importance items
|
||||
self._long_term.sort(key=lambda x: x.importance, reverse=True)
|
||||
self._long_term = self._long_term[:limit]
|
||||
|
||||
self._save_long_term()
|
||||
self._log(f"Added long-term memory: {content[:50]}...")
|
||||
|
||||
# Emit trace event
|
||||
self._emit_memory_event("store", "long_term", len(content), metadata=metadata)
|
||||
|
||||
return item.id
|
||||
with self._lock:
|
||||
item = MemoryItem(
|
||||
id=self._generate_id(content),
|
||||
content=content,
|
||||
metadata=metadata or {},
|
||||
importance=importance
|
||||
)
|
||||
|
||||
self._long_term.append(item)
|
||||
|
||||
# Enforce limit
|
||||
limit = self.config["long_term_limit"]
|
||||
if len(self._long_term) > limit:
|
||||
# Remove lowest importance items
|
||||
self._long_term.sort(key=lambda x: x.importance, reverse=True)
|
||||
self._long_term = self._long_term[:limit]
|
||||
|
||||
self._save_long_term()
|
||||
self._log(f"Added long-term memory: {content[:50]}...")
|
||||
|
||||
# Emit trace event
|
||||
self._emit_memory_event("store", "long_term", len(content), metadata=metadata)
|
||||
|
||||
return item.id
|
||||
|
||||
def get_long_term(self, limit: Optional[int] = None) -> List[MemoryItem]:
|
||||
"""Get long-term memories, sorted by importance."""
|
||||
items = sorted(self._long_term, key=lambda x: x.importance, reverse=True)
|
||||
if limit:
|
||||
items = items[:limit]
|
||||
return items
|
||||
with self._lock:
|
||||
items = sorted(self._long_term, key=lambda x: x.importance, reverse=True)
|
||||
if limit:
|
||||
items = items[:limit]
|
||||
return items
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Entity Memory
|
||||
@@ -444,43 +451,46 @@ class FileMemory:
|
||||
Returns:
|
||||
The entity ID
|
||||
"""
|
||||
entity_id = self._generate_id(f"{name}:{entity_type}")
|
||||
|
||||
# Check if entity exists
|
||||
existing = self._find_entity_by_name(name, entity_type)
|
||||
if existing:
|
||||
# Update existing entity
|
||||
existing.attributes.update(attributes or {})
|
||||
if relationships:
|
||||
existing.relationships.extend(relationships)
|
||||
existing.updated_at = time.time()
|
||||
entity_id = existing.id
|
||||
else:
|
||||
# Create new entity
|
||||
entity = EntityItem(
|
||||
id=entity_id,
|
||||
name=name,
|
||||
entity_type=entity_type,
|
||||
attributes=attributes or {},
|
||||
relationships=relationships or []
|
||||
)
|
||||
self._entities[entity_id] = entity
|
||||
|
||||
self._save_entities()
|
||||
self._log(f"Added/updated entity: {name} ({entity_type})")
|
||||
|
||||
return entity_id
|
||||
with self._lock:
|
||||
entity_id = self._generate_id(f"{name}:{entity_type}")
|
||||
|
||||
# Check if entity exists
|
||||
existing = self._find_entity_by_name(name, entity_type)
|
||||
if existing:
|
||||
# Update existing entity
|
||||
existing.attributes.update(attributes or {})
|
||||
if relationships:
|
||||
existing.relationships.extend(relationships)
|
||||
existing.updated_at = time.time()
|
||||
entity_id = existing.id
|
||||
else:
|
||||
# Create new entity
|
||||
entity = EntityItem(
|
||||
id=entity_id,
|
||||
name=name,
|
||||
entity_type=entity_type,
|
||||
attributes=attributes or {},
|
||||
relationships=relationships or []
|
||||
)
|
||||
self._entities[entity_id] = entity
|
||||
|
||||
self._save_entities()
|
||||
self._log(f"Added/updated entity: {name} ({entity_type})")
|
||||
|
||||
return entity_id
|
||||
|
||||
def get_entity(self, name: str, entity_type: Optional[str] = None) -> Optional[EntityItem]:
|
||||
"""Get entity by name and optionally type."""
|
||||
return self._find_entity_by_name(name, entity_type)
|
||||
with self._lock:
|
||||
return self._find_entity_by_name(name, entity_type)
|
||||
|
||||
def get_all_entities(self, entity_type: Optional[str] = None) -> List[EntityItem]:
|
||||
"""Get all entities, optionally filtered by type."""
|
||||
entities = list(self._entities.values())
|
||||
if entity_type:
|
||||
entities = [e for e in entities if e.entity_type == entity_type]
|
||||
return entities
|
||||
with self._lock:
|
||||
entities = list(self._entities.values())
|
||||
if entity_type:
|
||||
entities = [e for e in entities if e.entity_type == entity_type]
|
||||
return entities
|
||||
|
||||
def _find_entity_by_name(self, name: str, entity_type: Optional[str] = None) -> Optional[EntityItem]:
|
||||
"""Find entity by name."""
|
||||
@@ -631,7 +641,9 @@ class FileMemory:
|
||||
|
||||
# Search short-term
|
||||
if "short_term" in memory_types:
|
||||
for item in self._short_term:
|
||||
with self._lock:
|
||||
short_term_items = list(self._short_term) # Create snapshot
|
||||
for item in short_term_items:
|
||||
score = score_match(item.content)
|
||||
if score > 0:
|
||||
results.append({
|
||||
@@ -642,7 +654,9 @@ class FileMemory:
|
||||
|
||||
# Search long-term
|
||||
if "long_term" in memory_types:
|
||||
for item in self._long_term:
|
||||
with self._lock:
|
||||
long_term_items = list(self._long_term) # Create snapshot
|
||||
for item in long_term_items:
|
||||
score = score_match(item.content)
|
||||
if score > 0:
|
||||
results.append({
|
||||
@@ -653,7 +667,9 @@ class FileMemory:
|
||||
|
||||
# Search entities
|
||||
if "entity" in memory_types:
|
||||
for entity in self._entities.values():
|
||||
with self._lock:
|
||||
entity_items = list(self._entities.values()) # Create snapshot
|
||||
for entity in entity_items:
|
||||
score = max(
|
||||
score_match(entity.name),
|
||||
score_match(str(entity.attributes))
|
||||
@@ -794,21 +810,23 @@ class FileMemory:
|
||||
source_count: Number of items summarized
|
||||
metadata: Optional metadata
|
||||
"""
|
||||
self._summaries.append({
|
||||
"id": self._generate_id(summary),
|
||||
"summary": summary,
|
||||
"source_type": source_type,
|
||||
"source_count": source_count,
|
||||
"metadata": metadata or {},
|
||||
"created_at": time.time()
|
||||
})
|
||||
|
||||
self._save_summaries()
|
||||
self._log(f"Added summary for {source_count} {source_type} items")
|
||||
with self._lock:
|
||||
self._summaries.append({
|
||||
"id": self._generate_id(summary),
|
||||
"summary": summary,
|
||||
"source_type": source_type,
|
||||
"source_count": source_count,
|
||||
"metadata": metadata or {},
|
||||
"created_at": time.time()
|
||||
})
|
||||
|
||||
self._save_summaries()
|
||||
self._log(f"Added summary for {source_count} {source_type} items")
|
||||
|
||||
def get_summaries(self, limit: int = 5) -> List[Dict[str, Any]]:
|
||||
"""Get recent summaries."""
|
||||
return list(reversed(self._summaries))[:limit]
|
||||
with self._lock:
|
||||
return list(reversed(self._summaries))[:limit]
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Utility Methods
|
||||
@@ -816,27 +834,29 @@ class FileMemory:
|
||||
|
||||
def clear_short_term(self):
|
||||
"""Clear all short-term memory."""
|
||||
self._short_term = []
|
||||
self._save_short_term()
|
||||
self._log("Cleared short-term memory")
|
||||
with self._lock:
|
||||
self._short_term = []
|
||||
self._save_short_term()
|
||||
self._log("Cleared short-term memory")
|
||||
|
||||
def clear_all(self):
|
||||
"""Clear all memory for this user."""
|
||||
self._short_term = []
|
||||
self._long_term = []
|
||||
self._entities = {}
|
||||
self._summaries = []
|
||||
|
||||
self._save_short_term()
|
||||
self._save_long_term()
|
||||
self._save_entities()
|
||||
self._save_summaries()
|
||||
|
||||
# Clear episodic
|
||||
for f in self.episodic_path.glob("*.json"):
|
||||
f.unlink()
|
||||
|
||||
self._log("Cleared all memory")
|
||||
with self._lock:
|
||||
self._short_term = []
|
||||
self._long_term = []
|
||||
self._entities = {}
|
||||
self._summaries = []
|
||||
|
||||
self._save_short_term()
|
||||
self._save_long_term()
|
||||
self._save_entities()
|
||||
self._save_summaries()
|
||||
|
||||
# Clear episodic
|
||||
for f in self.episodic_path.glob("*.json"):
|
||||
f.unlink()
|
||||
|
||||
self._log("Cleared all memory")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Selective Deletion
|
||||
@@ -852,13 +872,14 @@ class FileMemory:
|
||||
Returns:
|
||||
True if memory was found and deleted, False otherwise
|
||||
"""
|
||||
for i, item in enumerate(self._short_term):
|
||||
if item.id == memory_id:
|
||||
del self._short_term[i]
|
||||
self._save_short_term()
|
||||
self._log(f"Deleted short-term memory: {memory_id}")
|
||||
return True
|
||||
return False
|
||||
with self._lock:
|
||||
for i, item in enumerate(self._short_term):
|
||||
if item.id == memory_id:
|
||||
del self._short_term[i]
|
||||
self._save_short_term()
|
||||
self._log(f"Deleted short-term memory: {memory_id}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def delete_long_term(self, memory_id: str) -> bool:
|
||||
"""
|
||||
@@ -870,13 +891,14 @@ class FileMemory:
|
||||
Returns:
|
||||
True if memory was found and deleted, False otherwise
|
||||
"""
|
||||
for i, item in enumerate(self._long_term):
|
||||
if item.id == memory_id:
|
||||
del self._long_term[i]
|
||||
self._save_long_term()
|
||||
self._log(f"Deleted long-term memory: {memory_id}")
|
||||
return True
|
||||
return False
|
||||
with self._lock:
|
||||
for i, item in enumerate(self._long_term):
|
||||
if item.id == memory_id:
|
||||
del self._long_term[i]
|
||||
self._save_long_term()
|
||||
self._log(f"Deleted long-term memory: {memory_id}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def delete_entity(self, name: str) -> bool:
|
||||
"""
|
||||
@@ -888,24 +910,25 @@ class FileMemory:
|
||||
Returns:
|
||||
True if entity was found and deleted, False otherwise
|
||||
"""
|
||||
# Find entity by name using existing helper
|
||||
entity = self._find_entity_by_name(name)
|
||||
if entity:
|
||||
# Delete by entity's ID (which is the dict key)
|
||||
if entity.id in self._entities:
|
||||
del self._entities[entity.id]
|
||||
with self._lock:
|
||||
# Find entity by name using existing helper
|
||||
entity = self._find_entity_by_name(name)
|
||||
if entity:
|
||||
# Delete by entity's ID (which is the dict key)
|
||||
if entity.id in self._entities:
|
||||
del self._entities[entity.id]
|
||||
self._save_entities()
|
||||
self._log(f"Deleted entity: {name}")
|
||||
return True
|
||||
|
||||
# Try direct ID match as fallback (in case name IS the ID)
|
||||
if name in self._entities:
|
||||
del self._entities[name]
|
||||
self._save_entities()
|
||||
self._log(f"Deleted entity: {name}")
|
||||
self._log(f"Deleted entity by ID: {name}")
|
||||
return True
|
||||
|
||||
# Try direct ID match as fallback (in case name IS the ID)
|
||||
if name in self._entities:
|
||||
del self._entities[name]
|
||||
self._save_entities()
|
||||
self._log(f"Deleted entity by ID: {name}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
def delete_episodic(self, memory_id: str, date: Optional[str] = None) -> bool:
|
||||
"""
|
||||
@@ -1056,15 +1079,16 @@ class FileMemory:
|
||||
"""Get memory statistics."""
|
||||
episodic_count = sum(1 for _ in self.episodic_path.glob("*.json"))
|
||||
|
||||
return {
|
||||
"user_id": self.user_id,
|
||||
"short_term_count": len(self._short_term),
|
||||
"long_term_count": len(self._long_term),
|
||||
"entity_count": len(self._entities),
|
||||
"episodic_days": episodic_count,
|
||||
"summary_count": len(self._summaries),
|
||||
"storage_path": str(self.user_path)
|
||||
}
|
||||
with self._lock:
|
||||
return {
|
||||
"user_id": self.user_id,
|
||||
"short_term_count": len(self._short_term),
|
||||
"long_term_count": len(self._long_term),
|
||||
"entity_count": len(self._entities),
|
||||
"episodic_days": episodic_count,
|
||||
"summary_count": len(self._summaries),
|
||||
"storage_path": str(self.user_path)
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# MemoryProtocol Compliance Aliases
|
||||
@@ -1108,44 +1132,47 @@ class FileMemory:
|
||||
|
||||
def get_all_memories(self, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""Get all memories from short-term and long-term stores."""
|
||||
all_memories = []
|
||||
for item in self._short_term:
|
||||
all_memories.append({"type": "short_term", **item.to_dict()})
|
||||
for item in self._long_term:
|
||||
all_memories.append({"type": "long_term", **item.to_dict()})
|
||||
return all_memories
|
||||
with self._lock:
|
||||
all_memories = []
|
||||
for item in self._short_term:
|
||||
all_memories.append({"type": "short_term", **item.to_dict()})
|
||||
for item in self._long_term:
|
||||
all_memories.append({"type": "long_term", **item.to_dict()})
|
||||
return all_memories
|
||||
|
||||
def export(self) -> Dict[str, Any]:
|
||||
"""Export all memory data."""
|
||||
return {
|
||||
"user_id": self.user_id,
|
||||
"config": self.config,
|
||||
"short_term": [item.to_dict() for item in self._short_term],
|
||||
"long_term": [item.to_dict() for item in self._long_term],
|
||||
"entities": {k: v.to_dict() for k, v in self._entities.items()},
|
||||
"summaries": self._summaries,
|
||||
"exported_at": time.time()
|
||||
}
|
||||
with self._lock:
|
||||
return {
|
||||
"user_id": self.user_id,
|
||||
"config": self.config,
|
||||
"short_term": [item.to_dict() for item in self._short_term],
|
||||
"long_term": [item.to_dict() for item in self._long_term],
|
||||
"entities": {k: v.to_dict() for k, v in self._entities.items()},
|
||||
"summaries": list(self._summaries),
|
||||
"exported_at": time.time()
|
||||
}
|
||||
|
||||
def import_data(self, data: Dict[str, Any]):
|
||||
"""Import memory data from export."""
|
||||
if "short_term" in data:
|
||||
self._short_term = [MemoryItem.from_dict(item) for item in data["short_term"]]
|
||||
self._save_short_term()
|
||||
|
||||
if "long_term" in data:
|
||||
self._long_term = [MemoryItem.from_dict(item) for item in data["long_term"]]
|
||||
self._save_long_term()
|
||||
|
||||
if "entities" in data:
|
||||
self._entities = {k: EntityItem.from_dict(v) for k, v in data["entities"].items()}
|
||||
self._save_entities()
|
||||
|
||||
if "summaries" in data:
|
||||
self._summaries = data["summaries"]
|
||||
self._save_summaries()
|
||||
|
||||
self._log("Imported memory data")
|
||||
with self._lock:
|
||||
if "short_term" in data:
|
||||
self._short_term = [MemoryItem.from_dict(item) for item in data["short_term"]]
|
||||
self._save_short_term()
|
||||
|
||||
if "long_term" in data:
|
||||
self._long_term = [MemoryItem.from_dict(item) for item in data["long_term"]]
|
||||
self._save_long_term()
|
||||
|
||||
if "entities" in data:
|
||||
self._entities = {k: EntityItem.from_dict(v) for k, v in data["entities"].items()}
|
||||
self._save_entities()
|
||||
|
||||
if "summaries" in data:
|
||||
self._summaries = data["summaries"]
|
||||
self._save_summaries()
|
||||
|
||||
self._log("Imported memory data")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Session Management
|
||||
@@ -1175,14 +1202,20 @@ class FileMemory:
|
||||
|
||||
session_file = sessions_path / f"{name}.json"
|
||||
|
||||
# Take snapshots under lock for consistent session data
|
||||
with self._lock:
|
||||
short_term_data = [item.to_dict() for item in self._short_term]
|
||||
long_term_snapshot = [item.to_dict() for item in self._long_term[-50:]] # Last 50
|
||||
entity_ids = list(self._entities.keys())
|
||||
|
||||
session_data = {
|
||||
"name": name,
|
||||
"user_id": self.user_id,
|
||||
"saved_at": time.time(),
|
||||
"saved_at_iso": datetime.now().isoformat(),
|
||||
"short_term": [item.to_dict() for item in self._short_term],
|
||||
"long_term_snapshot": [item.to_dict() for item in self._long_term[-50:]], # Last 50
|
||||
"entity_ids": list(self._entities.keys()),
|
||||
"short_term": short_term_data,
|
||||
"long_term_snapshot": long_term_snapshot,
|
||||
"entity_ids": entity_ids,
|
||||
"conversation_history": conversation_history or [],
|
||||
"metadata": metadata or {},
|
||||
"config": self.config
|
||||
@@ -1215,8 +1248,9 @@ class FileMemory:
|
||||
|
||||
# Restore short-term memory from session
|
||||
if "short_term" in session_data:
|
||||
self._short_term = [MemoryItem.from_dict(item) for item in session_data["short_term"]]
|
||||
self._save_short_term()
|
||||
with self._lock:
|
||||
self._short_term = [MemoryItem.from_dict(item) for item in session_data["short_term"]]
|
||||
self._save_short_term()
|
||||
|
||||
self._log(f"Resumed session '{name}'")
|
||||
|
||||
@@ -1287,14 +1321,16 @@ class FileMemory:
|
||||
Returns:
|
||||
The generated summary
|
||||
"""
|
||||
if len(self._short_term) <= max_items:
|
||||
return "" # No compression needed
|
||||
# Check length and gather content under lock
|
||||
with self._lock:
|
||||
if len(self._short_term) <= max_items:
|
||||
return "" # No compression needed
|
||||
|
||||
# Gather content to compress while holding lock
|
||||
items_to_compress = self._short_term[:-max_items]
|
||||
content_list = [item.content for item in items_to_compress]
|
||||
|
||||
# Gather content to compress
|
||||
items_to_compress = self._short_term[:-max_items]
|
||||
content_list = [item.content for item in items_to_compress]
|
||||
|
||||
# Generate summary
|
||||
# Generate summary OUTSIDE lock (LLM call may be slow)
|
||||
if llm_func:
|
||||
prompt = f"""Summarize the following conversation context into key points.
|
||||
Preserve important facts, decisions, and context.
|
||||
@@ -1309,16 +1345,17 @@ Summary:"""
|
||||
# Simple concatenation if no LLM
|
||||
summary = "Compressed context: " + " | ".join(content_list[:5]) + "..."
|
||||
|
||||
# Add summary as a high-importance long-term memory
|
||||
# Add summary as a high-importance long-term memory (add_long_term has its own lock)
|
||||
self.add_long_term(
|
||||
content=f"[Session Summary] {summary}",
|
||||
metadata={"type": "compression_summary", "items_compressed": len(items_to_compress)},
|
||||
importance=0.9
|
||||
)
|
||||
|
||||
# Keep only recent items
|
||||
self._short_term = self._short_term[-max_items:]
|
||||
self._save_short_term()
|
||||
# Keep only recent items under lock
|
||||
with self._lock:
|
||||
self._short_term = self._short_term[-max_items:]
|
||||
self._save_short_term()
|
||||
|
||||
self._log(f"Compressed {len(items_to_compress)} items into summary")
|
||||
|
||||
@@ -1567,7 +1604,9 @@ Summary:"""
|
||||
})
|
||||
|
||||
if memory_type in ("all", "entity", "entities"):
|
||||
for name, entity in self._entities.items():
|
||||
with self._lock:
|
||||
entities_snapshot = dict(self._entities)
|
||||
for name, entity in entities_snapshot.items():
|
||||
items.append({
|
||||
"type": "entity",
|
||||
"id": name,
|
||||
|
||||
@@ -3,6 +3,7 @@ import sqlite3
|
||||
import json
|
||||
import time
|
||||
import shutil
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional, Union, Literal
|
||||
import logging
|
||||
from datetime import datetime
|
||||
@@ -23,102 +24,147 @@ def trace(self, message, *args, **kwargs):
|
||||
|
||||
logging.Logger.trace = trace
|
||||
|
||||
# Lazy availability flags and cached imports
|
||||
_chromadb_cache = {"available": None, "module": None, "settings": None}
|
||||
_mem0_cache = {"available": None, "module": None}
|
||||
_openai_cache = {"available": None, "module": None}
|
||||
_litellm_cache = {"available": None, "module": None}
|
||||
_pymongo_cache = {"available": None, "module": None, "client": None}
|
||||
# Thread-safe lazy imports using proper thread synchronization
|
||||
import threading
|
||||
|
||||
# Thread-safe import cache
|
||||
_import_lock = threading.Lock()
|
||||
_module_cache = {}
|
||||
|
||||
def _check_chromadb():
|
||||
"""Lazily check chromadb availability and cache."""
|
||||
if _chromadb_cache["available"] is None:
|
||||
"""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
|
||||
_chromadb_cache["available"] = True
|
||||
_chromadb_cache["module"] = chromadb
|
||||
_chromadb_cache["settings"] = ChromaSettings
|
||||
_module_cache["chromadb"] = {
|
||||
"available": True,
|
||||
"module": chromadb,
|
||||
"settings": ChromaSettings
|
||||
}
|
||||
except ImportError:
|
||||
_chromadb_cache["available"] = False
|
||||
return _chromadb_cache["available"]
|
||||
_module_cache["chromadb"] = {"available": False}
|
||||
|
||||
return _module_cache["chromadb"]["available"]
|
||||
|
||||
def _get_chromadb():
|
||||
"""Get chromadb module (lazy load)."""
|
||||
"""Get chromadb module and settings (thread-safe lazy load)."""
|
||||
if not _check_chromadb():
|
||||
raise ImportError("chromadb is required. Install with: pip install chromadb")
|
||||
return _chromadb_cache["module"], _chromadb_cache["settings"]
|
||||
return _module_cache["chromadb"]["module"], _module_cache["chromadb"]["settings"]
|
||||
|
||||
def _check_mem0():
|
||||
"""Lazily check mem0 availability."""
|
||||
if _mem0_cache["available"] is None:
|
||||
"""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
|
||||
_mem0_cache["available"] = True
|
||||
_mem0_cache["module"] = mem0
|
||||
_module_cache["mem0"] = {
|
||||
"available": True,
|
||||
"module": mem0
|
||||
}
|
||||
except ImportError:
|
||||
_mem0_cache["available"] = False
|
||||
return _mem0_cache["available"]
|
||||
_module_cache["mem0"] = {"available": False}
|
||||
|
||||
return _module_cache["mem0"]["available"]
|
||||
|
||||
def _get_mem0():
|
||||
"""Get mem0 module (lazy load)."""
|
||||
"""Get mem0 module (thread-safe lazy load)."""
|
||||
if not _check_mem0():
|
||||
raise ImportError("mem0 is required. Install with: pip install mem0ai")
|
||||
return _mem0_cache["module"]
|
||||
return _module_cache["mem0"]["module"]
|
||||
|
||||
def _check_openai():
|
||||
"""Lazily check openai availability."""
|
||||
if _openai_cache["available"] is None:
|
||||
"""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
|
||||
_openai_cache["available"] = True
|
||||
_openai_cache["module"] = openai
|
||||
_module_cache["openai"] = {
|
||||
"available": True,
|
||||
"module": openai
|
||||
}
|
||||
except ImportError:
|
||||
_openai_cache["available"] = False
|
||||
return _openai_cache["available"]
|
||||
_module_cache["openai"] = {"available": False}
|
||||
|
||||
return _module_cache["openai"]["available"]
|
||||
|
||||
def _get_openai():
|
||||
"""Get openai module (lazy load)."""
|
||||
"""Get openai module (thread-safe lazy load)."""
|
||||
if not _check_openai():
|
||||
raise ImportError("openai is required. Install with: pip install openai")
|
||||
return _openai_cache["module"]
|
||||
return _module_cache["openai"]["module"]
|
||||
|
||||
def _check_litellm():
|
||||
"""Lazily check litellm availability."""
|
||||
if _litellm_cache["available"] is None:
|
||||
"""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
|
||||
_litellm_cache["available"] = True
|
||||
_litellm_cache["module"] = litellm
|
||||
_module_cache["litellm"] = {
|
||||
"available": True,
|
||||
"module": litellm
|
||||
}
|
||||
except ImportError:
|
||||
_litellm_cache["available"] = False
|
||||
return _litellm_cache["available"]
|
||||
_module_cache["litellm"] = {"available": False}
|
||||
|
||||
return _module_cache["litellm"]["available"]
|
||||
|
||||
def _get_litellm():
|
||||
"""Get litellm module (lazy load)."""
|
||||
"""Get litellm module (thread-safe lazy load)."""
|
||||
if not _check_litellm():
|
||||
raise ImportError("litellm is required. Install with: pip install litellm")
|
||||
return _litellm_cache["module"]
|
||||
return _module_cache["litellm"]["module"]
|
||||
|
||||
def _check_pymongo():
|
||||
"""Lazily check pymongo availability."""
|
||||
if _pymongo_cache["available"] is None:
|
||||
"""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
|
||||
_pymongo_cache["available"] = True
|
||||
_pymongo_cache["module"] = pymongo
|
||||
_pymongo_cache["client"] = MongoClient
|
||||
_module_cache["pymongo"] = {
|
||||
"available": True,
|
||||
"module": pymongo,
|
||||
"client": MongoClient
|
||||
}
|
||||
except ImportError:
|
||||
_pymongo_cache["available"] = False
|
||||
return _pymongo_cache["available"]
|
||||
_module_cache["pymongo"] = {"available": False}
|
||||
|
||||
return _module_cache["pymongo"]["available"]
|
||||
|
||||
def _get_pymongo():
|
||||
"""Get pymongo module and MongoClient (lazy load)."""
|
||||
"""Get pymongo module and MongoClient (thread-safe lazy load)."""
|
||||
if not _check_pymongo():
|
||||
raise ImportError("pymongo is required. Install with: pip install pymongo")
|
||||
return _pymongo_cache["module"], _pymongo_cache["client"]
|
||||
return _module_cache["pymongo"]["module"], _module_cache["pymongo"]["client"]
|
||||
|
||||
|
||||
|
||||
@@ -189,6 +235,16 @@ class Memory:
|
||||
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(logging.INFO)
|
||||
@@ -247,6 +303,50 @@ class Memory:
|
||||
elif self.use_rag:
|
||||
self._init_chroma()
|
||||
|
||||
def _get_stm_conn(self):
|
||||
"""Get thread-local short-term memory SQLite connection."""
|
||||
if not hasattr(self._local, 'stm_conn') or self._local.stm_conn is None:
|
||||
self._local.stm_conn = sqlite3.connect(
|
||||
self.short_db,
|
||||
check_same_thread=False, # Allow cross-thread cleanup
|
||||
timeout=30.0 # 30 second timeout for lock contention
|
||||
)
|
||||
# Configure busy timeout for better contention handling
|
||||
self._local.stm_conn.execute("PRAGMA busy_timeout=30000") # 30 seconds
|
||||
|
||||
# Enable WAL mode for concurrent read/write without blocking
|
||||
result = self._local.stm_conn.execute("PRAGMA journal_mode=WAL").fetchone()
|
||||
if result and result[0].upper() != 'WAL':
|
||||
logger.warning(f"WAL mode not enabled for STM, got: {result[0]}")
|
||||
self._local.stm_conn.commit()
|
||||
|
||||
# Register connection for cleanup
|
||||
with self._connection_lock:
|
||||
self._all_connections.add(self._local.stm_conn)
|
||||
return self._local.stm_conn
|
||||
|
||||
def _get_ltm_conn(self):
|
||||
"""Get thread-local long-term memory SQLite connection."""
|
||||
if not hasattr(self._local, 'ltm_conn') or self._local.ltm_conn is None:
|
||||
self._local.ltm_conn = sqlite3.connect(
|
||||
self.long_db,
|
||||
check_same_thread=False, # Allow cross-thread cleanup
|
||||
timeout=30.0 # 30 second timeout for lock contention
|
||||
)
|
||||
# Configure busy timeout for better contention handling
|
||||
self._local.ltm_conn.execute("PRAGMA busy_timeout=30000") # 30 seconds
|
||||
|
||||
# Enable WAL mode for concurrent read/write without blocking
|
||||
result = self._local.ltm_conn.execute("PRAGMA journal_mode=WAL").fetchone()
|
||||
if result and result[0].upper() != 'WAL':
|
||||
logger.warning(f"WAL mode not enabled for LTM, got: {result[0]}")
|
||||
self._local.ltm_conn.commit()
|
||||
|
||||
# Register connection for cleanup
|
||||
with self._connection_lock:
|
||||
self._all_connections.add(self._local.ltm_conn)
|
||||
return self._local.ltm_conn
|
||||
|
||||
def _log_verbose(self, msg: str, level: int = logging.INFO):
|
||||
"""Only log if verbose >= 5"""
|
||||
if self.verbose >= 5:
|
||||
@@ -276,7 +376,7 @@ class Memory:
|
||||
def _init_stm(self):
|
||||
"""Creates or verifies short-term memory table."""
|
||||
os.makedirs(os.path.dirname(self.short_db) or ".", exist_ok=True)
|
||||
conn = sqlite3.connect(self.short_db)
|
||||
conn = self._get_stm_conn()
|
||||
c = conn.cursor()
|
||||
c.execute("""
|
||||
CREATE TABLE IF NOT EXISTS short_mem (
|
||||
@@ -287,12 +387,11 @@ class Memory:
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def _init_ltm(self):
|
||||
"""Creates or verifies long-term memory table."""
|
||||
os.makedirs(os.path.dirname(self.long_db) or ".", exist_ok=True)
|
||||
conn = sqlite3.connect(self.long_db)
|
||||
conn = self._get_ltm_conn()
|
||||
c = conn.cursor()
|
||||
c.execute("""
|
||||
CREATE TABLE IF NOT EXISTS long_mem (
|
||||
@@ -303,7 +402,6 @@ class Memory:
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def _init_mem0(self):
|
||||
"""Initialize Mem0 client for agent or user memory with optional graph support."""
|
||||
@@ -579,15 +677,15 @@ class Memory:
|
||||
logger.error(f"Failed to store in MongoDB short-term memory: {e}")
|
||||
raise
|
||||
|
||||
# Existing SQLite store logic
|
||||
# Existing SQLite store logic (with write lock for concurrency safety)
|
||||
try:
|
||||
conn = sqlite3.connect(self.short_db)
|
||||
conn.execute(
|
||||
"INSERT INTO short_mem (id, content, meta, created_at) VALUES (?,?,?,?)",
|
||||
(ident, text, json.dumps(metadata), created_at)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
conn = self._get_stm_conn()
|
||||
with self._write_lock: # Serialize write operations
|
||||
conn.execute(
|
||||
"INSERT INTO short_mem (id, content, meta, created_at) VALUES (?,?,?,?)",
|
||||
(ident, text, json.dumps(metadata), created_at)
|
||||
)
|
||||
conn.commit()
|
||||
logger.info(f"Successfully stored in SQLite short-term memory with ID: {ident}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store in SQLite short-term memory: {e}")
|
||||
@@ -713,13 +811,12 @@ class Memory:
|
||||
|
||||
else:
|
||||
# Local fallback
|
||||
conn = sqlite3.connect(self.short_db)
|
||||
conn = self._get_stm_conn()
|
||||
c = conn.cursor()
|
||||
rows = c.execute(
|
||||
"SELECT id, content, meta FROM short_mem WHERE content LIKE ? LIMIT ?",
|
||||
(f"%{query}%", limit)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
results = []
|
||||
for row in rows:
|
||||
@@ -739,10 +836,10 @@ class Memory:
|
||||
|
||||
def reset_short_term(self):
|
||||
"""Completely clears short-term memory."""
|
||||
conn = sqlite3.connect(self.short_db)
|
||||
conn.execute("DELETE FROM short_mem")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
conn = self._get_stm_conn()
|
||||
with self._write_lock: # Serialize write operations
|
||||
conn.execute("DELETE FROM short_mem")
|
||||
conn.commit()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Long-Term Methods
|
||||
@@ -813,15 +910,15 @@ class Memory:
|
||||
logger.error(f"Failed to store in MongoDB long-term memory: {e}")
|
||||
# Continue to SQLite fallback
|
||||
|
||||
# Store in SQLite
|
||||
# Store in SQLite (with write lock for concurrency safety)
|
||||
try:
|
||||
conn = sqlite3.connect(self.long_db)
|
||||
conn.execute(
|
||||
"INSERT INTO long_mem (id, content, meta, created_at) VALUES (?,?,?,?)",
|
||||
(ident, text, json.dumps(metadata), created)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
conn = self._get_ltm_conn()
|
||||
with self._write_lock: # Serialize write operations
|
||||
conn.execute(
|
||||
"INSERT INTO long_mem (id, content, meta, created_at) VALUES (?,?,?,?)",
|
||||
(ident, text, json.dumps(metadata), created)
|
||||
)
|
||||
conn.commit()
|
||||
logger.info(f"Successfully stored in SQLite with ID: {ident}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error storing in SQLite: {e}")
|
||||
@@ -1002,13 +1099,12 @@ class Memory:
|
||||
self._log_verbose(f"Error searching ChromaDB: {e}", logging.ERROR)
|
||||
|
||||
# Always try SQLite as fallback or additional source
|
||||
conn = sqlite3.connect(self.long_db)
|
||||
conn = self._get_ltm_conn()
|
||||
c = conn.cursor()
|
||||
rows = c.execute(
|
||||
"SELECT id, content, meta, created_at FROM long_mem WHERE content LIKE ? LIMIT ?",
|
||||
(f"%{query}%", limit)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
for row in rows:
|
||||
meta = json.loads(row[2] or "{}")
|
||||
@@ -1051,10 +1147,10 @@ class Memory:
|
||||
|
||||
def reset_long_term(self):
|
||||
"""Clear local LTM DB, plus Chroma, MongoDB, or mem0 if in use."""
|
||||
conn = sqlite3.connect(self.long_db)
|
||||
conn.execute("DELETE FROM long_mem")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
conn = self._get_ltm_conn()
|
||||
with self._write_lock: # Serialize write operations
|
||||
conn.execute("DELETE FROM long_mem")
|
||||
conn.commit()
|
||||
|
||||
if self.use_mem0 and hasattr(self, "mem0_client"):
|
||||
# Mem0 has no universal reset API. Could implement partial or no-op.
|
||||
@@ -1085,16 +1181,16 @@ class Memory:
|
||||
"""
|
||||
deleted = False
|
||||
|
||||
# Delete from SQLite
|
||||
# Delete from SQLite (with write lock for concurrency safety)
|
||||
try:
|
||||
conn = sqlite3.connect(self.short_db)
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM short_mem WHERE id = ?", (memory_id,)
|
||||
)
|
||||
if cursor.rowcount > 0:
|
||||
deleted = True
|
||||
conn.commit()
|
||||
conn.close()
|
||||
conn = self._get_stm_conn()
|
||||
with self._write_lock: # Serialize write operations
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM short_mem WHERE id = ?", (memory_id,)
|
||||
)
|
||||
if cursor.rowcount > 0:
|
||||
deleted = True
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
self._log_verbose(f"Error deleting from SQLite short-term: {e}", logging.ERROR)
|
||||
|
||||
@@ -1126,16 +1222,16 @@ class Memory:
|
||||
"""
|
||||
deleted = False
|
||||
|
||||
# Delete from SQLite
|
||||
# Delete from SQLite (with write lock for concurrency safety)
|
||||
try:
|
||||
conn = sqlite3.connect(self.long_db)
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM long_mem WHERE id = ?", (memory_id,)
|
||||
)
|
||||
if cursor.rowcount > 0:
|
||||
deleted = True
|
||||
conn.commit()
|
||||
conn.close()
|
||||
conn = self._get_ltm_conn()
|
||||
with self._write_lock: # Serialize write operations
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM long_mem WHERE id = ?", (memory_id,)
|
||||
)
|
||||
if cursor.rowcount > 0:
|
||||
deleted = True
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
self._log_verbose(f"Error deleting from SQLite long-term: {e}", logging.ERROR)
|
||||
|
||||
@@ -1790,10 +1886,9 @@ class Memory:
|
||||
|
||||
try:
|
||||
# Get short-term memories
|
||||
conn = sqlite3.connect(self.short_db)
|
||||
conn = self._get_stm_conn()
|
||||
c = conn.cursor()
|
||||
rows = c.execute("SELECT id, content, meta, created_at FROM short_mem").fetchall()
|
||||
conn.close()
|
||||
|
||||
for row in rows:
|
||||
meta = json.loads(row[2] or "{}")
|
||||
@@ -1806,10 +1901,9 @@ class Memory:
|
||||
})
|
||||
|
||||
# Get long-term memories
|
||||
conn = sqlite3.connect(self.long_db)
|
||||
conn = self._get_ltm_conn()
|
||||
c = conn.cursor()
|
||||
rows = c.execute("SELECT id, content, meta, created_at FROM long_mem").fetchall()
|
||||
conn.close()
|
||||
|
||||
for row in rows:
|
||||
meta = json.loads(row[2] or "{}")
|
||||
@@ -1873,3 +1967,57 @@ class Memory:
|
||||
if self.learn is None:
|
||||
return ""
|
||||
return self.learn.to_system_prompt_context()
|
||||
|
||||
def close_connections(self):
|
||||
"""
|
||||
Close database connections.
|
||||
|
||||
Closes the current thread's connections and attempts to close all known
|
||||
connections from other threads. Each thread should call this method before
|
||||
terminating to ensure proper cleanup.
|
||||
"""
|
||||
# Close current thread's connections
|
||||
if hasattr(self._local, 'stm_conn') and self._local.stm_conn:
|
||||
try:
|
||||
self._local.stm_conn.close()
|
||||
self._local.stm_conn = None
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing current thread's STM connection: {e}")
|
||||
|
||||
if hasattr(self._local, 'ltm_conn') and self._local.ltm_conn:
|
||||
try:
|
||||
self._local.ltm_conn.close()
|
||||
self._local.ltm_conn = None
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing current thread's LTM connection: {e}")
|
||||
|
||||
# Close all known connections from the registry
|
||||
with self._connection_lock: # Ensure thread safety during cleanup
|
||||
connections_to_close = list(self._all_connections)
|
||||
for conn in connections_to_close:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.debug(f"Error closing registered connection: {e}")
|
||||
# Clear the registry
|
||||
self._all_connections.clear()
|
||||
|
||||
def __enter__(self):
|
||||
"""Allow Memory to be used as a context manager."""
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Ensure connections are closed when leaving a context manager block."""
|
||||
self.close_connections()
|
||||
|
||||
def __del__(self):
|
||||
"""
|
||||
Attempt to clean up any open SQLite connections when this instance
|
||||
is garbage-collected. Errors are suppressed to avoid issues during
|
||||
interpreter shutdown.
|
||||
"""
|
||||
try:
|
||||
self.close_connections()
|
||||
except Exception:
|
||||
# Best-effort cleanup during garbage collection
|
||||
pass
|
||||
@@ -0,0 +1,543 @@
|
||||
"""
|
||||
Editor-Style Output Mode for PraisonAI Agents.
|
||||
|
||||
Provides a user-friendly display format with:
|
||||
- Numbered steps: Step 1: 📄 Creating file: /path
|
||||
- Human-readable tool names (not technical names)
|
||||
- Smart result formatting (JSON → summary, exit → ✓ Done)
|
||||
- Completion summary with duration and block count
|
||||
|
||||
Usage:
|
||||
# Via preset:
|
||||
agent = Agent(output="editor")
|
||||
|
||||
# Programmatic:
|
||||
from praisonaiagents.output.editor import enable_editor_output
|
||||
enable_editor_output()
|
||||
"""
|
||||
|
||||
import time
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
from enum import Enum
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Module-level state (same pattern as status.py)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
_editor_output_enabled = False
|
||||
_editor_output: Optional['EditorOutput'] = None
|
||||
|
||||
|
||||
class BlockType(Enum):
|
||||
"""Types of display blocks."""
|
||||
NARRATIVE = "narrative"
|
||||
COMMAND = "command"
|
||||
SUMMARY = "summary"
|
||||
ACTION = "action"
|
||||
CODE = "code"
|
||||
LIST = "list"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DisplayBlock:
|
||||
"""A single display block."""
|
||||
type: BlockType
|
||||
content: str
|
||||
title: Optional[str] = None
|
||||
items: List[str] = field(default_factory=list)
|
||||
output: Optional[str] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
# Human-readable tool name mappings
|
||||
TOOL_LABELS: Dict[str, tuple] = {
|
||||
# Web / search
|
||||
'internet_search': ('🔍', 'Searching the web'),
|
||||
'search_web': ('🔍', 'Searching the web'),
|
||||
'web_search': ('🔍', 'Searching the web'),
|
||||
# File operations
|
||||
'read_file': ('📖', 'Reading file'),
|
||||
'write_file': ('📝', 'Writing file'),
|
||||
'create_file': ('📄', 'Creating file'),
|
||||
'acp_create_file': ('📄', 'Creating file'),
|
||||
'acp_edit_file': ('✏️', 'Editing file'),
|
||||
'acp_delete_file': ('🗑️', 'Deleting file'),
|
||||
# Execution
|
||||
'execute_command': ('⚡', 'Running command'),
|
||||
'acp_execute_command': ('⚡', 'Running command'),
|
||||
# Directory
|
||||
'list_files': ('📂', 'Listing files'),
|
||||
'get_system_info': ('💻', 'Getting system info'),
|
||||
# LSP
|
||||
'lsp_get_diagnostics': ('🔬', 'Analyzing code'),
|
||||
'lsp_list_symbols': ('🔎', 'Finding code symbols'),
|
||||
'lsp_find_definition': ('📍', 'Finding definition'),
|
||||
'lsp_find_references': ('🔗', 'Finding references'),
|
||||
}
|
||||
|
||||
|
||||
class EditorOutput:
|
||||
"""
|
||||
User-friendly display for agent output.
|
||||
|
||||
Renders tool calls as numbered steps with emoji icons and
|
||||
human-readable labels. Formats results smartly (JSON → summary).
|
||||
|
||||
Thread-safe for multi-agent execution.
|
||||
"""
|
||||
|
||||
def __init__(self, console=None, use_rich: bool = True, agent_name: Optional[str] = None):
|
||||
self._use_rich = use_rich
|
||||
self._console = console
|
||||
self._blocks: List[DisplayBlock] = []
|
||||
self._start_time = time.time()
|
||||
self._step_count = 0
|
||||
self._llm_call_count = 0
|
||||
self._lock = threading.Lock()
|
||||
self._agent_name = agent_name # For multi-agent prefix support
|
||||
self._multi_agent_mode = False # Set True when multiple agents detected
|
||||
|
||||
if use_rich and console is None:
|
||||
try:
|
||||
from rich.console import Console
|
||||
self._console = Console()
|
||||
except ImportError:
|
||||
self._use_rich = False
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Public API
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def tool_call(
|
||||
self,
|
||||
tool_name: str,
|
||||
args: Optional[Dict[str, Any]] = None,
|
||||
result: Optional[str] = None,
|
||||
duration: Optional[float] = None,
|
||||
) -> None:
|
||||
"""Display a tool call as a numbered step with emoji."""
|
||||
with self._lock:
|
||||
self._step_count += 1
|
||||
step_num = self._step_count
|
||||
|
||||
# Get human-readable label
|
||||
icon, label = TOOL_LABELS.get(tool_name, ('🔧', f'Using {tool_name}'))
|
||||
|
||||
# Format action description with step number and context
|
||||
step_prefix = f"Step {step_num}: "
|
||||
action = self._format_action(step_prefix, icon, label, args)
|
||||
|
||||
# Format result for display
|
||||
display_result = self._format_result(result)
|
||||
|
||||
self._add_block(BlockType.COMMAND, action, output=display_result)
|
||||
self._render_command(action, display_result)
|
||||
|
||||
def llm_indicator(self, phase: str = "thinking") -> None:
|
||||
"""Display a subtle LLM activity indicator between steps."""
|
||||
if self._use_rich:
|
||||
self._console.print(f"[dim]▸ {phase.capitalize()}...[/dim]")
|
||||
else:
|
||||
print(f"▸ {phase.capitalize()}...")
|
||||
|
||||
def output(self, content: str, agent_name: Optional[str] = None) -> None:
|
||||
"""Display final agent output."""
|
||||
if self._use_rich:
|
||||
self._console.print()
|
||||
self._console.print(content)
|
||||
self._console.print()
|
||||
else:
|
||||
print()
|
||||
print(content)
|
||||
print()
|
||||
|
||||
def summary(self, title: str, items: Optional[List[str]] = None) -> None:
|
||||
"""Display completion summary."""
|
||||
self._add_block(BlockType.SUMMARY, "", title=title, items=items or [])
|
||||
self._render_summary(title, items)
|
||||
|
||||
def elapsed_time(self) -> float:
|
||||
"""Get elapsed time since display started."""
|
||||
return time.time() - self._start_time
|
||||
|
||||
def get_blocks(self) -> List[DisplayBlock]:
|
||||
"""Get all display blocks."""
|
||||
with self._lock:
|
||||
return self._blocks.copy()
|
||||
|
||||
def set_agent_name(self, name: str) -> None:
|
||||
"""Set the agent name for multi-agent prefix."""
|
||||
with self._lock:
|
||||
self._agent_name = name
|
||||
|
||||
def enable_multi_agent_mode(self) -> None:
|
||||
"""Enable multi-agent mode to show agent prefixes."""
|
||||
with self._lock:
|
||||
self._multi_agent_mode = True
|
||||
|
||||
def _get_prefix(self, agent_name: Optional[str] = None) -> str:
|
||||
"""Get the agent prefix for multi-agent mode."""
|
||||
if not self._multi_agent_mode:
|
||||
return ""
|
||||
name = agent_name or self._agent_name
|
||||
if name:
|
||||
return f"[{name}] "
|
||||
return ""
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Additional display methods (merged from CLI EditorDisplay)
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def narrative(self, text: str, agent_name: Optional[str] = None) -> None:
|
||||
"""
|
||||
Display a narrative block (agent thinking/explanation).
|
||||
|
||||
Args:
|
||||
text: The narrative text
|
||||
agent_name: Optional agent name for multi-agent prefix
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return
|
||||
self._add_block(BlockType.NARRATIVE, text)
|
||||
prefix = self._get_prefix(agent_name)
|
||||
if self._use_rich:
|
||||
self._console.print(f"{prefix}{text}")
|
||||
else:
|
||||
print(f"{prefix}{text}")
|
||||
|
||||
def code(self, code: str, language: str = "") -> None:
|
||||
"""
|
||||
Display a code block.
|
||||
|
||||
Args:
|
||||
code: The code content
|
||||
language: Programming language for syntax highlighting
|
||||
"""
|
||||
self._add_block(BlockType.CODE, code, metadata={"language": language})
|
||||
if self._use_rich:
|
||||
try:
|
||||
from rich.syntax import Syntax
|
||||
syntax = Syntax(code, language or "text", theme="monokai")
|
||||
self._console.print(syntax)
|
||||
except ImportError:
|
||||
self._console.print(f"```{language}\n{code}\n```")
|
||||
else:
|
||||
print(f"```{language}")
|
||||
print(code)
|
||||
print("```")
|
||||
|
||||
def action(self, title: str, details: Optional[List[str]] = None) -> None:
|
||||
"""
|
||||
Display an action summary block.
|
||||
|
||||
Args:
|
||||
title: Action title (e.g., "Changes applied")
|
||||
details: List of detail items
|
||||
"""
|
||||
self._add_block(BlockType.ACTION, "", title=title, items=details or [])
|
||||
if self._use_rich:
|
||||
self._console.print(f"[bold green]✓ {title}[/bold green]")
|
||||
if details:
|
||||
for item in details:
|
||||
self._console.print(f" - {item}")
|
||||
else:
|
||||
print(f"✓ {title}")
|
||||
if details:
|
||||
for item in details:
|
||||
print(f" - {item}")
|
||||
|
||||
def list_items(self, items: List[str], title: Optional[str] = None) -> None:
|
||||
"""
|
||||
Display a bulleted list.
|
||||
|
||||
Args:
|
||||
items: List items
|
||||
title: Optional title
|
||||
"""
|
||||
self._add_block(BlockType.LIST, "", title=title, items=items)
|
||||
if self._use_rich:
|
||||
if title:
|
||||
self._console.print(f"[bold]{title}[/bold]")
|
||||
for item in items:
|
||||
self._console.print(f" • {item}")
|
||||
else:
|
||||
if title:
|
||||
print(title)
|
||||
for item in items:
|
||||
print(f" • {item}")
|
||||
|
||||
def error(self, message: str, agent_name: Optional[str] = None) -> None:
|
||||
"""
|
||||
Display an error message.
|
||||
|
||||
Args:
|
||||
message: Error message
|
||||
agent_name: Optional agent name for multi-agent prefix
|
||||
"""
|
||||
prefix = self._get_prefix(agent_name)
|
||||
if self._use_rich:
|
||||
self._console.print(f"[red]{prefix}✗ Error: {message}[/red]")
|
||||
else:
|
||||
print(f"{prefix}✗ Error: {message}")
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
"""
|
||||
Export all blocks as Markdown.
|
||||
|
||||
Returns:
|
||||
Markdown string representation of all blocks
|
||||
"""
|
||||
lines = []
|
||||
with self._lock:
|
||||
blocks = self._blocks.copy()
|
||||
|
||||
for block in blocks:
|
||||
if block.type == BlockType.NARRATIVE:
|
||||
lines.append(block.content)
|
||||
lines.append("")
|
||||
elif block.type == BlockType.COMMAND:
|
||||
lines.append(f"**{block.content}**")
|
||||
if block.output:
|
||||
lines.append(f"> {block.output}")
|
||||
lines.append("")
|
||||
elif block.type == BlockType.CODE:
|
||||
lang = block.metadata.get("language", "")
|
||||
lines.append(f"```{lang}")
|
||||
lines.append(block.content)
|
||||
lines.append("```")
|
||||
lines.append("")
|
||||
elif block.type == BlockType.SUMMARY:
|
||||
if block.title:
|
||||
lines.append(f"## {block.title}")
|
||||
if block.items:
|
||||
for item in block.items:
|
||||
lines.append(f"- {item}")
|
||||
lines.append("")
|
||||
elif block.type == BlockType.ACTION:
|
||||
if block.title:
|
||||
lines.append(f"✓ **{block.title}**")
|
||||
if block.items:
|
||||
for item in block.items:
|
||||
lines.append(f" - {item}")
|
||||
lines.append("")
|
||||
elif block.type == BlockType.LIST:
|
||||
if block.title:
|
||||
lines.append(f"**{block.title}**")
|
||||
for item in block.items:
|
||||
lines.append(f"- {item}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Formatting helpers
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _format_action(
|
||||
step_prefix: str,
|
||||
icon: str,
|
||||
label: str,
|
||||
args: Optional[Dict[str, Any]],
|
||||
) -> str:
|
||||
"""Build human-readable action string from tool args."""
|
||||
if not args:
|
||||
return f"{step_prefix}{icon} {label}"
|
||||
|
||||
# Extract the most meaningful argument for context
|
||||
for key in ('query', 'filepath', 'file_path', 'directory', 'command'):
|
||||
if key in args:
|
||||
val = args[key]
|
||||
if key == 'query':
|
||||
return f'{step_prefix}{icon} {label} for "{val}"'
|
||||
return f"{step_prefix}{icon} {label}: {val}"
|
||||
|
||||
return f"{step_prefix}{icon} {label}"
|
||||
|
||||
@staticmethod
|
||||
def _format_result(result: Optional[str]) -> Optional[str]:
|
||||
"""Convert raw tool result to a user-friendly summary."""
|
||||
if not result:
|
||||
return None
|
||||
|
||||
result_str = str(result)
|
||||
|
||||
# JSON → summary
|
||||
if result_str.startswith(('[', '{')):
|
||||
try:
|
||||
import json
|
||||
data = json.loads(result_str)
|
||||
if isinstance(data, list):
|
||||
return f"✓ Found {len(data)} items"
|
||||
if isinstance(data, dict):
|
||||
if data.get('success') is True:
|
||||
return f"✓ Created: {data['file_created']}" if data.get('file_created') else "✓ Success"
|
||||
if data.get('success') is False:
|
||||
return f"⚠ Failed: {data.get('error', 'Unknown')[:80]}"
|
||||
if data.get('error'):
|
||||
return f"⚠ {data['error'][:80]}"
|
||||
if data.get('stdout'):
|
||||
first_line = data['stdout'].strip().split('\n')[0][:80]
|
||||
return f"✓ {first_line}"
|
||||
if data.get('exit_code') == 0:
|
||||
return "✓ Command completed"
|
||||
return "✓ Done"
|
||||
except (ValueError, TypeError):
|
||||
return "✓ Done"
|
||||
|
||||
# Boolean strings
|
||||
if result_str.lower() == 'true':
|
||||
return "✓ Success"
|
||||
if result_str.lower() == 'false':
|
||||
return "⚠ Failed"
|
||||
|
||||
# Short plain text → show it; long text → "Done"
|
||||
if len(result_str) <= 80 and '\n' not in result_str:
|
||||
return result_str
|
||||
return "✓ Done"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Internal rendering
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _add_block(self, block_type, content, **kwargs):
|
||||
block = DisplayBlock(type=block_type, content=content, **kwargs)
|
||||
with self._lock:
|
||||
self._blocks.append(block)
|
||||
|
||||
def _render_command(self, cmd: str, output: Optional[str] = None):
|
||||
if self._use_rich:
|
||||
self._console.print(f"[dim]{cmd}[/dim]")
|
||||
if output:
|
||||
self._console.print(output)
|
||||
else:
|
||||
print(cmd)
|
||||
if output:
|
||||
print(output)
|
||||
|
||||
def _render_summary(self, title: str, items: Optional[List[str]] = None):
|
||||
if self._use_rich:
|
||||
self._console.print()
|
||||
self._console.print(f"[bold]{title}[/bold]")
|
||||
if items:
|
||||
for item in items:
|
||||
self._console.print(f"- {item}")
|
||||
else:
|
||||
print()
|
||||
print(title)
|
||||
if items:
|
||||
for item in items:
|
||||
print(f"- {item}")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Module-level enable / disable (same pattern as status.py)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def is_editor_output_enabled() -> bool:
|
||||
"""Check whether editor output mode is currently active."""
|
||||
return _editor_output_enabled
|
||||
|
||||
|
||||
def enable_editor_output(
|
||||
use_color: bool = True,
|
||||
) -> EditorOutput:
|
||||
"""
|
||||
Enable editor output mode globally.
|
||||
|
||||
Registers display callbacks that render tool calls as numbered steps.
|
||||
|
||||
Returns:
|
||||
EditorOutput instance for programmatic access.
|
||||
"""
|
||||
global _editor_output_enabled, _editor_output
|
||||
|
||||
_editor_output = EditorOutput(use_rich=use_color)
|
||||
_editor_output_enabled = True
|
||||
|
||||
# Register callbacks with the display system
|
||||
from ..main import register_display_callback
|
||||
|
||||
def on_tool_call(
|
||||
message: str = None,
|
||||
tool_name: str = None,
|
||||
tool_input: dict = None,
|
||||
tool_output: str = None,
|
||||
**kwargs,
|
||||
):
|
||||
if not _editor_output_enabled or _editor_output is None:
|
||||
return
|
||||
if tool_name:
|
||||
_editor_output.tool_call(
|
||||
tool_name=tool_name,
|
||||
args=tool_input,
|
||||
result=str(tool_output)[:500] if tool_output else None,
|
||||
)
|
||||
|
||||
def on_interaction(
|
||||
message: str = None,
|
||||
response: str = None,
|
||||
agent_name: str = None,
|
||||
generation_time: float = None,
|
||||
**kwargs,
|
||||
):
|
||||
if not _editor_output_enabled or _editor_output is None:
|
||||
return
|
||||
# Dedup: skip if this exact response was already displayed
|
||||
# Covers both llm_content→interaction and interaction→interaction duplicates
|
||||
# (LLM layer and Agent layer both fire execute_sync_callback('interaction'))
|
||||
if response and hasattr(_editor_output, '_last_displayed_response') and _editor_output._last_displayed_response == response.strip():
|
||||
_editor_output._last_displayed_response = None # Reset for next turn
|
||||
return
|
||||
if response:
|
||||
_editor_output._last_displayed_response = response.strip()
|
||||
_editor_output.output(response, agent_name)
|
||||
|
||||
def on_error(message: str = None, **kwargs):
|
||||
if not _editor_output_enabled or _editor_output is None:
|
||||
return
|
||||
if message and _editor_output._use_rich:
|
||||
_editor_output._console.print(f"[red]✗ Error: {message}[/red]")
|
||||
elif message:
|
||||
print(f"✗ Error: {message}")
|
||||
|
||||
def on_llm_start(model: str = None, agent_name: str = None, **kwargs):
|
||||
if not _editor_output_enabled or _editor_output is None:
|
||||
return
|
||||
with _editor_output._lock:
|
||||
_editor_output._llm_call_count += 1
|
||||
count = _editor_output._llm_call_count
|
||||
phase = "Thinking" if count == 1 else "Responding"
|
||||
_editor_output.llm_indicator(phase)
|
||||
|
||||
def on_llm_content(content: str = None, agent_name: str = None, **kwargs):
|
||||
if not _editor_output_enabled or _editor_output is None:
|
||||
return
|
||||
if content and content.strip():
|
||||
# Dedup: skip if on_interaction already displayed this exact text
|
||||
if hasattr(_editor_output, '_last_displayed_response') and _editor_output._last_displayed_response == content.strip():
|
||||
return
|
||||
_editor_output._last_displayed_response = content.strip()
|
||||
_editor_output.narrative(content.strip(), agent_name=agent_name)
|
||||
|
||||
register_display_callback('tool_call', on_tool_call)
|
||||
register_display_callback('interaction', on_interaction)
|
||||
register_display_callback('error', on_error)
|
||||
register_display_callback('llm_start', on_llm_start)
|
||||
register_display_callback('llm_content', on_llm_content)
|
||||
|
||||
return _editor_output
|
||||
|
||||
|
||||
def disable_editor_output() -> None:
|
||||
"""Disable editor output mode."""
|
||||
global _editor_output_enabled, _editor_output
|
||||
_editor_output_enabled = False
|
||||
_editor_output = None
|
||||
|
||||
|
||||
def get_editor_output() -> Optional[EditorOutput]:
|
||||
"""Get the current editor output instance."""
|
||||
return _editor_output
|
||||
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
Lightweight output models for PraisonAI Agents.
|
||||
|
||||
This module contains Pydantic models that are used across the codebase.
|
||||
It is kept separate from main.py to avoid importing rich at module level.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Optional, Dict, Any, Literal, TYPE_CHECKING
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..telemetry.token_collector import TokenMetrics
|
||||
|
||||
|
||||
class TaskOutput(BaseModel):
|
||||
"""Output model for task results."""
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
description: str
|
||||
summary: Optional[str] = None
|
||||
raw: str
|
||||
pydantic: Optional[BaseModel] = None
|
||||
json_dict: Optional[Dict[str, Any]] = None
|
||||
agent: str
|
||||
output_format: Literal["RAW", "JSON", "Pydantic"] = "RAW"
|
||||
token_metrics: Optional['TokenMetrics'] = None
|
||||
|
||||
def json(self) -> Optional[str]:
|
||||
if self.output_format == "JSON" and self.json_dict:
|
||||
return json.dumps(self.json_dict)
|
||||
return None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
output_dict = {}
|
||||
if self.json_dict:
|
||||
output_dict.update(self.json_dict)
|
||||
if self.pydantic:
|
||||
output_dict.update(self.pydantic.model_dump())
|
||||
return output_dict
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.pydantic:
|
||||
return str(self.pydantic)
|
||||
if self.json_dict:
|
||||
return json.dumps(self.json_dict, indent=2)
|
||||
return self.raw
|
||||
|
||||
|
||||
class ReflectionOutput(BaseModel):
|
||||
"""Output model for self-reflection results."""
|
||||
reflection: str
|
||||
satisfactory: Literal["yes", "no"]
|
||||
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
Tool Loop Detection Plugin for PraisonAI Agents.
|
||||
|
||||
Registers a BEFORE_TOOL hook that detects repetitive tool call patterns
|
||||
and blocks the agent when stuck in a loop.
|
||||
|
||||
Inspired by OpenClaw's tool-loop-detection.ts (624 lines, 4 detectors).
|
||||
|
||||
Usage:
|
||||
# Import to auto-register the hook globally
|
||||
import praisonaiagents.plugins.loop_detection_plugin
|
||||
|
||||
# Or use with an agent via explicit hook registration
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.hooks.registry import get_default_registry
|
||||
from praisonaiagents.hooks.types import HookEvent
|
||||
|
||||
Zero Performance Impact:
|
||||
- Only active when this module is imported
|
||||
- Uses stdlib only (hashlib, json)
|
||||
- Disabled in production unless explicitly imported
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import List
|
||||
|
||||
from praisonaiagents.hooks.registry import add_hook
|
||||
from praisonaiagents.hooks.types import HookEvent, HookInput, HookResult
|
||||
|
||||
from praisonaiagents.agent.loop_detection import (
|
||||
LoopDetectionConfig,
|
||||
LoopDetectionResult,
|
||||
detect_tool_loop,
|
||||
record_tool_call,
|
||||
ToolCallRecord,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default configuration (can be overridden before import)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_CONFIG = LoopDetectionConfig(
|
||||
enabled=True,
|
||||
history_size=30,
|
||||
warn_threshold=10,
|
||||
critical_threshold=20,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-thread history (avoids shared mutable state across agents/threads)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_thread_local = threading.local()
|
||||
|
||||
|
||||
def _get_history() -> List[ToolCallRecord]:
|
||||
"""Get the thread-local tool call history."""
|
||||
if not hasattr(_thread_local, "history"):
|
||||
_thread_local.history = []
|
||||
return _thread_local.history
|
||||
|
||||
|
||||
def reset_history() -> None:
|
||||
"""Reset the tool call history for the current thread. Useful in tests."""
|
||||
_thread_local.history = []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hook implementation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _loop_detection_hook(event: HookInput) -> HookResult:
|
||||
"""
|
||||
BEFORE_TOOL hook: detect if agent is stuck in a tool call loop.
|
||||
|
||||
Returns:
|
||||
- HookResult.allow() if not stuck (or below warning threshold)
|
||||
- HookResult.allow(reason=warning_msg) if at warning threshold
|
||||
- HookResult.block(reason=critical_msg) if at critical threshold
|
||||
"""
|
||||
# BeforeToolInput has tool_name and tool_input fields.
|
||||
# Fall back to event.extra for compatibility with custom callers.
|
||||
tool_name: str = (
|
||||
getattr(event, "tool_name", None)
|
||||
or event.extra.get("tool_name", "")
|
||||
)
|
||||
tool_args = (
|
||||
getattr(event, "tool_input", None)
|
||||
or event.extra.get("tool_args", {})
|
||||
or {}
|
||||
)
|
||||
|
||||
history = _get_history()
|
||||
|
||||
# Check before recording
|
||||
result: LoopDetectionResult = detect_tool_loop(history, tool_name, tool_args, DEFAULT_CONFIG)
|
||||
|
||||
# Record the call (after detection to count existing history)
|
||||
record_tool_call(history, tool_name, tool_args, DEFAULT_CONFIG)
|
||||
|
||||
if not result.get("stuck"):
|
||||
return HookResult.allow()
|
||||
|
||||
level = result.get("level")
|
||||
message = result.get("message", "Tool loop detected")
|
||||
count = result.get("count", 0)
|
||||
detector = result.get("detector", "unknown")
|
||||
|
||||
if level == "critical":
|
||||
logger.error(
|
||||
f"[loop_detection_plugin] BLOCKED: {detector} detector, "
|
||||
f"tool={tool_name!r}, count={count}"
|
||||
)
|
||||
return HookResult.block(message)
|
||||
|
||||
# Warning: allow but annotate (agent can see the warning in hook output)
|
||||
logger.warning(
|
||||
f"[loop_detection_plugin] WARNING: {detector} detector, "
|
||||
f"tool={tool_name!r}, count={count}"
|
||||
)
|
||||
# Return allow with additional_context so the runner can pass it back
|
||||
return HookResult(decision="allow", reason=message)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Register hook on import (side effect is intentional for plugin pattern)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_hook_id = add_hook(HookEvent.BEFORE_TOOL, _loop_detection_hook)
|
||||
logger.debug(f"[loop_detection_plugin] Registered BEFORE_TOOL hook: {_hook_id}")
|
||||
@@ -17,276 +17,296 @@ from contextlib import redirect_stdout, redirect_stderr
|
||||
import traceback
|
||||
from ..approval import require_approval
|
||||
|
||||
def _safe_getattr(obj, name, *default):
|
||||
"""getattr wrapper that blocks access to dunder attributes."""
|
||||
if isinstance(name, str) and name.startswith('_'):
|
||||
raise AttributeError(
|
||||
f"Access to private/protected attribute '{name}' is restricted"
|
||||
)
|
||||
return getattr(obj, name, *default) if default else getattr(obj, name)
|
||||
|
||||
|
||||
def _validate_code_ast(code: str):
|
||||
"""Validate code using AST — catches attacks that bypass text checks.
|
||||
|
||||
Returns error message string if dangerous, None if safe.
|
||||
"""
|
||||
import ast
|
||||
|
||||
try:
|
||||
tree = ast.parse(code)
|
||||
except SyntaxError:
|
||||
return None # let compile() handle syntax errors later
|
||||
|
||||
# Dangerous dunder attributes attackers use for sandbox escape
|
||||
_blocked_attrs = frozenset({
|
||||
'__subclasses__', '__bases__', '__mro__', '__globals__',
|
||||
'__code__', '__class__', '__dict__', '__builtins__',
|
||||
'__import__', '__loader__', '__spec__', '__init_subclass__',
|
||||
'__set_name__', '__reduce__', '__reduce_ex__',
|
||||
'__traceback__', '__qualname__', '__module__',
|
||||
'__wrapped__', '__closure__', '__annotations__',
|
||||
# Frame/code object introspection
|
||||
'gi_frame', 'gi_code', 'cr_frame', 'cr_code',
|
||||
'ag_frame', 'ag_code', 'tb_frame', 'tb_next',
|
||||
'f_globals', 'f_locals', 'f_builtins', 'f_code',
|
||||
'co_consts', 'co_names',
|
||||
})
|
||||
|
||||
for node in ast.walk(tree):
|
||||
# Block import statements
|
||||
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
||||
return f"Import statements are not allowed"
|
||||
|
||||
# Block attribute access to dangerous dunders
|
||||
if isinstance(node, ast.Attribute):
|
||||
if node.attr in _blocked_attrs:
|
||||
return (
|
||||
f"Access to attribute '{node.attr}' is restricted"
|
||||
)
|
||||
|
||||
# Block calls to dangerous builtins by name
|
||||
if isinstance(node, ast.Call):
|
||||
func = node.func
|
||||
if isinstance(func, ast.Name) and func.id in (
|
||||
'exec', 'eval', 'compile', '__import__',
|
||||
'open', 'input', 'breakpoint',
|
||||
'setattr', 'delattr', 'dir',
|
||||
):
|
||||
return f"Call to '{func.id}' is not allowed"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Standalone execute_code — NO optional deps required (no black/pylint)
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@require_approval(risk_level="critical")
|
||||
def execute_code(
|
||||
code: str,
|
||||
globals_dict: Optional[Dict[str, Any]] = None,
|
||||
locals_dict: Optional[Dict[str, Any]] = None,
|
||||
timeout: int = 30,
|
||||
max_output_size: int = 10000
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute Python code safely with restricted builtins.
|
||||
|
||||
This function is available without any optional dependencies.
|
||||
It uses a 3-layer security sandbox:
|
||||
1. AST-based validation (blocks imports, dangerous dunders, eval/exec)
|
||||
2. Text-pattern blocklist (defense-in-depth)
|
||||
3. Restricted __builtins__ (only safe functions exposed)
|
||||
"""
|
||||
try:
|
||||
# Create safe builtins - restricted set of functions
|
||||
safe_builtins = {
|
||||
# Basic functions
|
||||
'print': print,
|
||||
'len': len,
|
||||
'range': range,
|
||||
'enumerate': enumerate,
|
||||
'zip': zip,
|
||||
'map': map,
|
||||
'filter': filter,
|
||||
'sum': sum,
|
||||
'min': min,
|
||||
'max': max,
|
||||
'abs': abs,
|
||||
'round': round,
|
||||
'sorted': sorted,
|
||||
'reversed': reversed,
|
||||
'any': any,
|
||||
'all': all,
|
||||
# Type constructors
|
||||
'int': int,
|
||||
'float': float,
|
||||
'str': str,
|
||||
'bool': bool,
|
||||
'list': list,
|
||||
'tuple': tuple,
|
||||
'dict': dict,
|
||||
'set': set,
|
||||
# Math functions
|
||||
'pow': pow,
|
||||
'divmod': divmod,
|
||||
# Exceptions
|
||||
'Exception': Exception,
|
||||
'ValueError': ValueError,
|
||||
'TypeError': TypeError,
|
||||
'KeyError': KeyError,
|
||||
'IndexError': IndexError,
|
||||
'RuntimeError': RuntimeError,
|
||||
# Safe introspection (dunder-blocked wrapper)
|
||||
'isinstance': isinstance,
|
||||
'type': type,
|
||||
'hasattr': hasattr,
|
||||
'getattr': _safe_getattr,
|
||||
# Class definition support
|
||||
'__build_class__': __builtins__['__build_class__'] if isinstance(__builtins__, dict) else getattr(__builtins__, '__build_class__', None),
|
||||
# Disable dangerous functions
|
||||
'__import__': None,
|
||||
'eval': None,
|
||||
'exec': None,
|
||||
'compile': None,
|
||||
'open': None,
|
||||
'input': None,
|
||||
'globals': None,
|
||||
'locals': None,
|
||||
'vars': None,
|
||||
}
|
||||
|
||||
# Set up execution environment with safe builtins
|
||||
if globals_dict is None:
|
||||
globals_dict = {'__builtins__': safe_builtins}
|
||||
else:
|
||||
# Override builtins in provided globals
|
||||
globals_dict['__builtins__'] = safe_builtins
|
||||
|
||||
if locals_dict is None:
|
||||
locals_dict = {}
|
||||
|
||||
# Security check 1: AST-based validation (cannot be bypassed
|
||||
# by string concatenation or runtime tricks)
|
||||
ast_error = _validate_code_ast(code)
|
||||
if ast_error:
|
||||
return {
|
||||
'result': None,
|
||||
'stdout': '',
|
||||
'stderr': f'Security Error: {ast_error}',
|
||||
'success': False
|
||||
}
|
||||
|
||||
# Security check 2: text-based patterns (defense-in-depth)
|
||||
dangerous_patterns = [
|
||||
'__import__', 'import ', 'from ', 'exec', 'eval',
|
||||
'compile', 'open(', 'file(', 'input(', 'raw_input',
|
||||
'__subclasses__', '__bases__', '__globals__', '__code__',
|
||||
'__class__', 'globals(', 'locals(', 'vars('
|
||||
]
|
||||
|
||||
code_lower = code.lower()
|
||||
for pattern in dangerous_patterns:
|
||||
if pattern.lower() in code_lower:
|
||||
return {
|
||||
'result': None,
|
||||
'stdout': '',
|
||||
'stderr': f'Security Error: Code contains restricted pattern: {pattern}',
|
||||
'success': False
|
||||
}
|
||||
|
||||
# Capture output
|
||||
stdout_buffer = io.StringIO()
|
||||
stderr_buffer = io.StringIO()
|
||||
|
||||
try:
|
||||
# Compile code with restricted mode
|
||||
compiled_code = compile(code, '<string>', 'exec')
|
||||
|
||||
# Execute with output capture
|
||||
with redirect_stdout(stdout_buffer), redirect_stderr(stderr_buffer):
|
||||
exec(compiled_code, globals_dict, locals_dict)
|
||||
|
||||
# Get last expression value if any
|
||||
import ast
|
||||
tree = ast.parse(code)
|
||||
if tree.body and isinstance(tree.body[-1], ast.Expr):
|
||||
result = eval(
|
||||
compile(ast.Expression(tree.body[-1].value), '<string>', 'eval'),
|
||||
globals_dict,
|
||||
locals_dict
|
||||
)
|
||||
else:
|
||||
result = None
|
||||
|
||||
# Get output
|
||||
stdout = stdout_buffer.getvalue()
|
||||
stderr = stderr_buffer.getvalue()
|
||||
|
||||
# Truncate output if too large (use smart format)
|
||||
if len(stdout) > max_output_size:
|
||||
tail_size = min(max_output_size // 5, 500)
|
||||
stdout = stdout[:max_output_size - tail_size] + f"\n...[{len(stdout):,} chars, showing first/last portions]...\n" + stdout[-tail_size:]
|
||||
if len(stderr) > max_output_size:
|
||||
tail_size = min(max_output_size // 5, 500)
|
||||
stderr = stderr[:max_output_size - tail_size] + f"\n...[{len(stderr):,} chars, showing first/last portions]...\n" + stderr[-tail_size:]
|
||||
|
||||
return {
|
||||
'result': result,
|
||||
'stdout': stdout,
|
||||
'stderr': stderr,
|
||||
'success': True
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error executing code: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
return {
|
||||
'result': None,
|
||||
'stdout': stdout_buffer.getvalue(),
|
||||
'stderr': error_msg,
|
||||
'success': False
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error executing code: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
return {
|
||||
'result': None,
|
||||
'stdout': '',
|
||||
'stderr': error_msg,
|
||||
'success': False
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# PythonTools class — requires optional deps (black, pylint, autopep8)
|
||||
# Only instantiated when analyze_code/format_code/lint_code are needed.
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
class PythonTools:
|
||||
"""Tools for Python code execution and analysis."""
|
||||
|
||||
"""Tools for Python code analysis, formatting, and linting.
|
||||
|
||||
Requires: pip install black pylint autopep8
|
||||
For code execution only, use the standalone execute_code() function.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize PythonTools."""
|
||||
"""Initialize PythonTools — checks for required packages."""
|
||||
self._check_dependencies()
|
||||
|
||||
|
||||
def _check_dependencies(self):
|
||||
"""Check if required packages are installed."""
|
||||
missing = []
|
||||
for package in ['black', 'pylint', 'autopep8']:
|
||||
if util.find_spec(package) is None:
|
||||
missing.append(package)
|
||||
|
||||
|
||||
if missing:
|
||||
raise ImportError(
|
||||
f"Required packages not available. Please install: {', '.join(missing)}\n"
|
||||
f"Run: pip install {' '.join(missing)}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _safe_getattr(obj, name, *default):
|
||||
"""getattr wrapper that blocks access to dunder attributes."""
|
||||
if isinstance(name, str) and name.startswith('_'):
|
||||
raise AttributeError(
|
||||
f"Access to private/protected attribute '{name}' is restricted"
|
||||
)
|
||||
return getattr(obj, name, *default) if default else getattr(obj, name)
|
||||
|
||||
@staticmethod
|
||||
def _validate_code_ast(code: str):
|
||||
"""Validate code using AST — catches attacks that bypass text checks.
|
||||
|
||||
Returns error message string if dangerous, None if safe.
|
||||
"""
|
||||
import ast
|
||||
|
||||
try:
|
||||
tree = ast.parse(code)
|
||||
except SyntaxError:
|
||||
return None # let compile() handle syntax errors later
|
||||
|
||||
# Dangerous dunder attributes attackers use for sandbox escape
|
||||
_blocked_attrs = frozenset({
|
||||
'__subclasses__', '__bases__', '__mro__', '__globals__',
|
||||
'__code__', '__class__', '__dict__', '__builtins__',
|
||||
'__import__', '__loader__', '__spec__', '__init_subclass__',
|
||||
'__set_name__', '__reduce__', '__reduce_ex__',
|
||||
'__traceback__', '__qualname__', '__module__',
|
||||
'__wrapped__', '__closure__', '__annotations__',
|
||||
# Frame/code object introspection
|
||||
'gi_frame', 'gi_code', 'cr_frame', 'cr_code',
|
||||
'ag_frame', 'ag_code', 'tb_frame', 'tb_next',
|
||||
'f_globals', 'f_locals', 'f_builtins', 'f_code',
|
||||
'co_consts', 'co_names',
|
||||
})
|
||||
|
||||
for node in ast.walk(tree):
|
||||
# Block import statements
|
||||
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
||||
return f"Import statements are not allowed"
|
||||
|
||||
# Block attribute access to dangerous dunders
|
||||
if isinstance(node, ast.Attribute):
|
||||
if node.attr in _blocked_attrs:
|
||||
return (
|
||||
f"Access to attribute '{node.attr}' is restricted"
|
||||
)
|
||||
|
||||
# Block calls to dangerous builtins by name
|
||||
if isinstance(node, ast.Call):
|
||||
func = node.func
|
||||
if isinstance(func, ast.Name) and func.id in (
|
||||
'exec', 'eval', 'compile', '__import__',
|
||||
'open', 'input', 'breakpoint',
|
||||
'setattr', 'delattr', 'dir',
|
||||
):
|
||||
return f"Call to '{func.id}' is not allowed"
|
||||
|
||||
return None
|
||||
|
||||
@require_approval(risk_level="critical")
|
||||
def execute_code(
|
||||
self,
|
||||
code: str,
|
||||
globals_dict: Optional[Dict[str, Any]] = None,
|
||||
locals_dict: Optional[Dict[str, Any]] = None,
|
||||
timeout: int = 30,
|
||||
max_output_size: int = 10000
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute Python code safely with restricted builtins."""
|
||||
try:
|
||||
# Create safe builtins - restricted set of functions
|
||||
safe_builtins = {
|
||||
# Basic functions
|
||||
'print': print,
|
||||
'len': len,
|
||||
'range': range,
|
||||
'enumerate': enumerate,
|
||||
'zip': zip,
|
||||
'map': map,
|
||||
'filter': filter,
|
||||
'sum': sum,
|
||||
'min': min,
|
||||
'max': max,
|
||||
'abs': abs,
|
||||
'round': round,
|
||||
'sorted': sorted,
|
||||
'reversed': reversed,
|
||||
'any': any,
|
||||
'all': all,
|
||||
# Type constructors
|
||||
'int': int,
|
||||
'float': float,
|
||||
'str': str,
|
||||
'bool': bool,
|
||||
'list': list,
|
||||
'tuple': tuple,
|
||||
'dict': dict,
|
||||
'set': set,
|
||||
# Math functions
|
||||
'pow': pow,
|
||||
'divmod': divmod,
|
||||
# Exceptions
|
||||
'Exception': Exception,
|
||||
'ValueError': ValueError,
|
||||
'TypeError': TypeError,
|
||||
'KeyError': KeyError,
|
||||
'IndexError': IndexError,
|
||||
'RuntimeError': RuntimeError,
|
||||
# Safe introspection (dunder-blocked wrapper)
|
||||
'isinstance': isinstance,
|
||||
'type': type,
|
||||
'hasattr': hasattr,
|
||||
'getattr': self._safe_getattr,
|
||||
# Class definition support
|
||||
'__build_class__': __builtins__['__build_class__'] if isinstance(__builtins__, dict) else getattr(__builtins__, '__build_class__', None),
|
||||
# Disable dangerous functions
|
||||
'__import__': None,
|
||||
'eval': None,
|
||||
'exec': None,
|
||||
'compile': None,
|
||||
'open': None,
|
||||
'input': None,
|
||||
'globals': None,
|
||||
'locals': None,
|
||||
'vars': None,
|
||||
}
|
||||
|
||||
# Set up execution environment with safe builtins
|
||||
if globals_dict is None:
|
||||
globals_dict = {'__builtins__': safe_builtins}
|
||||
else:
|
||||
# Override builtins in provided globals
|
||||
globals_dict['__builtins__'] = safe_builtins
|
||||
|
||||
if locals_dict is None:
|
||||
locals_dict = {}
|
||||
|
||||
# Security check 1: AST-based validation (cannot be bypassed
|
||||
# by string concatenation or runtime tricks)
|
||||
ast_error = self._validate_code_ast(code)
|
||||
if ast_error:
|
||||
return {
|
||||
'result': None,
|
||||
'stdout': '',
|
||||
'stderr': f'Security Error: {ast_error}',
|
||||
'success': False
|
||||
}
|
||||
|
||||
# Security check 2: text-based patterns (defense-in-depth)
|
||||
dangerous_patterns = [
|
||||
'__import__', 'import ', 'from ', 'exec', 'eval',
|
||||
'compile', 'open(', 'file(', 'input(', 'raw_input',
|
||||
'__subclasses__', '__bases__', '__globals__', '__code__',
|
||||
'__class__', 'globals(', 'locals(', 'vars('
|
||||
]
|
||||
|
||||
code_lower = code.lower()
|
||||
for pattern in dangerous_patterns:
|
||||
if pattern.lower() in code_lower:
|
||||
return {
|
||||
'result': None,
|
||||
'stdout': '',
|
||||
'stderr': f'Security Error: Code contains restricted pattern: {pattern}',
|
||||
'success': False
|
||||
}
|
||||
|
||||
# Capture output
|
||||
stdout_buffer = io.StringIO()
|
||||
stderr_buffer = io.StringIO()
|
||||
|
||||
try:
|
||||
# Compile code with restricted mode
|
||||
compiled_code = compile(code, '<string>', 'exec')
|
||||
|
||||
# Execute with output capture
|
||||
with redirect_stdout(stdout_buffer), redirect_stderr(stderr_buffer):
|
||||
exec(compiled_code, globals_dict, locals_dict)
|
||||
|
||||
# Get last expression value if any
|
||||
import ast
|
||||
tree = ast.parse(code)
|
||||
if tree.body and isinstance(tree.body[-1], ast.Expr):
|
||||
result = eval(
|
||||
compile(ast.Expression(tree.body[-1].value), '<string>', 'eval'),
|
||||
globals_dict,
|
||||
locals_dict
|
||||
)
|
||||
else:
|
||||
result = None
|
||||
|
||||
# Get output
|
||||
stdout = stdout_buffer.getvalue()
|
||||
stderr = stderr_buffer.getvalue()
|
||||
|
||||
# Truncate output if too large (use smart format)
|
||||
if len(stdout) > max_output_size:
|
||||
tail_size = min(max_output_size // 5, 500)
|
||||
stdout = stdout[:max_output_size - tail_size] + f"\n...[{len(stdout):,} chars, showing first/last portions]...\n" + stdout[-tail_size:]
|
||||
if len(stderr) > max_output_size:
|
||||
tail_size = min(max_output_size // 5, 500)
|
||||
stderr = stderr[:max_output_size - tail_size] + f"\n...[{len(stderr):,} chars, showing first/last portions]...\n" + stderr[-tail_size:]
|
||||
|
||||
return {
|
||||
'result': result,
|
||||
'stdout': stdout,
|
||||
'stderr': stderr,
|
||||
'success': True
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error executing code: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
return {
|
||||
'result': None,
|
||||
'stdout': stdout_buffer.getvalue(),
|
||||
'stderr': error_msg,
|
||||
'success': False
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error executing code: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
return {
|
||||
'result': None,
|
||||
'stdout': '',
|
||||
'stderr': error_msg,
|
||||
'success': False
|
||||
}
|
||||
|
||||
def analyze_code(
|
||||
self,
|
||||
code: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Analyze Python code structure and quality.
|
||||
|
||||
|
||||
Args:
|
||||
code: Python code to analyze
|
||||
|
||||
|
||||
Returns:
|
||||
Dictionary with analysis results
|
||||
"""
|
||||
try:
|
||||
# Import ast only when needed
|
||||
import ast
|
||||
|
||||
|
||||
# Parse code
|
||||
tree = ast.parse(code)
|
||||
|
||||
|
||||
# Analyze structure
|
||||
analysis = {
|
||||
'imports': [],
|
||||
@@ -300,7 +320,7 @@ class PythonTools:
|
||||
'branches': 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Analyze nodes
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
@@ -338,7 +358,7 @@ class PythonTools:
|
||||
analysis['variables'].append(target.id)
|
||||
elif isinstance(node, (ast.If, ast.While, ast.For)):
|
||||
analysis['complexity']['branches'] += 1
|
||||
|
||||
|
||||
return analysis
|
||||
except Exception as e:
|
||||
error_msg = f"Error analyzing code: {str(e)}"
|
||||
@@ -352,12 +372,12 @@ class PythonTools:
|
||||
line_length: int = 88
|
||||
) -> Optional[str]:
|
||||
"""Format Python code according to style guide.
|
||||
|
||||
|
||||
Args:
|
||||
code: Python code to format
|
||||
style: Formatting style ('black' or 'pep8')
|
||||
line_length: Maximum line length
|
||||
|
||||
|
||||
Returns:
|
||||
Formatted code
|
||||
"""
|
||||
@@ -393,10 +413,10 @@ class PythonTools:
|
||||
code: str
|
||||
) -> Optional[Dict[str, List[Dict[str, Any]]]]:
|
||||
"""Lint Python code for potential issues.
|
||||
|
||||
|
||||
Args:
|
||||
code: Python code to lint
|
||||
|
||||
|
||||
Returns:
|
||||
Dictionary with linting results
|
||||
"""
|
||||
@@ -409,7 +429,7 @@ class PythonTools:
|
||||
# Import pylint only when needed
|
||||
from pylint.reporters import JSONReporter
|
||||
from pylint.lint.run import Run
|
||||
|
||||
|
||||
# Create temporary file for pylint
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(
|
||||
@@ -419,7 +439,7 @@ class PythonTools:
|
||||
) as f:
|
||||
f.write(code)
|
||||
temp_path = f.name
|
||||
|
||||
|
||||
# Run pylint
|
||||
reporter = JSONReporter()
|
||||
Run(
|
||||
@@ -427,14 +447,14 @@ class PythonTools:
|
||||
reporter=reporter,
|
||||
exit=False
|
||||
)
|
||||
|
||||
|
||||
# Process results
|
||||
results = {
|
||||
'errors': [],
|
||||
'warnings': [],
|
||||
'conventions': []
|
||||
}
|
||||
|
||||
|
||||
for msg in reporter.messages:
|
||||
item = {
|
||||
'type': msg.category,
|
||||
@@ -447,18 +467,18 @@ class PythonTools:
|
||||
'message': msg.msg,
|
||||
'message-id': msg.msg_id
|
||||
}
|
||||
|
||||
|
||||
if msg.category in ['error', 'fatal']:
|
||||
results['errors'].append(item)
|
||||
elif msg.category == 'warning':
|
||||
results['warnings'].append(item)
|
||||
else:
|
||||
results['conventions'].append(item)
|
||||
|
||||
|
||||
# Clean up
|
||||
import os
|
||||
os.unlink(temp_path)
|
||||
|
||||
|
||||
return results
|
||||
except Exception as e:
|
||||
error_msg = f"Error linting code: {str(e)}"
|
||||
@@ -474,38 +494,60 @@ class PythonTools:
|
||||
code: str
|
||||
) -> Optional[str]:
|
||||
"""Disassemble Python code to bytecode.
|
||||
|
||||
|
||||
Args:
|
||||
code: Python code to disassemble
|
||||
|
||||
|
||||
Returns:
|
||||
Disassembled bytecode as string
|
||||
"""
|
||||
try:
|
||||
# Import dis only when needed
|
||||
import dis
|
||||
|
||||
|
||||
# Compile code
|
||||
compiled_code = compile(code, '<string>', 'exec')
|
||||
|
||||
|
||||
# Capture disassembly
|
||||
output = io.StringIO()
|
||||
with redirect_stdout(output):
|
||||
dis.dis(compiled_code)
|
||||
|
||||
|
||||
return output.getvalue()
|
||||
except Exception as e:
|
||||
error_msg = f"Error disassembling code: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
return None
|
||||
|
||||
# Create instance for direct function access
|
||||
_python_tools = PythonTools()
|
||||
execute_code = _python_tools.execute_code
|
||||
analyze_code = _python_tools.analyze_code
|
||||
format_code = _python_tools.format_code
|
||||
lint_code = _python_tools.lint_code
|
||||
disassemble_code = _python_tools.disassemble_code
|
||||
|
||||
# Lazy accessors for optional-dep tools (PythonTools requires black/pylint/autopep8)
|
||||
# execute_code is already a standalone function above — always available.
|
||||
def _get_python_tools():
|
||||
"""Lazy-init PythonTools (requires black/pylint/autopep8)."""
|
||||
global _python_tools_instance
|
||||
try:
|
||||
return _python_tools_instance
|
||||
except NameError:
|
||||
_python_tools_instance = PythonTools()
|
||||
return _python_tools_instance
|
||||
|
||||
_python_tools_instance = None
|
||||
|
||||
def analyze_code(code: str) -> Optional[Dict[str, Any]]:
|
||||
"""Analyze Python code structure and quality. Requires: pip install black pylint autopep8"""
|
||||
return _get_python_tools().analyze_code(code)
|
||||
|
||||
def format_code(code: str, style: str = 'black', line_length: int = 88) -> Optional[str]:
|
||||
"""Format Python code. Requires: pip install black pylint autopep8"""
|
||||
return _get_python_tools().format_code(code, style, line_length)
|
||||
|
||||
def lint_code(code: str) -> Optional[Dict[str, List[Dict[str, Any]]]]:
|
||||
"""Lint Python code. Requires: pip install black pylint autopep8"""
|
||||
return _get_python_tools().lint_code(code)
|
||||
|
||||
def disassemble_code(code: str) -> Optional[str]:
|
||||
"""Disassemble Python code to bytecode. Requires: pip install black pylint autopep8"""
|
||||
return _get_python_tools().disassemble_code(code)
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("\n==================================================")
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Skill Import Bridge — migrates OpenClaw-style skill files to PraisonAI tools.
|
||||
|
||||
Parses SKILL.md manifests and Python tool definitions from OpenClaw's
|
||||
directory structure and converts them into PraisonAI @tool-decorated functions.
|
||||
|
||||
Usage::
|
||||
|
||||
from praisonaiagents.tools.skill_bridge import import_skill, scan_skills
|
||||
|
||||
# Import a single skill
|
||||
tools = import_skill("/path/to/openclaw/skills/web_search/")
|
||||
|
||||
# Scan a skills directory
|
||||
report = scan_skills("/path/to/openclaw/skills/")
|
||||
"""
|
||||
|
||||
import ast
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillInfo:
|
||||
"""Parsed skill metadata from a SKILL.md or __init__.py file."""
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
source_path: str = ""
|
||||
functions: List[str] = field(default_factory=list)
|
||||
dependencies: List[str] = field(default_factory=list)
|
||||
security_warnings: List[str] = field(default_factory=list)
|
||||
compatible: bool = True
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
# Security patterns to flag during import
|
||||
_SECURITY_PATTERNS = [
|
||||
(r"subprocess\.(call|run|Popen|check_output)", "Subprocess execution detected"),
|
||||
(r"os\.system\s*\(", "os.system() call detected"),
|
||||
(r"eval\s*\(", "eval() usage detected"),
|
||||
(r"exec\s*\(", "exec() usage detected"),
|
||||
(r"__import__\s*\(", "Dynamic import detected"),
|
||||
(r"pickle\.loads?\s*\(", "Pickle deserialization detected"),
|
||||
(r"shutil\.rmtree\s*\(", "Recursive directory deletion detected"),
|
||||
]
|
||||
|
||||
|
||||
def scan_skills(skills_dir: str) -> List[SkillInfo]:
|
||||
"""Scan a directory of skills and return metadata for each.
|
||||
|
||||
Args:
|
||||
skills_dir: Path to directory containing skill subdirectories.
|
||||
|
||||
Returns:
|
||||
List of SkillInfo for each discovered skill.
|
||||
"""
|
||||
skills_path = Path(skills_dir)
|
||||
if not skills_path.is_dir():
|
||||
logger.error(f"Skills directory not found: {skills_dir}")
|
||||
return []
|
||||
|
||||
results = []
|
||||
for child in sorted(skills_path.iterdir()):
|
||||
if child.is_dir() and not child.name.startswith((".", "_")):
|
||||
info = _parse_skill_dir(child)
|
||||
results.append(info)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def import_skill(skill_path: str) -> List[Any]:
|
||||
"""Import a skill directory and return PraisonAI-compatible tool functions.
|
||||
|
||||
Args:
|
||||
skill_path: Path to a single skill directory.
|
||||
|
||||
Returns:
|
||||
List of callable tool functions that can be passed to Agent(tools=[...]).
|
||||
"""
|
||||
path = Path(skill_path)
|
||||
info = _parse_skill_dir(path)
|
||||
|
||||
if info.security_warnings:
|
||||
logger.warning(
|
||||
f"[skill-bridge] Security warnings for '{info.name}':\n"
|
||||
+ "\n".join(f" ⚠ {w}" for w in info.security_warnings)
|
||||
)
|
||||
|
||||
if not info.compatible:
|
||||
logger.error(f"[skill-bridge] Skill '{info.name}' is not compatible: {info.error}")
|
||||
return []
|
||||
|
||||
# Try to load Python functions from the skill
|
||||
tools = []
|
||||
py_files = list(path.glob("*.py"))
|
||||
for py_file in py_files:
|
||||
if py_file.name.startswith("_"):
|
||||
continue
|
||||
try:
|
||||
funcs = _extract_tool_functions(py_file)
|
||||
tools.extend(funcs)
|
||||
except Exception as e:
|
||||
logger.warning(f"[skill-bridge] Failed to extract tools from {py_file}: {e}")
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
def format_scan_report(skills: List[SkillInfo]) -> str:
|
||||
"""Format scan results for terminal display.
|
||||
|
||||
Args:
|
||||
skills: List of SkillInfo from scan_skills().
|
||||
|
||||
Returns:
|
||||
Formatted string for terminal output.
|
||||
"""
|
||||
if not skills:
|
||||
return "No skills found."
|
||||
|
||||
lines = []
|
||||
lines.append(f"{'Skill':<25} {'Functions':<10} {'Deps':<10} {'Warnings':<10} {'Compatible':<10}")
|
||||
lines.append("-" * 65)
|
||||
for s in skills:
|
||||
compat = "✅" if s.compatible else "❌"
|
||||
lines.append(
|
||||
f"{s.name:<25} "
|
||||
f"{len(s.functions):<10} "
|
||||
f"{len(s.dependencies):<10} "
|
||||
f"{len(s.security_warnings):<10} "
|
||||
f"{compat:<10}"
|
||||
)
|
||||
|
||||
total = len(skills)
|
||||
compat_count = sum(1 for s in skills if s.compatible)
|
||||
warns = sum(len(s.security_warnings) for s in skills)
|
||||
lines.append("")
|
||||
lines.append(f"Total: {total} skills, {compat_count} compatible, {warns} security warnings")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── internals ────────────────────────────────────────────────────
|
||||
|
||||
def _parse_skill_dir(skill_dir: Path) -> SkillInfo:
|
||||
"""Parse a single skill directory into SkillInfo."""
|
||||
info = SkillInfo(
|
||||
name=skill_dir.name,
|
||||
source_path=str(skill_dir),
|
||||
)
|
||||
|
||||
# Parse SKILL.md if present
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
if skill_md.exists():
|
||||
try:
|
||||
content = skill_md.read_text(encoding="utf-8", errors="replace")
|
||||
info.description = _extract_description(content)
|
||||
except Exception as e:
|
||||
info.error = f"Failed to read SKILL.md: {e}"
|
||||
|
||||
# Scan Python files
|
||||
py_files = list(skill_dir.glob("*.py"))
|
||||
for py_file in py_files:
|
||||
if py_file.name.startswith("_"):
|
||||
continue
|
||||
try:
|
||||
source = py_file.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
# Extract function names
|
||||
tree = ast.parse(source)
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef):
|
||||
info.functions.append(node.name)
|
||||
|
||||
# Extract imports as dependencies
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
info.dependencies.append(alias.name.split(".")[0])
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
if node.module:
|
||||
info.dependencies.append(node.module.split(".")[0])
|
||||
|
||||
# Security scan
|
||||
for pattern, warning in _SECURITY_PATTERNS:
|
||||
if re.search(pattern, source):
|
||||
info.security_warnings.append(f"{py_file.name}: {warning}")
|
||||
|
||||
except SyntaxError as e:
|
||||
info.compatible = False
|
||||
info.error = f"Syntax error in {py_file.name}: {e}"
|
||||
|
||||
# Deduplicate dependencies
|
||||
info.dependencies = sorted(set(info.dependencies))
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def _extract_description(content: str) -> str:
|
||||
"""Extract description from SKILL.md content.
|
||||
|
||||
Handles both YAML frontmatter and plain markdown.
|
||||
"""
|
||||
# Try YAML frontmatter
|
||||
if content.startswith("---"):
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
frontmatter = parts[1]
|
||||
for line in frontmatter.strip().split("\n"):
|
||||
if line.strip().startswith("description:"):
|
||||
return line.split(":", 1)[1].strip().strip("'\"")
|
||||
|
||||
# Fallback: first non-empty, non-heading line
|
||||
for line in content.split("\n"):
|
||||
stripped = line.strip()
|
||||
if stripped and not stripped.startswith("#"):
|
||||
return stripped[:200]
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_tool_functions(py_file: Path) -> list:
|
||||
"""Extract functions from a Python file and wrap them as simple callables.
|
||||
|
||||
This does NOT execute the file — it uses AST parsing to read function
|
||||
signatures and returns wrapper info. The actual import requires the
|
||||
user to install the skill's dependencies.
|
||||
"""
|
||||
source = py_file.read_text(encoding="utf-8", errors="replace")
|
||||
tree = ast.parse(source)
|
||||
|
||||
functions = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef) and not node.name.startswith("_"):
|
||||
# Extract docstring
|
||||
docstring = ast.get_docstring(node) or f"Tool from {py_file.name}"
|
||||
|
||||
# Create a metadata dict (not executing the code)
|
||||
func_info = {
|
||||
"name": node.name,
|
||||
"description": docstring,
|
||||
"source_file": str(py_file),
|
||||
"args": [arg.arg for arg in node.args.args if arg.arg != "self"],
|
||||
}
|
||||
functions.append(func_info)
|
||||
|
||||
return functions
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "1.5.80"
|
||||
version = "1.5.87"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -29,7 +29,7 @@ mcp = [
|
||||
|
||||
memory = [
|
||||
"chromadb>=1.0.0",
|
||||
"litellm>=1.81.0",
|
||||
"litellm>=1.81.0,<=1.82.6",
|
||||
]
|
||||
|
||||
knowledge = [
|
||||
@@ -47,7 +47,7 @@ graph = [
|
||||
|
||||
# Add LLM dependencies
|
||||
llm = [
|
||||
"litellm>=1.81.0",
|
||||
"litellm>=1.81.0,<=1.82.6",
|
||||
"pydantic>=2.10.0"
|
||||
]
|
||||
|
||||
|
||||
@@ -24,9 +24,12 @@ if __name__ == "__main__":
|
||||
|
||||
script_to_run = sys.argv[1]
|
||||
|
||||
# Execute the target script
|
||||
with open(script_to_run, 'r') as f:
|
||||
script_content = f.read()
|
||||
|
||||
# Execute in the same namespace
|
||||
exec(script_content, {'__name__': '__main__'})
|
||||
# Execute the target script in a subprocess to avoid sharing the current namespace
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
[sys.executable, script_to_run],
|
||||
stdin=sys.stdin,
|
||||
stdout=sys.stdout,
|
||||
stderr=sys.stderr,
|
||||
)
|
||||
sys.exit(result.returncode)
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Real LLM integration test for F1 + F3 features.
|
||||
|
||||
This test makes ACTUAL LLM calls to verify:
|
||||
1. F1: Token budget tracks cost (total_cost > 0 after a real call)
|
||||
2. F1: cost_summary returns valid data
|
||||
3. F3: Permission tiers block tools at runtime
|
||||
4. F2: Heartbeat/HeartbeatConfig import and init with real Agent
|
||||
|
||||
Requires OPENAI_API_KEY in environment.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import pytest
|
||||
|
||||
|
||||
# Skip if no API key available
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not os.environ.get("OPENAI_API_KEY"),
|
||||
reason="OPENAI_API_KEY not set — skipping real LLM test"
|
||||
)
|
||||
|
||||
|
||||
class TestRealLLMBudgetGuard:
|
||||
"""Real LLM integration test for F1 Token Budget Guard."""
|
||||
|
||||
def test_real_agent_tracks_cost(self):
|
||||
"""Run a REAL agent with a budget, verify cost/token tracking."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.config.feature_configs import ExecutionConfig
|
||||
|
||||
agent = Agent(
|
||||
name="budget_test",
|
||||
instructions="You are a helpful assistant. Be very brief.",
|
||||
llm="gpt-4o-mini",
|
||||
execution=ExecutionConfig(max_budget=1.00), # $1 budget
|
||||
)
|
||||
|
||||
result = agent.start("Say hello in exactly 5 words.")
|
||||
|
||||
# Verify LLM responded
|
||||
assert result is not None
|
||||
result_text = str(result)
|
||||
assert len(result_text) > 0
|
||||
print(f"\n[LLM Response]: {result_text}")
|
||||
|
||||
# Verify cost tracking
|
||||
assert agent.total_cost > 0, "Cost should be > 0 after real LLM call"
|
||||
summary = agent.cost_summary
|
||||
assert summary["llm_calls"] >= 1, "Should record at least 1 LLM call"
|
||||
assert summary["tokens_in"] > 0, "Should record input tokens"
|
||||
assert summary["tokens_out"] > 0, "Should record output tokens"
|
||||
assert summary["cost"] > 0, "Cost should be > 0"
|
||||
|
||||
print(f"[Cost Summary]: {summary}")
|
||||
print(f"[Total Cost]: ${agent.total_cost:.6f}")
|
||||
|
||||
def test_real_agent_no_budget_still_tracks(self):
|
||||
"""Agent without budget should still track cost (zero overhead confirmed)."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="no_budget_test",
|
||||
instructions="You are helpful. Be extremely brief.",
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
|
||||
result = agent.start("Reply with just the word 'OK'.")
|
||||
|
||||
assert result is not None
|
||||
print(f"\n[LLM Response]: {str(result)}")
|
||||
|
||||
# Cost tracking still works (always active)
|
||||
summary = agent.cost_summary
|
||||
print(f"[Cost Summary]: {summary}")
|
||||
assert summary["llm_calls"] >= 1
|
||||
|
||||
|
||||
class TestRealLLMPermissions:
|
||||
"""Real LLM integration test for F3 Permission Tiers."""
|
||||
|
||||
def test_safe_mode_agent_runs_normally(self):
|
||||
"""Agent with approval='safe' can still answer questions (no tools used)."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="safe_agent",
|
||||
instructions="You are helpful. Be very brief.",
|
||||
llm="gpt-4o-mini",
|
||||
approval="safe",
|
||||
)
|
||||
|
||||
result = agent.start("What is 2+2? Reply with just the number.")
|
||||
|
||||
assert result is not None
|
||||
result_text = str(result)
|
||||
assert "4" in result_text
|
||||
print(f"\n[Safe Agent Response]: {result_text}")
|
||||
|
||||
# Verify permission deny set is active
|
||||
assert len(agent._perm_deny) > 0
|
||||
assert "execute_command" in agent._perm_deny
|
||||
|
||||
|
||||
class TestRealHeartbeatInit:
|
||||
"""Real integration test for F2 Heartbeat with actual Agent."""
|
||||
|
||||
def test_heartbeat_with_real_agent(self):
|
||||
"""Heartbeat initializes correctly with a real Agent instance."""
|
||||
from praisonaiagents import Agent, Heartbeat
|
||||
|
||||
agent = Agent(
|
||||
name="heartbeat_test",
|
||||
instructions="Monitor status briefly.",
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
|
||||
hb = Heartbeat(agent, schedule="every 30m", prompt="Quick status check")
|
||||
|
||||
assert hb.agent is agent
|
||||
assert hb.config.schedule == "every 30m"
|
||||
assert hb.config.prompt == "Quick status check"
|
||||
assert hb._interval_seconds == 1800.0
|
||||
assert not hb.is_running
|
||||
|
||||
|
||||
class TestRealCostPersistence:
|
||||
"""Real integration test for F4 cost persistence after actual LLM call."""
|
||||
|
||||
def test_save_real_cost_report(self, tmp_path):
|
||||
"""Run real agent, save cost report, verify it persists."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.agent.cost_persistence import save_cost_report, load_cost_report
|
||||
from unittest.mock import patch
|
||||
|
||||
agent = Agent(
|
||||
name="cost_persist_test",
|
||||
instructions="Be extremely brief.",
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
|
||||
agent.start("Say 'hi'.")
|
||||
|
||||
with patch("praisonaiagents.agent.cost_persistence.COST_DIR", tmp_path):
|
||||
filepath = save_cost_report(agent, session_name="real_test")
|
||||
loaded = load_cost_report("real_test")
|
||||
|
||||
assert loaded is not None
|
||||
assert loaded["agent_name"] == "cost_persist_test"
|
||||
assert loaded["llm_calls"] >= 1
|
||||
print(f"\n[Saved Cost Report]: {loaded}")
|
||||
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
TDD tests for compaction wiring into agent.
|
||||
|
||||
Tests are written BEFORE implementation (TDD).
|
||||
Verifies ExecutionConfig gets context_compaction field,
|
||||
and ContextCompactor is called when enabled.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
class TestExecutionConfigCompactionFields:
|
||||
"""ExecutionConfig must have context_compaction and max_context_tokens fields."""
|
||||
|
||||
def test_has_context_compaction_field(self):
|
||||
from praisonaiagents.config.feature_configs import ExecutionConfig
|
||||
cfg = ExecutionConfig()
|
||||
assert hasattr(cfg, "context_compaction")
|
||||
assert cfg.context_compaction is False # safe default
|
||||
|
||||
def test_has_max_context_tokens_field(self):
|
||||
from praisonaiagents.config.feature_configs import ExecutionConfig
|
||||
cfg = ExecutionConfig()
|
||||
assert hasattr(cfg, "max_context_tokens")
|
||||
assert cfg.max_context_tokens is None # auto by default
|
||||
|
||||
def test_context_compaction_opt_in(self):
|
||||
from praisonaiagents.config.feature_configs import ExecutionConfig
|
||||
cfg = ExecutionConfig(context_compaction=True, max_context_tokens=8000)
|
||||
assert cfg.context_compaction is True
|
||||
assert cfg.max_context_tokens == 8000
|
||||
|
||||
|
||||
class TestContextCompactorExists:
|
||||
"""ContextCompactor must be importable and functional (existing module)."""
|
||||
|
||||
def test_import(self):
|
||||
from praisonaiagents.compaction import ContextCompactor
|
||||
assert ContextCompactor is not None
|
||||
|
||||
def test_needs_compaction_false_within_limit(self):
|
||||
from praisonaiagents.compaction import ContextCompactor
|
||||
compactor = ContextCompactor(max_tokens=10000)
|
||||
short_msgs = [{"role": "user", "content": "Hello"}, {"role": "assistant", "content": "Hi"}]
|
||||
assert compactor.needs_compaction(short_msgs) is False
|
||||
|
||||
def test_needs_compaction_true_over_limit(self):
|
||||
from praisonaiagents.compaction import ContextCompactor
|
||||
compactor = ContextCompactor(max_tokens=10) # tiny limit
|
||||
long_msgs = [{"role": "user", "content": "A" * 1000}]
|
||||
assert compactor.needs_compaction(long_msgs) is True
|
||||
|
||||
def test_compact_reduces_tokens(self):
|
||||
from praisonaiagents.compaction import ContextCompactor
|
||||
compactor = ContextCompactor(max_tokens=100, preserve_recent=1)
|
||||
many_msgs = [{"role": "user", "content": "Message " + str(i) * 50} for i in range(20)]
|
||||
compacted_msgs, result = compactor.compact(many_msgs)
|
||||
assert result.compacted_tokens < result.original_tokens
|
||||
|
||||
|
||||
class TestCompactionHookEvents:
|
||||
"""BEFORE_COMPACTION and AFTER_COMPACTION must be defined in HookEvent."""
|
||||
|
||||
def test_before_compaction_event_exists(self):
|
||||
from praisonaiagents.hooks.types import HookEvent
|
||||
assert hasattr(HookEvent, "BEFORE_COMPACTION")
|
||||
assert HookEvent.BEFORE_COMPACTION == "before_compaction"
|
||||
|
||||
def test_after_compaction_event_exists(self):
|
||||
from praisonaiagents.hooks.types import HookEvent
|
||||
assert hasattr(HookEvent, "AFTER_COMPACTION")
|
||||
assert HookEvent.AFTER_COMPACTION == "after_compaction"
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Tests for Cost Dashboard persistence (F4).
|
||||
|
||||
Tests save/load/list/format for cost reports in ~/.praisonai/costs/.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TestSaveCostReport:
|
||||
"""Test saving cost reports to disk."""
|
||||
|
||||
def test_save_creates_file(self, tmp_path):
|
||||
from praisonaiagents.agent.cost_persistence import save_cost_report
|
||||
agent = MagicMock()
|
||||
agent.name = "test_agent"
|
||||
agent.cost_summary = {"tokens_in": 100, "tokens_out": 50, "cost": 0.001, "llm_calls": 1}
|
||||
with patch("praisonaiagents.agent.cost_persistence.COST_DIR", tmp_path):
|
||||
filepath = save_cost_report(agent, session_name="test_session")
|
||||
assert os.path.exists(filepath)
|
||||
|
||||
def test_save_content(self, tmp_path):
|
||||
from praisonaiagents.agent.cost_persistence import save_cost_report
|
||||
agent = MagicMock()
|
||||
agent.name = "my_agent"
|
||||
agent.cost_summary = {"tokens_in": 500, "tokens_out": 200, "cost": 0.05, "llm_calls": 3}
|
||||
with patch("praisonaiagents.agent.cost_persistence.COST_DIR", tmp_path):
|
||||
filepath = save_cost_report(agent, session_name="s1")
|
||||
with open(filepath) as f:
|
||||
data = json.load(f)
|
||||
assert data["agent_name"] == "my_agent"
|
||||
assert data["cost"] == 0.05
|
||||
assert data["tokens_in"] == 500
|
||||
assert data["llm_calls"] == 3
|
||||
|
||||
def test_save_auto_session_name(self, tmp_path):
|
||||
from praisonaiagents.agent.cost_persistence import save_cost_report
|
||||
agent = MagicMock()
|
||||
agent.name = "bot"
|
||||
agent.cost_summary = {"tokens_in": 0, "tokens_out": 0, "cost": 0.0, "llm_calls": 0}
|
||||
with patch("praisonaiagents.agent.cost_persistence.COST_DIR", tmp_path):
|
||||
filepath = save_cost_report(agent)
|
||||
assert "bot_" in filepath
|
||||
|
||||
|
||||
class TestLoadCostReport:
|
||||
"""Test loading cost reports from disk."""
|
||||
|
||||
def test_load_existing(self, tmp_path):
|
||||
from praisonaiagents.agent.cost_persistence import load_cost_report
|
||||
data = {"session": "test", "cost": 0.01}
|
||||
(tmp_path / "test.json").write_text(json.dumps(data))
|
||||
with patch("praisonaiagents.agent.cost_persistence.COST_DIR", tmp_path):
|
||||
result = load_cost_report("test")
|
||||
assert result["cost"] == 0.01
|
||||
|
||||
def test_load_missing(self, tmp_path):
|
||||
from praisonaiagents.agent.cost_persistence import load_cost_report
|
||||
with patch("praisonaiagents.agent.cost_persistence.COST_DIR", tmp_path):
|
||||
result = load_cost_report("nonexistent")
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestListCostReports:
|
||||
"""Test listing cost reports."""
|
||||
|
||||
def test_list_empty(self, tmp_path):
|
||||
from praisonaiagents.agent.cost_persistence import list_cost_reports
|
||||
with patch("praisonaiagents.agent.cost_persistence.COST_DIR", tmp_path):
|
||||
results = list_cost_reports()
|
||||
assert results == []
|
||||
|
||||
def test_list_multiple(self, tmp_path):
|
||||
from praisonaiagents.agent.cost_persistence import list_cost_reports
|
||||
for i in range(3):
|
||||
data = {"session": f"s{i}", "cost": i * 0.01}
|
||||
(tmp_path / f"s{i}.json").write_text(json.dumps(data))
|
||||
with patch("praisonaiagents.agent.cost_persistence.COST_DIR", tmp_path):
|
||||
results = list_cost_reports()
|
||||
assert len(results) == 3
|
||||
|
||||
def test_list_limit(self, tmp_path):
|
||||
from praisonaiagents.agent.cost_persistence import list_cost_reports
|
||||
for i in range(10):
|
||||
data = {"session": f"s{i}", "cost": i * 0.01}
|
||||
(tmp_path / f"s{i}.json").write_text(json.dumps(data))
|
||||
with patch("praisonaiagents.agent.cost_persistence.COST_DIR", tmp_path):
|
||||
results = list_cost_reports(limit=5)
|
||||
assert len(results) == 5
|
||||
|
||||
|
||||
class TestFormatCostTable:
|
||||
"""Test terminal formatting."""
|
||||
|
||||
def test_format_empty(self):
|
||||
from praisonaiagents.agent.cost_persistence import format_cost_table
|
||||
assert format_cost_table([]) == "No cost reports found."
|
||||
|
||||
def test_format_as_json(self):
|
||||
from praisonaiagents.agent.cost_persistence import format_cost_table
|
||||
reports = [{"session": "s1", "cost": 0.01}]
|
||||
result = format_cost_table(reports, as_json=True)
|
||||
parsed = json.loads(result)
|
||||
assert parsed[0]["cost"] == 0.01
|
||||
|
||||
def test_format_table(self):
|
||||
from praisonaiagents.agent.cost_persistence import format_cost_table
|
||||
reports = [{"session": "s1", "agent_name": "bot", "cost": 0.05, "tokens_in": 100, "tokens_out": 50, "llm_calls": 2}]
|
||||
result = format_cost_table(reports)
|
||||
assert "s1" in result
|
||||
assert "bot" in result
|
||||
assert "0.0500" in result
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Tests for Heartbeat Agent (F2).
|
||||
|
||||
Tests the standalone Heartbeat class that runs agents on a schedule.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
class TestHeartbeatImports:
|
||||
"""Test lazy imports work correctly."""
|
||||
|
||||
def test_import_from_agent_module(self):
|
||||
from praisonaiagents.agent import Heartbeat
|
||||
assert Heartbeat is not None
|
||||
|
||||
def test_import_from_package(self):
|
||||
from praisonaiagents import Heartbeat
|
||||
assert Heartbeat is not None
|
||||
|
||||
def test_import_config(self):
|
||||
from praisonaiagents import HeartbeatConfig
|
||||
assert HeartbeatConfig is not None
|
||||
|
||||
def test_import_config_from_agent(self):
|
||||
from praisonaiagents.agent import HeartbeatConfig
|
||||
assert HeartbeatConfig is not None
|
||||
|
||||
|
||||
class TestHeartbeatConfig:
|
||||
"""Test HeartbeatConfig dataclass."""
|
||||
|
||||
def test_defaults(self):
|
||||
from praisonaiagents.agent.heartbeat import HeartbeatConfig
|
||||
cfg = HeartbeatConfig()
|
||||
assert cfg.schedule == "hourly"
|
||||
assert cfg.prompt is None
|
||||
assert cfg.on_result is None
|
||||
assert cfg.on_error == "retry"
|
||||
assert cfg.max_retries == 3
|
||||
|
||||
def test_custom_values(self):
|
||||
from praisonaiagents.agent.heartbeat import HeartbeatConfig
|
||||
cb = lambda x: x
|
||||
cfg = HeartbeatConfig(schedule="daily", prompt="check", on_result=cb, max_retries=5)
|
||||
assert cfg.schedule == "daily"
|
||||
assert cfg.prompt == "check"
|
||||
assert cfg.on_result is cb
|
||||
assert cfg.max_retries == 5
|
||||
|
||||
|
||||
class TestHeartbeatInit:
|
||||
"""Test Heartbeat initialization."""
|
||||
|
||||
def test_creates_with_agent(self):
|
||||
from praisonaiagents.agent.heartbeat import Heartbeat
|
||||
agent = MagicMock(name="test_agent")
|
||||
hb = Heartbeat(agent, schedule="hourly")
|
||||
assert hb.agent is agent
|
||||
assert hb.config.schedule == "hourly"
|
||||
assert hb._running is False
|
||||
|
||||
def test_hourly_interval(self):
|
||||
from praisonaiagents.agent.heartbeat import Heartbeat
|
||||
hb = Heartbeat(MagicMock(), schedule="hourly")
|
||||
assert hb._interval_seconds == 3600.0
|
||||
|
||||
def test_daily_interval(self):
|
||||
from praisonaiagents.agent.heartbeat import Heartbeat
|
||||
hb = Heartbeat(MagicMock(), schedule="daily")
|
||||
assert hb._interval_seconds == 86400.0
|
||||
|
||||
def test_weekly_interval(self):
|
||||
from praisonaiagents.agent.heartbeat import Heartbeat
|
||||
hb = Heartbeat(MagicMock(), schedule="weekly")
|
||||
assert hb._interval_seconds == 604800.0
|
||||
|
||||
def test_every_30m_interval(self):
|
||||
from praisonaiagents.agent.heartbeat import Heartbeat
|
||||
hb = Heartbeat(MagicMock(), schedule="every 30m")
|
||||
assert hb._interval_seconds == 1800.0
|
||||
|
||||
def test_every_6h_interval(self):
|
||||
from praisonaiagents.agent.heartbeat import Heartbeat
|
||||
hb = Heartbeat(MagicMock(), schedule="every 6h")
|
||||
assert hb._interval_seconds == 21600.0
|
||||
|
||||
def test_every_10s_interval(self):
|
||||
from praisonaiagents.agent.heartbeat import Heartbeat
|
||||
hb = Heartbeat(MagicMock(), schedule="every 10s")
|
||||
assert hb._interval_seconds == 10.0
|
||||
|
||||
|
||||
class TestHeartbeatStartStop:
|
||||
"""Test start/stop lifecycle."""
|
||||
|
||||
def test_start_nonblocking(self):
|
||||
from praisonaiagents.agent.heartbeat import Heartbeat
|
||||
agent = MagicMock()
|
||||
agent.start.side_effect = Exception("stop")
|
||||
hb = Heartbeat(agent, schedule="hourly", max_retries=1)
|
||||
hb.start(blocking=False)
|
||||
assert hb._running is True
|
||||
hb.stop()
|
||||
assert hb._running is False
|
||||
|
||||
def test_stop_sets_flag(self):
|
||||
from praisonaiagents.agent.heartbeat import Heartbeat
|
||||
hb = Heartbeat(MagicMock(), schedule="hourly")
|
||||
hb._running = True
|
||||
hb.stop()
|
||||
assert not hb.is_running
|
||||
|
||||
def test_is_running_property(self):
|
||||
from praisonaiagents.agent.heartbeat import Heartbeat
|
||||
hb = Heartbeat(MagicMock(), schedule="hourly")
|
||||
assert not hb.is_running
|
||||
hb._running = True
|
||||
assert hb.is_running
|
||||
|
||||
|
||||
class TestHeartbeatDoesNotModifyAgent:
|
||||
"""Verify Heartbeat is truly standalone."""
|
||||
|
||||
def test_agent_class_unchanged(self):
|
||||
"""Agent class should have no heartbeat-related attributes."""
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="test", instructions="test")
|
||||
assert not hasattr(agent, '_heartbeat')
|
||||
assert not hasattr(agent, 'heartbeat')
|
||||
@@ -0,0 +1,223 @@
|
||||
"""
|
||||
TDD tests for PraisonAI tool loop detection.
|
||||
|
||||
Tests are written BEFORE implementation (TDD).
|
||||
Port of OpenClaw's tool-loop-detection.ts patterns to Python.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: compute expected hash the same way the module will
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _hash(tool_name: str, args: dict) -> str:
|
||||
stable = json.dumps({"t": tool_name, "a": args}, sort_keys=True, default=str)
|
||||
return hashlib.sha256(stable.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import the module under test (will fail until implemented — that's TDD)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLoopDetectionImport:
|
||||
"""The module must be importable with zero side-effects."""
|
||||
|
||||
def test_module_importable(self):
|
||||
from praisonaiagents.agent.loop_detection import (
|
||||
LoopDetectionConfig,
|
||||
LoopDetectionResult,
|
||||
detect_tool_loop,
|
||||
record_tool_call,
|
||||
record_tool_outcome,
|
||||
hash_tool_call,
|
||||
)
|
||||
assert LoopDetectionConfig is not None
|
||||
assert detect_tool_loop is not None
|
||||
|
||||
def test_no_side_effects_on_import(self):
|
||||
"""Importing must not change global state or call LLM."""
|
||||
import praisonaiagents.agent.loop_detection # noqa
|
||||
|
||||
|
||||
class TestLoopDetectionConfig:
|
||||
def setup_method(self):
|
||||
from praisonaiagents.agent.loop_detection import LoopDetectionConfig
|
||||
self.Config = LoopDetectionConfig
|
||||
|
||||
def test_default_disabled(self):
|
||||
cfg = self.Config()
|
||||
assert cfg.enabled is False # safe default: opt-in only
|
||||
|
||||
def test_custom_thresholds(self):
|
||||
cfg = self.Config(enabled=True, warn_threshold=3, critical_threshold=5)
|
||||
assert cfg.warn_threshold == 3
|
||||
assert cfg.critical_threshold == 5
|
||||
|
||||
def test_critical_must_be_greater_than_warn(self):
|
||||
"""If critical <= warn, it should be auto-corrected."""
|
||||
cfg = self.Config(enabled=True, warn_threshold=10, critical_threshold=5)
|
||||
assert cfg.critical_threshold > cfg.warn_threshold
|
||||
|
||||
|
||||
class TestHashToolCall:
|
||||
def test_hash_is_deterministic(self):
|
||||
from praisonaiagents.agent.loop_detection import hash_tool_call
|
||||
h1 = hash_tool_call("search", {"query": "foo"})
|
||||
h2 = hash_tool_call("search", {"query": "foo"})
|
||||
assert h1 == h2
|
||||
|
||||
def test_different_args_produce_different_hashes(self):
|
||||
from praisonaiagents.agent.loop_detection import hash_tool_call
|
||||
h1 = hash_tool_call("search", {"query": "foo"})
|
||||
h2 = hash_tool_call("search", {"query": "bar"})
|
||||
assert h1 != h2
|
||||
|
||||
def test_different_tools_produce_different_hashes(self):
|
||||
from praisonaiagents.agent.loop_detection import hash_tool_call
|
||||
h1 = hash_tool_call("search", {"query": "foo"})
|
||||
h2 = hash_tool_call("fetch", {"query": "foo"})
|
||||
assert h1 != h2
|
||||
|
||||
def test_arg_order_does_not_matter(self):
|
||||
"""Stable JSON sort keys: {'a':1,'b':2} == {'b':2,'a':1}"""
|
||||
from praisonaiagents.agent.loop_detection import hash_tool_call
|
||||
h1 = hash_tool_call("t", {"b": 2, "a": 1})
|
||||
h2 = hash_tool_call("t", {"a": 1, "b": 2})
|
||||
assert h1 == h2
|
||||
|
||||
|
||||
class TestDetectToolLoop:
|
||||
def setup_method(self):
|
||||
from praisonaiagents.agent.loop_detection import (
|
||||
LoopDetectionConfig,
|
||||
detect_tool_loop,
|
||||
record_tool_call,
|
||||
)
|
||||
self.Config = LoopDetectionConfig
|
||||
self.detect = detect_tool_loop
|
||||
self.record = record_tool_call
|
||||
|
||||
# --- disabled ---
|
||||
|
||||
def test_disabled_always_returns_not_stuck(self):
|
||||
cfg = self.Config(enabled=False)
|
||||
history = []
|
||||
# Fill history with many identical calls
|
||||
for _ in range(50):
|
||||
self.record(history, "search", {"q": "x"}, cfg)
|
||||
result = self.detect(history, "search", {"q": "x"}, cfg)
|
||||
assert result["stuck"] is False
|
||||
|
||||
# --- generic_repeat ---
|
||||
|
||||
def test_below_warn_threshold_not_stuck(self):
|
||||
cfg = self.Config(enabled=True, warn_threshold=5, critical_threshold=10)
|
||||
history = []
|
||||
for _ in range(4):
|
||||
self.record(history, "search", {"q": "foo"}, cfg)
|
||||
result = self.detect(history, "search", {"q": "foo"}, cfg)
|
||||
assert result["stuck"] is False
|
||||
|
||||
def test_at_warn_threshold_warning(self):
|
||||
cfg = self.Config(enabled=True, warn_threshold=5, critical_threshold=10)
|
||||
history = []
|
||||
for _ in range(5):
|
||||
self.record(history, "search", {"q": "foo"}, cfg)
|
||||
result = self.detect(history, "search", {"q": "foo"}, cfg)
|
||||
assert result["stuck"] is True
|
||||
assert result["level"] == "warning"
|
||||
assert result["detector"] == "generic_repeat"
|
||||
|
||||
def test_at_critical_threshold_critical(self):
|
||||
cfg = self.Config(enabled=True, warn_threshold=5, critical_threshold=10)
|
||||
history = []
|
||||
for _ in range(10):
|
||||
self.record(history, "search", {"q": "foo"}, cfg)
|
||||
result = self.detect(history, "search", {"q": "foo"}, cfg)
|
||||
assert result["stuck"] is True
|
||||
assert result["level"] == "critical"
|
||||
|
||||
def test_different_tools_no_false_positive(self):
|
||||
"""Alternating different tools should NOT trigger generic_repeat."""
|
||||
cfg = self.Config(enabled=True, warn_threshold=3, critical_threshold=6)
|
||||
history = []
|
||||
for i in range(6):
|
||||
self.record(history, "search", {"q": str(i)}, cfg) # different args each time
|
||||
result = self.detect(history, "search", {"q": "0"}, cfg)
|
||||
# Only 1 occurrence of {"q": "0"} — not stuck
|
||||
assert result["stuck"] is False
|
||||
|
||||
# --- message content ---
|
||||
|
||||
def test_warning_message_contains_tool_name(self):
|
||||
cfg = self.Config(enabled=True, warn_threshold=3, critical_threshold=10)
|
||||
history = []
|
||||
for _ in range(3):
|
||||
self.record(history, "my_tool", {"x": 1}, cfg)
|
||||
result = self.detect(history, "my_tool", {"x": 1}, cfg)
|
||||
if result["stuck"]:
|
||||
assert "my_tool" in result["message"]
|
||||
|
||||
def test_result_has_count_field(self):
|
||||
cfg = self.Config(enabled=True, warn_threshold=3, critical_threshold=10)
|
||||
history = []
|
||||
for _ in range(3):
|
||||
self.record(history, "t", {}, cfg)
|
||||
result = self.detect(history, "t", {}, cfg)
|
||||
assert "count" in result
|
||||
|
||||
|
||||
class TestRecordToolCall:
|
||||
def test_record_appends_to_history(self):
|
||||
from praisonaiagents.agent.loop_detection import (
|
||||
LoopDetectionConfig, record_tool_call
|
||||
)
|
||||
cfg = LoopDetectionConfig(enabled=True)
|
||||
history = []
|
||||
record_tool_call(history, "search", {"q": "test"}, cfg)
|
||||
assert len(history) == 1
|
||||
assert history[0]["tool_name"] == "search"
|
||||
|
||||
def test_history_respects_max_size(self):
|
||||
from praisonaiagents.agent.loop_detection import (
|
||||
LoopDetectionConfig, record_tool_call
|
||||
)
|
||||
cfg = LoopDetectionConfig(enabled=True, history_size=5)
|
||||
history = []
|
||||
for i in range(10):
|
||||
record_tool_call(history, "t", {"i": i}, cfg)
|
||||
assert len(history) <= 5
|
||||
|
||||
|
||||
class TestRecordToolOutcome:
|
||||
def test_outcome_updates_result_hash(self):
|
||||
from praisonaiagents.agent.loop_detection import (
|
||||
LoopDetectionConfig, record_tool_call, record_tool_outcome
|
||||
)
|
||||
cfg = LoopDetectionConfig(enabled=True)
|
||||
history = []
|
||||
record_tool_call(history, "search", {"q": "x"}, cfg)
|
||||
assert history[0].get("result_hash") is None
|
||||
record_tool_outcome(history, "search", {"q": "x"}, "some result", cfg)
|
||||
assert history[0].get("result_hash") is not None
|
||||
|
||||
|
||||
class TestLoopDetectionPlugin:
|
||||
"""Tests for the plugin interface (hooks integration)."""
|
||||
|
||||
def test_plugin_importable(self):
|
||||
from praisonaiagents.plugins import loop_detection_plugin # noqa
|
||||
assert loop_detection_plugin is not None
|
||||
|
||||
def test_plugin_registers_hook(self):
|
||||
"""Plugin import should register a BEFORE_TOOL hook."""
|
||||
from praisonaiagents.hooks.registry import get_default_registry
|
||||
from praisonaiagents.hooks.types import HookEvent
|
||||
import praisonaiagents.plugins.loop_detection_plugin # noqa ensure imported
|
||||
hooks = get_default_registry().get_hooks(HookEvent.BEFORE_TOOL)
|
||||
assert len(hooks) >= 1
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Tests for Permission Tiers (F3).
|
||||
|
||||
Tests the approval="safe"/"read_only"/"full" permission presets that provide
|
||||
declarative tool access control via the existing approval= Agent param.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
def _mock_registry_approve_sync(*args, **kwargs):
|
||||
"""Mock approve_sync that auto-approves (no console prompt)."""
|
||||
from praisonaiagents.approval.protocols import ApprovalDecision
|
||||
return ApprovalDecision(approved=True, reason="mocked")
|
||||
|
||||
|
||||
class TestPermissionPresets:
|
||||
"""Test PERMISSION_PRESETS in approval/registry.py."""
|
||||
|
||||
def test_presets_exist(self):
|
||||
from praisonaiagents.approval.registry import PERMISSION_PRESETS
|
||||
assert "safe" in PERMISSION_PRESETS
|
||||
assert "read_only" in PERMISSION_PRESETS
|
||||
assert "full" in PERMISSION_PRESETS
|
||||
|
||||
def test_safe_preset_is_frozenset(self):
|
||||
from praisonaiagents.approval.registry import PERMISSION_PRESETS
|
||||
assert isinstance(PERMISSION_PRESETS["safe"], frozenset)
|
||||
|
||||
def test_safe_blocks_execute_command(self):
|
||||
from praisonaiagents.approval.registry import PERMISSION_PRESETS
|
||||
assert "execute_command" in PERMISSION_PRESETS["safe"]
|
||||
|
||||
def test_safe_blocks_delete_file(self):
|
||||
from praisonaiagents.approval.registry import PERMISSION_PRESETS
|
||||
assert "delete_file" in PERMISSION_PRESETS["safe"]
|
||||
|
||||
def test_safe_blocks_all_dangerous(self):
|
||||
from praisonaiagents.approval.registry import PERMISSION_PRESETS, DEFAULT_DANGEROUS_TOOLS
|
||||
for tool_name in DEFAULT_DANGEROUS_TOOLS:
|
||||
assert tool_name in PERMISSION_PRESETS["safe"], f"{tool_name} not in safe preset"
|
||||
|
||||
def test_read_only_is_superset_of_safe(self):
|
||||
from praisonaiagents.approval.registry import PERMISSION_PRESETS
|
||||
assert PERMISSION_PRESETS["safe"].issubset(PERMISSION_PRESETS["read_only"])
|
||||
|
||||
def test_full_is_empty(self):
|
||||
from praisonaiagents.approval.registry import PERMISSION_PRESETS
|
||||
assert len(PERMISSION_PRESETS["full"]) == 0
|
||||
|
||||
|
||||
class TestAgentPermissionInit:
|
||||
"""Test that Agent correctly resolves permission presets from approval= param."""
|
||||
|
||||
def test_no_approval_no_denials(self):
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="test", instructions="test")
|
||||
assert agent._perm_deny == frozenset()
|
||||
assert agent._perm_allow is None
|
||||
|
||||
def test_approval_safe_sets_deny(self):
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="test", instructions="test", approval="safe")
|
||||
assert len(agent._perm_deny) > 0
|
||||
assert "execute_command" in agent._perm_deny
|
||||
|
||||
def test_approval_read_only_sets_deny(self):
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="test", instructions="test", approval="read_only")
|
||||
assert "execute_command" in agent._perm_deny
|
||||
assert "write_file" in agent._perm_deny
|
||||
|
||||
def test_approval_full_empty_deny(self):
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="test", instructions="test", approval="full")
|
||||
assert agent._perm_deny == frozenset()
|
||||
|
||||
def test_approval_true_still_works(self):
|
||||
"""Backward compat: approval=True should still create AutoApproveBackend."""
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="test", instructions="test", approval=True)
|
||||
assert agent._approval_backend is not None
|
||||
assert agent._perm_deny == frozenset()
|
||||
|
||||
def test_approval_false_still_works(self):
|
||||
"""Backward compat: approval=False should still disable."""
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="test", instructions="test", approval=False)
|
||||
assert agent._approval_backend is None
|
||||
assert agent._perm_deny == frozenset()
|
||||
|
||||
def test_approval_none_still_works(self):
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="test", instructions="test", approval=None)
|
||||
assert agent._approval_backend is None
|
||||
|
||||
|
||||
class TestPermissionCheck:
|
||||
"""Test _check_tool_approval_sync permission fast-path."""
|
||||
|
||||
def test_safe_blocks_execute_command(self):
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="test", instructions="test", approval="safe")
|
||||
result = agent._check_tool_approval_sync("execute_command", {"command": "ls"})
|
||||
assert isinstance(result, dict)
|
||||
assert "permission_denied" in result
|
||||
assert result["permission_denied"] is True
|
||||
|
||||
def test_safe_blocks_delete_file(self):
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="test", instructions="test", approval="safe")
|
||||
result = agent._check_tool_approval_sync("delete_file", {"path": "/tmp/x"})
|
||||
assert result.get("permission_denied") is True
|
||||
|
||||
def test_safe_allows_read_file(self):
|
||||
"""read_file is NOT in the dangerous tools list, so it should pass."""
|
||||
from praisonaiagents import Agent
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.approve_sync.return_value = MagicMock(
|
||||
approved=True, reason="mock", modified_args=None
|
||||
)
|
||||
mock_registry.mark_approved = MagicMock()
|
||||
with patch("praisonaiagents.approval.get_approval_registry", return_value=mock_registry):
|
||||
agent = Agent(name="test", instructions="test", approval="safe")
|
||||
result = agent._check_tool_approval_sync("read_file", {"path": "/tmp/x"})
|
||||
assert result is None or (isinstance(result, tuple) and len(result) == 2)
|
||||
|
||||
def test_full_allows_dangerous_tool(self):
|
||||
"""approval='full' should not deny execute_command via permission tier."""
|
||||
from praisonaiagents import Agent
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.approve_sync.return_value = MagicMock(
|
||||
approved=True, reason="mock", modified_args=None
|
||||
)
|
||||
mock_registry.mark_approved = MagicMock()
|
||||
with patch("praisonaiagents.approval.get_approval_registry", return_value=mock_registry):
|
||||
agent = Agent(name="test", instructions="test", approval="full")
|
||||
result = agent._check_tool_approval_sync("execute_command", {"command": "ls"})
|
||||
assert result is None or (isinstance(result, tuple) and len(result) == 2)
|
||||
|
||||
def test_no_approval_allows_non_dangerous(self):
|
||||
"""Without approval= param, non-dangerous tools pass through."""
|
||||
from praisonaiagents import Agent
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.approve_sync.return_value = MagicMock(
|
||||
approved=True, reason="mock", modified_args=None
|
||||
)
|
||||
mock_registry.mark_approved = MagicMock()
|
||||
with patch("praisonaiagents.approval.get_approval_registry", return_value=mock_registry):
|
||||
agent = Agent(name="test", instructions="test")
|
||||
result = agent._check_tool_approval_sync("my_custom_tool", {})
|
||||
assert result is None or (isinstance(result, tuple) and len(result) == 2)
|
||||
|
||||
|
||||
class TestPermissionZeroOverhead:
|
||||
"""Verify zero overhead when no permission preset is set."""
|
||||
|
||||
def test_empty_frozenset_falsy(self):
|
||||
"""Empty frozenset is falsy, so 'if self._perm_deny' skips the check."""
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="test", instructions="test")
|
||||
assert not agent._perm_deny # frozenset() is falsy
|
||||
|
||||
def test_none_perm_allow_skips(self):
|
||||
"""None _perm_allow means 'if self._perm_allow is not None' skips."""
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="test", instructions="test")
|
||||
assert agent._perm_allow is None
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Tests for Skill Import Bridge (F5).
|
||||
|
||||
Tests scanning, parsing, and security analysis of OpenClaw-style skill directories.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TestSkillInfo:
|
||||
"""Test SkillInfo dataclass."""
|
||||
|
||||
def test_defaults(self):
|
||||
from praisonaiagents.tools.skill_bridge import SkillInfo
|
||||
info = SkillInfo()
|
||||
assert info.name == ""
|
||||
assert info.compatible is True
|
||||
assert info.functions == []
|
||||
assert info.security_warnings == []
|
||||
|
||||
|
||||
class TestScanSkills:
|
||||
"""Test scanning a directory of skills."""
|
||||
|
||||
def test_scan_empty_dir(self, tmp_path):
|
||||
from praisonaiagents.tools.skill_bridge import scan_skills
|
||||
results = scan_skills(str(tmp_path))
|
||||
assert results == []
|
||||
|
||||
def test_scan_single_skill(self, tmp_path):
|
||||
from praisonaiagents.tools.skill_bridge import scan_skills
|
||||
skill_dir = tmp_path / "web_search"
|
||||
skill_dir.mkdir()
|
||||
(skill_dir / "tool.py").write_text("def search(query: str) -> str:\n \"\"\"Search the web.\"\"\"\n return query\n")
|
||||
results = scan_skills(str(tmp_path))
|
||||
assert len(results) == 1
|
||||
assert results[0].name == "web_search"
|
||||
assert "search" in results[0].functions
|
||||
|
||||
def test_scan_multiple_skills(self, tmp_path):
|
||||
from praisonaiagents.tools.skill_bridge import scan_skills
|
||||
for name in ["skill_a", "skill_b", "skill_c"]:
|
||||
d = tmp_path / name
|
||||
d.mkdir()
|
||||
(d / "tool.py").write_text(f"def {name}_func(): pass\n")
|
||||
results = scan_skills(str(tmp_path))
|
||||
assert len(results) == 3
|
||||
|
||||
def test_scan_ignores_hidden_dirs(self, tmp_path):
|
||||
from praisonaiagents.tools.skill_bridge import scan_skills
|
||||
hidden = tmp_path / ".hidden_skill"
|
||||
hidden.mkdir()
|
||||
(hidden / "tool.py").write_text("def hidden(): pass\n")
|
||||
results = scan_skills(str(tmp_path))
|
||||
assert len(results) == 0
|
||||
|
||||
def test_scan_nonexistent_dir(self):
|
||||
from praisonaiagents.tools.skill_bridge import scan_skills
|
||||
results = scan_skills("/nonexistent/path")
|
||||
assert results == []
|
||||
|
||||
|
||||
class TestSkillMdParsing:
|
||||
"""Test SKILL.md frontmatter/description parsing."""
|
||||
|
||||
def test_yaml_frontmatter(self, tmp_path):
|
||||
from praisonaiagents.tools.skill_bridge import scan_skills
|
||||
skill = tmp_path / "my_skill"
|
||||
skill.mkdir()
|
||||
(skill / "SKILL.md").write_text("---\ndescription: Search the web for information\n---\n# My Skill\n")
|
||||
(skill / "search.py").write_text("def search(): pass\n")
|
||||
results = scan_skills(str(tmp_path))
|
||||
assert results[0].description == "Search the web for information"
|
||||
|
||||
def test_plain_markdown(self, tmp_path):
|
||||
from praisonaiagents.tools.skill_bridge import scan_skills
|
||||
skill = tmp_path / "my_skill"
|
||||
skill.mkdir()
|
||||
(skill / "SKILL.md").write_text("# My Skill\nThis skill does something cool.\n")
|
||||
(skill / "tool.py").write_text("def cool(): pass\n")
|
||||
results = scan_skills(str(tmp_path))
|
||||
assert "something cool" in results[0].description
|
||||
|
||||
|
||||
class TestSecurityScanner:
|
||||
"""Test security pattern detection."""
|
||||
|
||||
def test_detects_subprocess(self, tmp_path):
|
||||
from praisonaiagents.tools.skill_bridge import scan_skills
|
||||
skill = tmp_path / "dangerous"
|
||||
skill.mkdir()
|
||||
(skill / "tool.py").write_text("import subprocess\ndef run(cmd):\n subprocess.run(cmd)\n")
|
||||
results = scan_skills(str(tmp_path))
|
||||
assert len(results[0].security_warnings) > 0
|
||||
assert any("Subprocess" in w for w in results[0].security_warnings)
|
||||
|
||||
def test_detects_eval(self, tmp_path):
|
||||
from praisonaiagents.tools.skill_bridge import scan_skills
|
||||
skill = tmp_path / "eval_skill"
|
||||
skill.mkdir()
|
||||
(skill / "tool.py").write_text("def execute(code):\n return eval(code)\n")
|
||||
results = scan_skills(str(tmp_path))
|
||||
assert any("eval" in w for w in results[0].security_warnings)
|
||||
|
||||
def test_detects_os_system(self, tmp_path):
|
||||
from praisonaiagents.tools.skill_bridge import scan_skills
|
||||
skill = tmp_path / "os_skill"
|
||||
skill.mkdir()
|
||||
(skill / "tool.py").write_text("import os\ndef run(cmd):\n os.system(cmd)\n")
|
||||
results = scan_skills(str(tmp_path))
|
||||
assert any("os.system" in w for w in results[0].security_warnings)
|
||||
|
||||
def test_safe_skill_no_warnings(self, tmp_path):
|
||||
from praisonaiagents.tools.skill_bridge import scan_skills
|
||||
skill = tmp_path / "safe"
|
||||
skill.mkdir()
|
||||
(skill / "tool.py").write_text("def greet(name: str) -> str:\n return f'Hello, {name}'\n")
|
||||
results = scan_skills(str(tmp_path))
|
||||
assert len(results[0].security_warnings) == 0
|
||||
|
||||
|
||||
class TestImportSkill:
|
||||
"""Test importing skills as tool metadata."""
|
||||
|
||||
def test_import_extracts_functions(self, tmp_path):
|
||||
from praisonaiagents.tools.skill_bridge import import_skill
|
||||
skill = tmp_path / "my_skill"
|
||||
skill.mkdir()
|
||||
(skill / "tool.py").write_text(
|
||||
"def search(query: str) -> str:\n"
|
||||
" \"\"\"Search the web.\"\"\"\n"
|
||||
" return query\n"
|
||||
"\n"
|
||||
"def fetch(url: str) -> str:\n"
|
||||
" \"\"\"Fetch a URL.\"\"\"\n"
|
||||
" return url\n"
|
||||
)
|
||||
tools = import_skill(str(skill))
|
||||
assert len(tools) == 2
|
||||
names = [t["name"] for t in tools]
|
||||
assert "search" in names
|
||||
assert "fetch" in names
|
||||
|
||||
def test_import_captures_docstrings(self, tmp_path):
|
||||
from praisonaiagents.tools.skill_bridge import import_skill
|
||||
skill = tmp_path / "my_skill"
|
||||
skill.mkdir()
|
||||
(skill / "tool.py").write_text("def calc(x: int) -> int:\n \"\"\"Calculate something.\"\"\"\n return x\n")
|
||||
tools = import_skill(str(skill))
|
||||
assert tools[0]["description"] == "Calculate something."
|
||||
|
||||
def test_import_skips_private(self, tmp_path):
|
||||
from praisonaiagents.tools.skill_bridge import import_skill
|
||||
skill = tmp_path / "my_skill"
|
||||
skill.mkdir()
|
||||
(skill / "tool.py").write_text("def public(): pass\ndef _private(): pass\n")
|
||||
tools = import_skill(str(skill))
|
||||
names = [t["name"] for t in tools]
|
||||
assert "public" in names
|
||||
assert "_private" not in names
|
||||
|
||||
|
||||
class TestFormatScanReport:
|
||||
"""Test terminal report formatting."""
|
||||
|
||||
def test_format_empty(self):
|
||||
from praisonaiagents.tools.skill_bridge import format_scan_report
|
||||
assert format_scan_report([]) == "No skills found."
|
||||
|
||||
def test_format_with_skills(self):
|
||||
from praisonaiagents.tools.skill_bridge import SkillInfo, format_scan_report
|
||||
skills = [
|
||||
SkillInfo(name="web_search", functions=["search"], compatible=True),
|
||||
SkillInfo(name="dangerous", functions=["run"], security_warnings=["eval"], compatible=False),
|
||||
]
|
||||
result = format_scan_report(skills)
|
||||
assert "web_search" in result
|
||||
assert "dangerous" in result
|
||||
assert "2 skills" in result
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Tests for Token Budget Guard (F1).
|
||||
|
||||
Tests the max_budget feature in ExecutionConfig that provides hard dollar
|
||||
limits per agent run with zero overhead when disabled.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
class TestBudgetExceededError:
|
||||
"""Test BudgetExceededError exception class."""
|
||||
|
||||
def test_import_from_agent_module(self):
|
||||
from praisonaiagents.agent.agent import BudgetExceededError
|
||||
assert issubclass(BudgetExceededError, Exception)
|
||||
|
||||
def test_import_from_package(self):
|
||||
from praisonaiagents import BudgetExceededError
|
||||
assert issubclass(BudgetExceededError, Exception)
|
||||
|
||||
def test_error_attributes(self):
|
||||
from praisonaiagents.agent.agent import BudgetExceededError
|
||||
err = BudgetExceededError("test_agent", 0.55, 0.50)
|
||||
assert err.agent_name == "test_agent"
|
||||
assert err.total_cost == 0.55
|
||||
assert err.max_budget == 0.50
|
||||
|
||||
def test_error_message(self):
|
||||
from praisonaiagents.agent.agent import BudgetExceededError
|
||||
err = BudgetExceededError("researcher", 0.5123, 0.50)
|
||||
assert "researcher" in str(err)
|
||||
assert "$0.5123" in str(err)
|
||||
assert "$0.5000" in str(err)
|
||||
|
||||
|
||||
class TestExecutionConfigBudget:
|
||||
"""Test max_budget fields on ExecutionConfig."""
|
||||
|
||||
def test_default_none(self):
|
||||
from praisonaiagents import ExecutionConfig
|
||||
config = ExecutionConfig()
|
||||
assert config.max_budget is None
|
||||
assert config.on_budget_exceeded == "stop"
|
||||
|
||||
def test_set_budget(self):
|
||||
from praisonaiagents import ExecutionConfig
|
||||
config = ExecutionConfig(max_budget=0.50)
|
||||
assert config.max_budget == 0.50
|
||||
assert config.on_budget_exceeded == "stop"
|
||||
|
||||
def test_warn_mode(self):
|
||||
from praisonaiagents import ExecutionConfig
|
||||
config = ExecutionConfig(max_budget=1.00, on_budget_exceeded="warn")
|
||||
assert config.on_budget_exceeded == "warn"
|
||||
|
||||
def test_callable_mode(self):
|
||||
from praisonaiagents import ExecutionConfig
|
||||
handler = lambda cost, budget: None
|
||||
config = ExecutionConfig(max_budget=1.00, on_budget_exceeded=handler)
|
||||
assert callable(config.on_budget_exceeded)
|
||||
|
||||
def test_to_dict_includes_budget(self):
|
||||
from praisonaiagents import ExecutionConfig
|
||||
config = ExecutionConfig(max_budget=0.75)
|
||||
d = config.to_dict()
|
||||
assert d["max_budget"] == 0.75
|
||||
|
||||
|
||||
class TestAgentBudgetInit:
|
||||
"""Test that Agent correctly initializes budget tracking from ExecutionConfig."""
|
||||
|
||||
def test_no_budget_defaults(self):
|
||||
"""Agent without max_budget has zero-cost tracking active but no limit."""
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="test", instructions="test")
|
||||
assert agent._max_budget is None
|
||||
assert agent._total_cost == 0.0
|
||||
assert agent._total_tokens_in == 0
|
||||
assert agent._total_tokens_out == 0
|
||||
assert agent._llm_call_count == 0
|
||||
|
||||
def test_budget_from_execution_config(self):
|
||||
"""Agent extracts max_budget from ExecutionConfig."""
|
||||
from praisonaiagents import Agent, ExecutionConfig
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="test",
|
||||
execution=ExecutionConfig(max_budget=0.50),
|
||||
)
|
||||
assert agent._max_budget == 0.50
|
||||
assert agent._on_budget_exceeded == "stop"
|
||||
|
||||
def test_budget_warn_mode(self):
|
||||
from praisonaiagents import Agent, ExecutionConfig
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="test",
|
||||
execution=ExecutionConfig(max_budget=1.0, on_budget_exceeded="warn"),
|
||||
)
|
||||
assert agent._on_budget_exceeded == "warn"
|
||||
|
||||
|
||||
class TestCostProperties:
|
||||
"""Test total_cost and cost_summary read-only properties."""
|
||||
|
||||
def test_total_cost_initial(self):
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="test", instructions="test")
|
||||
assert agent.total_cost == 0.0
|
||||
|
||||
def test_cost_summary_initial(self):
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="test", instructions="test")
|
||||
summary = agent.cost_summary
|
||||
assert summary == {
|
||||
"tokens_in": 0,
|
||||
"tokens_out": 0,
|
||||
"cost": 0.0,
|
||||
"llm_calls": 0,
|
||||
}
|
||||
|
||||
def test_cost_accumulates(self):
|
||||
"""Manually simulate cost accumulation to verify properties work."""
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="test", instructions="test")
|
||||
# Simulate what _chat_completion does
|
||||
agent._total_cost += 0.01
|
||||
agent._total_tokens_in += 100
|
||||
agent._total_tokens_out += 50
|
||||
agent._llm_call_count += 1
|
||||
|
||||
assert agent.total_cost == 0.01
|
||||
assert agent.cost_summary == {
|
||||
"tokens_in": 100,
|
||||
"tokens_out": 50,
|
||||
"cost": 0.01,
|
||||
"llm_calls": 1,
|
||||
}
|
||||
|
||||
def test_cost_accumulates_multiple(self):
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="test", instructions="test")
|
||||
for i in range(5):
|
||||
agent._total_cost += 0.01
|
||||
agent._total_tokens_in += 100
|
||||
agent._total_tokens_out += 50
|
||||
agent._llm_call_count += 1
|
||||
|
||||
assert agent.total_cost == pytest.approx(0.05)
|
||||
assert agent.cost_summary["llm_calls"] == 5
|
||||
assert agent.cost_summary["tokens_in"] == 500
|
||||
|
||||
|
||||
class TestBudgetNoneOverhead:
|
||||
"""Verify zero overhead when max_budget is None (the default path)."""
|
||||
|
||||
def test_none_budget_falsy(self):
|
||||
"""None is falsy, so 'if self._max_budget' skips the check."""
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="test", instructions="test")
|
||||
# This is the check in _chat_completion:
|
||||
assert not agent._max_budget # None is falsy → skips budget check
|
||||
|
||||
def test_zero_budget_falsy(self):
|
||||
"""0.0 budget is also falsy but shouldn't be used — verify behavior."""
|
||||
from praisonaiagents import Agent, ExecutionConfig
|
||||
agent = Agent(
|
||||
name="test", instructions="test",
|
||||
execution=ExecutionConfig(max_budget=0.0),
|
||||
)
|
||||
# 0.0 is falsy → budget check is skipped (intentional)
|
||||
assert not agent._max_budget
|
||||
@@ -14,15 +14,15 @@ import pytest
|
||||
|
||||
|
||||
class _SandboxHarness:
|
||||
"""Directly exercises PythonTools sandbox without installing black/pylint."""
|
||||
"""Directly exercises execute_code sandbox without installing black/pylint."""
|
||||
|
||||
def __init__(self):
|
||||
# Import the class but bypass _check_dependencies
|
||||
from praisonaiagents.tools.python_tools import PythonTools
|
||||
self._pt = PythonTools.__new__(PythonTools)
|
||||
# Import the standalone execute_code function (no optional deps needed)
|
||||
from praisonaiagents.tools.python_tools import execute_code
|
||||
self._execute_code = execute_code
|
||||
|
||||
def run(self, code: str) -> dict:
|
||||
return self._pt.execute_code.__wrapped__(self._pt, code)
|
||||
return self._execute_code.__wrapped__(code)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
Generated
+3
-3
@@ -2951,7 +2951,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.5.80"
|
||||
version = "1.5.87"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -3054,8 +3054,8 @@ requires-dist = [
|
||||
{ name = "fastapi", marker = "extra == 'api'", specifier = ">=0.115.0" },
|
||||
{ name = "fastapi", marker = "extra == 'mcp'", specifier = ">=0.115.0" },
|
||||
{ name = "fastapi", marker = "extra == 'os'", specifier = ">=0.115.0" },
|
||||
{ name = "litellm", marker = "extra == 'llm'", specifier = ">=1.81.0" },
|
||||
{ name = "litellm", marker = "extra == 'memory'", specifier = ">=1.81.0" },
|
||||
{ name = "litellm", marker = "extra == 'llm'", specifier = ">=1.81.0,<=1.82.6" },
|
||||
{ name = "litellm", marker = "extra == 'memory'", specifier = ">=1.81.0,<=1.82.6" },
|
||||
{ name = "markitdown", extras = ["all"], marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
|
||||
{ name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.20.0" },
|
||||
{ name = "mem0ai", marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"version": "1.5.5",
|
||||
"lastUpdated": "2026-02-05",
|
||||
"version": "1.5.87",
|
||||
"lastUpdated": "2026-03-30",
|
||||
"generatedBy": "praisonai._dev.parity.generator",
|
||||
"sourceOfTruth": "Python SDK (praisonaiagents)",
|
||||
"status": "PARITY_ACHIEVED",
|
||||
"summary": {
|
||||
"pythonCoreFeatures": 282,
|
||||
"rustFeatures": 666,
|
||||
"gapCount": 7,
|
||||
"parityPercentage": 236.2
|
||||
"pythonCoreFeatures": 311,
|
||||
"rustFeatures": 667,
|
||||
"gapCount": 33,
|
||||
"parityPercentage": 214.5
|
||||
},
|
||||
"pythonCoreSDK": {
|
||||
"exports": [
|
||||
@@ -27,19 +27,25 @@
|
||||
"Agents",
|
||||
"ApprovalCallback",
|
||||
"ArrayMode",
|
||||
"AsyncLearnProtocol",
|
||||
"AudioAgent",
|
||||
"AudioConfig",
|
||||
"AuthProfile",
|
||||
"AutoAgents",
|
||||
"AutoApproveBackend",
|
||||
"AutoRagAgent",
|
||||
"AutoRagConfig",
|
||||
"AutonomyConfig",
|
||||
"AutonomyLevel",
|
||||
"BaseTool",
|
||||
"BotChannel",
|
||||
"BotConfig",
|
||||
"BotMessage",
|
||||
"BotOSConfig",
|
||||
"BotOSProtocol",
|
||||
"BotProtocol",
|
||||
"BotUser",
|
||||
"BudgetExceededError",
|
||||
"CACHING_PRESETS",
|
||||
"CONTEXT_PRESETS",
|
||||
"CachingConfig",
|
||||
@@ -52,6 +58,7 @@
|
||||
"CodeExecutionStep",
|
||||
"ConditionProtocol",
|
||||
"ConfigValidationError",
|
||||
"ConsoleBackend",
|
||||
"ContextAgent",
|
||||
"ContextConfig",
|
||||
"ContextEvent",
|
||||
@@ -68,10 +75,13 @@
|
||||
"DeepResearchResponse",
|
||||
"DefaultsConfig",
|
||||
"DictCondition",
|
||||
"DoomLoopDetector",
|
||||
"EXECUTION_PRESETS",
|
||||
"EmbeddingAgent",
|
||||
"EmbeddingConfig",
|
||||
"EmbeddingResult",
|
||||
"EscalationPipeline",
|
||||
"EscalationStage",
|
||||
"EventType",
|
||||
"ExecutionConfig",
|
||||
"ExecutionPreset",
|
||||
@@ -86,6 +96,7 @@
|
||||
"FileSearchCall",
|
||||
"FlowDisplay",
|
||||
"FunctionPlugin",
|
||||
"FunctionTool",
|
||||
"GUARDRAIL_PRESETS",
|
||||
"GatewayClientProtocol",
|
||||
"GatewayConfig",
|
||||
@@ -104,6 +115,8 @@
|
||||
"HandoffInputData",
|
||||
"HandoffResult",
|
||||
"HandoffTimeoutError",
|
||||
"Heartbeat",
|
||||
"HeartbeatConfig",
|
||||
"HookPluginProtocol",
|
||||
"HooksConfig",
|
||||
"If",
|
||||
@@ -113,6 +126,13 @@
|
||||
"KnowledgeConfig",
|
||||
"LLMGuardrail",
|
||||
"LLMPluginProtocol",
|
||||
"LearnBackend",
|
||||
"LearnConfig",
|
||||
"LearnManager",
|
||||
"LearnManagerProtocol",
|
||||
"LearnMode",
|
||||
"LearnProtocol",
|
||||
"LearnScope",
|
||||
"LineRange",
|
||||
"Loop",
|
||||
"MCP",
|
||||
@@ -135,6 +155,8 @@
|
||||
"OCRAgent",
|
||||
"OCRConfig",
|
||||
"OUTPUT_PRESETS",
|
||||
"ObservabilityEventType",
|
||||
"ObservabilityHooks",
|
||||
"OptimizerStrategy",
|
||||
"OutputConfig",
|
||||
"OutputPreset",
|
||||
@@ -201,6 +223,9 @@
|
||||
"TodoItem",
|
||||
"TodoList",
|
||||
"ToolPluginProtocol",
|
||||
"ToolRegistry",
|
||||
"ToolResult",
|
||||
"ToolValidationError",
|
||||
"Tools",
|
||||
"TraceSink",
|
||||
"TraceSinkProtocol",
|
||||
@@ -252,7 +277,9 @@
|
||||
"get_plugin_manager",
|
||||
"get_plugin_template",
|
||||
"get_plugins_config",
|
||||
"get_registry",
|
||||
"get_telemetry",
|
||||
"get_tool",
|
||||
"handoff",
|
||||
"handoff_filters",
|
||||
"is_path_like",
|
||||
@@ -267,6 +294,7 @@
|
||||
"parse_policy_string",
|
||||
"prompt_with_handoff_instructions",
|
||||
"register_display_callback",
|
||||
"register_tool",
|
||||
"repeat",
|
||||
"resolve",
|
||||
"resolve_autonomy",
|
||||
@@ -292,6 +320,7 @@
|
||||
"trace_context",
|
||||
"track_workflow",
|
||||
"validate_config",
|
||||
"validate_tool",
|
||||
"when",
|
||||
"workflows"
|
||||
]
|
||||
@@ -756,6 +785,7 @@
|
||||
"Tool",
|
||||
"ToolCall",
|
||||
"ToolCallData",
|
||||
"ToolCallFunction",
|
||||
"ToolCallResult",
|
||||
"ToolDefinition",
|
||||
"ToolPluginProtocol",
|
||||
@@ -1051,7 +1081,6 @@
|
||||
"WebSearchProvider"
|
||||
],
|
||||
"config_loader": [
|
||||
"AutoRagConfig",
|
||||
"SessionConfig"
|
||||
],
|
||||
"context": [
|
||||
@@ -1657,6 +1686,7 @@
|
||||
"OpenAiProvider",
|
||||
"Role",
|
||||
"ToolCall",
|
||||
"ToolCallFunction",
|
||||
"Usage"
|
||||
],
|
||||
"mcp": [
|
||||
@@ -1672,6 +1702,7 @@
|
||||
"planning": [],
|
||||
"plugins": [
|
||||
"PluginRegistry",
|
||||
"ToolDefinition",
|
||||
"disable_plugins",
|
||||
"enable_plugins",
|
||||
"get_plugin_manager",
|
||||
@@ -1685,6 +1716,9 @@
|
||||
"sandbox": [],
|
||||
"session": [],
|
||||
"skills": [],
|
||||
"specialized": [
|
||||
"AutoRagConfig"
|
||||
],
|
||||
"specialized_agents": [],
|
||||
"streaming": [],
|
||||
"task": [],
|
||||
@@ -1693,7 +1727,6 @@
|
||||
"tools": [
|
||||
"FunctionTool",
|
||||
"Tool",
|
||||
"ToolDefinition",
|
||||
"ToolRegistry",
|
||||
"ToolResult"
|
||||
],
|
||||
@@ -1751,6 +1784,24 @@
|
||||
"status": "DONE",
|
||||
"category": "agent"
|
||||
},
|
||||
{
|
||||
"feature": "BaseTool",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P0",
|
||||
"effort": "high",
|
||||
"status": "TODO",
|
||||
"category": "tools"
|
||||
},
|
||||
{
|
||||
"feature": "BudgetExceededError",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P0",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "agent"
|
||||
},
|
||||
{
|
||||
"feature": "CodeAgent",
|
||||
"python": true,
|
||||
@@ -1859,6 +1910,15 @@
|
||||
"status": "DONE",
|
||||
"category": "agent"
|
||||
},
|
||||
{
|
||||
"feature": "FunctionTool",
|
||||
"python": true,
|
||||
"rust": true,
|
||||
"priority": "P0",
|
||||
"effort": "high",
|
||||
"status": "DONE",
|
||||
"category": "tools"
|
||||
},
|
||||
{
|
||||
"feature": "Handoff",
|
||||
"python": true,
|
||||
@@ -2048,6 +2108,42 @@
|
||||
"status": "DONE",
|
||||
"category": "agent"
|
||||
},
|
||||
{
|
||||
"feature": "ToolRegistry",
|
||||
"python": true,
|
||||
"rust": true,
|
||||
"priority": "P0",
|
||||
"effort": "high",
|
||||
"status": "DONE",
|
||||
"category": "tools"
|
||||
},
|
||||
{
|
||||
"feature": "ToolResult",
|
||||
"python": true,
|
||||
"rust": true,
|
||||
"priority": "P0",
|
||||
"effort": "low",
|
||||
"status": "DONE",
|
||||
"category": "tools"
|
||||
},
|
||||
{
|
||||
"feature": "ToolValidationError",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P0",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "tools"
|
||||
},
|
||||
{
|
||||
"feature": "Tools",
|
||||
"python": true,
|
||||
"rust": true,
|
||||
"priority": "P0",
|
||||
"effort": "high",
|
||||
"status": "DONE",
|
||||
"category": "tools"
|
||||
},
|
||||
{
|
||||
"feature": "VideoAgent",
|
||||
"python": true,
|
||||
@@ -2102,6 +2198,24 @@
|
||||
"status": "DONE",
|
||||
"category": "agent"
|
||||
},
|
||||
{
|
||||
"feature": "get_registry",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P0",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "tools"
|
||||
},
|
||||
{
|
||||
"feature": "get_tool",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P0",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "tools"
|
||||
},
|
||||
{
|
||||
"feature": "handoff",
|
||||
"python": true,
|
||||
@@ -2128,6 +2242,33 @@
|
||||
"effort": "low",
|
||||
"status": "DONE",
|
||||
"category": "agent"
|
||||
},
|
||||
{
|
||||
"feature": "register_tool",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P0",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "tools"
|
||||
},
|
||||
{
|
||||
"feature": "tool",
|
||||
"python": true,
|
||||
"rust": true,
|
||||
"priority": "P0",
|
||||
"effort": "low",
|
||||
"status": "DONE",
|
||||
"category": "tools"
|
||||
},
|
||||
{
|
||||
"feature": "validate_tool",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P0",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "tools"
|
||||
}
|
||||
],
|
||||
"P1_Persistence": [
|
||||
@@ -2792,6 +2933,15 @@
|
||||
"status": "DONE",
|
||||
"category": "config"
|
||||
},
|
||||
{
|
||||
"feature": "AsyncLearnProtocol",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "medium",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "AuthProfile",
|
||||
"python": true,
|
||||
@@ -2810,6 +2960,15 @@
|
||||
"status": "DONE",
|
||||
"category": "agents"
|
||||
},
|
||||
{
|
||||
"feature": "AutoApproveBackend",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "high",
|
||||
"status": "TODO",
|
||||
"category": "app"
|
||||
},
|
||||
{
|
||||
"feature": "AutoRagAgent",
|
||||
"python": true,
|
||||
@@ -2835,7 +2994,7 @@
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "DONE",
|
||||
"category": "config"
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "AutonomyLevel",
|
||||
@@ -2873,6 +3032,24 @@
|
||||
"status": "DONE",
|
||||
"category": "bots"
|
||||
},
|
||||
{
|
||||
"feature": "BotOSConfig",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "bots"
|
||||
},
|
||||
{
|
||||
"feature": "BotOSProtocol",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "medium",
|
||||
"status": "TODO",
|
||||
"category": "bots"
|
||||
},
|
||||
{
|
||||
"feature": "BotProtocol",
|
||||
"python": true,
|
||||
@@ -2945,6 +3122,15 @@
|
||||
"status": "DONE",
|
||||
"category": "config"
|
||||
},
|
||||
{
|
||||
"feature": "ConsoleBackend",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "high",
|
||||
"status": "TODO",
|
||||
"category": "app"
|
||||
},
|
||||
{
|
||||
"feature": "ContextEvent",
|
||||
"python": true,
|
||||
@@ -3026,6 +3212,15 @@
|
||||
"status": "DONE",
|
||||
"category": "conditions"
|
||||
},
|
||||
{
|
||||
"feature": "DoomLoopDetector",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "high",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "EXECUTION_PRESETS",
|
||||
"python": true,
|
||||
@@ -3044,6 +3239,24 @@
|
||||
"status": "DONE",
|
||||
"category": "embedding"
|
||||
},
|
||||
{
|
||||
"feature": "EscalationPipeline",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "high",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "EscalationStage",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "high",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "EventType",
|
||||
"python": true,
|
||||
@@ -3197,6 +3410,24 @@
|
||||
"status": "DONE",
|
||||
"category": "config"
|
||||
},
|
||||
{
|
||||
"feature": "Heartbeat",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "high",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "HeartbeatConfig",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "HookPluginProtocol",
|
||||
"python": true,
|
||||
@@ -3242,6 +3473,69 @@
|
||||
"status": "DONE",
|
||||
"category": "plugins"
|
||||
},
|
||||
{
|
||||
"feature": "LearnBackend",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "high",
|
||||
"status": "TODO",
|
||||
"category": "config"
|
||||
},
|
||||
{
|
||||
"feature": "LearnConfig",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "config"
|
||||
},
|
||||
{
|
||||
"feature": "LearnManager",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "high",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "LearnManagerProtocol",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "medium",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "LearnMode",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "high",
|
||||
"status": "TODO",
|
||||
"category": "config"
|
||||
},
|
||||
{
|
||||
"feature": "LearnProtocol",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "medium",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "LearnScope",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "high",
|
||||
"status": "TODO",
|
||||
"category": "config"
|
||||
},
|
||||
{
|
||||
"feature": "MEMORY_PRESETS",
|
||||
"python": true,
|
||||
@@ -3359,6 +3653,24 @@
|
||||
"status": "DONE",
|
||||
"category": "config"
|
||||
},
|
||||
{
|
||||
"feature": "ObservabilityEventType",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "high",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "ObservabilityHooks",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "high",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "OutputConfig",
|
||||
"python": true,
|
||||
@@ -3638,15 +3950,6 @@
|
||||
"status": "DONE",
|
||||
"category": "plugins"
|
||||
},
|
||||
{
|
||||
"feature": "Tools",
|
||||
"python": true,
|
||||
"rust": true,
|
||||
"priority": "P3",
|
||||
"effort": "high",
|
||||
"status": "DONE",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "TraceSink",
|
||||
"python": true,
|
||||
@@ -3852,7 +4155,7 @@
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "DONE",
|
||||
"category": "embedding"
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "embeddings",
|
||||
@@ -3861,7 +4164,7 @@
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "DONE",
|
||||
"category": "embedding"
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "ensure_plugin_dir",
|
||||
@@ -4214,15 +4517,6 @@
|
||||
"status": "DONE",
|
||||
"category": "main"
|
||||
},
|
||||
{
|
||||
"feature": "tool",
|
||||
"python": true,
|
||||
"rust": true,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "DONE",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "tools",
|
||||
"python": true,
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# Rust Feature Parity Tracker
|
||||
|
||||
> **Python Features:** 282 | **Rust Features:** 666 | **Parity:** 100.0%
|
||||
> **Python Features:** 311 | **Rust Features:** 667 | **Parity:** 91.6%
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Python Core Features | 282 |
|
||||
| Rust Features | 666 |
|
||||
| **Actual Gap Count** | **0** |
|
||||
| Python Core Features | 311 |
|
||||
| Rust Features | 667 |
|
||||
| **Actual Gap Count** | **26** |
|
||||
| Language Limitations (N/A) | 6 |
|
||||
| **Parity** | **100.0%** |
|
||||
| **Parity** | **91.6%** |
|
||||
|
||||
## Implemented Features
|
||||
|
||||
@@ -471,6 +471,7 @@
|
||||
- ✅ `Tool`
|
||||
- ✅ `ToolCall`
|
||||
- ✅ `ToolCallData`
|
||||
- ✅ `ToolCallFunction`
|
||||
- ✅ `ToolCallResult`
|
||||
- ✅ `ToolDefinition`
|
||||
- ✅ `ToolPluginProtocol`
|
||||
@@ -692,6 +693,35 @@ These Python features cannot be directly implemented in Rust due to reserved key
|
||||
- ⚠️ `tools` (Rust reserved/module conflict)
|
||||
- ⚠️ `workflows` (Rust reserved/module conflict)
|
||||
|
||||
## Missing Features
|
||||
|
||||
- ❌ `AsyncLearnProtocol`
|
||||
- ❌ `AutoApproveBackend`
|
||||
- ❌ `BaseTool`
|
||||
- ❌ `BotOSConfig`
|
||||
- ❌ `BotOSProtocol`
|
||||
- ❌ `BudgetExceededError`
|
||||
- ❌ `ConsoleBackend`
|
||||
- ❌ `DoomLoopDetector`
|
||||
- ❌ `EscalationPipeline`
|
||||
- ❌ `EscalationStage`
|
||||
- ❌ `Heartbeat`
|
||||
- ❌ `HeartbeatConfig`
|
||||
- ❌ `LearnBackend`
|
||||
- ❌ `LearnConfig`
|
||||
- ❌ `LearnManager`
|
||||
- ❌ `LearnManagerProtocol`
|
||||
- ❌ `LearnMode`
|
||||
- ❌ `LearnProtocol`
|
||||
- ❌ `LearnScope`
|
||||
- ❌ `ObservabilityEventType`
|
||||
- ❌ `ObservabilityHooks`
|
||||
- ❌ `ToolValidationError`
|
||||
- ❌ `get_registry`
|
||||
- ❌ `get_tool`
|
||||
- ❌ `register_tool`
|
||||
- ❌ `validate_tool`
|
||||
|
||||
---
|
||||
|
||||
*Generated by `praisonai._dev.parity.generator`*
|
||||
File diff suppressed because it is too large.
Load diff
+105
-237
@@ -1,70 +1,36 @@
|
||||
# Feature Parity Tracker
|
||||
|
||||
> **Version:** 1.5.5 | **Last Updated:** 2026-02-05
|
||||
> **Version:** 1.5.87 | **Last Updated:** 2026-03-30
|
||||
> **Source of Truth:** Python SDK (praisonaiagents)
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Python Core Features | 282 |
|
||||
| Python Wrapper Features | 94 |
|
||||
<<<<<<< Updated upstream
|
||||
| TypeScript Features | 1118 |
|
||||
| **Gap Count** | **97** |
|
||||
| P0 (Critical) | 21 |
|
||||
| P1 (High) | 8 |
|
||||
| P2 (Medium) | 19 |
|
||||
| P3 (Low) | 49 |
|
||||
=======
|
||||
| Python Core Features | 311 |
|
||||
| Python Wrapper Features | 99 |
|
||||
| TypeScript Features | 1183 |
|
||||
| **Gap Count** | **0** |
|
||||
| P0 (Critical) | 0 |
|
||||
| **Gap Count** | **23** |
|
||||
| P0 (Critical) | 5 |
|
||||
| P1 (High) | 0 |
|
||||
| P2 (Medium) | 0 |
|
||||
| P3 (Low) | 0 |
|
||||
>>>>>>> Stashed changes
|
||||
| P3 (Low) | 18 |
|
||||
|
||||
## Gap Matrix
|
||||
|
||||
### P0_CoreParity (24 done, 21 todo)
|
||||
### P0_CoreParity (52 done, 5 todo)
|
||||
|
||||
| Feature | Python | TypeScript | Effort | Status |
|
||||
|---------|--------|------------|--------|--------|
|
||||
<<<<<<< Updated upstream
|
||||
| `AudioConfig` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `CodeAgent` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `CodeConfig` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `CodeExecutionStep` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `DeepResearchResponse` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `EmbeddingAgent` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `FileSearchCall` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `MCPCall` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `OCRAgent` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `OCRConfig` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `Provider` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `RealtimeAgent` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `RealtimeConfig` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `VideoAgent` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `VideoConfig` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `VisionAgent` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `VisionConfig` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `WebSearchCall` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `create\_context\_agent` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `handoff\_filters` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `prompt\_with\_handoff\_instructions` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `Agent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `AudioAgent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `ContextAgent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `ContextPolicy` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `DeepResearchAgent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `EmbeddingConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `ExpandResult` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `ExpandStrategy` | ✅ | ✅ | high | ✅ DONE |
|
||||
=======
|
||||
| `BudgetExceededError` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `get\_registry` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `get\_tool` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `register\_tool` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `validate\_tool` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `Agent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `AudioAgent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `AudioConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `BaseTool` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `CodeAgent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `CodeConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `CodeExecutionStep` | ✅ | ✅ | high | ✅ DONE |
|
||||
@@ -77,7 +43,7 @@
|
||||
| `ExpandResult` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `ExpandStrategy` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `FileSearchCall` | ✅ | ✅ | high | ✅ DONE |
|
||||
>>>>>>> Stashed changes
|
||||
| `FunctionTool` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `Handoff` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `HandoffConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `HandoffCycleError` | ✅ | ✅ | low | ✅ DONE |
|
||||
@@ -87,14 +53,6 @@
|
||||
| `HandoffResult` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `HandoffTimeoutError` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `ImageAgent` | ✅ | ✅ | high | ✅ DONE |
|
||||
<<<<<<< Updated upstream
|
||||
| `PromptExpanderAgent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `QueryRewriterAgent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `RECOMMENDED\_PROMPT\_PREFIX` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `ReasoningStep` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `RewriteResult` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `RewriteStrategy` | ✅ | ✅ | high | ✅ DONE |
|
||||
=======
|
||||
| `MCPCall` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `OCRAgent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `OCRConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
@@ -107,38 +65,30 @@
|
||||
| `ReasoningStep` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `RewriteResult` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `RewriteStrategy` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `ToolRegistry` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `ToolResult` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `ToolValidationError` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `Tools` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `VideoAgent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `VideoConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `VisionAgent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `VisionConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `WebSearchCall` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `create\_context\_agent` | ✅ | ✅ | low | ✅ DONE |
|
||||
>>>>>>> Stashed changes
|
||||
| `handoff` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `handoff\_filters` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `prompt\_with\_handoff\_instructions` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `tool` | ✅ | ✅ | low | ✅ DONE |
|
||||
|
||||
### P1_Persistence (12 done, 8 todo)
|
||||
### P1_Persistence (20 done, 0 todo)
|
||||
|
||||
| Feature | Python | TypeScript | Effort | Status |
|
||||
|---------|--------|------------|--------|--------|
|
||||
<<<<<<< Updated upstream
|
||||
| `Chunking` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `If` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `Knowledge` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `Loop` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `Parallel` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `Route` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `Session` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `when` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `AgentFlow` | ✅ | ✅ | high | ✅ DONE |
|
||||
=======
|
||||
| `AgentFlow` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `Chunking` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `If` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `Knowledge` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `Loop` | ✅ | ✅ | high | ✅ DONE |
|
||||
>>>>>>> Stashed changes
|
||||
| `Memory` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `Parallel` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `Pipeline` | ✅ | ✅ | high | ✅ DONE |
|
||||
@@ -155,36 +105,10 @@
|
||||
| `route` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `when` | ✅ | ✅ | low | ✅ DONE |
|
||||
|
||||
### P2_CLI (22 done, 19 todo)
|
||||
### P2_CLI (41 done, 0 todo)
|
||||
|
||||
| Feature | Python | TypeScript | Effort | Status |
|
||||
|---------|--------|------------|--------|--------|
|
||||
<<<<<<< Updated upstream
|
||||
| `ApprovalCallback` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `ContextConfig` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `ContextManager` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `ContextPack` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `FastContext` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `GuardrailResult` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `LineRange` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `MCP` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `ManagerConfig` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `MinimalTelemetry` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `OptimizerStrategy` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `Plan` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `SkillLoader` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `cleanup\_telemetry\_resources` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `disable\_performance\_mode` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `disable\_telemetry` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `enable\_performance\_mode` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `enable\_telemetry` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `get\_telemetry` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `Citation` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `CitationsMode` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `FastContextResult` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `FileMatch` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `LLMGuardrail` | ✅ | ✅ | high | ✅ DONE |
|
||||
=======
|
||||
| `ApprovalCallback` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `Citation` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `CitationsMode` | ✅ | ✅ | high | ✅ DONE |
|
||||
@@ -202,7 +126,6 @@
|
||||
| `MinimalTelemetry` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `OptimizerStrategy` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `Plan` | ✅ | ✅ | high | ✅ DONE |
|
||||
>>>>>>> Stashed changes
|
||||
| `PlanStep` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `PlanStorage` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `PlanningAgent` | ✅ | ✅ | high | ✅ DONE |
|
||||
@@ -214,20 +137,13 @@
|
||||
| `RESTRICTED\_TOOLS` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `RetrievalConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `RetrievalPolicy` | ✅ | ✅ | high | ✅ DONE |
|
||||
<<<<<<< Updated upstream
|
||||
=======
|
||||
| `SkillLoader` | ✅ | ✅ | high | ✅ DONE |
|
||||
>>>>>>> Stashed changes
|
||||
| `SkillManager` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `SkillMetadata` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `SkillProperties` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `TelemetryCollector` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `TodoItem` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `TodoList` | ✅ | ✅ | high | ✅ DONE |
|
||||
<<<<<<< Updated upstream
|
||||
|
||||
### P3_Advanced (127 done, 49 todo)
|
||||
=======
|
||||
| `cleanup\_telemetry\_resources` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `disable\_performance\_mode` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `disable\_telemetry` | ✅ | ✅ | low | ✅ DONE |
|
||||
@@ -235,68 +151,34 @@
|
||||
| `enable\_telemetry` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `get\_telemetry` | ✅ | ✅ | low | ✅ DONE |
|
||||
|
||||
### P3_Advanced (176 done, 0 todo)
|
||||
>>>>>>> Stashed changes
|
||||
### P3_Advanced (175 done, 18 todo)
|
||||
|
||||
| Feature | Python | TypeScript | Effort | Status |
|
||||
|---------|--------|------------|--------|--------|
|
||||
| `AGUI` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `AgentManager` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `BotConfig` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `ConditionProtocol` | ✅ | ❌ | medium | ⏳ TODO |
|
||||
| `ConfigValidationError` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `ContextEvent` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `ContextEventType` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `DictCondition` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `EmbeddingResult` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `FailoverManager` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `FlowDisplay` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `GatewayConfig` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `MEMORY\_PRESETS` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `MemoryBackend` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `Plugin` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `PluginHook` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `PluginMetadata` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `ProviderStatus` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `SandboxStatus` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `Task` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `TraceSink` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `async\_display\_callbacks` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `config` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `detect\_url\_scheme` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `discover\_and\_load\_plugins` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `discover\_plugins` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `display\_error` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `display\_generating` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `display\_instruction` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `display\_interaction` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `display\_self\_reflection` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `display\_tool\_call` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `embed` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `ensure\_plugin\_dir` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `error\_logs` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `evaluate\_condition` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `get\_default\_plugin\_dirs` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `get\_dimensions` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `get\_plugin\_manager` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `get\_plugin\_template` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `load\_plugin` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `parse\_plugin\_header` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `parse\_plugin\_header\_from\_file` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `register\_display\_callback` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `resolve` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `resolve\_guardrail\_policies` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `sync\_display\_callbacks` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `trace\_context` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `track\_workflow` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `AsyncLearnProtocol` | ✅ | ❌ | medium | ⏳ TODO |
|
||||
| `AutoApproveBackend` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `BotOSConfig` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `BotOSProtocol` | ✅ | ❌ | medium | ⏳ TODO |
|
||||
| `ConsoleBackend` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `DoomLoopDetector` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `EscalationPipeline` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `EscalationStage` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `Heartbeat` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `HeartbeatConfig` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `LearnBackend` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `LearnManager` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `LearnManagerProtocol` | ✅ | ❌ | medium | ⏳ TODO |
|
||||
| `LearnMode` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `LearnProtocol` | ✅ | ❌ | medium | ⏳ TODO |
|
||||
| `LearnScope` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `ObservabilityEventType` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `ObservabilityHooks` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `A2A` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `AGUI` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `AUTONOMY\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `AgentAppConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `AgentAppProtocol` | ✅ | ✅ | medium | ✅ DONE |
|
||||
<<<<<<< Updated upstream
|
||||
=======
|
||||
| `AgentManager` | ✅ | ✅ | high | ✅ DONE |
|
||||
>>>>>>> Stashed changes
|
||||
| `AgentOSConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `AgentOSProtocol` | ✅ | ✅ | medium | ✅ DONE |
|
||||
| `AgentPluginProtocol` | ✅ | ✅ | medium | ✅ DONE |
|
||||
@@ -310,6 +192,7 @@
|
||||
| `AutonomyConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `AutonomyLevel` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `BotChannel` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `BotConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `BotMessage` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `BotProtocol` | ✅ | ✅ | medium | ✅ DONE |
|
||||
| `BotUser` | ✅ | ✅ | high | ✅ DONE |
|
||||
@@ -317,21 +200,30 @@
|
||||
| `CONTEXT\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `CachingConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `ChunkingStrategy` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `ConditionProtocol` | ✅ | ✅ | medium | ✅ DONE |
|
||||
| `ConfigValidationError` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `ContextEvent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `ContextEventType` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `ContextListSink` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `ContextNoOpSink` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `ContextTraceEmitter` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `ContextTraceSink` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `ContextTraceSinkProtocol` | ✅ | ✅ | medium | ✅ DONE |
|
||||
| `DefaultsConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `DictCondition` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `EXECUTION\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `EmbeddingResult` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `EventType` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `ExecutionConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `ExecutionPreset` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `ExpressionCondition` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `FailoverConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `FailoverManager` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `FlowDisplay` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `FunctionPlugin` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `GUARDRAIL\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `GatewayClientProtocol` | ✅ | ✅ | medium | ✅ DONE |
|
||||
| `GatewayConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `GatewayEvent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `GatewayMessage` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `GatewayProtocol` | ✅ | ✅ | medium | ✅ DONE |
|
||||
@@ -343,13 +235,12 @@
|
||||
| `KNOWLEDGE\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `KnowledgeConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `LLMPluginProtocol` | ✅ | ✅ | medium | ✅ DONE |
|
||||
| `LearnConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `MEMORY\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `MEMORY\_URL\_SCHEMES` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `MULTI\_AGENT\_EXECUTION\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `MULTI\_AGENT\_OUTPUT\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
|
||||
<<<<<<< Updated upstream
|
||||
=======
|
||||
| `MemoryBackend` | ✅ | ✅ | high | ✅ DONE |
|
||||
>>>>>>> Stashed changes
|
||||
| `MemoryConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `MessageType` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `MultiAgentExecutionConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
@@ -362,12 +253,16 @@
|
||||
| `OutputPreset` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `PLANNING\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `PlanningConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `Plugin` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `PluginHook` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `PluginInfo` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `PluginManager` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `PluginMetadata` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `PluginParseError` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `PluginProtocol` | ✅ | ✅ | medium | ✅ DONE |
|
||||
| `PluginsConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `PraisonConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `ProviderStatus` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `REFLECTION\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `RagRetrievalPolicy` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `ReflectionConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
@@ -377,17 +272,15 @@
|
||||
| `SandboxConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `SandboxProtocol` | ✅ | ✅ | medium | ✅ DONE |
|
||||
| `SandboxResult` | ✅ | ✅ | low | ✅ DONE |
|
||||
<<<<<<< Updated upstream
|
||||
=======
|
||||
| `SandboxStatus` | ✅ | ✅ | high | ✅ DONE |
|
||||
>>>>>>> Stashed changes
|
||||
| `SecurityPolicy` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `SessionConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `SkillsConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `Task` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `TaskOutput` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `TemplateConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `ToolPluginProtocol` | ✅ | ✅ | medium | ✅ DONE |
|
||||
| `Tools` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `TraceSink` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `TraceSinkProtocol` | ✅ | ✅ | medium | ✅ DONE |
|
||||
| `WEB\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `WebConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
@@ -396,23 +289,48 @@
|
||||
| `aembedding` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `aembeddings` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `apply\_config\_defaults` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `async\_display\_callbacks` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `clean\_triple\_backticks` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `config` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `detect\_url\_scheme` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `discover\_and\_load\_plugins` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `discover\_plugins` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `display\_error` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `display\_generating` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `display\_instruction` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `display\_interaction` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `display\_self\_reflection` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `display\_tool\_call` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `embed` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `embedding` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `embeddings` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `ensure\_plugin\_dir` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `error\_logs` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `evaluate\_condition` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `get\_config` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `get\_config\_path` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `get\_default` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `get\_default\_plugin\_dirs` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `get\_defaults\_config` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `get\_dimensions` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `get\_plugin\_manager` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `get\_plugin\_template` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `get\_plugins\_config` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `is\_path\_like` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `is\_policy\_string` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `load\_plugin` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `memory` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `obs` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `parse\_plugin\_header` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `parse\_plugin\_header\_from\_file` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `parse\_policy\_string` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `register\_display\_callback` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `resolve` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `resolve\_autonomy` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `resolve\_caching` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `resolve\_context` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `resolve\_execution` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `resolve\_guardrail\_policies` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `resolve\_guardrails` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `resolve\_hooks` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `resolve\_knowledge` | ✅ | ✅ | low | ✅ DONE |
|
||||
@@ -424,16 +342,10 @@
|
||||
| `resolve\_skills` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `resolve\_web` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `suggest\_similar` | ✅ | ✅ | low | ✅ DONE |
|
||||
<<<<<<< Updated upstream
|
||||
| `tool` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `tools` | ✅ | ✅ | low | ✅ DONE |
|
||||
=======
|
||||
| `sync\_display\_callbacks` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `tool` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `tools` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `trace\_context` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `track\_workflow` | ✅ | ✅ | low | ✅ DONE |
|
||||
>>>>>>> Stashed changes
|
||||
| `validate\_config` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `workflows` | ✅ | ✅ | low | ✅ DONE |
|
||||
|
||||
@@ -442,10 +354,10 @@
|
||||
**Path:** `/home/runner/work/PraisonAI/PraisonAI/src/praisonai-agents/praisonaiagents`
|
||||
|
||||
<details>
|
||||
<summary><strong>agent</strong> (45 exports)</summary>
|
||||
<summary><strong>agent</strong> (46 exports)</summary>
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent, AudioAgent, AudioConfig, CodeAgent, CodeConfig, CodeExecutionStep, ContextAgent, ContextPolicy, DeepResearchAgent, DeepResearchResponse...
|
||||
from praisonaiagents import Agent, AudioAgent, AudioConfig, BudgetExceededError, CodeAgent, CodeConfig, CodeExecutionStep, ContextAgent, ContextPolicy, DeepResearchAgent...
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -460,19 +372,19 @@ from praisonaiagents import AgentManager, AgentTeam, AutoAgents, AutoRagAgent, A
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>app</strong> (4 exports)</summary>
|
||||
<summary><strong>app</strong> (6 exports)</summary>
|
||||
|
||||
```python
|
||||
from praisonaiagents import AgentAppConfig, AgentAppProtocol, AgentOSConfig, AgentOSProtocol
|
||||
from praisonaiagents import AgentAppConfig, AgentAppProtocol, AgentOSConfig, AgentOSProtocol, AutoApproveBackend, ConsoleBackend
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>bots</strong> (6 exports)</summary>
|
||||
<summary><strong>bots</strong> (8 exports)</summary>
|
||||
|
||||
```python
|
||||
from praisonaiagents import BotChannel, BotConfig, BotMessage, BotProtocol, BotUser, MessageType
|
||||
from praisonaiagents import BotChannel, BotConfig, BotMessage, BotOSConfig, BotOSProtocol, BotProtocol, BotUser, MessageType
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -487,10 +399,10 @@ from praisonaiagents import ConditionProtocol, DictCondition, ExpressionConditio
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>config</strong> (72 exports)</summary>
|
||||
<summary><strong>config</strong> (75 exports)</summary>
|
||||
|
||||
```python
|
||||
from praisonaiagents import AUTONOMY_PRESETS, ArrayMode, AutonomyConfig, AutonomyLevel, CACHING_PRESETS, CONTEXT_PRESETS, CachingConfig, ChunkingStrategy, ConfigValidationError, DefaultsConfig...
|
||||
from praisonaiagents import AUTONOMY_PRESETS, ArrayMode, AutonomyLevel, CACHING_PRESETS, CONTEXT_PRESETS, CachingConfig, ChunkingStrategy, ConfigValidationError, DefaultsConfig, EXECUTION_PRESETS...
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -514,10 +426,10 @@ from praisonaiagents import db
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>embedding</strong> (8 exports)</summary>
|
||||
<summary><strong>embedding</strong> (6 exports)</summary>
|
||||
|
||||
```python
|
||||
from praisonaiagents import EmbeddingResult, aembed, aembedding, aembeddings, embed, embedding, embeddings, get_dimensions
|
||||
from praisonaiagents import EmbeddingResult, aembed, aembedding, aembeddings, embed, get_dimensions
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -604,10 +516,10 @@ from praisonaiagents import obs
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>other</strong> (7 exports)</summary>
|
||||
<summary><strong>other</strong> (19 exports)</summary>
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agents, Tools, config, memory, tool, tools, workflows
|
||||
from praisonaiagents import Agents, AsyncLearnProtocol, AutonomyConfig, DoomLoopDetector, EscalationPipeline, EscalationStage, Heartbeat, HeartbeatConfig, LearnManager, LearnManagerProtocol...
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -684,6 +596,15 @@ from praisonaiagents import MinimalTelemetry, TelemetryCollector, cleanup_teleme
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>tools</strong> (11 exports)</summary>
|
||||
|
||||
```python
|
||||
from praisonaiagents import BaseTool, FunctionTool, ToolRegistry, ToolResult, ToolValidationError, Tools, get_registry, get_tool, register_tool, tool...
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>trace</strong> (10 exports)</summary>
|
||||
|
||||
@@ -755,21 +676,7 @@ import { BaseCache, CacheConfig, CacheEntry, FileCache, MemoryCache, createFileC
|
||||
<summary><strong>cli</strong> (130 exports)</summary>
|
||||
|
||||
```typescript
|
||||
<<<<<<< Updated upstream
|
||||
import { // Autonomy Mode
|
||||
AutonomyManager, // Background Jobs
|
||||
JobQueue, // Checkpoints
|
||||
CheckpointManager, // Cost Tracker
|
||||
CostTracker, // External Agents
|
||||
BaseExternalAgent, // Fast Context (Python parity with praisonaiagents/context/fast)
|
||||
FastContext, // Flow Display
|
||||
FlowDisplay, // Git Integration
|
||||
GitManager, // Interactive TUI
|
||||
InteractiveTUI, // N8N Integration
|
||||
N8NIntegration... } from 'praisonai';
|
||||
=======
|
||||
import { ActionDecision, ActionRequest, ActionType, AiderAgent, ApprovalPolicy, AutonomyConfig, AutonomyManager, AutonomyMode, BaseExternalAgent, CLI_SPEC_VERSION... } from 'praisonai';
|
||||
>>>>>>> Stashed changes
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -883,57 +790,24 @@ import { AgentApp, AgentAppConfig, AgentAppOptions, AgentAppProtocol, AgentOS, A
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<<<<<<< Updated upstream
|
||||
<summary><strong>other</strong> (233 exports)</summary>
|
||||
|
||||
```typescript
|
||||
import { // A2A Protocol
|
||||
A2ATaskState, // AGUI Protocol
|
||||
AGUI, // AgentManager alias type
|
||||
AgentManager, // Bot types
|
||||
BotConfig, // Classes
|
||||
DictCondition, // Classes
|
||||
FailoverManager, // Classes
|
||||
Plugin, // Classes
|
||||
TraceSink, // Enums
|
||||
ContextEventType, // Enums
|
||||
MemoryBackend... } from 'praisonai';
|
||||
=======
|
||||
<summary><strong>other</strong> (203 exports)</summary>
|
||||
|
||||
```typescript
|
||||
import { AUTONOMY_PRESETS, AgentPluginProtocol, ArrayMode, AsyncDisplayCallback, AuthProfile, AutoRagConfig, AutonomyLevel, BatchEmbeddingResult, BotChannel, BotConfig... } from 'praisonai';
|
||||
>>>>>>> Stashed changes
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<<<<<<< Updated upstream
|
||||
<summary><strong>planning</strong> (25 exports)</summary>
|
||||
|
||||
```typescript
|
||||
import { // Core classes
|
||||
Plan, // Python parity additions
|
||||
ApprovalCallback, ApprovalCallbackConfig, PlanConfig, PlanResult, PlanStatus, PlanStep, PlanStepConfig, PlanStorage, PlanningAgent... } from 'praisonai';
|
||||
=======
|
||||
<summary><strong>parity</strong> (65 exports)</summary>
|
||||
|
||||
```typescript
|
||||
import { AudioConfig, Chunking, CodeAgent, CodeConfig, CodeExecutionStep, ContextConfig, ContextManager, ContextPack, DeepResearchResponse, EmbeddingAgent... } from 'praisonai';
|
||||
>>>>>>> Stashed changes
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<<<<<<< Updated upstream
|
||||
<summary><strong>skills</strong> (10 exports)</summary>
|
||||
|
||||
```typescript
|
||||
import { // Python parity additions
|
||||
SkillLoader, Skill, SkillDiscoveryOptions, SkillManager, SkillMetadata, SkillProperties, createSkillLoader, createSkillManager, createSkillProperties, parseSkillFile } from 'praisonai';
|
||||
=======
|
||||
<summary><strong>planning</strong> (25 exports)</summary>
|
||||
|
||||
```typescript
|
||||
@@ -956,7 +830,6 @@ import { A2A, A2AAgentCapabilities, A2AAgentCard, A2AAgentSkill, A2AArtifact, A2
|
||||
|
||||
```typescript
|
||||
import { Skill, SkillDiscoveryOptions, SkillLoader, SkillManager, SkillMetadata, SkillProperties, createSkillLoader, createSkillManager, createSkillProperties, parseSkillFile } from 'praisonai';
|
||||
>>>>>>> Stashed changes
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -974,12 +847,7 @@ import { AgentTask, AgentTaskConfig, BaseTask, TaskOutput, createTaskOutput } fr
|
||||
<summary><strong>telemetry</strong> (26 exports)</summary>
|
||||
|
||||
```typescript
|
||||
<<<<<<< Updated upstream
|
||||
import { // Python parity additions
|
||||
MinimalTelemetry, AgentStats, AgentTelemetry, MetricEntry, PerformanceMonitor, PerformanceMonitorConfig, PerformanceStats, TelemetryCollector, TelemetryConfig, TelemetryEvent... } from 'praisonai';
|
||||
=======
|
||||
import { AgentStats, AgentTelemetry, MetricEntry, MinimalTelemetry, PerformanceMonitor, PerformanceMonitorConfig, PerformanceStats, TelemetryCollector, TelemetryConfig, TelemetryEvent... } from 'praisonai';
|
||||
>>>>>>> Stashed changes
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
@@ -23,7 +23,9 @@ async function testAgentWithTools() {
|
||||
const getWeather = (city: string) => `Weather in ${city}: 22°C, sunny`;
|
||||
const calculate = (expr: string) => {
|
||||
try {
|
||||
return String(eval(expr));
|
||||
if (!/^[0-9+\-*/.() ]+$/.test(expr)) return 'Error: Invalid expression';
|
||||
const result = new Function(`"use strict"; return (${expr})`)();
|
||||
return typeof result === 'number' && isFinite(result) ? String(result) : 'Error';
|
||||
} catch {
|
||||
return 'Error';
|
||||
}
|
||||
|
||||
@@ -96,7 +96,13 @@ async function main() {
|
||||
});
|
||||
|
||||
await runTest('Agent: With Custom Tool', async () => {
|
||||
const calculator = (expression: string) => String(eval(expression));
|
||||
const calculator = (expression: string) => {
|
||||
try {
|
||||
if (!/^[0-9+\-*/.() ]+$/.test(expression)) return 'Error: Invalid expression';
|
||||
const result = new Function(`"use strict"; return (${expression})`)();
|
||||
return typeof result === 'number' && isFinite(result) ? String(result) : 'Error';
|
||||
} catch { return 'Error'; }
|
||||
};
|
||||
const agent = new Agent({
|
||||
name: 'ToolAgent',
|
||||
instructions: 'Use the calculator tool for math.',
|
||||
|
||||
+78
-2
@@ -43,8 +43,9 @@ PraisonAI 🦞 — Automate and solve complex challenges with AI agent teams tha
|
||||
> - 🆕 **New here?** → [Quick Start](#-quick-start) *(1 minute to first agent)*
|
||||
> - 📦 **Installing?** → [Installation](#-installation)
|
||||
> - 🐍 **Python SDK?** → [Python Examples](#-using-python-code)
|
||||
> - 🎯 **CLI user?** → [CLI Quick Reference](#cli-quick-reference)
|
||||
> - 🤝 **Contributing?** → [Development](#-development)
|
||||
> - 📄 **YAML/No-Code?** → [YAML Examples](#-using-yaml-no-code)
|
||||
> - 🎯 **CLI user?** → [CLI Quick Reference](#-cli-quick-reference)
|
||||
> - 🤝 **Contributing?** → [Contributing](#-contributing)
|
||||
|
||||
---
|
||||
|
||||
@@ -328,6 +329,81 @@ Open **http://localhost:8082** — the dashboard comes with 13 built-in pages: C
|
||||
|
||||
---
|
||||
|
||||
## 📄 Using YAML (No Code)
|
||||
|
||||
### Example 1: Two Agents Working Together
|
||||
|
||||
Create `agents.yaml`:
|
||||
|
||||
```yaml
|
||||
framework: praisonai
|
||||
topic: "Write a blog post about AI"
|
||||
|
||||
agents:
|
||||
researcher:
|
||||
role: Research Analyst
|
||||
goal: Research AI trends and gather information
|
||||
instructions: "Find accurate information about AI trends"
|
||||
|
||||
writer:
|
||||
role: Content Writer
|
||||
goal: Write engaging blog posts
|
||||
instructions: "Write clear, engaging content based on research"
|
||||
```
|
||||
|
||||
Run with:
|
||||
```bash
|
||||
praisonai agents.yaml
|
||||
```
|
||||
|
||||
> The agents automatically work together sequentially
|
||||
|
||||
### Example 2: Agent with Custom Tool
|
||||
|
||||
Create two files in the same folder:
|
||||
|
||||
**agents.yaml:**
|
||||
```yaml
|
||||
framework: praisonai
|
||||
topic: "Calculate the sum of 25 and 15"
|
||||
|
||||
agents:
|
||||
calculator_agent:
|
||||
role: Calculator
|
||||
goal: Perform calculations
|
||||
instructions: "Use the add_numbers tool to help with calculations"
|
||||
tools:
|
||||
- add_numbers
|
||||
```
|
||||
|
||||
**tools.py:**
|
||||
```python
|
||||
def add_numbers(a: float, b: float) -> float:
|
||||
"""
|
||||
Add two numbers together.
|
||||
|
||||
Args:
|
||||
a: First number
|
||||
b: Second number
|
||||
|
||||
Returns:
|
||||
The sum of a and b
|
||||
"""
|
||||
return a + b
|
||||
```
|
||||
|
||||
Run with:
|
||||
```bash
|
||||
praisonai agents.yaml
|
||||
```
|
||||
|
||||
> 💡 **Tips:**
|
||||
> - Use the function name (e.g., `add_numbers`) in the tools list, not the file name
|
||||
> - Tools in `tools.py` are automatically discovered
|
||||
> - The function's docstring helps the AI understand how to use it
|
||||
|
||||
---
|
||||
|
||||
## 🎯 CLI Quick Reference
|
||||
|
||||
| Category | Commands |
|
||||
|
||||
+11
-1
@@ -3,6 +3,16 @@ import markdown
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
try:
|
||||
import nh3
|
||||
def _sanitize_html(html: str) -> str:
|
||||
"""Sanitize HTML to prevent XSS from markdown-generated content."""
|
||||
return nh3.clean(html)
|
||||
except ImportError:
|
||||
def _sanitize_html(html: str) -> str:
|
||||
"""Fallback: no nh3, return as-is (install nh3 for XSS protection)."""
|
||||
return html
|
||||
|
||||
def basic():
|
||||
from praisonai import PraisonAI
|
||||
praisonai = PraisonAI(agent_file="agents.yaml")
|
||||
@@ -11,7 +21,7 @@ def basic():
|
||||
@app.route('/')
|
||||
def home():
|
||||
output = basic()
|
||||
html_output = markdown.markdown(output)
|
||||
html_output = _sanitize_html(markdown.markdown(str(output)))
|
||||
return f'<html><body>{html_output}</body></html>'
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -3,8 +3,8 @@ class Praisonai < Formula
|
||||
|
||||
desc "AI tools for various AI applications"
|
||||
homepage "https://github.com/MervinPraison/PraisonAI"
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.80.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.80.tar.gz | shasum -a 256`.split.first
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.87.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.87.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -55,6 +55,16 @@ try:
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
AG2_AVAILABLE = False
|
||||
try:
|
||||
import importlib.metadata as _importlib_metadata
|
||||
_importlib_metadata.distribution('ag2')
|
||||
from autogen import LLMConfig as _AG2LLMConfig # noqa: F401 — AG2-exclusive class
|
||||
AG2_AVAILABLE = True
|
||||
del _AG2LLMConfig, _importlib_metadata
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
import agentops
|
||||
AGENTOPS_AVAILABLE = True
|
||||
@@ -65,7 +75,7 @@ except ImportError:
|
||||
pass
|
||||
|
||||
# Only try to import praisonai_tools if either CrewAI or AutoGen is available
|
||||
if CREWAI_AVAILABLE or AUTOGEN_AVAILABLE or PRAISONAI_AVAILABLE:
|
||||
if CREWAI_AVAILABLE or AUTOGEN_AVAILABLE or PRAISONAI_AVAILABLE or AG2_AVAILABLE:
|
||||
try:
|
||||
from praisonai_tools import (
|
||||
CodeDocsSearchTool, CSVSearchTool, DirectorySearchTool, DOCXSearchTool, DirectoryReadTool,
|
||||
@@ -216,6 +226,8 @@ class AgentsGenerator:
|
||||
raise ImportError("AutoGen is not installed. Please install it with 'pip install praisonai[autogen]' for v0.2 or 'pip install praisonai[autogen-v4]' for v0.4")
|
||||
elif framework == "praisonai" and not PRAISONAI_AVAILABLE:
|
||||
raise ImportError("PraisonAI is not installed. Please install it with 'pip install praisonaiagents'")
|
||||
elif framework == "ag2" and not AG2_AVAILABLE:
|
||||
raise ImportError("AG2 is not installed. Please install it with 'pip install praisonai[ag2]'")
|
||||
|
||||
def is_function_or_decorated(self, obj):
|
||||
"""
|
||||
@@ -391,7 +403,7 @@ class AgentsGenerator:
|
||||
tools_dict = {}
|
||||
|
||||
# Only try to use praisonai_tools if it's available and needed
|
||||
if PRAISONAI_TOOLS_AVAILABLE and (CREWAI_AVAILABLE or AUTOGEN_AVAILABLE or PRAISONAI_AVAILABLE):
|
||||
if PRAISONAI_TOOLS_AVAILABLE and (CREWAI_AVAILABLE or AUTOGEN_AVAILABLE or PRAISONAI_AVAILABLE or AG2_AVAILABLE):
|
||||
tools_dict = {
|
||||
'CodeDocsSearchTool': CodeDocsSearchTool(),
|
||||
'CSVSearchTool': CSVSearchTool(),
|
||||
@@ -462,6 +474,12 @@ class AgentsGenerator:
|
||||
else:
|
||||
self.logger.info("Using AutoGen v0.2")
|
||||
return self._run_autogen(config, topic, tools_dict)
|
||||
elif framework == "ag2":
|
||||
if not AG2_AVAILABLE:
|
||||
raise ImportError("AG2 is not installed. Please install it with 'pip install praisonai[ag2]'")
|
||||
if AGENTOPS_AVAILABLE:
|
||||
agentops.init(os.environ.get("AGENTOPS_API_KEY"), default_tags=["ag2"])
|
||||
return self._run_ag2(config, topic, tools_dict)
|
||||
elif framework == "praisonai":
|
||||
if not PRAISONAI_AVAILABLE:
|
||||
raise ImportError("PraisonAI is not installed. Please install it with 'pip install praisonaiagents'")
|
||||
@@ -711,6 +729,148 @@ class AgentsGenerator:
|
||||
self.logger.error(f"Error running AutoGen v0.4: {str(e)}")
|
||||
return f"### AutoGen v0.4 Error ###\n{str(e)}"
|
||||
|
||||
def _run_ag2(self, config, topic, tools_dict):
|
||||
"""
|
||||
Run agents using the AG2 framework (community fork of AutoGen, PyPI: ag2).
|
||||
|
||||
AG2 installs under the 'autogen' namespace — there is no 'import ag2'.
|
||||
Uses LLMConfig context manager + AssistantAgent + GroupChat pattern.
|
||||
|
||||
Args:
|
||||
config (dict): Configuration dictionary parsed from YAML
|
||||
topic (str): The topic/task to process
|
||||
tools_dict (dict): Dictionary of available tools
|
||||
|
||||
Returns:
|
||||
str: Result prefixed with '### AG2 Output ###'
|
||||
"""
|
||||
import re as _re
|
||||
from autogen import (
|
||||
AssistantAgent, UserProxyAgent, GroupChat, GroupChatManager, LLMConfig
|
||||
)
|
||||
|
||||
model_config = self.config_list[0] if self.config_list else {}
|
||||
|
||||
# Allow YAML top-level llm block to override config_list values
|
||||
yaml_llm = config.get("llm", {}) or {}
|
||||
# Also check first role's llm block as a fallback
|
||||
first_role_llm = {}
|
||||
for role_details in config.get("roles", {}).values():
|
||||
first_role_llm = role_details.get("llm", {}) or {}
|
||||
break
|
||||
|
||||
# Priority: YAML top-level llm > first role llm > config_list > env vars
|
||||
def _resolve(key, env_var=None, default=None):
|
||||
return (yaml_llm.get(key) or first_role_llm.get(key)
|
||||
or model_config.get(key)
|
||||
or (os.environ.get(env_var) if env_var else None)
|
||||
or default)
|
||||
|
||||
api_type = _resolve("api_type", default="openai").lower()
|
||||
model_name = _resolve("model", default="gpt-4o-mini")
|
||||
api_key = _resolve("api_key", env_var="OPENAI_API_KEY")
|
||||
base_url = (model_config.get("base_url")
|
||||
or yaml_llm.get("base_url")
|
||||
or os.environ.get("OPENAI_BASE_URL")
|
||||
or os.environ.get("OPENAI_API_BASE"))
|
||||
|
||||
# Build LLMConfig — Bedrock needs no api_key
|
||||
if api_type == "bedrock":
|
||||
llm_config_entry = {"api_type": "bedrock", "model": model_name}
|
||||
else:
|
||||
llm_config_entry = {"model": model_name}
|
||||
if api_key:
|
||||
llm_config_entry["api_key"] = api_key
|
||||
if base_url and base_url not in ("https://api.openai.com/v1", "https://api.openai.com/v1/"):
|
||||
llm_config_entry["base_url"] = base_url
|
||||
llm_config = LLMConfig(llm_config_entry)
|
||||
|
||||
user_proxy = UserProxyAgent(
|
||||
name="User",
|
||||
human_input_mode="NEVER",
|
||||
is_termination_msg=lambda x: "TERMINATE" in (x.get("content") or ""),
|
||||
code_execution_config=False,
|
||||
)
|
||||
|
||||
# Create one AssistantAgent per role
|
||||
ag2_agent_entries = []
|
||||
for role, details in config["roles"].items():
|
||||
agent_name = details.get("role", role).replace("{topic}", topic)
|
||||
backstory = details.get("backstory", "").replace("{topic}", topic)
|
||||
agent_name_safe = _re.sub(r"[^a-zA-Z0-9_\-]", "_", agent_name)
|
||||
assistant = AssistantAgent(
|
||||
name=agent_name_safe,
|
||||
system_message=backstory + "\nWhen the task is done, reply 'TERMINATE'.",
|
||||
llm_config=llm_config,
|
||||
)
|
||||
ag2_agent_entries.append((role, details, assistant))
|
||||
|
||||
# Register tools via AG2 decorator pattern
|
||||
for role, details, assistant in ag2_agent_entries:
|
||||
for tool_name in details.get("tools", []):
|
||||
tool = tools_dict.get(tool_name)
|
||||
if tool is None:
|
||||
continue
|
||||
func = tool if callable(tool) else getattr(tool, "run", None)
|
||||
if func is None:
|
||||
continue
|
||||
|
||||
def make_tool_fn(f):
|
||||
def tool_fn(**kwargs):
|
||||
return f(**kwargs) if callable(f) else str(f)
|
||||
tool_fn.__name__ = tool_name
|
||||
return tool_fn
|
||||
|
||||
wrapped = make_tool_fn(func)
|
||||
assistant.register_for_llm(description=f"Tool: {tool_name}")(wrapped)
|
||||
user_proxy.register_for_execution()(wrapped)
|
||||
|
||||
all_assistants = [a for _, _, a in ag2_agent_entries]
|
||||
if not all_assistants:
|
||||
return "### AG2 Output ###\nNo agents created from configuration."
|
||||
|
||||
# Build initial message from all task descriptions
|
||||
task_lines = []
|
||||
for role, details, _ in ag2_agent_entries:
|
||||
for task_name, task_details in details.get("tasks", {}).items():
|
||||
desc = task_details.get("description", "").replace("{topic}", topic)
|
||||
if desc:
|
||||
task_lines.append(desc)
|
||||
initial_message = "\n".join(task_lines) if task_lines else topic
|
||||
|
||||
groupchat = GroupChat(
|
||||
agents=[user_proxy] + all_assistants,
|
||||
messages=[],
|
||||
max_round=12,
|
||||
)
|
||||
manager = GroupChatManager(groupchat=groupchat, llm_config=llm_config)
|
||||
|
||||
try:
|
||||
chat_result = user_proxy.initiate_chat(manager, message=initial_message)
|
||||
except Exception as e:
|
||||
return f"### AG2 Error ###\n{str(e)}"
|
||||
|
||||
# Prefer ChatResult.summary if available, otherwise scan messages
|
||||
result_content = ""
|
||||
summary = getattr(chat_result, "summary", None)
|
||||
if summary and isinstance(summary, str) and summary.strip():
|
||||
result_content = _re.sub(r'[\s\.\,]*TERMINATE[\s\.\,]*$', '', summary, flags=_re.IGNORECASE).strip().rstrip('.')
|
||||
|
||||
if not result_content:
|
||||
for msg in reversed(groupchat.messages):
|
||||
if msg.get("name") == "User":
|
||||
continue
|
||||
content = (msg.get("content") or "").strip()
|
||||
if content:
|
||||
result_content = _re.sub(r'[\s\.\,]*TERMINATE[\s\.\,]*$', '', content, flags=_re.IGNORECASE).strip().rstrip('.')
|
||||
if result_content:
|
||||
break
|
||||
|
||||
if not result_content:
|
||||
result_content = "Task completed."
|
||||
|
||||
return f"### AG2 Output ###\n{result_content}"
|
||||
|
||||
def _run_crewai(self, config, topic, tools_dict):
|
||||
"""
|
||||
Run agents using the CrewAI framework.
|
||||
|
||||
@@ -91,6 +91,23 @@ def _check_autogen_v4_available() -> bool:
|
||||
return _autogen_v4_available
|
||||
|
||||
|
||||
# --- AG2 lazy loading ---
|
||||
_ag2_available = None
|
||||
|
||||
def _check_ag2_available() -> bool:
|
||||
"""Check if AG2 (community fork of AutoGen) is available (cached)."""
|
||||
global _ag2_available
|
||||
if _ag2_available is None:
|
||||
try:
|
||||
import importlib.metadata
|
||||
importlib.metadata.distribution('ag2')
|
||||
from autogen import LLMConfig # noqa: F401 — AG2-exclusive class
|
||||
_ag2_available = True
|
||||
except Exception:
|
||||
_ag2_available = False
|
||||
return _ag2_available
|
||||
|
||||
|
||||
def _get_autogen():
|
||||
"""Lazy load autogen module."""
|
||||
global _autogen_module
|
||||
@@ -658,6 +675,11 @@ AutoGen is not installed. Please install with:
|
||||
Praisonai is not installed. Please install with:
|
||||
pip install praisonaiagents
|
||||
""")
|
||||
elif framework == "ag2" and not _check_ag2_available():
|
||||
raise ImportError("""
|
||||
AG2 is not installed. Please install with:
|
||||
pip install "praisonai[ag2]"
|
||||
""")
|
||||
|
||||
# Only show tools message if using a framework and tools are needed
|
||||
if (framework in ["crewai", "autogen"]) and not _check_praisonai_tools_available():
|
||||
|
||||
@@ -312,13 +312,31 @@ class HybridWorkflowExecutor:
|
||||
return flags
|
||||
|
||||
def _eval_condition(self, condition: str, flags: Dict) -> bool:
|
||||
"""Evaluate a simple condition expression."""
|
||||
"""Evaluate a simple condition expression safely using AST whitelisting."""
|
||||
import ast
|
||||
|
||||
condition = condition.strip()
|
||||
if condition.startswith("{{") and condition.endswith("}}"):
|
||||
condition = condition[2:-2].strip()
|
||||
|
||||
# Validate AST - only allow safe node types
|
||||
try:
|
||||
tree = ast.parse(condition, mode='eval')
|
||||
except SyntaxError:
|
||||
return False
|
||||
|
||||
_safe_nodes = (
|
||||
ast.Expression, ast.Compare, ast.BoolOp, ast.UnaryOp, ast.BinOp,
|
||||
ast.Constant, ast.Name, ast.Attribute, ast.Load,
|
||||
ast.And, ast.Or, ast.Not, ast.Eq, ast.NotEq, ast.Lt, ast.LtE,
|
||||
ast.Gt, ast.GtE, ast.Is, ast.IsNot, ast.In, ast.NotIn,
|
||||
)
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, _safe_nodes):
|
||||
return False
|
||||
|
||||
context = {"flags": type("Flags", (), flags)(), "env": os.environ}
|
||||
try:
|
||||
return bool(eval(condition, {"__builtins__": {}}, context))
|
||||
return bool(eval(compile(tree, '<condition>', 'eval'), {"__builtins__": {}}, context))
|
||||
except Exception:
|
||||
return False
|
||||
@@ -267,12 +267,24 @@ class JobWorkflowExecutor:
|
||||
|
||||
def _exec_inline_python(self, code: str, step: Dict, flags: Dict) -> Dict:
|
||||
"""Execute inline Python code in an isolated namespace."""
|
||||
_safe_builtins = {
|
||||
"True": True, "False": False, "None": None,
|
||||
"int": int, "float": float, "str": str, "bool": bool,
|
||||
"list": list, "dict": dict, "tuple": tuple, "set": set,
|
||||
"len": len, "range": range, "enumerate": enumerate,
|
||||
"zip": zip, "map": map, "filter": filter,
|
||||
"sorted": sorted, "reversed": reversed,
|
||||
"min": min, "max": max, "sum": sum, "abs": abs, "round": round,
|
||||
"isinstance": isinstance, "type": type,
|
||||
"print": print, "repr": repr,
|
||||
"hasattr": hasattr, "getattr": getattr, "setattr": setattr,
|
||||
}
|
||||
namespace = {
|
||||
"flags": flags,
|
||||
"vars": {k: self._resolve_var_value(v) for k, v in self._vars.items()},
|
||||
"env": os.environ.copy(),
|
||||
"cwd": self._cwd,
|
||||
"__builtins__": __builtins__,
|
||||
"__builtins__": _safe_builtins,
|
||||
}
|
||||
try:
|
||||
exec(code, namespace)
|
||||
@@ -719,16 +731,34 @@ class JobWorkflowExecutor:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _eval_condition(self, condition: str, flags: Dict) -> bool:
|
||||
"""Evaluate a simple condition expression."""
|
||||
"""Evaluate a simple condition expression safely using AST whitelisting."""
|
||||
import ast
|
||||
|
||||
# Strip {{ }} if present
|
||||
condition = condition.strip()
|
||||
if condition.startswith("{{") and condition.endswith("}}"):
|
||||
condition = condition[2:-2].strip()
|
||||
|
||||
# Validate AST - only allow safe node types
|
||||
try:
|
||||
tree = ast.parse(condition, mode='eval')
|
||||
except SyntaxError:
|
||||
return False
|
||||
|
||||
_safe_nodes = (
|
||||
ast.Expression, ast.Compare, ast.BoolOp, ast.UnaryOp, ast.BinOp,
|
||||
ast.Constant, ast.Name, ast.Attribute, ast.Load,
|
||||
ast.And, ast.Or, ast.Not, ast.Eq, ast.NotEq, ast.Lt, ast.LtE,
|
||||
ast.Gt, ast.GtE, ast.Is, ast.IsNot, ast.In, ast.NotIn,
|
||||
)
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, _safe_nodes):
|
||||
return False
|
||||
|
||||
# Build evaluation context
|
||||
context = {"flags": type("Flags", (), flags)(), "env": os.environ}
|
||||
try:
|
||||
return bool(eval(condition, {"__builtins__": {}}, context))
|
||||
return bool(eval(compile(tree, '<condition>', 'eval'), {"__builtins__": {}}, context))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@@ -67,6 +67,56 @@ import importlib
|
||||
# REMOVED: from praisonai.inbuilt_tools import * - causes ~3200ms crewai import
|
||||
# REMOVED: from praisonai.inc.config import generate_config - causes ~3500ms langchain import
|
||||
|
||||
|
||||
# Security: blocklist of environment variable keys that must not be set from
|
||||
# untrusted YAML config files. These keys can alter code-loading behaviour
|
||||
# (LD_PRELOAD, PYTHONPATH, …) or redirect subprocesses (PATH) and are
|
||||
# therefore a vector for arbitrary code execution (CWE-78).
|
||||
_BLOCKED_ENV_KEYS = frozenset({
|
||||
# Dynamic linker injection
|
||||
"LD_PRELOAD", "LD_LIBRARY_PATH", "LD_AUDIT",
|
||||
"DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH",
|
||||
"DYLD_FALLBACK_LIBRARY_PATH",
|
||||
# Executable / module search paths
|
||||
"PATH",
|
||||
"PYTHONPATH", "PYTHONHOME", "PYTHONSTARTUP",
|
||||
"NODE_PATH", "NODE_OPTIONS",
|
||||
"RUBYLIB", "PERL5LIB", "PERL5OPT",
|
||||
"CLASSPATH",
|
||||
# Proxy / redirect (could exfiltrate traffic)
|
||||
"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY",
|
||||
# Miscellaneous dangerous keys
|
||||
"BASH_ENV", "ENV", "CDPATH",
|
||||
"PROMPT_COMMAND",
|
||||
"SHLVL",
|
||||
})
|
||||
|
||||
# Pre-compute uppercase lookup set once at module load (avoids rebuilding per call)
|
||||
_BLOCKED_ENV_KEYS_UPPER = frozenset(k.upper() for k in _BLOCKED_ENV_KEYS)
|
||||
|
||||
|
||||
def _validate_env_key(key) -> None:
|
||||
"""Raise ``ValueError`` if *key* is a blocked environment variable name.
|
||||
|
||||
The check is case-insensitive so that ``ld_preload`` is caught as well as
|
||||
``LD_PRELOAD``. Non-string keys (e.g. YAML integer or null keys) are
|
||||
rejected with a clear validation error.
|
||||
"""
|
||||
if not isinstance(key, str):
|
||||
raise ValueError(
|
||||
f"Environment variable key must be a string, got {type(key).__name__}: {key!r}"
|
||||
)
|
||||
if not key or "=" in key or "\x00" in key:
|
||||
raise ValueError(
|
||||
"Environment variable key must be a non-empty string without '=' or NUL characters."
|
||||
)
|
||||
if key.upper() in _BLOCKED_ENV_KEYS_UPPER:
|
||||
raise ValueError(
|
||||
f"Setting environment variable '{key}' is not allowed in schedule "
|
||||
f"config files because it can be used to execute arbitrary code."
|
||||
)
|
||||
|
||||
|
||||
# Lazy import helpers for inbuilt_tools and config
|
||||
def _get_inbuilt_tools():
|
||||
"""Lazy import inbuilt_tools only when crewai/autogen features are used."""
|
||||
@@ -444,21 +494,38 @@ class PraisonAI:
|
||||
try:
|
||||
with open(args.schedule_config, 'r') as f:
|
||||
file_config = yaml.safe_load(f)
|
||||
if file_config is None:
|
||||
file_config = {}
|
||||
if not isinstance(file_config, dict):
|
||||
raise ValueError("Schedule config must be a YAML mapping")
|
||||
|
||||
# Extract deployment config
|
||||
deploy_config = file_config.get('deployment', {})
|
||||
if deploy_config is None:
|
||||
deploy_config = {}
|
||||
if not isinstance(deploy_config, dict):
|
||||
raise ValueError("'deployment' must be a mapping")
|
||||
schedule_expr = schedule_expr or deploy_config.get('schedule')
|
||||
provider = deploy_config.get('provider', provider)
|
||||
config['max_retries'] = deploy_config.get('max_retries', config['max_retries'])
|
||||
|
||||
# Apply environment variables if specified
|
||||
env_vars = file_config.get('environment', {})
|
||||
if not isinstance(env_vars, dict):
|
||||
raise ValueError("'environment' must be a mapping of KEY: value pairs")
|
||||
# Validate all keys first (fail-closed) before mutating os.environ
|
||||
validated_env = {}
|
||||
for key, value in env_vars.items():
|
||||
os.environ[key] = str(value)
|
||||
_validate_env_key(key)
|
||||
validated_env[key] = str(value)
|
||||
os.environ.update(validated_env)
|
||||
|
||||
except FileNotFoundError:
|
||||
print(f"Configuration file not found: {args.schedule_config}")
|
||||
sys.exit(1)
|
||||
except ValueError as e:
|
||||
print(f"Invalid schedule configuration: {e}")
|
||||
sys.exit(1)
|
||||
except yaml.YAMLError as e:
|
||||
print(f"Error parsing configuration file: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
@@ -57,7 +57,7 @@ class CloudDeployer:
|
||||
file.write("FROM python:3.11-slim\n")
|
||||
file.write("WORKDIR /app\n")
|
||||
file.write("COPY . .\n")
|
||||
file.write("RUN pip install flask praisonai==4.5.80 gunicorn markdown\n")
|
||||
file.write("RUN pip install flask praisonai==4.5.87 gunicorn markdown\n")
|
||||
file.write("EXPOSE 8080\n")
|
||||
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
|
||||
|
||||
@@ -87,7 +87,8 @@ class CloudDeployer:
|
||||
file.write(" html_output = markdown.markdown(output)\n")
|
||||
file.write(" return f'<html><body>{html_output}</body></html>'\n\n")
|
||||
file.write("if __name__ == \"__main__\":\n")
|
||||
file.write(" app.run(debug=True)\n")
|
||||
file.write(" import os\n")
|
||||
file.write(" app.run(debug=os.environ.get('DEBUG', 'false').lower() == 'true')\n")
|
||||
|
||||
def set_environment_variables(self):
|
||||
"""Sets environment variables with fallback to .env values or defaults."""
|
||||
|
||||
@@ -5,6 +5,8 @@ Requires: singlestoredb
|
||||
Install: pip install singlestoredb
|
||||
"""
|
||||
|
||||
import ast
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -165,7 +167,7 @@ class SingleStoreVectorKnowledgeStore(KnowledgeStore):
|
||||
INSERT INTO {table_name} (id, content, embedding, metadata, content_hash, created_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
""", (doc.id, doc.content, embedding_str,
|
||||
str(doc.metadata) if doc.metadata else None,
|
||||
json.dumps(doc.metadata) if doc.metadata else None,
|
||||
doc.content_hash, doc.created_at))
|
||||
ids.append(doc.id)
|
||||
|
||||
@@ -189,7 +191,7 @@ class SingleStoreVectorKnowledgeStore(KnowledgeStore):
|
||||
REPLACE INTO {table_name} (id, content, embedding, metadata, content_hash, created_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
""", (doc.id, doc.content, embedding_str,
|
||||
str(doc.metadata) if doc.metadata else None,
|
||||
json.dumps(doc.metadata) if doc.metadata else None,
|
||||
doc.content_hash, doc.created_at))
|
||||
ids.append(doc.id)
|
||||
|
||||
@@ -227,7 +229,7 @@ class SingleStoreVectorKnowledgeStore(KnowledgeStore):
|
||||
documents.append(KnowledgeDocument(
|
||||
id=row[0],
|
||||
content=row[1],
|
||||
metadata=eval(row[2]) if row[2] else None,
|
||||
metadata=self._deserialize_metadata(row[2]),
|
||||
content_hash=row[3],
|
||||
created_at=row[4]
|
||||
))
|
||||
@@ -256,12 +258,52 @@ class SingleStoreVectorKnowledgeStore(KnowledgeStore):
|
||||
KnowledgeDocument(
|
||||
id=row[0],
|
||||
content=row[1],
|
||||
metadata=eval(row[2]) if row[2] else None,
|
||||
metadata=self._deserialize_metadata(row[2]),
|
||||
content_hash=row[3],
|
||||
created_at=row[4]
|
||||
)
|
||||
for row in cur.fetchall()
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _deserialize_metadata(value: Any) -> Optional[Dict[str, Any]]:
|
||||
"""Safely deserialize metadata from JSON/legacy literal formats."""
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
try:
|
||||
parsed = ast.literal_eval(value)
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
if SingleStoreVectorKnowledgeStore._is_json_compatible(parsed):
|
||||
return parsed
|
||||
return None
|
||||
except (ValueError, SyntaxError) as exc:
|
||||
logging.getLogger(__name__).warning(
|
||||
"Failed to parse metadata value from SingleStore: %s",
|
||||
type(exc).__name__,
|
||||
)
|
||||
return None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _is_json_compatible(value: Any) -> bool:
|
||||
"""Ensure legacy metadata uses JSON-compatible value types only."""
|
||||
if value is None or isinstance(value, (str, int, float, bool)):
|
||||
return True
|
||||
if isinstance(value, list):
|
||||
return all(SingleStoreVectorKnowledgeStore._is_json_compatible(item) for item in value)
|
||||
if isinstance(value, dict):
|
||||
return all(
|
||||
isinstance(key, str) and SingleStoreVectorKnowledgeStore._is_json_compatible(item)
|
||||
for key, item in value.items()
|
||||
)
|
||||
return False
|
||||
|
||||
def delete(
|
||||
self,
|
||||
|
||||
@@ -40,6 +40,7 @@ trace_exporter: none # none, otlp, jaeger, zipkin
|
||||
```
|
||||
"""
|
||||
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
@@ -332,7 +333,7 @@ def create_auth_middleware(auth_type: str, api_key: Optional[str] = None, jwt_se
|
||||
# No key configured, allow request
|
||||
return await call_next(request)
|
||||
|
||||
if provided_key != expected_key:
|
||||
if not provided_key or not hmac.compare_digest(provided_key, expected_key):
|
||||
return JSONResponse(
|
||||
{"error": {"code": "unauthorized", "message": "Invalid or missing API key"}},
|
||||
status_code=401
|
||||
|
||||
@@ -853,7 +853,13 @@ from praisonaiagents import Agent
|
||||
# Define a simple tool
|
||||
def calculator(expression: str) -> str:
|
||||
"""Calculate a math expression."""
|
||||
return str(eval(expression))
|
||||
import ast
|
||||
node = ast.parse(expression.strip(), mode='eval')
|
||||
allowed = (ast.Expression, ast.BinOp, ast.UnaryOp, ast.Constant, ast.operator, ast.unaryop)
|
||||
for child in ast.walk(node):
|
||||
if not isinstance(child, allowed):
|
||||
return "Error: unsupported expression"
|
||||
return str(eval(compile(node, '<expr>', 'eval'), {"__builtins__": {}}))
|
||||
|
||||
# Create agent with tool
|
||||
agent = Agent(
|
||||
|
||||
@@ -583,10 +583,12 @@ print(f"[DEBUG] tools_dict: {tools_dict}")
|
||||
with open(agent_file, 'r') as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
import secrets
|
||||
AUTH_PASSWORD_ENABLED = os.getenv("AUTH_PASSWORD_ENABLED", "true").lower() == "true"
|
||||
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
|
||||
if not CHAINLIT_AUTH_SECRET:
|
||||
os.environ["CHAINLIT_AUTH_SECRET"] = "p8BPhQChpg@J>jBz$wGxqLX2V>yTVgP*7Ky9H$aV:axW~ANNX-7_T:o@lnyCBu^U"
|
||||
os.environ["CHAINLIT_AUTH_SECRET"] = secrets.token_hex(32)
|
||||
logger.warning("CHAINLIT_AUTH_SECRET not set; generated a random secret for this session.")
|
||||
|
||||
username_env = os.getenv("CHAINLIT_USERNAME", "admin")
|
||||
password_env = os.getenv("CHAINLIT_PASSWORD", "admin")
|
||||
|
||||
@@ -25,9 +25,13 @@ logger = logging.getLogger(__name__)
|
||||
log_level = os.getenv("LOGLEVEL", "INFO").upper() or "INFO"
|
||||
logging.basicConfig(level=log_level, format="%(asctime)s - %(levelname)s - %(message)s")
|
||||
|
||||
import secrets
|
||||
|
||||
# Auth secret (required by Chainlit)
|
||||
if not os.getenv("CHAINLIT_AUTH_SECRET"):
|
||||
os.environ["CHAINLIT_AUTH_SECRET"] = "p8BPhQChpg@J>jBz$wGxqLX2V>yTVgP*7Ky9H$aV:axW~ANNX-7_T:o@lnyCBu^U"
|
||||
os.environ["CHAINLIT_AUTH_SECRET"] = secrets.token_hex(32)
|
||||
logger.warning("CHAINLIT_AUTH_SECRET not set; generated a random secret for this session.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lazy imports
|
||||
|
||||
@@ -194,10 +194,13 @@ def _ensure_env_loaded():
|
||||
_cached_modules['env_loaded'] = True
|
||||
|
||||
# Auth secret setup (required early)
|
||||
import secrets
|
||||
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
|
||||
if not CHAINLIT_AUTH_SECRET:
|
||||
os.environ["CHAINLIT_AUTH_SECRET"] = "p8BPhQChpg@J>jBz$wGxqLX2V>yTVgP*7Ky9H$aV:axW~ANNX-7_T:o@lnyCBu^U"
|
||||
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
|
||||
CHAINLIT_AUTH_SECRET = secrets.token_hex(32)
|
||||
os.environ["CHAINLIT_AUTH_SECRET"] = CHAINLIT_AUTH_SECRET
|
||||
logger.warning("CHAINLIT_AUTH_SECRET not set; generated a random secret for this session.")
|
||||
|
||||
|
||||
def save_setting(key: str, value: str):
|
||||
"""Save a setting to the database"""
|
||||
|
||||
@@ -193,10 +193,13 @@ def _ensure_env_loaded():
|
||||
_cached_modules['env_loaded'] = True
|
||||
|
||||
# Auth secret setup (required early)
|
||||
import secrets
|
||||
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
|
||||
if not CHAINLIT_AUTH_SECRET:
|
||||
os.environ["CHAINLIT_AUTH_SECRET"] = "p8BPhQChpg@J>jBz$wGxqLX2V>yTVgP*7Ky9H$aV:axW~ANNX-7_T:o@lnyCBu^U"
|
||||
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
|
||||
CHAINLIT_AUTH_SECRET = secrets.token_hex(32)
|
||||
os.environ["CHAINLIT_AUTH_SECRET"] = CHAINLIT_AUTH_SECRET
|
||||
logger.warning("CHAINLIT_AUTH_SECRET not set; generated a random secret for this session.")
|
||||
|
||||
|
||||
def save_setting(key: str, value: str):
|
||||
"""Save a setting to the database"""
|
||||
|
||||
@@ -36,11 +36,13 @@ logger.addHandler(console_handler)
|
||||
logger.setLevel(log_level)
|
||||
|
||||
# Set up CHAINLIT_AUTH_SECRET
|
||||
import secrets
|
||||
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
|
||||
|
||||
if not CHAINLIT_AUTH_SECRET:
|
||||
os.environ["CHAINLIT_AUTH_SECRET"] = "p8BPhQChpg@J>jBz$wGxqLX2V>yTVgP*7Ky9H$aV:axW~ANNX-7_T:o@lnyCBu^U"
|
||||
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
|
||||
CHAINLIT_AUTH_SECRET = secrets.token_hex(32)
|
||||
os.environ["CHAINLIT_AUTH_SECRET"] = CHAINLIT_AUTH_SECRET
|
||||
logger.warning("CHAINLIT_AUTH_SECRET not set; generated a random secret for this session.")
|
||||
|
||||
|
||||
# Database path
|
||||
DB_PATH = os.path.expanduser("~/.praison/database.sqlite")
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.5.80"
|
||||
__version__ = "4.5.87"
|
||||
@@ -12,9 +12,9 @@ dependencies = [
|
||||
"rich>=13.7",
|
||||
"markdown>=3.5",
|
||||
"pyparsing>=3.0.0",
|
||||
"praisonaiagents>=1.5.80",
|
||||
"praisonaiagents>=1.5.87",
|
||||
"python-dotenv>=0.19.0",
|
||||
"litellm>=1.81.0",
|
||||
"litellm>=1.81.0,<=1.82.6",
|
||||
"PyYAML>=6.0",
|
||||
"mcp>=1.20.0",
|
||||
"typer>=0.9.0",
|
||||
@@ -91,6 +91,7 @@ call = [
|
||||
train = []
|
||||
crewai = ["crewai>=0.157.0", "praisonai-tools>=0.1.0"]
|
||||
autogen = ["pyautogen==0.2.29", "praisonai-tools>=0.1.0", "crewai"]
|
||||
ag2 = ["ag2>=0.11.0", "praisonai-tools>=0.1.0"]
|
||||
autogen-v4 = [
|
||||
"autogen-agentchat>=0.4.0",
|
||||
"autogen-ext[openai]>=0.4.0",
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,477 @@
|
||||
"""
|
||||
AG2 Mock Integration Tests — verifies PraisonAI correctly orchestrates AG2 agents.
|
||||
|
||||
Uses mocked AG2 responses — no real LLM API calls are made.
|
||||
Fast: all tests should complete in < 1s each.
|
||||
|
||||
Run:
|
||||
pytest tests/integration/ag2/test_ag2_integration.py -v
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../../"))
|
||||
|
||||
# Stub heavy dependencies that auto.py (develop branch) imports at module level
|
||||
# so that tests can import praisonai without a full installation.
|
||||
for _stub in ("instructor",):
|
||||
if _stub not in sys.modules:
|
||||
sys.modules[_stub] = MagicMock()
|
||||
|
||||
import importlib as _importlib
|
||||
if "openai" not in sys.modules:
|
||||
try:
|
||||
_importlib.import_module("openai")
|
||||
except ImportError:
|
||||
_mock_openai = MagicMock()
|
||||
_mock_openai.__version__ = "1.0.0"
|
||||
sys.modules["openai"] = _mock_openai
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def single_agent_yaml():
|
||||
return """
|
||||
framework: ag2
|
||||
topic: Write a short poem about Python
|
||||
roles:
|
||||
poet:
|
||||
role: "Creative Poet"
|
||||
goal: "Write elegant and concise poems"
|
||||
backstory: "You are a creative poet who loves Python and technology."
|
||||
tasks:
|
||||
write_poem:
|
||||
description: "Write a short 4-line poem about Python programming"
|
||||
expected_output: "A 4-line poem about Python"
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def multi_agent_yaml():
|
||||
return """
|
||||
framework: ag2
|
||||
topic: Explain why open-source AI matters
|
||||
roles:
|
||||
researcher:
|
||||
role: "Research Analyst"
|
||||
goal: "Research and gather key facts"
|
||||
backstory: "Expert in technology research and trend analysis."
|
||||
tasks:
|
||||
research:
|
||||
description: "Research why open-source AI matters"
|
||||
expected_output: "Key facts and data points"
|
||||
writer:
|
||||
role: "Technical Writer"
|
||||
goal: "Write clear technical content"
|
||||
backstory: "Professional technical writer with 10 years experience."
|
||||
tasks:
|
||||
write:
|
||||
description: "Write a clear explanation of why open-source AI matters"
|
||||
expected_output: "A 200-word explanation"
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ag2_classes():
|
||||
"""Mock all AG2 classes used in _run_ag2."""
|
||||
mock_llm_config = MagicMock()
|
||||
mock_llm_config.__enter__ = MagicMock(return_value=mock_llm_config)
|
||||
mock_llm_config.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
mock_assistant = MagicMock()
|
||||
mock_assistant.name = "test_agent"
|
||||
|
||||
mock_user_proxy = MagicMock()
|
||||
mock_user_proxy.name = "User"
|
||||
|
||||
mock_groupchat = MagicMock()
|
||||
mock_groupchat.messages = [
|
||||
{
|
||||
"name": "test_agent",
|
||||
"role": "assistant",
|
||||
"content": "Task completed successfully. TERMINATE",
|
||||
}
|
||||
]
|
||||
|
||||
mock_manager = MagicMock()
|
||||
|
||||
return {
|
||||
"llm_config": mock_llm_config,
|
||||
"assistant": mock_assistant,
|
||||
"user_proxy": mock_user_proxy,
|
||||
"groupchat": mock_groupchat,
|
||||
"manager": mock_manager,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AG2 import detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAG2Import:
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_ag2_distribution_available(self):
|
||||
"""ag2 PyPI distribution should be findable when installed.
|
||||
|
||||
Note: ag2 installs under the 'autogen' namespace — 'import ag2' does NOT work.
|
||||
Detection uses importlib.metadata to check the 'ag2' distribution name.
|
||||
"""
|
||||
import importlib.metadata
|
||||
try:
|
||||
dist = importlib.metadata.distribution('ag2')
|
||||
assert dist.metadata['Name'] == 'ag2'
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
pytest.skip("ag2 not installed — skipping AG2 integration tests")
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_autogen_namespace_importable_with_ag2(self):
|
||||
"""When ag2 is installed, autogen namespace classes are importable."""
|
||||
try:
|
||||
from autogen import AssistantAgent, UserProxyAgent, GroupChat, LLMConfig
|
||||
assert AssistantAgent is not None
|
||||
assert UserProxyAgent is not None
|
||||
assert GroupChat is not None
|
||||
assert LLMConfig is not None
|
||||
except ImportError:
|
||||
pytest.skip("ag2 not installed — autogen namespace not available")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single-agent flow (mocked)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAG2SingleAgentFlow:
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_single_agent_yaml_initialises_praisonai(self, single_agent_yaml, mock_ag2_classes):
|
||||
"""PraisonAI initialises correctly with framework='ag2' from YAML."""
|
||||
import tempfile
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
||||
f.write(single_agent_yaml)
|
||||
yaml_path = f.name
|
||||
|
||||
try:
|
||||
with patch("praisonai.agents_generator.AG2_AVAILABLE", True), \
|
||||
patch("praisonai.agents_generator.PRAISONAI_AVAILABLE", True):
|
||||
try:
|
||||
from praisonai import PraisonAI
|
||||
ai = PraisonAI(agent_file=yaml_path, framework="ag2")
|
||||
assert ai.framework == "ag2"
|
||||
except ImportError as e:
|
||||
pytest.skip(f"PraisonAI not available: {e}")
|
||||
finally:
|
||||
os.unlink(yaml_path)
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_single_agent_run_returns_ag2_output(self, mock_ag2_classes):
|
||||
"""_run_ag2 executes single-agent flow and returns '### AG2 Output ###'."""
|
||||
config = {
|
||||
"framework": "ag2",
|
||||
"topic": "Write a poem",
|
||||
"roles": {
|
||||
"poet": {
|
||||
"role": "Creative Poet",
|
||||
"goal": "Write poems",
|
||||
"backstory": "A creative poet.",
|
||||
"tasks": {
|
||||
"task1": {
|
||||
"description": "Write a poem about Python",
|
||||
"expected_output": "A short poem",
|
||||
}
|
||||
},
|
||||
"tools": [],
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
m = mock_ag2_classes
|
||||
with patch("praisonai.agents_generator.AG2_AVAILABLE", True), \
|
||||
patch("praisonai.agents_generator.CREWAI_AVAILABLE", False), \
|
||||
patch("praisonai.agents_generator.AUTOGEN_AVAILABLE", False), \
|
||||
patch("praisonai.agents_generator.PRAISONAI_AVAILABLE", True):
|
||||
try:
|
||||
from praisonai.agents_generator import AgentsGenerator
|
||||
except ImportError as e:
|
||||
pytest.skip(f"AgentsGenerator not available: {e}")
|
||||
|
||||
gen = AgentsGenerator(
|
||||
agent_file="agents.yaml",
|
||||
framework="ag2",
|
||||
config_list=[{"model": "gpt-4o-mini", "api_key": "sk-test"}],
|
||||
)
|
||||
|
||||
with patch("autogen.LLMConfig", return_value=m["llm_config"]), \
|
||||
patch("autogen.AssistantAgent", return_value=m["assistant"]), \
|
||||
patch("autogen.UserProxyAgent", return_value=m["user_proxy"]), \
|
||||
patch("autogen.GroupChat", return_value=m["groupchat"]), \
|
||||
patch("autogen.GroupChatManager", return_value=m["manager"]):
|
||||
|
||||
result = gen._run_ag2(config, "Write a poem", {})
|
||||
|
||||
assert "### AG2 Output ###" in result
|
||||
assert "Task completed successfully" in result
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_single_agent_calls_initiate_chat(self, mock_ag2_classes):
|
||||
"""user_proxy.initiate_chat is called with the manager and initial message."""
|
||||
config = {
|
||||
"framework": "ag2",
|
||||
"topic": "Test topic",
|
||||
"roles": {
|
||||
"agent": {
|
||||
"role": "Agent",
|
||||
"goal": "Help",
|
||||
"backstory": "Helpful agent.",
|
||||
"tasks": {
|
||||
"t": {"description": "Do the task", "expected_output": "Done"}
|
||||
},
|
||||
"tools": [],
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
m = mock_ag2_classes
|
||||
with patch("praisonai.agents_generator.AG2_AVAILABLE", True), \
|
||||
patch("praisonai.agents_generator.CREWAI_AVAILABLE", False), \
|
||||
patch("praisonai.agents_generator.AUTOGEN_AVAILABLE", False), \
|
||||
patch("praisonai.agents_generator.PRAISONAI_AVAILABLE", True):
|
||||
try:
|
||||
from praisonai.agents_generator import AgentsGenerator
|
||||
except ImportError as e:
|
||||
pytest.skip(f"AgentsGenerator not available: {e}")
|
||||
|
||||
gen = AgentsGenerator(
|
||||
agent_file="agents.yaml",
|
||||
framework="ag2",
|
||||
config_list=[{"model": "gpt-4o-mini", "api_key": "sk-test"}],
|
||||
)
|
||||
|
||||
with patch("autogen.LLMConfig", return_value=m["llm_config"]), \
|
||||
patch("autogen.AssistantAgent", return_value=m["assistant"]), \
|
||||
patch("autogen.UserProxyAgent", return_value=m["user_proxy"]), \
|
||||
patch("autogen.GroupChat", return_value=m["groupchat"]), \
|
||||
patch("autogen.GroupChatManager", return_value=m["manager"]):
|
||||
|
||||
gen._run_ag2(config, "Test topic", {})
|
||||
|
||||
m["user_proxy"].initiate_chat.assert_called_once()
|
||||
call_args = m["user_proxy"].initiate_chat.call_args
|
||||
# First positional arg should be the manager
|
||||
assert call_args[0][0] is m["manager"]
|
||||
# message kwarg should be non-empty (task description or topic)
|
||||
message = call_args[1].get("message", "")
|
||||
assert message != ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-agent GroupChat flow (mocked)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAG2MultiAgentGroupChatFlow:
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_multi_agent_creates_correct_number_of_assistants(self, multi_agent_yaml, mock_ag2_classes):
|
||||
"""Two roles in YAML → two AssistantAgents created."""
|
||||
config = {
|
||||
"framework": "ag2",
|
||||
"topic": "Explain open-source AI",
|
||||
"roles": {
|
||||
"researcher": {
|
||||
"role": "Research Analyst", "goal": "Research", "backstory": "Analyst.",
|
||||
"tasks": {"r": {"description": "Research it", "expected_output": "Facts"}},
|
||||
"tools": [],
|
||||
},
|
||||
"writer": {
|
||||
"role": "Technical Writer", "goal": "Write", "backstory": "Writer.",
|
||||
"tasks": {"w": {"description": "Write it", "expected_output": "Article"}},
|
||||
"tools": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
m = mock_ag2_classes
|
||||
assistant_call_count = [0]
|
||||
|
||||
def count_assistant(**kwargs):
|
||||
assistant_call_count[0] += 1
|
||||
a = MagicMock()
|
||||
a.name = kwargs.get("name", f"agent_{assistant_call_count[0]}")
|
||||
return a
|
||||
|
||||
with patch("praisonai.agents_generator.AG2_AVAILABLE", True), \
|
||||
patch("praisonai.agents_generator.CREWAI_AVAILABLE", False), \
|
||||
patch("praisonai.agents_generator.AUTOGEN_AVAILABLE", False), \
|
||||
patch("praisonai.agents_generator.PRAISONAI_AVAILABLE", True):
|
||||
try:
|
||||
from praisonai.agents_generator import AgentsGenerator
|
||||
except ImportError as e:
|
||||
pytest.skip(f"AgentsGenerator not available: {e}")
|
||||
|
||||
gen = AgentsGenerator(
|
||||
agent_file="agents.yaml",
|
||||
framework="ag2",
|
||||
config_list=[{"model": "gpt-4o-mini", "api_key": "sk-test"}],
|
||||
)
|
||||
|
||||
with patch("autogen.LLMConfig", return_value=m["llm_config"]), \
|
||||
patch("autogen.AssistantAgent", side_effect=count_assistant), \
|
||||
patch("autogen.UserProxyAgent", return_value=m["user_proxy"]), \
|
||||
patch("autogen.GroupChat", return_value=m["groupchat"]), \
|
||||
patch("autogen.GroupChatManager", return_value=m["manager"]):
|
||||
|
||||
gen._run_ag2(config, "Explain open-source AI", {})
|
||||
|
||||
assert assistant_call_count[0] == 2
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_multi_agent_groupchat_max_round_set(self, mock_ag2_classes):
|
||||
"""GroupChat is created with max_round parameter."""
|
||||
config = {
|
||||
"framework": "ag2",
|
||||
"topic": "Test",
|
||||
"roles": {
|
||||
"a": {
|
||||
"role": "Agent", "goal": "Help", "backstory": "Helper.",
|
||||
"tasks": {"t": {"description": "Do", "expected_output": "Done"}},
|
||||
"tools": [],
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
m = mock_ag2_classes
|
||||
groupchat_kwargs = {}
|
||||
|
||||
def capture_groupchat(**kwargs):
|
||||
groupchat_kwargs.update(kwargs)
|
||||
return m["groupchat"]
|
||||
|
||||
with patch("praisonai.agents_generator.AG2_AVAILABLE", True), \
|
||||
patch("praisonai.agents_generator.CREWAI_AVAILABLE", False), \
|
||||
patch("praisonai.agents_generator.AUTOGEN_AVAILABLE", False), \
|
||||
patch("praisonai.agents_generator.PRAISONAI_AVAILABLE", True):
|
||||
try:
|
||||
from praisonai.agents_generator import AgentsGenerator
|
||||
except ImportError as e:
|
||||
pytest.skip(f"AgentsGenerator not available: {e}")
|
||||
|
||||
gen = AgentsGenerator(
|
||||
agent_file="agents.yaml",
|
||||
framework="ag2",
|
||||
config_list=[{"model": "gpt-4o-mini", "api_key": "sk-test"}],
|
||||
)
|
||||
|
||||
with patch("autogen.LLMConfig", return_value=m["llm_config"]), \
|
||||
patch("autogen.AssistantAgent", return_value=m["assistant"]), \
|
||||
patch("autogen.UserProxyAgent", return_value=m["user_proxy"]), \
|
||||
patch("autogen.GroupChat", side_effect=capture_groupchat), \
|
||||
patch("autogen.GroupChatManager", return_value=m["manager"]):
|
||||
|
||||
gen._run_ag2(config, "Test", {})
|
||||
|
||||
assert "max_round" in groupchat_kwargs
|
||||
assert groupchat_kwargs["max_round"] > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backward-compatibility: existing autogen/crewai paths unaffected
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAG2BackwardCompatibility:
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_autogen_framework_still_works(self):
|
||||
"""framework='autogen' dispatches to _run_autogen, not _run_ag2."""
|
||||
import yaml
|
||||
|
||||
with patch("praisonai.agents_generator.AUTOGEN_AVAILABLE", True), \
|
||||
patch("praisonai.agents_generator.AG2_AVAILABLE", False), \
|
||||
patch("praisonai.agents_generator.CREWAI_AVAILABLE", False), \
|
||||
patch("praisonai.agents_generator.PRAISONAI_AVAILABLE", True):
|
||||
try:
|
||||
from praisonai.agents_generator import AgentsGenerator
|
||||
except ImportError as e:
|
||||
pytest.skip(f"AgentsGenerator not available: {e}")
|
||||
|
||||
gen = AgentsGenerator(
|
||||
agent_file="agents.yaml",
|
||||
framework="autogen",
|
||||
config_list=[{"model": "gpt-4o-mini", "api_key": "sk-test"}],
|
||||
)
|
||||
assert gen.framework == "autogen"
|
||||
|
||||
gen.agent_yaml = yaml.dump({
|
||||
"framework": "autogen",
|
||||
"topic": "Test",
|
||||
"roles": {
|
||||
"agent": {
|
||||
"role": "Agent", "goal": "Help", "backstory": "Helper.",
|
||||
"tasks": {"t": {"description": "Do", "expected_output": "Done"}},
|
||||
"tools": [],
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
# Verify _run_ag2 is NOT called when framework='autogen'
|
||||
with patch.object(gen, "_run_ag2") as mock_run_ag2, \
|
||||
patch.object(gen, "_run_autogen", return_value="autogen result") as mock_run_autogen:
|
||||
gen.generate_crew_and_kickoff()
|
||||
|
||||
mock_run_ag2.assert_not_called()
|
||||
mock_run_autogen.assert_called_once()
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_ag2_framework_dispatches_to_run_ag2(self, mock_ag2_classes):
|
||||
"""framework='ag2' dispatches to _run_ag2 and NOT to _run_autogen."""
|
||||
import yaml
|
||||
|
||||
m = mock_ag2_classes
|
||||
with patch("praisonai.agents_generator.AG2_AVAILABLE", True), \
|
||||
patch("praisonai.agents_generator.CREWAI_AVAILABLE", False), \
|
||||
patch("praisonai.agents_generator.AUTOGEN_AVAILABLE", False), \
|
||||
patch("praisonai.agents_generator.PRAISONAI_AVAILABLE", True):
|
||||
try:
|
||||
from praisonai.agents_generator import AgentsGenerator
|
||||
except ImportError as e:
|
||||
pytest.skip(f"AgentsGenerator not available: {e}")
|
||||
|
||||
gen = AgentsGenerator(
|
||||
agent_file="agents.yaml",
|
||||
framework="ag2",
|
||||
config_list=[{"model": "gpt-4o-mini", "api_key": "sk-test"}],
|
||||
)
|
||||
|
||||
gen.agent_yaml = yaml.dump({
|
||||
"framework": "ag2",
|
||||
"topic": "Test",
|
||||
"roles": {
|
||||
"agent": {
|
||||
"role": "Agent", "goal": "Help", "backstory": "Helper.",
|
||||
"tasks": {"t": {"description": "Do", "expected_output": "Done"}},
|
||||
"tools": [],
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
with patch.object(gen, "_run_autogen") as mock_autogen, \
|
||||
patch("autogen.LLMConfig", return_value=m["llm_config"]), \
|
||||
patch("autogen.AssistantAgent", return_value=m["assistant"]), \
|
||||
patch("autogen.UserProxyAgent", return_value=m["user_proxy"]), \
|
||||
patch("autogen.GroupChat", return_value=m["groupchat"]), \
|
||||
patch("autogen.GroupChatManager", return_value=m["manager"]):
|
||||
|
||||
result = gen.generate_crew_and_kickoff()
|
||||
|
||||
mock_autogen.assert_not_called()
|
||||
assert "### AG2 Output ###" in result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
Example of AG2 tool registration for use with PraisonAI's --framework ag2.
|
||||
|
||||
AG2 (community fork of AutoGen, PyPI package: ag2) installs under the
|
||||
'autogen' namespace but is a distinct package from pyautogen/pyautogen2.
|
||||
|
||||
Usage with PraisonAI:
|
||||
pip install "praisonai[ag2]"
|
||||
praisonai --framework ag2 agents.yaml
|
||||
|
||||
Standalone run:
|
||||
python tests/source/ag2_function_tools.py
|
||||
"""
|
||||
from typing import Annotated, Literal
|
||||
import os
|
||||
|
||||
# ag2 installs under the 'autogen' namespace
|
||||
from autogen import (
|
||||
AssistantAgent,
|
||||
UserProxyAgent,
|
||||
GroupChat,
|
||||
GroupChatManager,
|
||||
LLMConfig,
|
||||
)
|
||||
|
||||
Operator = Literal["+", "-", "*", "/"]
|
||||
|
||||
|
||||
def calculator(
|
||||
a: Annotated[int, "First operand"],
|
||||
b: Annotated[int, "Second operand"],
|
||||
operator: Annotated[Operator, "Arithmetic operator: +, -, *, /"],
|
||||
) -> int:
|
||||
"""Perform basic arithmetic operations."""
|
||||
if operator == "+":
|
||||
return a + b
|
||||
elif operator == "-":
|
||||
return a - b
|
||||
elif operator == "*":
|
||||
return a * b
|
||||
elif operator == "/":
|
||||
if b == 0:
|
||||
raise ValueError("Division by zero")
|
||||
return int(a / b)
|
||||
else:
|
||||
raise ValueError(f"Invalid operator: {operator}")
|
||||
|
||||
|
||||
# Build LLMConfig — AG2 uses a context manager pattern so agents
|
||||
# created inside the 'with' block automatically inherit the config.
|
||||
llm_config = LLMConfig(
|
||||
api_type="openai",
|
||||
model=os.environ.get("MODEL_NAME", "gpt-4o-mini"),
|
||||
api_key=os.environ.get("OPENAI_API_KEY"),
|
||||
)
|
||||
|
||||
# Create AssistantAgent inside llm_config context
|
||||
with llm_config:
|
||||
assistant = AssistantAgent(
|
||||
name="Calculator_Assistant",
|
||||
system_message=(
|
||||
"You are a helpful AI assistant that can perform arithmetic calculations. "
|
||||
"Use the calculator tool when math operations are needed. "
|
||||
"Return 'TERMINATE' when the task is done."
|
||||
),
|
||||
)
|
||||
|
||||
# UserProxyAgent does not use LLM — created outside context
|
||||
user_proxy = UserProxyAgent(
|
||||
name="User",
|
||||
human_input_mode="NEVER",
|
||||
is_termination_msg=lambda msg: (
|
||||
msg.get("content") is not None and "TERMINATE" in msg["content"]
|
||||
),
|
||||
code_execution_config=False,
|
||||
)
|
||||
|
||||
# AG2 tool registration pattern:
|
||||
# @agent.register_for_llm(description="...") — exposes tool schema to the LLM
|
||||
# @user_proxy.register_for_execution() — tells user_proxy to execute it
|
||||
|
||||
|
||||
@assistant.register_for_llm(description="A simple calculator for arithmetic operations.")
|
||||
@user_proxy.register_for_execution()
|
||||
def calculator_tool(
|
||||
a: Annotated[int, "First operand"],
|
||||
b: Annotated[int, "Second operand"],
|
||||
operator: Annotated[Operator, "Arithmetic operator"],
|
||||
) -> int:
|
||||
return calculator(a, b, operator)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
chat_result = user_proxy.initiate_chat(
|
||||
assistant,
|
||||
message="What is (44232 + 13312 / (232 - 32)) * 5?",
|
||||
)
|
||||
print(f"\nResult: {chat_result.summary}")
|
||||
@@ -0,0 +1,542 @@
|
||||
"""
|
||||
Unit tests for the AG2 framework integration in PraisonAI.
|
||||
|
||||
Tests cover:
|
||||
- AG2 availability detection (AG2_AVAILABLE flag)
|
||||
- Framework validation in AgentsGenerator.__init__
|
||||
- _run_ag2 method: config parsing, agent creation, tool handling
|
||||
- LLMConfig construction (OpenAI and Bedrock paths)
|
||||
|
||||
All external calls are mocked — no real LLM API calls made.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import patch, MagicMock, call
|
||||
import importlib
|
||||
|
||||
# Ensure src is on path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../"))
|
||||
|
||||
# Stub heavy dependencies that auto.py (develop branch) imports at module level
|
||||
# so that tests can import praisonai without a full installation.
|
||||
for _stub in ("instructor",):
|
||||
if _stub not in sys.modules:
|
||||
sys.modules[_stub] = MagicMock()
|
||||
|
||||
# openai is installed (required by ag2/autogen internals), but auto.py also
|
||||
# imports it at module level. Ensure it's really loaded, not a mock.
|
||||
import importlib as _importlib
|
||||
if "openai" not in sys.modules:
|
||||
try:
|
||||
_importlib.import_module("openai")
|
||||
except ImportError:
|
||||
_mock_openai = MagicMock()
|
||||
_mock_openai.__version__ = "1.0.0"
|
||||
sys.modules["openai"] = _mock_openai
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_config(roles=None, framework="ag2", topic="Test topic"):
|
||||
"""Build a minimal agents config dict."""
|
||||
if roles is None:
|
||||
roles = {
|
||||
"researcher": {
|
||||
"role": "Researcher",
|
||||
"goal": "Research things",
|
||||
"backstory": "You are a researcher.",
|
||||
"tasks": {
|
||||
"task1": {
|
||||
"description": "Research {topic}",
|
||||
"expected_output": "A research report",
|
||||
}
|
||||
},
|
||||
"tools": [],
|
||||
}
|
||||
}
|
||||
return {"framework": framework, "topic": topic, "roles": roles}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AG2_AVAILABLE flag detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAG2AvailabilityFlag:
|
||||
|
||||
def test_ag2_available_false_when_not_installed(self):
|
||||
"""AG2_AVAILABLE is False when 'ag2' distribution is not found."""
|
||||
import importlib.metadata as meta
|
||||
original_fn = meta.distribution
|
||||
|
||||
def raise_not_found(name):
|
||||
if name == 'ag2':
|
||||
raise meta.PackageNotFoundError('ag2')
|
||||
return original_fn(name)
|
||||
|
||||
# Verify detection logic directly without relying on cached module state
|
||||
ag2_detected = True
|
||||
try:
|
||||
with patch('importlib.metadata.distribution', side_effect=raise_not_found):
|
||||
meta.distribution('ag2')
|
||||
except meta.PackageNotFoundError:
|
||||
ag2_detected = False
|
||||
assert ag2_detected is False
|
||||
|
||||
def test_ag2_available_true_when_installed(self):
|
||||
"""AG2_AVAILABLE is True when 'ag2' distribution and LLMConfig are present."""
|
||||
import importlib.metadata as meta
|
||||
try:
|
||||
dist = meta.distribution('ag2')
|
||||
assert dist is not None
|
||||
from autogen import LLMConfig # noqa: F401
|
||||
except (meta.PackageNotFoundError, ImportError):
|
||||
pytest.skip("ag2 not installed — skipping")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AgentsGenerator.__init__ framework validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAgentsGeneratorAG2Validation:
|
||||
|
||||
def _make_generator(self, framework, ag2_available=True):
|
||||
"""Create AgentsGenerator with mocked availability flags."""
|
||||
with patch("praisonai.agents_generator.AG2_AVAILABLE", ag2_available), \
|
||||
patch("praisonai.agents_generator.CREWAI_AVAILABLE", False), \
|
||||
patch("praisonai.agents_generator.AUTOGEN_AVAILABLE", False), \
|
||||
patch("praisonai.agents_generator.PRAISONAI_AVAILABLE", True):
|
||||
from praisonai.agents_generator import AgentsGenerator
|
||||
return AgentsGenerator(
|
||||
agent_file="agents.yaml",
|
||||
framework=framework,
|
||||
config_list=[{"model": "gpt-4o-mini", "api_key": "test-key"}],
|
||||
)
|
||||
|
||||
def test_ag2_framework_accepted_when_available(self):
|
||||
"""No ImportError when framework='ag2' and AG2 is installed."""
|
||||
gen = self._make_generator("ag2", ag2_available=True)
|
||||
assert gen.framework == "ag2"
|
||||
|
||||
def test_ag2_framework_raises_when_not_available(self):
|
||||
"""ImportError raised with helpful message when ag2 not installed."""
|
||||
with pytest.raises(ImportError, match="AG2 is not installed"):
|
||||
self._make_generator("ag2", ag2_available=False)
|
||||
|
||||
def test_autogen_framework_unaffected(self):
|
||||
"""Existing autogen framework path still works independently."""
|
||||
with patch("praisonai.agents_generator.AG2_AVAILABLE", False), \
|
||||
patch("praisonai.agents_generator.AUTOGEN_AVAILABLE", True), \
|
||||
patch("praisonai.agents_generator.CREWAI_AVAILABLE", False), \
|
||||
patch("praisonai.agents_generator.PRAISONAI_AVAILABLE", True):
|
||||
from praisonai.agents_generator import AgentsGenerator
|
||||
gen = AgentsGenerator(
|
||||
agent_file="agents.yaml",
|
||||
framework="autogen",
|
||||
config_list=[{"model": "gpt-4o-mini", "api_key": "test-key"}],
|
||||
)
|
||||
assert gen.framework == "autogen"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _run_ag2: LLMConfig construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRunAG2LLMConfig:
|
||||
|
||||
def _make_gen_with_config(self, config_list):
|
||||
"""Build an AgentsGenerator instance with given config_list."""
|
||||
with patch("praisonai.agents_generator.AG2_AVAILABLE", True), \
|
||||
patch("praisonai.agents_generator.CREWAI_AVAILABLE", False), \
|
||||
patch("praisonai.agents_generator.AUTOGEN_AVAILABLE", False), \
|
||||
patch("praisonai.agents_generator.PRAISONAI_AVAILABLE", True):
|
||||
from praisonai.agents_generator import AgentsGenerator
|
||||
return AgentsGenerator(
|
||||
agent_file="agents.yaml",
|
||||
framework="ag2",
|
||||
config_list=config_list,
|
||||
)
|
||||
|
||||
@patch("praisonai.agents_generator.AG2_AVAILABLE", True)
|
||||
def test_openai_llm_config_constructed(self):
|
||||
"""LLMConfig is built with api_type='openai' for standard config."""
|
||||
gen = self._make_gen_with_config([
|
||||
{"model": "gpt-4o-mini", "api_key": "sk-test", "api_type": "openai"}
|
||||
])
|
||||
|
||||
mock_llm_config = MagicMock()
|
||||
mock_llm_config.__enter__ = MagicMock(return_value=mock_llm_config)
|
||||
mock_llm_config.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
mock_assistant = MagicMock()
|
||||
mock_user_proxy = MagicMock()
|
||||
mock_groupchat = MagicMock()
|
||||
mock_groupchat.messages = [{"name": "Researcher", "content": "Done. TERMINATE", "role": "assistant"}]
|
||||
mock_manager = MagicMock()
|
||||
|
||||
with patch("praisonai.agents_generator.AG2_AVAILABLE", True), \
|
||||
patch("autogen.LLMConfig", return_value=mock_llm_config) as mock_llmcfg, \
|
||||
patch("autogen.AssistantAgent", return_value=mock_assistant), \
|
||||
patch("autogen.UserProxyAgent", return_value=mock_user_proxy), \
|
||||
patch("autogen.GroupChat", return_value=mock_groupchat), \
|
||||
patch("autogen.GroupChatManager", return_value=mock_manager):
|
||||
|
||||
config = _make_config()
|
||||
gen._run_ag2(config, "Test topic", {})
|
||||
|
||||
mock_llmcfg.assert_called_once()
|
||||
# LLMConfig is called with a positional dict arg
|
||||
call_args = mock_llmcfg.call_args[0][0]
|
||||
assert call_args.get("model") == "gpt-4o-mini"
|
||||
assert call_args.get("api_key") == "sk-test"
|
||||
|
||||
@patch("praisonai.agents_generator.AG2_AVAILABLE", True)
|
||||
def test_bedrock_llm_config_constructed(self):
|
||||
"""LLMConfig uses api_type='bedrock' when config specifies bedrock."""
|
||||
gen = self._make_gen_with_config([
|
||||
{
|
||||
"model": "anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
"api_type": "bedrock",
|
||||
}
|
||||
])
|
||||
|
||||
mock_llm_config = MagicMock()
|
||||
mock_llm_config.__enter__ = MagicMock(return_value=mock_llm_config)
|
||||
mock_llm_config.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
mock_assistant = MagicMock()
|
||||
mock_user_proxy = MagicMock()
|
||||
mock_groupchat = MagicMock()
|
||||
mock_groupchat.messages = [{"name": "Agent", "content": "Report ready. TERMINATE", "role": "assistant"}]
|
||||
mock_manager = MagicMock()
|
||||
|
||||
with patch("autogen.LLMConfig", return_value=mock_llm_config) as mock_llmcfg, \
|
||||
patch("autogen.AssistantAgent", return_value=mock_assistant), \
|
||||
patch("autogen.UserProxyAgent", return_value=mock_user_proxy), \
|
||||
patch("autogen.GroupChat", return_value=mock_groupchat), \
|
||||
patch("autogen.GroupChatManager", return_value=mock_manager):
|
||||
|
||||
config = _make_config()
|
||||
gen._run_ag2(config, "AWS deployment", {})
|
||||
|
||||
mock_llmcfg.assert_called_once()
|
||||
# LLMConfig is called with a positional dict arg
|
||||
call_args = mock_llmcfg.call_args[0][0]
|
||||
assert call_args.get("api_type") == "bedrock"
|
||||
assert "api_key" not in call_args # no api_key for bedrock
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _run_ag2: agent and GroupChat creation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRunAG2AgentCreation:
|
||||
|
||||
def _make_gen(self):
|
||||
with patch("praisonai.agents_generator.AG2_AVAILABLE", True), \
|
||||
patch("praisonai.agents_generator.CREWAI_AVAILABLE", False), \
|
||||
patch("praisonai.agents_generator.AUTOGEN_AVAILABLE", False), \
|
||||
patch("praisonai.agents_generator.PRAISONAI_AVAILABLE", True):
|
||||
from praisonai.agents_generator import AgentsGenerator
|
||||
return AgentsGenerator(
|
||||
agent_file="agents.yaml",
|
||||
framework="ag2",
|
||||
config_list=[{"model": "gpt-4o-mini", "api_key": "sk-test"}],
|
||||
)
|
||||
|
||||
def test_assistant_created_per_role(self):
|
||||
"""One AssistantAgent is created for each role in the config."""
|
||||
gen = self._make_gen()
|
||||
config = _make_config(roles={
|
||||
"role_a": {
|
||||
"role": "Agent A", "goal": "Goal A", "backstory": "Backstory A",
|
||||
"tasks": {"t1": {"description": "Do A", "expected_output": "A done"}},
|
||||
"tools": [],
|
||||
},
|
||||
"role_b": {
|
||||
"role": "Agent B", "goal": "Goal B", "backstory": "Backstory B",
|
||||
"tasks": {"t2": {"description": "Do B", "expected_output": "B done"}},
|
||||
"tools": [],
|
||||
},
|
||||
})
|
||||
|
||||
mock_llm_config = MagicMock()
|
||||
mock_llm_config.__enter__ = MagicMock(return_value=mock_llm_config)
|
||||
mock_llm_config.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
created_agents = []
|
||||
|
||||
def fake_assistant(**kwargs):
|
||||
m = MagicMock()
|
||||
m.name = kwargs.get("name", "agent")
|
||||
created_agents.append(m)
|
||||
return m
|
||||
|
||||
mock_user_proxy = MagicMock()
|
||||
mock_groupchat = MagicMock()
|
||||
mock_groupchat.messages = [{"name": "Agent A", "content": "Done. TERMINATE", "role": "assistant"}]
|
||||
mock_manager = MagicMock()
|
||||
|
||||
with patch("autogen.LLMConfig", return_value=mock_llm_config), \
|
||||
patch("autogen.AssistantAgent", side_effect=fake_assistant), \
|
||||
patch("autogen.UserProxyAgent", return_value=mock_user_proxy), \
|
||||
patch("autogen.GroupChat", return_value=mock_groupchat), \
|
||||
patch("autogen.GroupChatManager", return_value=mock_manager):
|
||||
|
||||
gen._run_ag2(config, "Test", {})
|
||||
|
||||
assert len(created_agents) == 2
|
||||
|
||||
def test_groupchat_includes_user_proxy_and_assistants(self):
|
||||
"""GroupChat receives user_proxy + all assistants."""
|
||||
gen = self._make_gen()
|
||||
config = _make_config()
|
||||
|
||||
mock_llm_config = MagicMock()
|
||||
mock_llm_config.__enter__ = MagicMock(return_value=mock_llm_config)
|
||||
mock_llm_config.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
mock_assistant = MagicMock()
|
||||
mock_assistant.name = "Researcher"
|
||||
mock_user_proxy = MagicMock()
|
||||
mock_user_proxy.name = "User"
|
||||
mock_groupchat = MagicMock()
|
||||
mock_groupchat.messages = [{"name": "Researcher", "content": "Done. TERMINATE", "role": "assistant"}]
|
||||
mock_manager = MagicMock()
|
||||
|
||||
groupchat_call_args = {}
|
||||
|
||||
def capture_groupchat(**kwargs):
|
||||
groupchat_call_args.update(kwargs)
|
||||
return mock_groupchat
|
||||
|
||||
with patch("autogen.LLMConfig", return_value=mock_llm_config), \
|
||||
patch("autogen.AssistantAgent", return_value=mock_assistant), \
|
||||
patch("autogen.UserProxyAgent", return_value=mock_user_proxy), \
|
||||
patch("autogen.GroupChat", side_effect=capture_groupchat), \
|
||||
patch("autogen.GroupChatManager", return_value=mock_manager):
|
||||
|
||||
gen._run_ag2(config, "Test", {})
|
||||
|
||||
agents_in_groupchat = groupchat_call_args.get("agents", [])
|
||||
assert mock_user_proxy in agents_in_groupchat
|
||||
assert mock_assistant in agents_in_groupchat
|
||||
|
||||
def test_empty_roles_returns_no_agents_message(self):
|
||||
"""Returns a clear message when no roles are defined in config."""
|
||||
gen = self._make_gen()
|
||||
config = _make_config(roles={})
|
||||
|
||||
mock_llm_config = MagicMock()
|
||||
mock_llm_config.__enter__ = MagicMock(return_value=mock_llm_config)
|
||||
mock_llm_config.__exit__ = MagicMock(return_value=False)
|
||||
mock_user_proxy = MagicMock()
|
||||
|
||||
with patch("autogen.LLMConfig", return_value=mock_llm_config), \
|
||||
patch("autogen.AssistantAgent"), \
|
||||
patch("autogen.UserProxyAgent", return_value=mock_user_proxy):
|
||||
|
||||
result = gen._run_ag2(config, "Test", {})
|
||||
|
||||
assert "No agents" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _run_ag2: system message composition
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRunAG2SystemMessage:
|
||||
|
||||
def _make_gen(self):
|
||||
with patch("praisonai.agents_generator.AG2_AVAILABLE", True), \
|
||||
patch("praisonai.agents_generator.CREWAI_AVAILABLE", False), \
|
||||
patch("praisonai.agents_generator.AUTOGEN_AVAILABLE", False), \
|
||||
patch("praisonai.agents_generator.PRAISONAI_AVAILABLE", True):
|
||||
from praisonai.agents_generator import AgentsGenerator
|
||||
return AgentsGenerator(
|
||||
agent_file="agents.yaml",
|
||||
framework="ag2",
|
||||
config_list=[{"model": "gpt-4o-mini", "api_key": "sk-test"}],
|
||||
)
|
||||
|
||||
def test_system_message_contains_backstory(self):
|
||||
"""AssistantAgent system_message includes the backstory from YAML."""
|
||||
gen = self._make_gen()
|
||||
config = _make_config(roles={
|
||||
"agent1": {
|
||||
"role": "Expert",
|
||||
"goal": "Help users",
|
||||
"backstory": "A unique backstory for testing purposes.",
|
||||
"tasks": {"t": {"description": "Do it", "expected_output": "Done"}},
|
||||
"tools": [],
|
||||
}
|
||||
})
|
||||
|
||||
mock_llm_config = MagicMock()
|
||||
mock_llm_config.__enter__ = MagicMock(return_value=mock_llm_config)
|
||||
mock_llm_config.__exit__ = MagicMock(return_value=False)
|
||||
mock_user_proxy = MagicMock()
|
||||
mock_groupchat = MagicMock()
|
||||
mock_groupchat.messages = [{"name": "Expert", "content": "Done. TERMINATE", "role": "assistant"}]
|
||||
mock_manager = MagicMock()
|
||||
|
||||
assistant_kwargs = {}
|
||||
|
||||
def capture_assistant(**kwargs):
|
||||
assistant_kwargs.update(kwargs)
|
||||
m = MagicMock()
|
||||
m.name = kwargs.get("name", "agent")
|
||||
return m
|
||||
|
||||
with patch("autogen.LLMConfig", return_value=mock_llm_config), \
|
||||
patch("autogen.AssistantAgent", side_effect=capture_assistant), \
|
||||
patch("autogen.UserProxyAgent", return_value=mock_user_proxy), \
|
||||
patch("autogen.GroupChat", return_value=mock_groupchat), \
|
||||
patch("autogen.GroupChatManager", return_value=mock_manager):
|
||||
|
||||
gen._run_ag2(config, "Test", {})
|
||||
|
||||
assert "A unique backstory for testing purposes." in assistant_kwargs.get("system_message", "")
|
||||
assert "TERMINATE" in assistant_kwargs.get("system_message", "")
|
||||
|
||||
def test_agent_name_sanitised(self):
|
||||
"""Agent names with special characters are sanitised for AG2 compatibility."""
|
||||
gen = self._make_gen()
|
||||
config = _make_config(roles={
|
||||
"agent1": {
|
||||
"role": "AI & ML Expert (2024)", # contains special chars
|
||||
"goal": "Help",
|
||||
"backstory": "Expert.",
|
||||
"tasks": {"t": {"description": "Do it", "expected_output": "Done"}},
|
||||
"tools": [],
|
||||
}
|
||||
})
|
||||
|
||||
mock_llm_config = MagicMock()
|
||||
mock_llm_config.__enter__ = MagicMock(return_value=mock_llm_config)
|
||||
mock_llm_config.__exit__ = MagicMock(return_value=False)
|
||||
mock_user_proxy = MagicMock()
|
||||
mock_groupchat = MagicMock()
|
||||
mock_groupchat.messages = [{"name": "AI___ML_Expert__2024_", "content": "Done. TERMINATE", "role": "assistant"}]
|
||||
mock_manager = MagicMock()
|
||||
created_name = {}
|
||||
|
||||
def capture_assistant(**kwargs):
|
||||
created_name["name"] = kwargs.get("name", "")
|
||||
m = MagicMock()
|
||||
m.name = created_name["name"]
|
||||
return m
|
||||
|
||||
with patch("autogen.LLMConfig", return_value=mock_llm_config), \
|
||||
patch("autogen.AssistantAgent", side_effect=capture_assistant), \
|
||||
patch("autogen.UserProxyAgent", return_value=mock_user_proxy), \
|
||||
patch("autogen.GroupChat", return_value=mock_groupchat), \
|
||||
patch("autogen.GroupChatManager", return_value=mock_manager):
|
||||
|
||||
gen._run_ag2(config, "Test", {})
|
||||
|
||||
name = created_name.get("name", "")
|
||||
# Should not contain special chars that would break AG2
|
||||
import re
|
||||
assert re.match(r"^[a-zA-Z0-9_\-]+$", name), f"Name '{name}' contains invalid characters"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _run_ag2: output extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRunAG2OutputExtraction:
|
||||
|
||||
def _make_gen(self):
|
||||
with patch("praisonai.agents_generator.AG2_AVAILABLE", True), \
|
||||
patch("praisonai.agents_generator.CREWAI_AVAILABLE", False), \
|
||||
patch("praisonai.agents_generator.AUTOGEN_AVAILABLE", False), \
|
||||
patch("praisonai.agents_generator.PRAISONAI_AVAILABLE", True):
|
||||
from praisonai.agents_generator import AgentsGenerator
|
||||
return AgentsGenerator(
|
||||
agent_file="agents.yaml",
|
||||
framework="ag2",
|
||||
config_list=[{"model": "gpt-4o-mini", "api_key": "sk-test"}],
|
||||
)
|
||||
|
||||
def _run_with_messages(self, messages):
|
||||
gen = self._make_gen()
|
||||
config = _make_config()
|
||||
|
||||
mock_llm_config = MagicMock()
|
||||
mock_assistant = MagicMock()
|
||||
mock_assistant.name = "Researcher"
|
||||
mock_user_proxy = MagicMock()
|
||||
# chat_result with no summary so extraction falls back to messages
|
||||
mock_chat_result = MagicMock()
|
||||
mock_chat_result.summary = None
|
||||
mock_user_proxy.initiate_chat.return_value = mock_chat_result
|
||||
mock_groupchat = MagicMock()
|
||||
mock_groupchat.messages = messages
|
||||
mock_manager = MagicMock()
|
||||
|
||||
with patch("autogen.LLMConfig", return_value=mock_llm_config), \
|
||||
patch("autogen.AssistantAgent", return_value=mock_assistant), \
|
||||
patch("autogen.UserProxyAgent", return_value=mock_user_proxy), \
|
||||
patch("autogen.GroupChat", return_value=mock_groupchat), \
|
||||
patch("autogen.GroupChatManager", return_value=mock_manager):
|
||||
|
||||
return gen._run_ag2(config, "Test", {})
|
||||
|
||||
def test_output_prefixed_with_ag2_header(self):
|
||||
"""Result always starts with '### AG2 Output ###'."""
|
||||
result = self._run_with_messages([
|
||||
{"name": "Researcher", "content": "Here are my findings. TERMINATE", "role": "assistant"}
|
||||
])
|
||||
assert result.startswith("### AG2 Output ###")
|
||||
|
||||
def test_terminate_marker_stripped_from_output(self):
|
||||
"""TERMINATE keyword is removed from the extracted output."""
|
||||
result = self._run_with_messages([
|
||||
{"name": "Researcher", "content": "Detailed findings here. TERMINATE", "role": "assistant"}
|
||||
])
|
||||
assert "TERMINATE" not in result
|
||||
assert "Detailed findings here" in result
|
||||
|
||||
def test_user_messages_skipped_in_extraction(self):
|
||||
"""User proxy messages are not included in the extracted output."""
|
||||
result = self._run_with_messages([
|
||||
{"name": "User", "content": "This is the user message", "role": "user"},
|
||||
{"name": "Researcher", "content": "This is the agent response. TERMINATE", "role": "assistant"},
|
||||
])
|
||||
assert "This is the user message" not in result
|
||||
assert "This is the agent response" in result
|
||||
|
||||
def test_execution_error_returns_error_message(self):
|
||||
"""Exception during initiate_chat returns a '### AG2 Error ###' message."""
|
||||
gen = self._make_gen()
|
||||
config = _make_config()
|
||||
|
||||
mock_llm_config = MagicMock()
|
||||
mock_llm_config.__enter__ = MagicMock(return_value=mock_llm_config)
|
||||
mock_llm_config.__exit__ = MagicMock(return_value=False)
|
||||
mock_assistant = MagicMock()
|
||||
mock_assistant.name = "Researcher"
|
||||
mock_user_proxy = MagicMock()
|
||||
mock_user_proxy.initiate_chat.side_effect = RuntimeError("Connection failed")
|
||||
mock_groupchat = MagicMock()
|
||||
mock_groupchat.messages = []
|
||||
mock_manager = MagicMock()
|
||||
|
||||
with patch("autogen.LLMConfig", return_value=mock_llm_config), \
|
||||
patch("autogen.AssistantAgent", return_value=mock_assistant), \
|
||||
patch("autogen.UserProxyAgent", return_value=mock_user_proxy), \
|
||||
patch("autogen.GroupChat", return_value=mock_groupchat), \
|
||||
patch("autogen.GroupChatManager", return_value=mock_manager):
|
||||
|
||||
result = gen._run_ag2(config, "Test", {})
|
||||
|
||||
assert "### AG2 Error ###" in result
|
||||
assert "Connection failed" in result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
PoC test for CWE-78: YAML-loaded environment variables set via
|
||||
os.environ without sanitization.
|
||||
|
||||
The schedule config YAML can set arbitrary environment variables
|
||||
including dangerous ones like LD_PRELOAD, PATH, PYTHONPATH, etc.,
|
||||
which can lead to arbitrary code execution.
|
||||
|
||||
This test verifies that dangerous environment variable keys are
|
||||
rejected by the _validate_env_key function added in the fix.
|
||||
"""
|
||||
import os
|
||||
import textwrap
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
DANGEROUS_KEYS = [
|
||||
"LD_PRELOAD",
|
||||
"LD_LIBRARY_PATH",
|
||||
"LD_AUDIT",
|
||||
"DYLD_INSERT_LIBRARIES",
|
||||
"DYLD_LIBRARY_PATH",
|
||||
"DYLD_FRAMEWORK_PATH",
|
||||
"DYLD_FALLBACK_LIBRARY_PATH",
|
||||
"PATH",
|
||||
"PYTHONPATH",
|
||||
"PYTHONSTARTUP",
|
||||
"PYTHONHOME",
|
||||
"NODE_OPTIONS",
|
||||
"NODE_PATH",
|
||||
"RUBYLIB",
|
||||
"PERL5LIB",
|
||||
"PERL5OPT",
|
||||
"CLASSPATH",
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"ALL_PROXY",
|
||||
"BASH_ENV",
|
||||
"ENV",
|
||||
"CDPATH",
|
||||
"PROMPT_COMMAND",
|
||||
"SHLVL",
|
||||
"ld_preload", # lowercase variant
|
||||
"Ld_Preload", # mixed case variant
|
||||
]
|
||||
|
||||
# Generic key names that won't trigger provider-detection
|
||||
SAFE_KEYS = [
|
||||
"MODEL_NAME",
|
||||
"MY_API_KEY",
|
||||
"MY_MODEL_NAME",
|
||||
"MY_BASE_URL",
|
||||
"MY_CUSTOM_VAR",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_env():
|
||||
"""Snapshot env vars before each test and restore them afterwards."""
|
||||
# Save original values (None means the key was not set)
|
||||
_snapshot = {k: os.environ.get(k) for k in DANGEROUS_KEYS + SAFE_KEYS}
|
||||
yield
|
||||
# Restore: reinstate original values or remove if they didn't exist
|
||||
for key, original in _snapshot.items():
|
||||
if original is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = original
|
||||
|
||||
|
||||
def _get_validate_env_key():
|
||||
"""Import _validate_env_key, failing clearly if absent."""
|
||||
try:
|
||||
from praisonai.cli.main import _validate_env_key
|
||||
return _validate_env_key
|
||||
except ImportError:
|
||||
pytest.fail(
|
||||
"_validate_env_key not found in praisonai.cli.main"
|
||||
" — the env-var validation fix has not been applied"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_dangerous_env_keys_are_blocked():
|
||||
"""Dangerous keys must be rejected with a ValueError."""
|
||||
validate = _get_validate_env_key()
|
||||
|
||||
for key in DANGEROUS_KEYS:
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=r"[Nn]ot allowed|[Bb]locked|[Dd]angerous",
|
||||
):
|
||||
validate(key)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_safe_env_keys_are_allowed():
|
||||
"""Safe keys must pass validation without error."""
|
||||
validate = _get_validate_env_key()
|
||||
|
||||
for key in SAFE_KEYS:
|
||||
# Should not raise
|
||||
validate(key)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_non_string_key_rejected():
|
||||
"""Non-string YAML keys (int, None) must produce a clear ValueError."""
|
||||
validate = _get_validate_env_key()
|
||||
|
||||
for bad_key in [123, None, 3.14, True]:
|
||||
with pytest.raises(ValueError, match=r"must be a string"):
|
||||
validate(bad_key)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_vulnerability_scenario_ld_preload():
|
||||
"""LD_PRELOAD in a YAML environment section must be blocked.
|
||||
|
||||
This test simulates the schedule-config loading path: parse the YAML,
|
||||
then validate-all-then-apply (matching the fail-closed logic in main.py).
|
||||
"""
|
||||
validate = _get_validate_env_key()
|
||||
|
||||
config_content = textwrap.dedent("""\
|
||||
deployment:
|
||||
schedule: "*/5 * * * *"
|
||||
provider: gcp
|
||||
environment:
|
||||
LD_PRELOAD: /tmp/evil.so
|
||||
MODEL_NAME: test-model
|
||||
""")
|
||||
|
||||
file_config = yaml.safe_load(config_content)
|
||||
env_vars = file_config.get("environment", {})
|
||||
|
||||
# Simulate the validate-all-then-apply pattern from main.py
|
||||
with pytest.raises(ValueError):
|
||||
validated_env = {}
|
||||
for key, value in env_vars.items():
|
||||
validate(key)
|
||||
validated_env[key] = str(value)
|
||||
# If we reach here, no key was blocked — the test fails via pytest.raises
|
||||
os.environ.update(validated_env)
|
||||
|
||||
# LD_PRELOAD must NOT have been applied (fail-closed)
|
||||
assert "LD_PRELOAD" not in os.environ or os.environ.get("LD_PRELOAD") != "/tmp/evil.so"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_malformed_env_keys_rejected():
|
||||
"""Empty strings, keys with '=', and keys with NUL must be rejected."""
|
||||
validate = _get_validate_env_key()
|
||||
|
||||
for bad_key in ["", "A=B", "FOO\x00BAR"]:
|
||||
with pytest.raises(ValueError, match=r"non-empty string without"):
|
||||
validate(bad_key)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_non_dict_root_config_rejected():
|
||||
"""A YAML schedule config whose root is not a mapping must be rejected.
|
||||
|
||||
This validates the root type-check added after yaml.safe_load().
|
||||
When the root config is a list or scalar, earlier .get() calls would
|
||||
raise AttributeError; after the fix they raise ValueError, which is
|
||||
caught and presented cleanly.
|
||||
"""
|
||||
import yaml as _yaml
|
||||
|
||||
for bad_doc in ["- item1\n- item2", "42", "just a string"]:
|
||||
file_config = _yaml.safe_load(bad_doc)
|
||||
# The check from main.py: if not isinstance(file_config, dict)
|
||||
assert not isinstance(file_config, dict), (
|
||||
f"Expected non-dict for {bad_doc!r}, got {type(file_config)}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_non_dict_deployment_rejected():
|
||||
"""A 'deployment' value that is not a mapping must be caught."""
|
||||
import yaml as _yaml
|
||||
|
||||
bad_config = _yaml.safe_load("deployment: not-a-mapping\nenvironment:\n MODEL: x")
|
||||
deploy_config = bad_config.get("deployment", {})
|
||||
assert not isinstance(deploy_config, dict), (
|
||||
f"Expected non-dict deployment, got {type(deploy_config)}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -52,9 +52,13 @@ def test_memory_config():
|
||||
}
|
||||
}
|
||||
|
||||
# Patch the chromadb cache to indicate it's available
|
||||
original_chromadb_cache = memory_module._chromadb_cache.copy()
|
||||
memory_module._chromadb_cache["available"] = True
|
||||
# Patch the module cache to indicate chromadb is available
|
||||
original_module_cache = memory_module._module_cache.copy()
|
||||
memory_module._module_cache["chromadb"] = {
|
||||
"available": True,
|
||||
"module": MagicMock(),
|
||||
"settings": MagicMock()
|
||||
}
|
||||
|
||||
try:
|
||||
with patch('chromadb.PersistentClient') as mock_chroma:
|
||||
@@ -71,7 +75,8 @@ def test_memory_config():
|
||||
memory = Memory(config=basic_config, verbose=1)
|
||||
print("✅ Basic memory configuration works")
|
||||
finally:
|
||||
memory_module._chromadb_cache.update(original_chromadb_cache)
|
||||
memory_module._module_cache.clear()
|
||||
memory_module._module_cache.update(original_module_cache)
|
||||
|
||||
# Test mem0 configuration with mocking
|
||||
# Skip this test as it requires network access to mem0 API
|
||||
|
||||
Generated
+51
-6
@@ -17,6 +17,27 @@ overrides = [
|
||||
{ name = "neonize", marker = "sys_platform == 'never'" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ag2"
|
||||
version = "0.11.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "diskcache" },
|
||||
{ name = "docker" },
|
||||
{ name = "fast-depends", extra = ["pydantic"] },
|
||||
{ name = "httpx" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "termcolor" },
|
||||
{ name = "tiktoken" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/77/20/46159b56f9c6069ea2bc174f3ce3597febc4f4bbf467c2c1e003c84f0da7/ag2-0.11.4.tar.gz", hash = "sha256:1949d27e889a908a258ad4bcda4bfb33770763a2e8f9ea2da13eec342da8f493", size = 3818369, upload-time = "2026-03-17T23:47:50.308Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/14/0d226e5fbb02949ae1ded5bcaa695a1ca11bb9535c71b53dfbc6ff2c4b5e/ag2-0.11.4-py3-none-any.whl", hash = "sha256:182805d940271bd9cfea2a40f5dc1f3db351745b07135efdb015a7f0c0681fd4", size = 1080405, upload-time = "2026-03-17T23:47:48.15Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "agent-client-protocol"
|
||||
version = "0.7.1"
|
||||
@@ -1339,6 +1360,24 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/37/b3ea9cd5558ff4cb51957caca2193981c6b0ff30bd0d2630ac62505d99d0/fake_useragent-2.2.0-py3-none-any.whl", hash = "sha256:67f35ca4d847b0d298187443aaf020413746e56acd985a611908c73dba2daa24", size = 161695, upload-time = "2025-04-14T15:32:17.732Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fast-depends"
|
||||
version = "3.0.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/6d/787a21ca8043a8fdb737cf28f645e94a46fc30b44a31de54573299156bad/fast_depends-3.0.8.tar.gz", hash = "sha256:896b16f79a512b6ea1df721b0aa1708a192a06f964be6597e01fcf5412559101", size = 18382, upload-time = "2026-03-02T19:54:28.649Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/1d/e4843e4eeb65f51447b8c22d200d12d8f94f27c97e77bb7162515cc8d61f/fast_depends-3.0.8-py3-none-any.whl", hash = "sha256:4c52c8a3907bca46d43e70e4364d6d016872d9a3aae4bc0c1c85e72e0a6a21c7", size = 25507, upload-time = "2026-03-02T19:54:27.594Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
pydantic = [
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.128.0"
|
||||
@@ -4726,6 +4765,10 @@ dependencies = [
|
||||
acp = [
|
||||
{ name = "agent-client-protocol" },
|
||||
]
|
||||
ag2 = [
|
||||
{ name = "ag2" },
|
||||
{ name = "praisonai-tools" },
|
||||
]
|
||||
agentops = [
|
||||
{ name = "agentops" },
|
||||
]
|
||||
@@ -4872,6 +4915,7 @@ ui = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "ag2", marker = "extra == 'ag2'", specifier = ">=0.11.0" },
|
||||
{ name = "agent-client-protocol", marker = "extra == 'acp'", specifier = ">=0.7.0" },
|
||||
{ name = "agentops", marker = "extra == 'agentops'", specifier = ">=0.3.12" },
|
||||
{ name = "aiohttp", marker = "extra == 'bot'", specifier = ">=3.8.0" },
|
||||
@@ -4925,7 +4969,7 @@ requires-dist = [
|
||||
{ name = "greenlet", marker = "extra == 'code'", specifier = ">=3.0.3" },
|
||||
{ name = "greenlet", marker = "extra == 'realtime'", specifier = ">=3.0.3" },
|
||||
{ name = "greenlet", marker = "extra == 'ui'", specifier = ">=3.0.3" },
|
||||
{ name = "litellm", specifier = ">=1.81.0" },
|
||||
{ name = "litellm", specifier = ">=1.81.0,<=1.82.6" },
|
||||
{ name = "markdown", specifier = ">=3.5" },
|
||||
{ name = "mcp", specifier = ">=1.20.0" },
|
||||
{ name = "neonize", marker = "extra == 'bot-whatsapp-web'", specifier = ">=0.3.14" },
|
||||
@@ -4936,10 +4980,11 @@ requires-dist = [
|
||||
{ name = "playwright", marker = "extra == 'realtime'", specifier = ">=1.47.0" },
|
||||
{ name = "plotly", marker = "extra == 'all'", specifier = ">=5.24.0" },
|
||||
{ name = "plotly", marker = "extra == 'realtime'", specifier = ">=5.24.0" },
|
||||
{ name = "praisonai-tools", marker = "extra == 'ag2'", specifier = ">=0.1.0" },
|
||||
{ name = "praisonai-tools", marker = "extra == 'autogen'", specifier = ">=0.1.0" },
|
||||
{ name = "praisonai-tools", marker = "extra == 'autogen-v4'", specifier = ">=0.1.0" },
|
||||
{ name = "praisonai-tools", marker = "extra == 'crewai'", specifier = ">=0.1.0" },
|
||||
{ name = "praisonaiagents", specifier = ">=1.5.80" },
|
||||
{ name = "praisonaiagents", specifier = ">=1.5.87" },
|
||||
{ name = "praisonaiagents", extras = ["all"], marker = "extra == 'claw'", specifier = ">=1.5.40" },
|
||||
{ name = "psutil", marker = "extra == 'all'", specifier = ">=5.9.0" },
|
||||
{ name = "psutil", marker = "extra == 'bot'", specifier = ">=5.9.0" },
|
||||
@@ -4993,7 +5038,7 @@ requires-dist = [
|
||||
{ name = "yfinance", marker = "extra == 'all'", specifier = ">=0.2.44" },
|
||||
{ name = "yfinance", marker = "extra == 'realtime'", specifier = ">=0.2.44" },
|
||||
]
|
||||
provides-extras = ["ui", "gradio", "api", "os", "agentops", "chat", "code", "realtime", "call", "train", "crewai", "autogen", "autogen-v4", "acp", "lite", "all", "bot", "bot-whatsapp-web", "claw", "dev"]
|
||||
provides-extras = ["ui", "gradio", "api", "os", "agentops", "chat", "code", "realtime", "call", "train", "crewai", "autogen", "ag2", "autogen-v4", "acp", "lite", "all", "bot", "bot-whatsapp-web", "claw", "dev"]
|
||||
|
||||
[[package]]
|
||||
name = "praisonai-tools"
|
||||
@@ -5009,7 +5054,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.5.80"
|
||||
version = "1.5.87"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -5018,9 +5063,9 @@ dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "rich" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6e/3b/6c88188227bac16f88e7b141568cebb03d6ef46f38935e3bb8408a400017/praisonaiagents-1.5.80.tar.gz", hash = "sha256:fa0b803943e5aab8ce4099c8257e8f5378648a19668ec046749aa3babad99d57", size = 1204298, upload-time = "2026-03-18T21:06:55.71Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/d1/78933b4dd5529a7b7e9b15d5b2f012d550cd6b29071b94a6145347373fdf/praisonaiagents-1.5.87.tar.gz", hash = "sha256:3ef6aaa1b1e2af54e2c7ffbcff00dd49674426393f3ead26f6cf983a99456966", size = 1216498, upload-time = "2026-03-30T20:16:07.46Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/76/ab37e313ad3e756c39df8f5b180613169ff8074a293aaba52fbdef88928e/praisonaiagents-1.5.80-py3-none-any.whl", hash = "sha256:e1590e6bbbda526e13d3654ab17cab6a2a602af4508fabe7a8cd389213658efe", size = 1248857, upload-time = "2026-03-18T21:06:53.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/b2/9f10d17d5d6f6e3ddf1ff7cd562d75afd2e4158feb376760028f62da9ea6/praisonaiagents-1.5.87-py3-none-any.whl", hash = "sha256:8bb315fbd0f92a64236eb31b82c78630f4dbc3066dca4c06fdd62619122760da", size = 1264793, upload-time = "2026-03-30T20:16:05.8Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
|
||||
Reference in new issue
Block a user