Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06eb6b197c | ||
|
|
d476379f45 | ||
|
|
d1034adc19 | ||
|
|
ee8d0cf4c3 | ||
|
|
acad479b19 | ||
|
|
1eb22d74e9 | ||
|
|
381c7448ea | ||
|
|
13c984d1aa | ||
|
|
b04513493e | ||
|
|
3a209d7c36 | ||
|
|
eb15d66e96 | ||
|
|
54ddbaf298 | ||
|
|
18b7dc1687 | ||
|
|
10869d7570 | ||
|
|
c796f75b41 | ||
|
|
79cda2604b | ||
|
|
7659748515 | ||
|
|
aa092c2c3f | ||
|
|
dc69cbc121 | ||
|
|
60121929d0 | ||
|
|
6646dd1a4c | ||
|
|
ba3a3c3ee0 | ||
|
|
b965bb1011 | ||
|
|
4d13ab4af7 | ||
|
|
84c4d221a2 | ||
|
|
64ae40e369 | ||
|
|
0f5849b734 | ||
|
|
3a2a2f8592 | ||
|
|
3193d19a5c | ||
|
|
3468f7743f | ||
|
|
a5dc5fae0e | ||
|
|
ffd78f0eb6 | ||
|
|
727967f459 | ||
|
|
9f45a221e6 | ||
|
|
3924bcea86 | ||
|
|
8dc3136af8 | ||
|
|
0e80a58ccd | ||
|
|
751f9b6f77 | ||
|
|
cec725f7ee | ||
|
|
4804a4d7ee | ||
|
|
8fa99cea83 | ||
|
|
08c40f17d8 | ||
|
|
6120623e21 | ||
|
|
9b365e6594 | ||
|
|
804cacbda4 | ||
|
|
4fe4c27516 | ||
|
|
1af11908de | ||
|
|
c634969263 | ||
|
|
80b10518dc | ||
|
|
259705e230 | ||
|
|
dd41c1ba01 | ||
|
|
e32cccf62a | ||
|
|
de668f1912 | ||
|
|
cfe8277638 | ||
|
|
a8a47c7b7f | ||
|
|
8a803888ea | ||
|
|
e33710e63a | ||
|
|
7f2976c1ff | ||
|
|
0c8ff4b1ee | ||
|
|
d143cce2f9 | ||
|
|
1260ede088 | ||
|
|
2a463d6b39 | ||
|
|
10068583bb | ||
|
|
9bb149a244 | ||
|
|
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:
|
||||
|
||||
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 429 B After Width: | Height: | Size: 429 B |
|
Before Width: | Height: | Size: 872 B After Width: | Height: | Size: 872 B |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 167 KiB |
@@ -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.GITHUB_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.GITHUB_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.GITHUB_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'
|
||||
# })
|
||||
@@ -20,4 +20,4 @@ jobs:
|
||||
uses: ./.github/actions/claude-issue-triage-action
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
github_token: ${{ secrets.PAT_TOKEN }}
|
||||
@@ -6,18 +6,23 @@ 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: |
|
||||
(github.event.action != 'labeled' || github.event.label.name == 'claude') &&
|
||||
(
|
||||
!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 +37,42 @@ jobs:
|
||||
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_ACCESS_TOKEN }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ secrets.PAT_TOKEN }}
|
||||
trigger_phrase: "@claude"
|
||||
label_trigger: "claude"
|
||||
direct_prompt: |
|
||||
You are working on the PraisonAI SDK. Follow AGENTS.md strictly.
|
||||
|
||||
STEP 0 — SETUP GIT IDENTITY:
|
||||
git config user.name "MervinPraison"
|
||||
git config user.email "[email protected]"
|
||||
|
||||
STEP 1 — READ GUIDELINES:
|
||||
Read AGENTS.md to understand the architecture rules.
|
||||
|
||||
STEP 2 — ARCHITECTURE VALIDATION (MANDATORY before writing code):
|
||||
Before implementing anything, answer these questions:
|
||||
- Does this add heavy implementations to the core SDK (praisonaiagents)? If YES → move it to the wrapper (praisonai) or make it a plugin.
|
||||
- Does it duplicate existing functionality? Check if Agent already supports this via existing params (reflection, planning, tools, hooks, memory).
|
||||
- Does it inherit from Agent properly? New agent types MUST inherit Agent, not wrap it with composition.
|
||||
- Does it add new dependencies? Only optional deps allowed, must be lazy-imported.
|
||||
- Will agent.py grow larger? If the change adds >50 lines to agent.py, find a way to extract instead.
|
||||
- Is there a name collision with existing exports in __init__.py?
|
||||
If ANY of these checks fail, add a comment to the issue explaining why and close it. Do NOT create a PR.
|
||||
|
||||
STEP 3 — IMPLEMENT:
|
||||
- Create a fix branch and implement a minimal, focused fix
|
||||
- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper
|
||||
- Keep changes small and backward-compatible
|
||||
|
||||
STEP 4 — TEST:
|
||||
- Run: cd src/praisonai-agents && PYTHONPATH=. python -m pytest tests/ -x -q --timeout=30
|
||||
- Ensure no regressions
|
||||
|
||||
STEP 5 — CREATE PR:
|
||||
- Commit with descriptive message, push, and create 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 +93,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
|
||||
|
||||
@@ -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: 20
|
||||
|
||||
- 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,28 @@ 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
|
||||
if: always()
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
file: src/praisonai/coverage.xml
|
||||
token: ${{ secrets.CODECOV_TOKEN || '' }}
|
||||
files: 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 * * *'
|
||||
@@ -149,8 +157,8 @@ jobs:
|
||||
if: matrix.python-version == '3.11'
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
file: src/praisonai/coverage.xml
|
||||
token: ${{ secrets.CODECOV_TOKEN || '' }}
|
||||
files: src/praisonai/coverage.xml
|
||||
flags: main-tests
|
||||
fail_ci_if_error: false
|
||||
|
||||
@@ -293,8 +301,10 @@ jobs:
|
||||
steps:
|
||||
- name: Check if Google key is available
|
||||
id: check-key
|
||||
env:
|
||||
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
run: |
|
||||
if [ -n "${{ secrets.GOOGLE_API_KEY }}" ]; then
|
||||
if [ -n "$GOOGLE_API_KEY" ]; then
|
||||
echo "has_key=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has_key=false" >> $GITHUB_OUTPUT
|
||||
@@ -348,8 +358,10 @@ jobs:
|
||||
steps:
|
||||
- name: Check if Groq key is available
|
||||
id: check-key
|
||||
env:
|
||||
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
run: |
|
||||
if [ -n "${{ secrets.GROQ_API_KEY }}" ]; then
|
||||
if [ -n "$GROQ_API_KEY" ]; then
|
||||
echo "has_key=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has_key=false" >> $GITHUB_OUTPUT
|
||||
@@ -403,8 +415,10 @@ jobs:
|
||||
steps:
|
||||
- name: Check if xAI key is available
|
||||
id: check-key
|
||||
env:
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
run: |
|
||||
if [ -n "${{ secrets.XAI_API_KEY }}" ]; then
|
||||
if [ -n "$XAI_API_KEY" ]; then
|
||||
echo "has_key=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has_key=false" >> $GITHUB_OUTPUT
|
||||
@@ -458,8 +472,10 @@ jobs:
|
||||
steps:
|
||||
- name: Check if Cohere key is available
|
||||
id: check-key
|
||||
env:
|
||||
COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }}
|
||||
run: |
|
||||
if [ -n "${{ secrets.COHERE_API_KEY }}" ]; then
|
||||
if [ -n "$COHERE_API_KEY" ]; then
|
||||
echo "has_key=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has_key=false" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -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>"
|
||||
@@ -27,11 +27,13 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Checkout PraisonAIDocs
|
||||
id: checkout_docs
|
||||
continue-on-error: true
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: MervinPraison/PraisonAIDocs
|
||||
path: PraisonAIDocs
|
||||
token: ${{ secrets.DOCS_PAT }}
|
||||
token: ${{ secrets.SECRETS_ADMIN_PAT }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
@@ -43,6 +45,7 @@ jobs:
|
||||
pip install pyyaml
|
||||
|
||||
- name: Generate docs parity trackers
|
||||
if: steps.checkout_docs.outcome == 'success'
|
||||
run: |
|
||||
cd $GITHUB_WORKSPACE
|
||||
python3 src/praisonai/scripts/generate_docs_parity.py --docs-root PraisonAIDocs/docs --copy-docs
|
||||
@@ -65,16 +68,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'
|
||||
if: steps.check_changes.outputs.changes == 'true' && steps.checkout_docs.outcome == 'success'
|
||||
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
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="src/praisonai/praisonai/public/logo_dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="src/praisonai/praisonai/public/logo_light.png" />
|
||||
<img alt="PraisonAI Logo" src="src/praisonai/praisonai/public/logo_light.png" width="250" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset=".github/images/logo_dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset=".github/images/logo_light.png" />
|
||||
<img alt="PraisonAI Logo" src=".github/images/logo_light.png" width="250" />
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
@@ -26,7 +26,11 @@
|
||||
PraisonAI 🦞 — Automate and solve complex challenges with AI agent teams that plan, research, code, and deliver results to Telegram, Discord, and WhatsApp — running 24/7. A low-code, production-ready multi-agent framework with handoffs, guardrails, memory, RAG, and 100+ LLM providers, built around simplicity, customisation, and effective human-agent collaboration.
|
||||
|
||||
<p align="center">
|
||||
<img src="src/praisonai/praisonai/public/dashboard.png" alt="PraisonAI Dashboard" width="800" />
|
||||
<img src=".github/images/dashboard.png" alt="PraisonAI Dashboard" width="800" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/images/latest_ai_news_and_crawl_each_url_to_find_info.gif" alt="PraisonAI command execution" width="800" />
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
@@ -43,8 +47,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 +333,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 |
|
||||
|
||||
@@ -30,32 +30,33 @@ Methods:
|
||||
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">achat</a>(prompt: str, temperature = 1.0, tools = None, output_json = None, output_pydantic = None, reasoning_steps = False, task_name = None, task_description = None, task_id = None, attachments = None)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">aexecute</a>(task, context = None)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">agent_id</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">agent_id</a>() -> str</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">analyze_prompt</a>(prompt: str) -> set</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">arun</a>(prompt: str, **kwargs)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">as_tool</a>(description: Optional[str] = None, tool_name: Optional[str] = None) -> 'Handoff'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">astart</a>(prompt: str, **kwargs)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">auto_memory</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">auto_memory</a>(value)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">background</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">background</a>(value)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">chat</a>(prompt, temperature = 1.0, tools = None, output_json = None, output_pydantic = None, reasoning_steps = False, stream = None, task_name = None, task_description = None, task_id = None, config = None, force_retrieval = False, skip_retrieval = False, attachments = None, tool_choice = None)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">auto_memory</a>() -> Optional[bool]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">auto_memory</a>(value: Optional[bool]) -> None</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">background</a>() -> Optional[bool]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">background</a>(value: Optional[bool]) -> None</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">chat</a>(prompt: str, temperature: float = 1.0, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None) -> Optional[str]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">chat_with_context</a>(message: str, context: 'ContextPack', **kwargs) -> str</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">check_pending_approvals</a>() -> Dict[str, Any]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">checkpoints</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">checkpoints</a>(value)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">checkpoints</a>() -> Optional[bool]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">checkpoints</a>(value: Optional[bool]) -> None</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">clean_json_output</a>(output: str) -> str</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">clear_history</a>()</code>
|
||||
* <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">console</a>() -> Optional[Any]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">context_manager</a>() -> Optional[Any]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">context_manager</a>(value)</code>
|
||||
* <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>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">display_name</a>() -> str</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">ephemeral</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">execute</a>(task, context = None)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">execute_tool</a>(function_name, arguments, tool_call_id = None)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">ephemeral</a>() -> Generator[None, None, None]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">execute</a>(task: Any, context: Optional[Any] = None) -> Optional[str]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">execute_tool</a>(function_name: str, arguments: Dict[str, Any], tool_call_id: Optional[str] = None) -> Any</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">execute_tool_async</a>(function_name: str, arguments: Dict[str, Any]) -> Any</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">from_template</a>(uri: str, config: Optional[Dict[str, Any]] = None, offline: bool = False, **kwargs) -> 'Agent'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">generate_task</a>() -> 'Task'</code>
|
||||
@@ -70,35 +71,36 @@ Methods:
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">handoff_to_async</a>(target_agent: 'Agent', prompt: str, context: Optional[Dict[str, Any]] = None, config: Optional['HandoffConfig'] = None) -> 'HandoffResult'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">iter_stream</a>(prompt: str, **kwargs)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">launch</a>(path: str = '/', port: int = 8000, host: str = '0.0.0.0', debug: bool = False, protocol: str = 'http')</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">llm_model</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">output_style</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">output_style</a>(value)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">llm_model</a>() -> Optional[str]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">output_style</a>() -> Optional[str]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">output_style</a>(value: Optional[str]) -> None</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">pending_approval_count</a>() -> int</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">policy</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">policy</a>(value)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">policy</a>() -> Optional[Any]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">policy</a>(value: Optional[Any]) -> None</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">prune_history</a>(keep_last: int = 5) -> int</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">query</a>(question: str, **kwargs) -> 'RAGResult'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">rag</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">rag</a>() -> Optional[Any]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">rag_query</a>(question: str, **kwargs) -> 'RAGResult'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">redo</a>() -> bool</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">retrieval_config</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">retrieval_config</a>() -> Optional[Any]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">retrieve</a>(query: str, **kwargs) -> 'ContextPack'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">rules_manager</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">run</a>(prompt: str, **kwargs)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">rules_manager</a>() -> Optional[Any]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">run</a>(prompt: str, **kwargs: Any) -> Optional[str]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">run_autonomous</a>(prompt: str, max_iterations: Optional[int] = None, timeout_seconds: Optional[float] = None, completion_promise: Optional[str] = None, clear_context: bool = False)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">run_autonomous_async</a>(prompt: str, max_iterations: Optional[int] = None, timeout_seconds: Optional[float] = None, completion_promise: Optional[str] = None, clear_context: bool = False)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">run_until</a>(prompt: str, criteria: str, threshold: float = 8.0, max_iterations: int = 5, mode: str = 'optimize', on_iteration: Optional[Callable[[Any], None]] = None, verbose: bool = False) -> 'EvaluationLoopResult'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">run_until_async</a>(prompt: str, criteria: str, threshold: float = 8.0, max_iterations: int = 5, mode: str = 'optimize', on_iteration: Optional[Callable[[Any], None]] = None, verbose: bool = False) -> 'EvaluationLoopResult'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">session_id</a>() -> Optional[str]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">skill_manager</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">start</a>(prompt: str = None, **kwargs)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">store_memory</a>(content: str, memory_type: str = 'short_term', **kwargs)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">stream_emitter</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">stream_emitter</a>(value)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">skill_manager</a>() -> Optional[Any]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">start</a>(prompt: Optional[str] = None, **kwargs: Any) -> Union[str, Generator[str, None, None], None]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">store_memory</a>(content: str, memory_type: str = 'short_term', **kwargs: Any) -> None</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">stream_emitter</a>() -> Optional[Any]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">stream_emitter</a>(value: Optional[Any]) -> None</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">submit_for_approval</a>(function_name: str, arguments: Dict[str, Any]) -> str</code>
|
||||
* <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">thinking_budget</a>() -> Optional[int]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">thinking_budget</a>(value: Optional[int]) -> None</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)
|
||||
@@ -812,6 +813,8 @@ Methods:
|
||||
* <code title="cli">praisonai completion completion-callback <a href="./src/praisonai/praisonai/cli/commands/completion.py">--help</a></code>
|
||||
* <code title="cli">praisonai completion fish <a href="./src/praisonai/praisonai/cli/commands/completion.py">--help</a></code>
|
||||
* <code title="cli">praisonai completion zsh <a href="./src/praisonai/praisonai/cli/commands/completion.py">--help</a></code>
|
||||
* <code title="cli">praisonai config doctor <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
|
||||
* <code title="cli">praisonai config env <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
|
||||
* <code title="cli">praisonai config get <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
|
||||
* <code title="cli">praisonai config list <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
|
||||
* <code title="cli">praisonai config path <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
|
||||
@@ -871,6 +874,8 @@ Methods:
|
||||
* <code title="cli">praisonai examples run <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
|
||||
* <code title="cli">praisonai examples run-all <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
|
||||
* <code title="cli">praisonai examples stats <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
|
||||
* <code title="cli">praisonai flow flow-start <a href="./src/praisonai/praisonai/cli/commands/flow.py">--help</a></code>
|
||||
* <code title="cli">praisonai flow version <a href="./src/praisonai/praisonai/cli/commands/flow.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway channels <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway gateway-callback <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway send <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
@@ -1200,7 +1205,7 @@ export { // Python parity additions
|
||||
export { // Subagent Tool (agent-as-tool pattern)
|
||||
SubagentTool, BaseTool, FunctionTool, ToolRegistry, ToolResult, ToolValidationError, createDelegator, createSubagentTool, createSubagentTools, createTool, getRegistry, getTool, registerTool, tool, validateTool } from "./tools";
|
||||
export { airweaveSearch, bedrockBrowserClick, bedrockBrowserFill, bedrockBrowserNavigate, bedrockCodeInterpreter, codeExecution, codeMode, createCustomTool, exaSearch, firecrawlCrawl, firecrawlScrape, parallelSearch, perplexitySearch, registerCustomTool, registerLocalTool, registerNpmTool, superagentGuard, superagentRedact, superagentVerify, tavilyCrawl, tavilyExtract, tavilySearch, valyuBioSearch, valyuCompanyResearch, valyuEconomicsSearch, valyuFinanceSearch, valyuPaperSearch, valyuPatentSearch, valyuSecSearch, valyuWebSearch } from "./tools/builtins";
|
||||
export { MissingDependencyError, MissingEnvVarError, ToolsRegistry, composeMiddleware, createLoggingMiddleware, createRateLimitMiddleware, createRedactionMiddleware, createRetryMiddleware, createTimeoutMiddleware, createToolsRegistry, createTracingMiddleware, createValidationMiddleware, getToolsRegistry, resetToolsRegistry } from "./tools/registry";
|
||||
export { BudgetExceededError, MissingDependencyError, MissingEnvVarError, ToolsRegistry, composeMiddleware, createLoggingMiddleware, createRateLimitMiddleware, createRedactionMiddleware, createRetryMiddleware, createTimeoutMiddleware, createToolsRegistry, createTracingMiddleware, createValidationMiddleware, getRegistry, getTool, getToolsRegistry, get_registry, get_tool, registerTool, register_tool, resetToolsRegistry, validateTool, validate_tool } from "./tools/registry";
|
||||
export type { InstallHints, PraisonTool, RedactionHooks, RegisteredTool, ToolCapabilities, ToolExecutionContext, ToolExecutionResult, ToolFactory, ToolHooks, ToolInstallStatus, ToolLimits, ToolLogger, ToolMetadata, ToolMiddleware, ToolParameterProperty, ToolParameterSchema } from "./tools/registry";
|
||||
export { registerBuiltinTools, tools } from "./tools/tools";
|
||||
export { // Classes
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.5.80" \
|
||||
"praisonai>=4.5.97" \
|
||||
"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.97" \
|
||||
"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.97" \
|
||||
"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';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Social Media Cross-Posting Agent Example.
|
||||
|
||||
Demonstrates using PraisonAI Agent with all social media tools
|
||||
to cross-post content across multiple platforms.
|
||||
|
||||
Required environment variables (set only the ones you need):
|
||||
# X (Twitter)
|
||||
X_API_KEY, X_API_SECRET, X_ACCESS_TOKEN, X_ACCESS_SECRET
|
||||
|
||||
# LinkedIn
|
||||
LINKEDIN_ACCESS_TOKEN, LINKEDIN_PERSON_URN
|
||||
|
||||
# Facebook
|
||||
FACEBOOK_PAGE_ACCESS_TOKEN, FACEBOOK_PAGE_ID
|
||||
|
||||
# Instagram
|
||||
INSTAGRAM_ACCESS_TOKEN, INSTAGRAM_BUSINESS_ACCOUNT_ID
|
||||
|
||||
# TikTok
|
||||
TIKTOK_ACCESS_TOKEN
|
||||
|
||||
# Threads
|
||||
THREADS_ACCESS_TOKEN, THREADS_USER_ID
|
||||
|
||||
# Pinterest
|
||||
PINTEREST_ACCESS_TOKEN
|
||||
|
||||
# Medium
|
||||
MEDIUM_TOKEN
|
||||
|
||||
# WordPress (uses praisonaiwp-cli config — no env vars needed)
|
||||
|
||||
# OpenAI (for the agent)
|
||||
OPENAI_API_KEY
|
||||
|
||||
Usage:
|
||||
python social_media_crosspost.py
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent
|
||||
|
||||
from praisonai_tools import (
|
||||
# X (Twitter)
|
||||
post_to_x,
|
||||
# LinkedIn
|
||||
linkedin_post_text,
|
||||
linkedin_post_image,
|
||||
linkedin_post_article,
|
||||
# Facebook
|
||||
facebook_post_text,
|
||||
facebook_post_image,
|
||||
facebook_post_link,
|
||||
# Instagram
|
||||
instagram_post_image,
|
||||
instagram_post_reel,
|
||||
# TikTok
|
||||
tiktok_post_video,
|
||||
tiktok_post_photo,
|
||||
# Threads
|
||||
threads_post_text,
|
||||
threads_post_image,
|
||||
# Pinterest
|
||||
pinterest_create_pin,
|
||||
pinterest_list_boards,
|
||||
# Medium
|
||||
medium_publish_post,
|
||||
# WordPress
|
||||
create_wp_post,
|
||||
check_wp_duplicate,
|
||||
)
|
||||
|
||||
# Create a social media manager agent
|
||||
agent = Agent(
|
||||
name="Social Media Manager",
|
||||
instructions="""You are a social media manager. You help users cross-post content
|
||||
across multiple platforms including X (Twitter), LinkedIn, Facebook, Instagram,
|
||||
TikTok, Threads, Pinterest, Medium, and WordPress.
|
||||
|
||||
When asked to cross-post:
|
||||
1. Adapt the content format for each platform (character limits, hashtags, etc.)
|
||||
2. Use the appropriate tool for each platform
|
||||
3. Report which platforms succeeded and which had errors
|
||||
|
||||
Platform-specific notes:
|
||||
- X: 280 char limit
|
||||
- LinkedIn: 3000 char limit, professional tone
|
||||
- Facebook: No strict limit, supports links
|
||||
- Instagram: Needs image URL (public URL), max 2200 chars caption
|
||||
- TikTok: Video/photo only, 150 char title
|
||||
- Threads: 500 char limit
|
||||
- Pinterest: Needs board_id and image URL
|
||||
- Medium: Full article support (markdown/HTML), default to 'draft' status
|
||||
- WordPress: Full article support (HTML/Gutenberg blocks), check duplicates first
|
||||
""",
|
||||
tools=[
|
||||
post_to_x,
|
||||
linkedin_post_text,
|
||||
linkedin_post_image,
|
||||
linkedin_post_article,
|
||||
facebook_post_text,
|
||||
facebook_post_image,
|
||||
facebook_post_link,
|
||||
instagram_post_image,
|
||||
instagram_post_reel,
|
||||
tiktok_post_video,
|
||||
tiktok_post_photo,
|
||||
threads_post_text,
|
||||
threads_post_image,
|
||||
pinterest_create_pin,
|
||||
pinterest_list_boards,
|
||||
medium_publish_post,
|
||||
create_wp_post,
|
||||
check_wp_duplicate,
|
||||
],
|
||||
)
|
||||
|
||||
# Example: Post to all text-based platforms
|
||||
result = agent.start(
|
||||
"Post this announcement to LinkedIn and Threads: "
|
||||
"'PraisonAI v2.0 is here! Build AI agents in 3 lines of code. "
|
||||
"Multi-agent workflows, memory, tools, and more. Try it: pip install praisonaiagents'"
|
||||
)
|
||||
print(result)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""HeyGen + ElevenLabs Video Generation Example.
|
||||
|
||||
Generate an AI avatar video using HeyGen with ElevenLabs voice.
|
||||
The agent autonomously: generates audio → uploads to HeyGen → creates video → waits for completion.
|
||||
|
||||
Required env vars:
|
||||
HEYGEN_API_KEY: HeyGen API key (https://app.heygen.com/settings/api)
|
||||
ELEVENLABS_API_KEY: ElevenLabs API key (https://elevenlabs.io)
|
||||
OPENAI_API_KEY: OpenAI API key (for the agent LLM)
|
||||
|
||||
Usage:
|
||||
export HEYGEN_API_KEY=your_key
|
||||
export ELEVENLABS_API_KEY=your_key
|
||||
export OPENAI_API_KEY=your_key
|
||||
python video_heygen_elevenlabs.py
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonai_tools import (
|
||||
elevenlabs_speak_to_file,
|
||||
heygen_upload_asset,
|
||||
heygen_generate_video_with_audio,
|
||||
heygen_wait_for_video,
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
name="Video Producer",
|
||||
instructions="""You create AI avatar videos. Follow these steps:
|
||||
|
||||
Step 1: Call elevenlabs_speak_to_file with text and output_path="/tmp/speech.mp3"
|
||||
Step 2: Call heygen_upload_asset with file_path="/tmp/speech.mp3", content_type="audio/mpeg"
|
||||
Step 3: Call heygen_generate_video_with_audio with the avatar_id and audio_asset_id from Step 2
|
||||
Step 4: Call heygen_wait_for_video with the video_id from Step 3
|
||||
Step 5: Report the final video URL
|
||||
""",
|
||||
tools=[
|
||||
elevenlabs_speak_to_file,
|
||||
heygen_upload_asset,
|
||||
heygen_generate_video_with_audio,
|
||||
heygen_wait_for_video,
|
||||
],
|
||||
)
|
||||
|
||||
result = agent.start(
|
||||
'Create a video with this script: "Welcome to PraisonAI! '
|
||||
'Build powerful AI agents in just three lines of code." '
|
||||
'Use avatar_id: "YOUR_AVATAR_ID"'
|
||||
)
|
||||
print(result)
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# HeyGen + ElevenLabs Video Generation Workflow
|
||||
#
|
||||
# Sequential multi-agent workflow:
|
||||
# 1. Audio Agent → generates speech with ElevenLabs
|
||||
# 2. Upload Agent → uploads audio to HeyGen
|
||||
# 3. Video Agent → triggers HeyGen video generation
|
||||
# 4. Status Agent → polls and waits for video completion
|
||||
#
|
||||
# Usage:
|
||||
# python video_heygen_elevenlabs_workflow.py
|
||||
#
|
||||
# Required env vars:
|
||||
# HEYGEN_API_KEY, ELEVENLABS_API_KEY, OPENAI_API_KEY
|
||||
|
||||
framework: praisonai
|
||||
process: sequential
|
||||
|
||||
agents:
|
||||
audio_generator:
|
||||
role: Audio Generator
|
||||
goal: Generate speech audio from text using ElevenLabs
|
||||
instructions: |
|
||||
You generate speech audio files using ElevenLabs text-to-speech.
|
||||
When given text, call elevenlabs_speak_to_file with the text and output_path.
|
||||
IMPORTANT: Only pass 'text' and 'output_path' parameters. Use all defaults for other params.
|
||||
Report the output_path and audio_bytes from the result.
|
||||
tools:
|
||||
- elevenlabs_speak_to_file
|
||||
|
||||
asset_uploader:
|
||||
role: Asset Uploader
|
||||
goal: Upload audio files to HeyGen as assets
|
||||
instructions: |
|
||||
You upload audio files to HeyGen for video generation.
|
||||
Call heygen_upload_asset with the file_path and content_type="audio/mpeg".
|
||||
Report the asset "id" from the result - this is critical for the next step.
|
||||
tools:
|
||||
- heygen_upload_asset
|
||||
|
||||
video_generator:
|
||||
role: Video Generator
|
||||
goal: Generate HeyGen avatar video using uploaded audio
|
||||
instructions: |
|
||||
You create HeyGen avatar videos using external audio.
|
||||
Call heygen_generate_video_with_audio with the avatar_id and audio_asset_id.
|
||||
The audio_asset_id comes from the previous step's upload result.
|
||||
Report the video_id from the result data.
|
||||
tools:
|
||||
- heygen_generate_video_with_audio
|
||||
|
||||
video_monitor:
|
||||
role: Video Monitor
|
||||
goal: Wait for video completion and report the final URL
|
||||
instructions: |
|
||||
You monitor HeyGen video generation status.
|
||||
Call heygen_wait_for_video with the video_id from the previous step.
|
||||
Use poll_interval=10 and max_wait=300.
|
||||
When complete, report the video_url from the result.
|
||||
tools:
|
||||
- heygen_wait_for_video
|
||||
|
||||
steps:
|
||||
- agent: audio_generator
|
||||
action: |
|
||||
Generate speech audio for this text: "{{script}}"
|
||||
Save to output_path: "/tmp/workflow_speech.mp3"
|
||||
|
||||
- agent: asset_uploader
|
||||
action: |
|
||||
Upload the audio file at "/tmp/workflow_speech.mp3" to HeyGen.
|
||||
Use content_type "audio/mpeg".
|
||||
|
||||
- agent: video_generator
|
||||
action: |
|
||||
Generate a HeyGen video using:
|
||||
- avatar_id: "{{avatar_id}}"
|
||||
- audio_asset_id: use the asset id from the previous upload step
|
||||
|
||||
- agent: video_monitor
|
||||
action: |
|
||||
Wait for the video to complete. Use the video_id from the previous step.
|
||||
Poll every 10 seconds, max wait 300 seconds.
|
||||
Report the final video URL.
|
||||
|
||||
variables:
|
||||
script: "Welcome to PraisonAI! Build powerful AI agents in just three lines of code. Fast, simple, and production-ready."
|
||||
avatar_id: "YOUR_AVATAR_ID"
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the HeyGen + ElevenLabs YAML workflow.
|
||||
|
||||
This loads the YAML workflow and registers the praisonai_tools
|
||||
functions so the YAML parser can resolve them by name.
|
||||
|
||||
Usage:
|
||||
export HEYGEN_API_KEY=your_key
|
||||
export ELEVENLABS_API_KEY=your_key
|
||||
export OPENAI_API_KEY=your_key
|
||||
python video_heygen_elevenlabs_workflow.py
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from praisonai_tools import (
|
||||
elevenlabs_speak_to_file,
|
||||
heygen_upload_asset,
|
||||
heygen_generate_video_with_audio,
|
||||
heygen_wait_for_video,
|
||||
)
|
||||
from praisonaiagents.workflows.yaml_parser import YAMLWorkflowParser
|
||||
|
||||
# Register praisonai_tools functions for YAML tool resolution
|
||||
tool_registry = {
|
||||
"elevenlabs_speak_to_file": elevenlabs_speak_to_file,
|
||||
"heygen_upload_asset": heygen_upload_asset,
|
||||
"heygen_generate_video_with_audio": heygen_generate_video_with_audio,
|
||||
"heygen_wait_for_video": heygen_wait_for_video,
|
||||
}
|
||||
|
||||
# Parse YAML workflow
|
||||
yaml_path = Path(__file__).parent / "video_heygen_elevenlabs.yaml"
|
||||
parser = YAMLWorkflowParser(tool_registry=tool_registry)
|
||||
workflow = parser.parse_file(yaml_path)
|
||||
|
||||
print("=" * 60)
|
||||
print("HeyGen + ElevenLabs YAML Workflow")
|
||||
print("=" * 60)
|
||||
|
||||
# Run the workflow
|
||||
result = workflow.start()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("WORKFLOW RESULT:")
|
||||
print("=" * 60)
|
||||
print(result)
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
description: PR review chain — how automated reviews flow for every PR
|
||||
---
|
||||
|
||||
## Review Chain (Sequential)
|
||||
|
||||
```
|
||||
PR opened
|
||||
↓
|
||||
CodeRabbit (@coderabbitai) ─── auto for human PRs, triggered via comment for bot PRs
|
||||
Qodo (/review) ─── auto for human PRs, triggered via comment for bot PRs
|
||||
Gemini (@gemini) ─── triggered via comment for bot PRs
|
||||
↓ (~3-5 min)
|
||||
Copilot (@copilot) ─── triggered ONLY after CodeRabbit or Qodo post their review
|
||||
↓
|
||||
Claude (@claude) ─── triggered ONLY after Copilot submits review (final reviewer)
|
||||
```
|
||||
|
||||
## Workflow Files
|
||||
|
||||
| File | Trigger | Does what |
|
||||
|------|---------|-----------|
|
||||
| `auto-pr-comment.yml` | `issue_comment`, `pull_request_review`, `pull_request:opened` | Triggers Copilot after CodeRabbit/Qodo finish. For bot PRs: triggers CodeRabbit+Qodo+Gemini first. |
|
||||
| `chain-claude-after-copilot.yml` | `pull_request_review:submitted` | Triggers Claude after Copilot reviews. |
|
||||
| `claude.yml` | `issue_comment`, `pull_request_review_comment`, `issues:assigned/labeled` | Claude responds to @claude mentions. |
|
||||
|
||||
## Bot PR fix
|
||||
|
||||
CodeRabbit and Qodo skip `github-actions[bot]` authored PRs by default. The `bot-pr-trigger-reviews` job in `auto-pr-comment.yml` explicitly triggers them via comments.
|
||||
|
||||
## Key rules
|
||||
|
||||
- **Copilot ignores bot comments.** All `@copilot` mentions MUST use `GH_TOKEN` (not `GITHUB_TOKEN`) so comments post as `MervinPraison`.
|
||||
- Copilot NEVER triggers before CodeRabbit/Qodo complete.
|
||||
- Claude NEVER triggers before Copilot completes.
|
||||
- Duplicate `@copilot` comments are prevented by checking existing comments.
|
||||
- PraisonAI PRs: NEVER merge automatically — user merges manually.
|
||||
@@ -93,4 +93,4 @@ chroma_db/
|
||||
|
||||
.praisonai
|
||||
.windsurf
|
||||
.agent
|
||||
.ruff_cache
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
# Centralized Logging in PraisonAI Agents
|
||||
|
||||
This document explains the enhanced centralized logging system implemented to address inconsistent logging patterns across the codebase.
|
||||
|
||||
## Problem Addressed
|
||||
|
||||
- **53+ scattered logger instances** with no unified configuration
|
||||
- **No standard log format** across modules
|
||||
- **Mixed logging approaches** (logger.debug(), print(), console.print())
|
||||
- **No structured logging option** for production deployments
|
||||
- **Difficult filtering** by subsystem in multi-agent runs
|
||||
|
||||
## Solution
|
||||
|
||||
Enhanced the existing `praisonaiagents/_logging.py` with:
|
||||
|
||||
1. **Consistent naming convention**: All loggers follow `praisonaiagents.<module>` pattern
|
||||
2. **Structured logging support**: JSON output for production environments
|
||||
3. **Backward compatibility**: Existing code continues to work
|
||||
4. **Centralized configuration**: Single place to configure all logging
|
||||
5. **Optional extra data**: Structured data for better observability
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```python
|
||||
# New recommended approach
|
||||
from praisonaiagents import get_logger
|
||||
|
||||
# Automatic module detection
|
||||
logger = get_logger()
|
||||
logger.info("This is a log message")
|
||||
|
||||
# Explicit module name
|
||||
logger = get_logger(__name__)
|
||||
logger.info("Explicit module logging")
|
||||
```
|
||||
|
||||
### Structured Logging
|
||||
|
||||
```python
|
||||
import os
|
||||
from praisonaiagents import get_logger, configure_structured_logging
|
||||
|
||||
# Enable structured JSON logging
|
||||
os.environ['PRAISONAI_STRUCTURED_LOGS'] = 'true'
|
||||
configure_structured_logging()
|
||||
|
||||
# Logger with extra structured data
|
||||
logger = get_logger(extra_data={"agent_id": "assistant", "session": "123"})
|
||||
logger.info("Task completed", extra={"task_id": "task_001", "duration": 1.5})
|
||||
```
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"timestamp": "2026-03-30T21:35:00Z",
|
||||
"level": "INFO",
|
||||
"logger": "praisonaiagents.agent.agent",
|
||||
"message": "Task completed",
|
||||
"module": "agent",
|
||||
"function": "start",
|
||||
"line": 42,
|
||||
"agent_id": "assistant",
|
||||
"session": "123",
|
||||
"task_id": "task_001",
|
||||
"duration": 1.5
|
||||
}
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `LOGLEVEL` | `WARNING` | Global log level (DEBUG, INFO, WARNING, ERROR) |
|
||||
| `PRAISONAI_STRUCTURED_LOGS` | `false` | Enable JSON structured logging |
|
||||
|
||||
### Per-Module Configuration
|
||||
|
||||
```bash
|
||||
# Set specific module to DEBUG level
|
||||
export LOGLEVEL=INFO
|
||||
# Configure in Python:
|
||||
logging.getLogger('praisonaiagents.memory').setLevel(logging.DEBUG)
|
||||
```
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### For New Code
|
||||
|
||||
Use the new logging utilities:
|
||||
|
||||
```python
|
||||
# ✅ Recommended - new code
|
||||
from praisonaiagents import get_logger
|
||||
logger = get_logger()
|
||||
```
|
||||
|
||||
### For Existing Code
|
||||
|
||||
**No changes required** - existing code continues to work:
|
||||
|
||||
```python
|
||||
# ✅ Still works - existing code
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
```
|
||||
|
||||
**Optional enhancement** for better observability:
|
||||
|
||||
```python
|
||||
# ✅ Enhanced - gradually migrate
|
||||
from praisonaiagents._logging import get_logger
|
||||
logger = get_logger(__name__, extra_data={"subsystem": "agent"})
|
||||
```
|
||||
|
||||
## Naming Convention
|
||||
|
||||
The `get_logger()` function automatically ensures consistent naming:
|
||||
|
||||
| Input | Output Logger Name |
|
||||
|-------|--------------------|
|
||||
| `"my_module"` | `"praisonaiagents.my_module"` |
|
||||
| `"praisonaiagents.existing"` | `"praisonaiagents.existing"` (unchanged) |
|
||||
| `"praisonai.other"` | `"praisonaiagents.other"` |
|
||||
| `"__main__"` | `"praisonaiagents.main"` |
|
||||
| `None` (auto-detect) | `"praisonaiagents.<current_module>"` |
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### ELK Stack / Elasticsearch
|
||||
|
||||
```python
|
||||
import os
|
||||
from praisonaiagents import configure_structured_logging
|
||||
|
||||
# Enable JSON logging for log shipping
|
||||
os.environ['PRAISONAI_STRUCTURED_LOGS'] = 'true'
|
||||
configure_structured_logging()
|
||||
```
|
||||
|
||||
### CloudWatch / Splunk
|
||||
|
||||
```python
|
||||
import logging
|
||||
from praisonaiagents import get_logger, StructuredFormatter
|
||||
|
||||
# Custom handler with structured format
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(StructuredFormatter())
|
||||
|
||||
logger = get_logger()
|
||||
logger.addHandler(handler)
|
||||
```
|
||||
|
||||
### Filtering by Subsystem
|
||||
|
||||
With structured logging enabled, you can easily filter logs:
|
||||
|
||||
```bash
|
||||
# Filter by agent subsystem
|
||||
jq 'select(.logger | startswith("praisonaiagents.agent"))' logs.json
|
||||
|
||||
# Filter by specific agent
|
||||
jq 'select(.agent_id == "assistant")' logs.json
|
||||
|
||||
# Filter by session
|
||||
jq 'select(.session == "session_123")' logs.json
|
||||
```
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
- ✅ All existing `logging.getLogger(__name__)` calls continue to work
|
||||
- ✅ No performance impact on existing code
|
||||
- ✅ No breaking changes to public APIs
|
||||
- ✅ Gradual migration path available
|
||||
|
||||
## Examples
|
||||
|
||||
See the updated modules for examples:
|
||||
- `praisonaiagents/plugins/builtin/logging_plugin.py` - Logging plugin with extra data
|
||||
- `praisonaiagents/memory/memory.py` - Memory module with subsystem tagging
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Unified format**: All PraisonAI logs follow the same format
|
||||
2. **Better observability**: Structured data for monitoring and debugging
|
||||
3. **Easy filtering**: Filter by subsystem, agent, or session
|
||||
4. **Production ready**: JSON output for log aggregation systems
|
||||
5. **Zero breaking changes**: Existing code continues to work unchanged
|
||||
6. **Performance conscious**: No import-time overhead for normal usage
|
||||
@@ -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'),
|
||||
@@ -489,6 +492,11 @@ _LAZY_IMPORTS = {
|
||||
'PraisonConfig': ('praisonaiagents.config.loader', 'PraisonConfig'),
|
||||
'PluginsConfig': ('praisonaiagents.config.loader', 'PluginsConfig'),
|
||||
'DefaultsConfig': ('praisonaiagents.config.loader', 'DefaultsConfig'),
|
||||
|
||||
# Centralized Logging Utilities
|
||||
'get_logger': ('praisonaiagents._logging', 'get_logger'),
|
||||
'configure_structured_logging': ('praisonaiagents._logging', 'configure_structured_logging'),
|
||||
'StructuredFormatter': ('praisonaiagents._logging', 'StructuredFormatter'),
|
||||
}
|
||||
|
||||
|
||||
@@ -503,21 +511,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 +543,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 +593,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
|
||||
)
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@ This module consolidates all logging configuration in one place to avoid duplica
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from typing import List
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
# ========================================================================
|
||||
# ENVIRONMENT CONFIGURATION
|
||||
@@ -161,5 +162,150 @@ def initialize_logging():
|
||||
# It will be called lazily when LLM class is instantiated
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# STRUCTURED LOGGING SUPPORT
|
||||
# ========================================================================
|
||||
class StructuredFormatter(logging.Formatter):
|
||||
"""JSON formatter for structured logging in production environments."""
|
||||
|
||||
_STANDARD_FIELDS: frozenset = frozenset({
|
||||
"timestamp", "level", "logger", "message",
|
||||
"module", "function", "line", "exc_info", "stack_info",
|
||||
})
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
"""Format log record as structured JSON."""
|
||||
log_data: Dict[str, Any] = {
|
||||
"timestamp": self.formatTime(record, self.datefmt),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"message": record.getMessage(),
|
||||
"module": record.module,
|
||||
"function": record.funcName,
|
||||
"line": record.lineno,
|
||||
}
|
||||
|
||||
# Include exception info if present
|
||||
if record.exc_info:
|
||||
log_data["exc_info"] = self.formatException(record.exc_info)
|
||||
if record.stack_info:
|
||||
log_data["stack_info"] = self.formatStack(record.stack_info)
|
||||
|
||||
# Merge extra fields without overwriting standard log fields
|
||||
if hasattr(record, 'extra_data'):
|
||||
extra = {k: v for k, v in record.extra_data.items() if k not in self._STANDARD_FIELDS}
|
||||
log_data.update(extra)
|
||||
|
||||
return json.dumps(log_data)
|
||||
|
||||
|
||||
def configure_structured_logging():
|
||||
"""Configure structured JSON logging for production environments.
|
||||
|
||||
Call this function to enable structured logging across all PraisonAI modules.
|
||||
Useful for log aggregation systems like ELK, Splunk, or CloudWatch.
|
||||
|
||||
Example:
|
||||
import os
|
||||
from praisonaiagents._logging import configure_structured_logging
|
||||
|
||||
# Enable structured logging
|
||||
os.environ['PRAISONAI_STRUCTURED_LOGS'] = 'true'
|
||||
configure_structured_logging()
|
||||
"""
|
||||
if os.environ.get('PRAISONAI_STRUCTURED_LOGS', '').lower() == 'true':
|
||||
structured_formatter = StructuredFormatter()
|
||||
# Reconfigure the root logger handler so propagating loggers get JSON output
|
||||
for handler in logging.getLogger().handlers:
|
||||
handler.setFormatter(structured_formatter)
|
||||
# Also reconfigure any praisonaiagents loggers that have their own handlers
|
||||
for logger_name in logging.Logger.manager.loggerDict:
|
||||
if logger_name.startswith('praisonaiagents.'):
|
||||
child_logger = logging.getLogger(logger_name)
|
||||
for handler in child_logger.handlers:
|
||||
handler.setFormatter(structured_formatter)
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# CONSISTENT LOGGER NAMING
|
||||
# ========================================================================
|
||||
class _ExtraDataAdapter(logging.LoggerAdapter):
|
||||
"""LoggerAdapter that injects extra_data into every log record."""
|
||||
|
||||
def process(self, msg: str, kwargs: Dict[str, Any]) -> tuple:
|
||||
kwargs.setdefault('extra', {})
|
||||
kwargs['extra']['extra_data'] = self.extra
|
||||
return msg, kwargs
|
||||
|
||||
|
||||
def get_logger(
|
||||
name: Optional[str] = None,
|
||||
*,
|
||||
extra_data: Optional[Dict[str, Any]] = None,
|
||||
) -> Union[logging.Logger, logging.LoggerAdapter]:
|
||||
"""Get a logger with consistent naming convention for PraisonAI modules.
|
||||
|
||||
This function ensures all loggers follow the 'praisonaiagents.<module>' pattern
|
||||
and provides optional structured logging support.
|
||||
|
||||
Args:
|
||||
name: Logger name. If None, uses the calling module's __name__.
|
||||
If it doesn't start with 'praisonaiagents.', the prefix is added.
|
||||
extra_data: Optional dict of extra data to include in all log records.
|
||||
|
||||
Returns:
|
||||
Logger instance with consistent naming and optional structured data.
|
||||
|
||||
Example:
|
||||
# In any module file:
|
||||
from praisonaiagents._logging import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Or with automatic detection:
|
||||
logger = get_logger()
|
||||
|
||||
# With extra structured data:
|
||||
logger = get_logger(extra_data={"agent_id": "assistant", "session": "123"})
|
||||
"""
|
||||
import inspect
|
||||
|
||||
# Auto-detect module name if not provided
|
||||
if name is None:
|
||||
frame = inspect.currentframe()
|
||||
try:
|
||||
caller_frame = frame.f_back
|
||||
name = caller_frame.f_globals.get('__name__', 'unknown')
|
||||
finally:
|
||||
del caller_frame # avoid reference cycle / frame leak
|
||||
del frame
|
||||
|
||||
# Ensure consistent naming convention
|
||||
if not name.startswith('praisonaiagents.'):
|
||||
if name == '__main__':
|
||||
name = 'praisonaiagents.main'
|
||||
elif name == 'praisonai' or name.startswith('praisonai.'):
|
||||
# Handle cases like 'praisonai.something' -> 'praisonaiagents.something'
|
||||
name = 'praisonaiagents' + name[len('praisonai'):]
|
||||
else:
|
||||
# Add prefix for non-praisonai modules
|
||||
name = f'praisonaiagents.{name}'
|
||||
|
||||
base_logger = logging.getLogger(name)
|
||||
|
||||
# Wrap with adapter when extra structured data is requested
|
||||
if extra_data:
|
||||
return _ExtraDataAdapter(base_logger, extra_data)
|
||||
|
||||
return base_logger
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# BACKWARD COMPATIBILITY ALIASES
|
||||
# ========================================================================
|
||||
# These maintain compatibility with existing code
|
||||
getLogger = get_logger # Snake case alias
|
||||
get_praisonai_logger = get_logger # Descriptive alias
|
||||
|
||||
|
||||
# Auto-initialize on import (but NOT litellm)
|
||||
initialize_logging()
|
||||
@@ -1,4 +1,12 @@
|
||||
"""Agent module for AI agents - uses lazy loading for performance"""
|
||||
"""Agent module for AI agents - uses lazy loading for performance
|
||||
|
||||
The Agent class has been decomposed into focused modules for better maintainability:
|
||||
- tool_execution: Tool calling and execution functionality
|
||||
- chat_handler: Chat and conversation management
|
||||
- session_manager: Session persistence and state management
|
||||
|
||||
Backward compatibility is maintained via mixins in the main Agent class.
|
||||
"""
|
||||
|
||||
# Lazy loading cache
|
||||
_lazy_cache = {}
|
||||
@@ -13,6 +21,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':
|
||||
@@ -141,10 +161,27 @@ def __getattr__(name):
|
||||
_lazy_cache[name] = value
|
||||
return value
|
||||
|
||||
# Decomposed mixins - for advanced use cases
|
||||
if name == 'ToolExecutionMixin':
|
||||
from .tool_execution import ToolExecutionMixin
|
||||
_lazy_cache[name] = ToolExecutionMixin
|
||||
return ToolExecutionMixin
|
||||
elif name == 'ChatHandlerMixin':
|
||||
from .chat_handler import ChatHandlerMixin
|
||||
_lazy_cache[name] = ChatHandlerMixin
|
||||
return ChatHandlerMixin
|
||||
elif name == 'SessionManagerMixin':
|
||||
from .session_manager import SessionManagerMixin
|
||||
_lazy_cache[name] = SessionManagerMixin
|
||||
return SessionManagerMixin
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
__all__ = [
|
||||
'Agent',
|
||||
'BudgetExceededError',
|
||||
'Heartbeat',
|
||||
'HeartbeatConfig',
|
||||
'ImageAgent',
|
||||
'VideoAgent',
|
||||
'VideoConfig',
|
||||
@@ -198,4 +235,8 @@ __all__ = [
|
||||
'MemoryAwareAgentProtocol',
|
||||
'FullAgentProtocol',
|
||||
'ContextEngineerProtocol',
|
||||
# Decomposed mixins (for advanced use cases)
|
||||
'ToolExecutionMixin',
|
||||
'ChatHandlerMixin',
|
||||
'SessionManagerMixin',
|
||||
]
|
||||
@@ -9,6 +9,7 @@ Follows the Agent() class patterns:
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
import warnings
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Any, Dict, Union, BinaryIO
|
||||
@@ -17,7 +18,6 @@ from pathlib import Path
|
||||
# Filter out Pydantic warning about fields
|
||||
warnings.filterwarnings("ignore", "Valid config keys have changed in V2", UserWarning)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# AudioConfig - Configuration dataclass following feature_configs.py patterns
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -59,7 +59,6 @@ class AudioConfig:
|
||||
"api_key": self.api_key,
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# AudioAgent Class - Agent-centric audio processing
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -192,10 +191,10 @@ class AudioAgent:
|
||||
def _configure_logging(self, verbose: Union[bool, int]) -> None:
|
||||
"""Configure logging levels."""
|
||||
if isinstance(verbose, int) and verbose >= 10:
|
||||
logging.getLogger("litellm").setLevel(logging.DEBUG)
|
||||
get_logger("litellm").setLevel(logging.DEBUG)
|
||||
else:
|
||||
logging.getLogger("litellm").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
get_logger("litellm").setLevel(logging.WARNING)
|
||||
get_logger("httpx").setLevel(logging.WARNING)
|
||||
|
||||
def _get_model_params(self, model: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Build parameters for LiteLLM calls."""
|
||||
|
||||
@@ -35,8 +35,9 @@ from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict, Any, List, Set
|
||||
from enum import Enum
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# ============================================================================
|
||||
# Unified Stage Enum (G-DUP-1 fix: single source of truth)
|
||||
@@ -72,7 +73,6 @@ _ESCALATION_TO_AUTONOMY_SIGNAL = {
|
||||
# Reverse map for converting autonomy signal strings back to EscalationSignal values
|
||||
_AUTONOMY_TO_ESCALATION_SIGNAL = {v: k for k, v in _ESCALATION_TO_AUTONOMY_SIGNAL.items()}
|
||||
|
||||
|
||||
# Valid autonomy modes
|
||||
VALID_AUTONOMY_MODES = {"caller", "iterative"}
|
||||
|
||||
@@ -83,7 +83,6 @@ _LEVEL_TO_DEFAULT_MODE = {
|
||||
"full_auto": "iterative",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AutonomyConfig:
|
||||
"""Configuration for Agent autonomy features.
|
||||
@@ -196,7 +195,6 @@ class AutonomyConfig:
|
||||
default_tools=data.get("default_tools"),
|
||||
)
|
||||
|
||||
|
||||
class AutonomySignal(str, Enum):
|
||||
"""Signals detected from prompts for autonomy decisions.
|
||||
|
||||
@@ -215,26 +213,27 @@ class AutonomySignal(str, Enum):
|
||||
COMPLEX_KEYWORDS = "complex_keywords"
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
import warnings
|
||||
warnings.warn(
|
||||
"AutonomySignal is deprecated. Use EscalationSignal instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
from ..utils.deprecation import warn_deprecated_param
|
||||
warn_deprecated_param(
|
||||
"AutonomySignal class",
|
||||
since="1.0.0",
|
||||
removal="2.0.0",
|
||||
alternative="use EscalationSignal from praisonaiagents.escalation.types instead",
|
||||
stacklevel=3
|
||||
)
|
||||
super().__init_subclass__(**kwargs)
|
||||
|
||||
|
||||
def _warn_autonomy_signal():
|
||||
"""Emit deprecation warning when AutonomySignal is accessed."""
|
||||
import warnings
|
||||
warnings.warn(
|
||||
"AutonomySignal is deprecated. Use EscalationSignal from "
|
||||
"praisonaiagents.escalation.types instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=3,
|
||||
from ..utils.deprecation import warn_deprecated_param
|
||||
warn_deprecated_param(
|
||||
"AutonomySignal",
|
||||
since="1.0.0",
|
||||
removal="2.0.0",
|
||||
alternative="use EscalationSignal from praisonaiagents.escalation.types instead",
|
||||
stacklevel=4
|
||||
)
|
||||
|
||||
|
||||
class AutonomyTrigger:
|
||||
"""Detects signals from prompts for autonomy decisions.
|
||||
|
||||
@@ -280,7 +279,6 @@ class AutonomyTrigger:
|
||||
pass
|
||||
return self._delegate.recommend_stage(esc_signals)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AutonomyResult:
|
||||
"""Result of an autonomous execution.
|
||||
@@ -315,7 +313,6 @@ class AutonomyResult:
|
||||
"""
|
||||
return self.output or ""
|
||||
|
||||
|
||||
class DoomLoopTracker:
|
||||
"""Tracks actions to detect doom loops with graduated recovery.
|
||||
|
||||
@@ -418,7 +415,6 @@ class DoomLoopTracker:
|
||||
self._delegate.start_session()
|
||||
self._recovery_attempts = 0
|
||||
|
||||
|
||||
class AutonomyMixin:
|
||||
"""Helper-only trait for autonomy utilities.
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
Chat and conversation handling functionality for Agent class.
|
||||
|
||||
This module contains methods related to chat, streaming, and conversation management.
|
||||
Split from the main agent.py file for better maintainability.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import asyncio
|
||||
from typing import Any, Dict, List, Optional, Union, Generator
|
||||
|
||||
|
||||
class ChatHandlerMixin:
|
||||
"""Mixin class containing chat handling methods for the Agent class."""
|
||||
|
||||
def chat(self, prompt: str, temperature: float = 1.0, tools: Optional[List[Any]] = None,
|
||||
output_json: Optional[str] = None, output_pydantic: Optional[Any] = None,
|
||||
reasoning_steps: bool = False, stream: Optional[bool] = None,
|
||||
task_name: Optional[str] = None, task_description: Optional[str] = None,
|
||||
task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None,
|
||||
force_retrieval: bool = False, skip_retrieval: bool = False,
|
||||
attachments: Optional[List[Any]] = None, tool_choice: Optional[str] = None) -> str:
|
||||
"""
|
||||
Chat with the agent using a prompt.
|
||||
|
||||
Args:
|
||||
prompt: The user message/prompt
|
||||
temperature: Sampling temperature (0.0-2.0)
|
||||
tools: Tools available for this conversation
|
||||
output_json: JSON schema for structured output
|
||||
output_pydantic: Pydantic model for structured output
|
||||
reasoning_steps: Whether to include reasoning steps
|
||||
stream: Whether to stream the response
|
||||
task_name: Name of the task for context
|
||||
task_description: Description of the task
|
||||
task_id: Unique identifier for the task
|
||||
config: Additional configuration
|
||||
force_retrieval: Force knowledge retrieval
|
||||
skip_retrieval: Skip knowledge retrieval
|
||||
attachments: File attachments
|
||||
tool_choice: Tool selection strategy
|
||||
|
||||
Returns:
|
||||
The agent's response as a string
|
||||
"""
|
||||
return self._chat_impl(
|
||||
prompt=prompt,
|
||||
temperature=temperature,
|
||||
tools=tools,
|
||||
output_json=output_json,
|
||||
output_pydantic=output_pydantic,
|
||||
reasoning_steps=reasoning_steps,
|
||||
stream=stream,
|
||||
task_name=task_name,
|
||||
task_description=task_description,
|
||||
task_id=task_id,
|
||||
config=config,
|
||||
force_retrieval=force_retrieval,
|
||||
skip_retrieval=skip_retrieval,
|
||||
attachments=attachments,
|
||||
_trace_emitter=None,
|
||||
tool_choice=tool_choice
|
||||
)
|
||||
|
||||
def _chat_impl(self, prompt: str, temperature: float, tools: Optional[List[Any]],
|
||||
output_json: Optional[str], output_pydantic: Optional[Any],
|
||||
reasoning_steps: bool, stream: Optional[bool],
|
||||
task_name: Optional[str], task_description: Optional[str],
|
||||
task_id: Optional[str], config: Optional[Dict[str, Any]],
|
||||
force_retrieval: bool, skip_retrieval: bool,
|
||||
attachments: Optional[List[Any]], _trace_emitter: Optional[Any],
|
||||
tool_choice: Optional[str] = None) -> str:
|
||||
"""Internal chat implementation with full parameter control."""
|
||||
raise NotImplementedError("Chat implementation moved from main Agent class")
|
||||
|
||||
async def achat(self, prompt: str, temperature: float = 1.0, tools: Optional[List[Any]] = None,
|
||||
output_json: Optional[str] = None, output_pydantic: Optional[Any] = None,
|
||||
reasoning_steps: bool = False, task_name: Optional[str] = None,
|
||||
task_description: Optional[str] = None, task_id: Optional[str] = None,
|
||||
attachments: Optional[List[Any]] = None) -> str:
|
||||
"""Async version of chat method."""
|
||||
return await self._achat_impl(
|
||||
prompt=prompt,
|
||||
temperature=temperature,
|
||||
tools=tools,
|
||||
output_json=output_json,
|
||||
output_pydantic=output_pydantic,
|
||||
reasoning_steps=reasoning_steps,
|
||||
task_name=task_name,
|
||||
task_description=task_description,
|
||||
task_id=task_id,
|
||||
attachments=attachments,
|
||||
_trace_emitter=None
|
||||
)
|
||||
|
||||
async def _achat_impl(self, prompt: str, temperature: float, tools: Optional[List[Any]],
|
||||
output_json: Optional[str], output_pydantic: Optional[Any],
|
||||
reasoning_steps: bool, task_name: Optional[str],
|
||||
task_description: Optional[str], task_id: Optional[str],
|
||||
attachments: Optional[List[Any]], _trace_emitter: Optional[Any]) -> str:
|
||||
"""Internal async chat implementation."""
|
||||
raise NotImplementedError("Async chat implementation moved from main Agent class")
|
||||
|
||||
def iter_stream(self, prompt: str, **kwargs) -> Generator[str, None, None]:
|
||||
"""Stream chat response as an iterator."""
|
||||
raise NotImplementedError("Stream iteration implementation moved from main Agent class")
|
||||
|
||||
def _start_stream(self, prompt: str, **kwargs) -> Generator[str, None, None]:
|
||||
"""Internal streaming implementation."""
|
||||
raise NotImplementedError("Stream implementation moved from main Agent class")
|
||||
|
||||
def clear_history(self) -> None:
|
||||
"""Clear the chat history."""
|
||||
if hasattr(self, 'chat_history'):
|
||||
self.chat_history.clear()
|
||||
logging.info(f"{self.name}: Chat history cleared")
|
||||
|
||||
def prune_history(self, keep_last: int = 5) -> int:
|
||||
"""
|
||||
Prune chat history to keep only the last N messages.
|
||||
|
||||
Args:
|
||||
keep_last: Number of recent messages to keep
|
||||
|
||||
Returns:
|
||||
Number of messages removed
|
||||
"""
|
||||
if not hasattr(self, 'chat_history'):
|
||||
return 0
|
||||
|
||||
original_length = len(self.chat_history)
|
||||
if original_length <= keep_last:
|
||||
return 0
|
||||
|
||||
self.chat_history = self.chat_history[-keep_last:]
|
||||
removed_count = original_length - len(self.chat_history)
|
||||
logging.info(f"{self.name}: Pruned {removed_count} messages from history")
|
||||
return removed_count
|
||||
|
||||
def get_history_size(self) -> int:
|
||||
"""Get the current chat history size."""
|
||||
if hasattr(self, 'chat_history'):
|
||||
return len(self.chat_history)
|
||||
return 0
|
||||
@@ -4,7 +4,7 @@ from dataclasses import dataclass, field, asdict
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from praisonaiagents._logging import get_logger
|
||||
|
||||
@dataclass
|
||||
class CodeConfig:
|
||||
@@ -29,7 +29,6 @@ class CodeConfig:
|
||||
"""Convert config to dictionary."""
|
||||
return {k: v for k, v in asdict(self).items() if v is not None}
|
||||
|
||||
|
||||
class CodeAgent:
|
||||
"""Agent for code generation, execution, review, and refactoring.
|
||||
|
||||
@@ -96,7 +95,7 @@ class CodeAgent:
|
||||
self._console = None
|
||||
|
||||
# Configure logging
|
||||
self._logger = logging.getLogger(f"CodeAgent.{name}")
|
||||
self._logger = get_logger(f"CodeAgent.{name}")
|
||||
if not verbose:
|
||||
self._logger.setLevel(logging.WARNING)
|
||||
|
||||
@@ -309,8 +308,9 @@ Follow best practices and coding standards."""
|
||||
# Clean up temp file
|
||||
try:
|
||||
os.unlink(temp_file)
|
||||
except:
|
||||
pass
|
||||
except Exception as e:
|
||||
# Temp file cleanup failed - not critical
|
||||
get_logger(__name__).debug(f"Failed to clean up temp file {temp_file}: {e}")
|
||||
|
||||
async def aexecute(self, code: str, language: str = "python", **kwargs) -> Dict[str, Any]:
|
||||
"""Execute code asynchronously.
|
||||
|
||||
@@ -14,6 +14,7 @@ This class implements proper Context Engineering principles following the PRD te
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, Any, Dict, Union, List, TYPE_CHECKING
|
||||
@@ -94,7 +95,6 @@ async def _async_subprocess_run(cmd: list, timeout: int = 60) -> tuple:
|
||||
from ..agent.agent import Agent
|
||||
from ..task import Task
|
||||
|
||||
|
||||
class ContextAgent(Agent):
|
||||
"""
|
||||
Advanced Context Engineering Agent - Comprehensive context generation for AI coding assistants.
|
||||
@@ -207,7 +207,7 @@ class ContextAgent(Agent):
|
||||
"""Setup comprehensive logging based on debug mode."""
|
||||
try:
|
||||
# Create logger
|
||||
self.logger = logging.getLogger(f"ContextAgent_{id(self)}")
|
||||
self.logger = get_logger(f"ContextAgent_{id(self)}")
|
||||
self.logger.setLevel(logging.DEBUG if self.debug_mode else logging.INFO)
|
||||
|
||||
# Clear existing handlers
|
||||
@@ -247,7 +247,7 @@ class ContextAgent(Agent):
|
||||
except Exception as e:
|
||||
print(f"⚠️ Warning: Could not setup logging: {e}")
|
||||
# Create a minimal logger as fallback
|
||||
self.logger = logging.getLogger(f"ContextAgent_{id(self)}")
|
||||
self.logger = get_logger(f"ContextAgent_{id(self)}")
|
||||
self.logger.setLevel(logging.INFO)
|
||||
|
||||
def setup_output_directories(self):
|
||||
@@ -2445,7 +2445,7 @@ This PRP provides:
|
||||
"""
|
||||
asyncio = _get_asyncio()
|
||||
# Run sync method in executor for now (subprocess calls are blocking)
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
self.analyze_codebase_with_gitingest,
|
||||
@@ -2461,7 +2461,7 @@ This PRP provides:
|
||||
Async version of generate_prp.
|
||||
"""
|
||||
asyncio = _get_asyncio()
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.generate_comprehensive_prp(feature_request, context_analysis)
|
||||
@@ -2476,13 +2476,12 @@ This PRP provides:
|
||||
Async version of create_implementation_blueprint.
|
||||
"""
|
||||
asyncio = _get_asyncio()
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.build_implementation_blueprint(feature_request, context_analysis)
|
||||
)
|
||||
|
||||
|
||||
def create_context_agent(llm: Optional[Union[str, Any]] = None, **kwargs) -> ContextAgent:
|
||||
"""
|
||||
Factory function to create a ContextAgent following Context Engineering and PRD methodology.
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""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
|
||||
from praisonaiagents._logging import get_logger
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = get_logger(__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)
|
||||
@@ -35,13 +35,13 @@ Example:
|
||||
|
||||
import os
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
import time
|
||||
import asyncio
|
||||
from typing import List, Optional, Any, Dict, Literal
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
|
||||
# Data classes for Deep Research response types
|
||||
@dataclass
|
||||
class Citation:
|
||||
@@ -54,7 +54,6 @@ class Citation:
|
||||
def __repr__(self):
|
||||
return f"Citation(title='{self.title}', url='{self.url}')"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReasoningStep:
|
||||
"""Represents a reasoning step in the research process."""
|
||||
@@ -64,7 +63,6 @@ class ReasoningStep:
|
||||
def __repr__(self):
|
||||
return f"ReasoningStep(text='{self.text[:50]}...')" if len(self.text) > 50 else f"ReasoningStep(text='{self.text}')"
|
||||
|
||||
|
||||
@dataclass
|
||||
class WebSearchCall:
|
||||
"""Represents a web search call made during research."""
|
||||
@@ -74,7 +72,6 @@ class WebSearchCall:
|
||||
def __repr__(self):
|
||||
return f"WebSearchCall(query='{self.query}', status='{self.status}')"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CodeExecutionStep:
|
||||
"""Represents a code execution step during research."""
|
||||
@@ -84,7 +81,6 @@ class CodeExecutionStep:
|
||||
def __repr__(self):
|
||||
return f"CodeExecutionStep(input='{self.input_code[:50]}...')" if len(self.input_code) > 50 else f"CodeExecutionStep(input='{self.input_code}')"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPCall:
|
||||
"""Represents an MCP tool call during research."""
|
||||
@@ -95,7 +91,6 @@ class MCPCall:
|
||||
def __repr__(self):
|
||||
return f"MCPCall(name='{self.name}', server='{self.server_label}')"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileSearchCall:
|
||||
"""Represents a file search call (Gemini-specific)."""
|
||||
@@ -104,7 +99,6 @@ class FileSearchCall:
|
||||
def __repr__(self):
|
||||
return f"FileSearchCall(stores={self.store_names})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeepResearchResponse:
|
||||
"""
|
||||
@@ -149,14 +143,12 @@ class DeepResearchResponse:
|
||||
sources.append({"title": c.title, "url": c.url})
|
||||
return sources
|
||||
|
||||
|
||||
class Provider(Enum):
|
||||
"""Supported Deep Research providers."""
|
||||
OPENAI = "openai"
|
||||
GEMINI = "gemini"
|
||||
LITELLM = "litellm"
|
||||
|
||||
|
||||
class DeepResearchAgent:
|
||||
"""
|
||||
Agent for performing deep research using multiple provider APIs.
|
||||
@@ -295,7 +287,7 @@ You are a professional research analyst. When conducting research:
|
||||
self._gemini_client = None
|
||||
|
||||
# Configure logging
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.logger = get_logger(__name__)
|
||||
|
||||
if self.verbose:
|
||||
self.logger.debug(f"DeepResearchAgent initialized: provider={self.provider.value}, model={self.model}")
|
||||
@@ -868,6 +860,78 @@ You are a professional research analyst. When conducting research:
|
||||
|
||||
return self._parse_gemini_response(interaction)
|
||||
|
||||
async def _research_gemini_async(
|
||||
self,
|
||||
query: str,
|
||||
instructions: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
file_search: bool = False,
|
||||
file_search_stores: Optional[List[Dict[str, Any]]] = None,
|
||||
grounding_file_prompt: Optional[str] = None,
|
||||
) -> DeepResearchResponse:
|
||||
"""
|
||||
Async version of _research_gemini with non-blocking polling.
|
||||
Uses asyncio.sleep() to prevent blocking the event loop.
|
||||
"""
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
if self.provider != Provider.GEMINI:
|
||||
raise ValueError("This method requires GEMINI provider")
|
||||
|
||||
if not self.gemini_client:
|
||||
raise RuntimeError("Gemini client is required for Deep Research")
|
||||
|
||||
create_params = {
|
||||
"query": query,
|
||||
"file_search": file_search,
|
||||
}
|
||||
|
||||
if instructions:
|
||||
create_params["instructions"] = instructions
|
||||
|
||||
if model:
|
||||
create_params["model"] = model
|
||||
|
||||
if file_search_stores:
|
||||
create_params["file_search_stores"] = file_search_stores
|
||||
|
||||
if grounding_file_prompt:
|
||||
create_params["grounding_file_prompt"] = grounding_file_prompt
|
||||
|
||||
interaction = self.gemini_client.interactions.create(**create_params)
|
||||
|
||||
if self.verbose:
|
||||
self.logger.debug(f"Gemini research started (async): {interaction.id}")
|
||||
|
||||
start_time = time.time()
|
||||
while True:
|
||||
interaction = self.gemini_client.interactions.get(interaction.id)
|
||||
|
||||
if interaction.status == "completed":
|
||||
if self.verbose:
|
||||
self.logger.debug("Gemini research completed (async)")
|
||||
break
|
||||
elif interaction.status == "failed":
|
||||
error = getattr(interaction, 'error', 'Unknown error')
|
||||
raise RuntimeError(f"Gemini Deep Research failed: {error}")
|
||||
elif interaction.status == "cancelled":
|
||||
raise RuntimeError("Gemini Deep Research was cancelled")
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
if elapsed > self.max_wait_time:
|
||||
raise TimeoutError(
|
||||
f"Gemini Deep Research timed out after {self.max_wait_time}s"
|
||||
)
|
||||
|
||||
if self.verbose:
|
||||
self.logger.debug(f"Research in progress (async)... ({elapsed:.0f}s)")
|
||||
|
||||
# Use async sleep to avoid blocking event loop
|
||||
await asyncio.sleep(self.poll_interval)
|
||||
|
||||
return self._parse_gemini_response(interaction)
|
||||
|
||||
def _research_gemini_streaming(
|
||||
self,
|
||||
create_params: Dict[str, Any],
|
||||
@@ -1183,17 +1247,13 @@ You are a professional research analyst. When conducting research:
|
||||
|
||||
try:
|
||||
if self.provider == Provider.GEMINI:
|
||||
# Gemini async - run sync in executor
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self._research_gemini(
|
||||
query=query,
|
||||
instructions=instructions,
|
||||
model=model,
|
||||
file_search=file_search if file_search is not None else self.enable_file_search,
|
||||
file_search_stores=file_search_stores or self.file_search_stores,
|
||||
)
|
||||
# Use async Gemini method for non-blocking polling
|
||||
result = await self._research_gemini_async(
|
||||
query=query,
|
||||
instructions=instructions,
|
||||
model=model,
|
||||
file_search=file_search if file_search is not None else self.enable_file_search,
|
||||
file_search_stores=file_search_stores or self.file_search_stores,
|
||||
)
|
||||
elif self.provider == Provider.LITELLM:
|
||||
# LiteLLM async
|
||||
|
||||
@@ -12,12 +12,12 @@ Follows the Agent() class patterns with:
|
||||
|
||||
import os
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Dict, Any, Union, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@dataclass
|
||||
class EmbeddingConfig:
|
||||
@@ -44,7 +44,6 @@ class EmbeddingConfig:
|
||||
"api_key": self.api_key,
|
||||
}
|
||||
|
||||
|
||||
class EmbeddingAgent:
|
||||
"""
|
||||
A specialized agent for generating text embeddings.
|
||||
@@ -164,10 +163,10 @@ class EmbeddingAgent:
|
||||
def _configure_logging(self, verbose: Union[bool, int]) -> None:
|
||||
"""Configure logging levels."""
|
||||
if isinstance(verbose, int) and verbose >= 10:
|
||||
logging.getLogger("litellm").setLevel(logging.DEBUG)
|
||||
get_logger("litellm").setLevel(logging.DEBUG)
|
||||
else:
|
||||
logging.getLogger("litellm").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
get_logger("litellm").setLevel(logging.WARNING)
|
||||
get_logger("httpx").setLevel(logging.WARNING)
|
||||
|
||||
def _get_model_params(self, model: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Build parameters for LiteLLM calls."""
|
||||
|
||||
@@ -15,6 +15,7 @@ from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
import inspect
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
@@ -22,8 +23,7 @@ import time
|
||||
if TYPE_CHECKING:
|
||||
from .agent import Agent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class ContextPolicy(Enum):
|
||||
"""Policy for context sharing during handoff."""
|
||||
@@ -32,7 +32,6 @@ class ContextPolicy(Enum):
|
||||
NONE = "none" # No context sharing
|
||||
LAST_N = "last_n" # Share last N messages
|
||||
|
||||
|
||||
@dataclass
|
||||
class HandoffConfig:
|
||||
"""
|
||||
@@ -98,19 +97,16 @@ class HandoffConfig:
|
||||
data["context_policy"] = ContextPolicy(data["context_policy"])
|
||||
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
|
||||
|
||||
|
||||
class HandoffError(Exception):
|
||||
"""Base exception for handoff errors."""
|
||||
pass
|
||||
|
||||
|
||||
class HandoffCycleError(HandoffError):
|
||||
"""Raised when a cycle is detected in handoff chain."""
|
||||
def __init__(self, chain: List[str]):
|
||||
self.chain = chain
|
||||
super().__init__(f"Handoff cycle detected: {' -> '.join(chain)}")
|
||||
|
||||
|
||||
class HandoffDepthError(HandoffError):
|
||||
"""Raised when max handoff depth is exceeded."""
|
||||
def __init__(self, depth: int, max_depth: int):
|
||||
@@ -118,7 +114,6 @@ class HandoffDepthError(HandoffError):
|
||||
self.max_depth = max_depth
|
||||
super().__init__(f"Max handoff depth exceeded: {depth} > {max_depth}")
|
||||
|
||||
|
||||
class HandoffTimeoutError(HandoffError):
|
||||
"""Raised when handoff times out."""
|
||||
def __init__(self, timeout: float, agent_name: str):
|
||||
@@ -126,41 +121,34 @@ class HandoffTimeoutError(HandoffError):
|
||||
self.agent_name = agent_name
|
||||
super().__init__(f"Handoff to {agent_name} timed out after {timeout}s")
|
||||
|
||||
|
||||
# Thread-local storage for tracking handoff chains
|
||||
_handoff_context = threading.local()
|
||||
|
||||
|
||||
def _get_handoff_chain() -> List[str]:
|
||||
"""Get current handoff chain from thread-local storage."""
|
||||
if not hasattr(_handoff_context, 'chain'):
|
||||
_handoff_context.chain = []
|
||||
return _handoff_context.chain
|
||||
|
||||
|
||||
def _get_handoff_depth() -> int:
|
||||
"""Get current handoff depth."""
|
||||
return len(_get_handoff_chain())
|
||||
|
||||
|
||||
def _push_handoff(agent_name: str) -> None:
|
||||
"""Push agent to handoff chain."""
|
||||
chain = _get_handoff_chain()
|
||||
chain.append(agent_name)
|
||||
|
||||
|
||||
def _pop_handoff() -> Optional[str]:
|
||||
"""Pop agent from handoff chain."""
|
||||
chain = _get_handoff_chain()
|
||||
return chain.pop() if chain else None
|
||||
|
||||
|
||||
def _clear_handoff_chain() -> None:
|
||||
"""Clear the handoff chain."""
|
||||
if hasattr(_handoff_context, 'chain'):
|
||||
_handoff_context.chain = []
|
||||
|
||||
|
||||
@dataclass
|
||||
class HandoffInputData:
|
||||
"""Data passed to a handoff target agent."""
|
||||
@@ -170,7 +158,6 @@ class HandoffInputData:
|
||||
handoff_depth: int = 0
|
||||
handoff_chain: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HandoffResult:
|
||||
"""Result of a handoff operation."""
|
||||
@@ -671,7 +658,6 @@ class Handoff:
|
||||
|
||||
return handoff_tool
|
||||
|
||||
|
||||
def handoff(
|
||||
agent: 'Agent',
|
||||
tool_name_override: Optional[str] = None,
|
||||
@@ -760,7 +746,6 @@ def handoff(
|
||||
config=config,
|
||||
)
|
||||
|
||||
|
||||
# Handoff filters - common patterns for filtering handoff data
|
||||
class handoff_filters:
|
||||
"""Common handoff input filters."""
|
||||
@@ -827,14 +812,12 @@ class handoff_filters:
|
||||
|
||||
return data
|
||||
|
||||
|
||||
# Recommended prompt prefix for agents that use handoffs
|
||||
RECOMMENDED_PROMPT_PREFIX = """You have the ability to transfer tasks to specialized agents when appropriate.
|
||||
When you determine that a task would be better handled by another agent with specific expertise,
|
||||
use the transfer tool to hand off the task. The receiving agent will have the full context of
|
||||
the conversation and will continue helping the user."""
|
||||
|
||||
|
||||
def prompt_with_handoff_instructions(base_prompt: str, agent: 'Agent') -> str:
|
||||
"""
|
||||
Add handoff instructions to an agent's prompt.
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""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
|
||||
from praisonaiagents._logging import get_logger
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Optional, Union
|
||||
|
||||
logger = get_logger(__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
|
||||
@@ -7,12 +7,10 @@ from typing import Optional, Any, Dict, Union, List
|
||||
from ..agent.agent import Agent
|
||||
from pydantic import BaseModel, Field
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
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")
|
||||
@@ -80,10 +78,10 @@ class ImageAgent(Agent):
|
||||
# Only suppress logs if not in debug mode
|
||||
if not isinstance(verbose, bool) and verbose >= 10:
|
||||
# Enable detailed debug logging
|
||||
logging.getLogger("asyncio").setLevel(logging.DEBUG)
|
||||
logging.getLogger("selector_events").setLevel(logging.DEBUG)
|
||||
logging.getLogger("litellm.utils").setLevel(logging.DEBUG)
|
||||
logging.getLogger("litellm.main").setLevel(logging.DEBUG)
|
||||
get_logger("asyncio").setLevel(logging.DEBUG)
|
||||
get_logger("selector_events").setLevel(logging.DEBUG)
|
||||
get_logger("litellm.utils").setLevel(logging.DEBUG)
|
||||
get_logger("litellm.main").setLevel(logging.DEBUG)
|
||||
if hasattr(self, 'litellm'):
|
||||
self.litellm.suppress_debug_messages = False
|
||||
self.litellm.set_verbose = True
|
||||
@@ -91,12 +89,12 @@ class ImageAgent(Agent):
|
||||
warnings.resetwarnings()
|
||||
else:
|
||||
# Suppress debug logging for normal operation
|
||||
logging.getLogger("asyncio").setLevel(logging.WARNING)
|
||||
logging.getLogger("selector_events").setLevel(logging.WARNING)
|
||||
logging.getLogger("litellm.utils").setLevel(logging.WARNING)
|
||||
logging.getLogger("litellm.main").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
get_logger("asyncio").setLevel(logging.WARNING)
|
||||
get_logger("selector_events").setLevel(logging.WARNING)
|
||||
get_logger("litellm.utils").setLevel(logging.WARNING)
|
||||
get_logger("litellm.main").setLevel(logging.WARNING)
|
||||
get_logger("httpx").setLevel(logging.WARNING)
|
||||
get_logger("httpcore").setLevel(logging.WARNING)
|
||||
if hasattr(self, 'litellm'):
|
||||
self.litellm.suppress_debug_messages = True
|
||||
self.litellm._logging._disable_debugging()
|
||||
@@ -155,6 +153,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,385 @@
|
||||
"""
|
||||
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
|
||||
from praisonaiagents._logging import get_logger
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Literal, Optional, TypedDict
|
||||
|
||||
logger = get_logger(__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
|
||||
@@ -9,12 +9,12 @@ import threading
|
||||
import heapq
|
||||
import time
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
from typing import Optional, Any, List, Tuple
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class MessagePriority(Enum):
|
||||
"""Message priority levels."""
|
||||
@@ -23,7 +23,6 @@ class MessagePriority(Enum):
|
||||
HIGH = 10
|
||||
URGENT = 20
|
||||
|
||||
|
||||
@dataclass(order=True)
|
||||
class QueuedMessage:
|
||||
"""A message in the queue with priority and timestamp."""
|
||||
@@ -36,7 +35,6 @@ class QueuedMessage:
|
||||
# Negate priority for max-heap behavior (higher priority = lower number in heap)
|
||||
self.priority = -self.priority
|
||||
|
||||
|
||||
class AgentMessageQueue:
|
||||
"""
|
||||
Thread-safe message queue for agent prompts.
|
||||
@@ -158,7 +156,6 @@ class AgentMessageQueue:
|
||||
"""Total number of messages ever enqueued."""
|
||||
return self._message_count
|
||||
|
||||
|
||||
class AsyncAgentMessageQueue:
|
||||
"""
|
||||
Async-compatible message queue for agent prompts.
|
||||
|
||||
@@ -9,6 +9,7 @@ Follows the Agent() class patterns:
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
import warnings
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Any, Dict, Union, List
|
||||
@@ -16,7 +17,6 @@ from pathlib import Path
|
||||
|
||||
warnings.filterwarnings("ignore", "Valid config keys have changed in V2", UserWarning)
|
||||
|
||||
|
||||
@dataclass
|
||||
class OCRConfig:
|
||||
"""Configuration for OCR settings."""
|
||||
@@ -38,7 +38,6 @@ class OCRConfig:
|
||||
"api_key": self.api_key,
|
||||
}
|
||||
|
||||
|
||||
class OCRAgent:
|
||||
"""
|
||||
A specialized agent for OCR (Optical Character Recognition).
|
||||
@@ -143,10 +142,10 @@ class OCRAgent:
|
||||
def _configure_logging(self, verbose: Union[bool, int]) -> None:
|
||||
"""Configure logging levels."""
|
||||
if isinstance(verbose, int) and verbose >= 10:
|
||||
logging.getLogger("litellm").setLevel(logging.DEBUG)
|
||||
get_logger("litellm").setLevel(logging.DEBUG)
|
||||
else:
|
||||
logging.getLogger("litellm").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
get_logger("litellm").setLevel(logging.WARNING)
|
||||
get_logger("httpx").setLevel(logging.WARNING)
|
||||
|
||||
def _get_model_params(self, model: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Build parameters for LiteLLM calls."""
|
||||
|
||||
@@ -34,6 +34,7 @@ Example:
|
||||
"""
|
||||
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
from typing import List, Optional, Any, Dict
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
@@ -47,7 +48,6 @@ class ExpandStrategy(Enum):
|
||||
CREATIVE = "creative"
|
||||
AUTO = "auto"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExpandResult:
|
||||
"""Result of a prompt expansion operation."""
|
||||
@@ -59,7 +59,6 @@ class ExpandResult:
|
||||
def __repr__(self):
|
||||
return f"ExpandResult(strategy={self.strategy_used.value}, original_len={len(self.original_prompt)}, expanded_len={len(self.expanded_prompt)})"
|
||||
|
||||
|
||||
class PromptExpanderAgent:
|
||||
"""
|
||||
Agent for expanding short prompts into detailed, comprehensive prompts.
|
||||
@@ -213,7 +212,7 @@ Expanded prompt:"""
|
||||
self._agent = None # Lazy initialized
|
||||
|
||||
# Setup logging
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.logger = get_logger(__name__)
|
||||
if self.verbose:
|
||||
self.logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
@@ -228,6 +228,80 @@ class ContextEngineerProtocol(Protocol):
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class HttpLauncherProtocol(Protocol):
|
||||
"""
|
||||
Protocol for HTTP server launchers.
|
||||
|
||||
This defines the interface for launching HTTP servers for agents.
|
||||
The core SDK defines the protocol, while heavy implementations
|
||||
(FastAPI, uvicorn) live in the wrapper layer.
|
||||
|
||||
Example:
|
||||
```python
|
||||
class FastAPILauncher:
|
||||
def launch(self, agent, path="/", port=8000, host="0.0.0.0", debug=False):
|
||||
# FastAPI implementation
|
||||
pass
|
||||
|
||||
# In Agent class
|
||||
launcher: HttpLauncherProtocol = get_http_launcher()
|
||||
launcher.launch(self, path, port, host, debug)
|
||||
```
|
||||
"""
|
||||
|
||||
def launch(
|
||||
self,
|
||||
agent: Any,
|
||||
path: str = "/",
|
||||
port: int = 8000,
|
||||
host: str = "0.0.0.0",
|
||||
debug: bool = False
|
||||
) -> None:
|
||||
"""
|
||||
Launch HTTP server for the agent.
|
||||
|
||||
Args:
|
||||
agent: The agent instance to serve
|
||||
path: API endpoint path (default: '/')
|
||||
port: Server port (default: 8000)
|
||||
host: Server host (default: '0.0.0.0')
|
||||
debug: Enable debug mode (default: False)
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class McpLauncherProtocol(Protocol):
|
||||
"""
|
||||
Protocol for MCP (Model Context Protocol) server launchers.
|
||||
|
||||
This defines the interface for launching MCP servers for agents.
|
||||
The core SDK defines the protocol, while heavy implementations
|
||||
(FastMCP, uvicorn) live in the wrapper layer.
|
||||
"""
|
||||
|
||||
def launch(
|
||||
self,
|
||||
agent: Any,
|
||||
path: str = "/",
|
||||
port: int = 8000,
|
||||
host: str = "0.0.0.0",
|
||||
debug: bool = False
|
||||
) -> None:
|
||||
"""
|
||||
Launch MCP server for the agent.
|
||||
|
||||
Args:
|
||||
agent: The agent instance to serve
|
||||
path: Base path for MCP endpoints
|
||||
port: Server port (default: 8000)
|
||||
host: Server host (default: '0.0.0.0')
|
||||
debug: Enable debug mode (default: False)
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
__all__ = [
|
||||
'AgentProtocol',
|
||||
'RunnableAgentProtocol',
|
||||
@@ -235,4 +309,6 @@ __all__ = [
|
||||
'MemoryAwareAgentProtocol',
|
||||
'FullAgentProtocol',
|
||||
'ContextEngineerProtocol',
|
||||
'HttpLauncherProtocol',
|
||||
'McpLauncherProtocol',
|
||||
]
|
||||
@@ -39,12 +39,12 @@ Example:
|
||||
"""
|
||||
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
import json
|
||||
from typing import List, Optional, Any, Dict
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class RewriteStrategy(Enum):
|
||||
"""Enumeration of available query rewriting strategies."""
|
||||
BASIC = "basic"
|
||||
@@ -55,7 +55,6 @@ class RewriteStrategy(Enum):
|
||||
CONTEXTUAL = "contextual"
|
||||
AUTO = "auto"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RewriteResult:
|
||||
"""Result of a query rewriting operation."""
|
||||
@@ -86,7 +85,6 @@ class RewriteResult:
|
||||
queries.extend(self.sub_queries)
|
||||
return list(set(queries)) # Remove duplicates
|
||||
|
||||
|
||||
class QueryRewriterAgent:
|
||||
"""
|
||||
Agent for rewriting queries to improve retrieval quality in RAG applications.
|
||||
@@ -271,7 +269,7 @@ Improved version:"""
|
||||
}
|
||||
|
||||
# Setup logging
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.logger = get_logger(__name__)
|
||||
if self.verbose:
|
||||
self.logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from dataclasses import dataclass, field, asdict
|
||||
from typing import Any, Callable, Dict, List, Optional, Union
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from praisonaiagents._logging import get_logger
|
||||
|
||||
@dataclass
|
||||
class RealtimeConfig:
|
||||
@@ -33,7 +33,6 @@ class RealtimeConfig:
|
||||
"""Convert config to dictionary."""
|
||||
return {k: v for k, v in asdict(self).items() if v is not None}
|
||||
|
||||
|
||||
class RealtimeAgent:
|
||||
"""Agent for real-time voice conversations using OpenAI Realtime API.
|
||||
|
||||
@@ -107,7 +106,7 @@ class RealtimeAgent:
|
||||
self._connected = False
|
||||
|
||||
# Configure logging
|
||||
self._logger = logging.getLogger(f"RealtimeAgent.{name}")
|
||||
self._logger = get_logger(f"RealtimeAgent.{name}")
|
||||
if not verbose:
|
||||
self._logger.setLevel(logging.WARNING)
|
||||
|
||||
@@ -138,16 +137,20 @@ class RealtimeAgent:
|
||||
True if connected successfully
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
# If already in async context, can't use run_until_complete
|
||||
self._log("Use aconnect() in async context", "yellow")
|
||||
return False
|
||||
return loop.run_until_complete(self.aconnect(**kwargs))
|
||||
# Try to get the running loop - if this succeeds, we're in async context
|
||||
loop = asyncio.get_running_loop()
|
||||
# If already in async context, can't use run_until_complete
|
||||
self._log("Use aconnect() in async context", "yellow")
|
||||
return False
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
return loop.run_until_complete(self.aconnect(**kwargs))
|
||||
# No running loop, safe to create/get one and run
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
return loop.run_until_complete(self.aconnect(**kwargs))
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
return loop.run_until_complete(self.aconnect(**kwargs))
|
||||
|
||||
async def aconnect(self, **kwargs) -> bool:
|
||||
"""Connect to the Realtime API asynchronously.
|
||||
@@ -209,15 +212,19 @@ class RealtimeAgent:
|
||||
True if disconnected successfully
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
self._log("Use adisconnect() in async context", "yellow")
|
||||
return False
|
||||
return loop.run_until_complete(self.adisconnect())
|
||||
# Try to get the running loop - if this succeeds, we're in async context
|
||||
loop = asyncio.get_running_loop()
|
||||
self._log("Use adisconnect() in async context", "yellow")
|
||||
return False
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
return loop.run_until_complete(self.adisconnect())
|
||||
# No running loop, safe to create/get one and run
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
return loop.run_until_complete(self.adisconnect())
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
return loop.run_until_complete(self.adisconnect())
|
||||
|
||||
async def adisconnect(self) -> bool:
|
||||
"""Disconnect from the Realtime API asynchronously.
|
||||
@@ -273,15 +280,19 @@ class RealtimeAgent:
|
||||
True if sent successfully
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
self._log("Use async send in async context", "yellow")
|
||||
return False
|
||||
return loop.run_until_complete(self.asend_text(text))
|
||||
# Try to get the running loop - if this succeeds, we're in async context
|
||||
loop = asyncio.get_running_loop()
|
||||
self._log("Use async send in async context", "yellow")
|
||||
return False
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
return loop.run_until_complete(self.asend_text(text))
|
||||
# No running loop, safe to create/get one and run
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
return loop.run_until_complete(self.asend_text(text))
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
return loop.run_until_complete(self.asend_text(text))
|
||||
|
||||
async def asend_text(self, text: str) -> bool:
|
||||
"""Send text message asynchronously.
|
||||
@@ -315,14 +326,18 @@ class RealtimeAgent:
|
||||
True if sent successfully
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
return False
|
||||
return loop.run_until_complete(self.asend_audio(audio_data))
|
||||
# Try to get the running loop - if this succeeds, we're in async context
|
||||
loop = asyncio.get_running_loop()
|
||||
return False
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
return loop.run_until_complete(self.asend_audio(audio_data))
|
||||
# No running loop, safe to create/get one and run
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
return loop.run_until_complete(self.asend_audio(audio_data))
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
return loop.run_until_complete(self.asend_audio(audio_data))
|
||||
|
||||
async def asend_audio(self, audio_data: bytes) -> bool:
|
||||
"""Send audio data asynchronously.
|
||||
|
||||
@@ -7,13 +7,14 @@ model selection based on task characteristics.
|
||||
|
||||
import os
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
from typing import Dict, List, Optional, Any, Union
|
||||
from .agent import Agent
|
||||
from ..llm.model_router import ModelRouter
|
||||
from ..llm import LLM
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from ..llm import LLM, TokenUsage
|
||||
from ..trace.protocol import get_default_emitter
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class RouterAgent(Agent):
|
||||
"""
|
||||
@@ -213,8 +214,8 @@ class RouterAgent(Agent):
|
||||
full_prompt = f"{context}\n\n{prompt}"
|
||||
|
||||
try:
|
||||
# Execute with the selected model
|
||||
response = llm_instance.get_response(
|
||||
# Execute with the selected model, requesting token usage tracking
|
||||
result = llm_instance.get_response(
|
||||
prompt=full_prompt,
|
||||
system_prompt=self._build_system_prompt(),
|
||||
tools=tools,
|
||||
@@ -225,16 +226,45 @@ class RouterAgent(Agent):
|
||||
agent_role=self.role,
|
||||
agent_tools=[t.__name__ if hasattr(t, '__name__') else str(t) for t in (tools or [])],
|
||||
execute_tool_fn=self.execute_tool if tools else None,
|
||||
return_token_usage=True, # Request token usage information
|
||||
**kwargs
|
||||
)
|
||||
|
||||
# Extract response and token usage
|
||||
if isinstance(result, tuple):
|
||||
response, token_usage = result
|
||||
else:
|
||||
# Fallback for backward compatibility
|
||||
response = result
|
||||
token_usage = TokenUsage()
|
||||
|
||||
# Update usage statistics
|
||||
self.model_usage_stats[model_name]['calls'] += 1
|
||||
self.model_usage_stats[model_name]['tokens'] += token_usage.total_tokens
|
||||
|
||||
# TODO: Implement token tracking when LLM.get_response() is updated to return token usage
|
||||
# The LLM response currently returns only text, but litellm provides usage info in:
|
||||
# response.get("usage") with prompt_tokens, completion_tokens, and total_tokens
|
||||
# This would require modifying the LLM class to return both text and metadata
|
||||
# Calculate and store cost estimate
|
||||
model_info = self.model_router.get_model_info(model_name)
|
||||
if model_info and token_usage.total_tokens > 0:
|
||||
cost = self.model_router.estimate_cost(model_name, token_usage.total_tokens)
|
||||
self.model_usage_stats[model_name]['cost'] += cost
|
||||
|
||||
# Emit token usage via trace system for observability
|
||||
try:
|
||||
trace_emitter = get_default_emitter()
|
||||
trace_emitter.output(
|
||||
content=f"RouterAgent routing decision completed",
|
||||
agent_name=self.name,
|
||||
metadata={
|
||||
'selected_model': model_name,
|
||||
'routing_strategy': self.routing_strategy,
|
||||
'token_usage': token_usage.to_dict(),
|
||||
'estimated_cost': self.model_usage_stats[model_name]['cost'],
|
||||
'total_calls': self.model_usage_stats[model_name]['calls'],
|
||||
}
|
||||
)
|
||||
except Exception as trace_error:
|
||||
# Don't fail the request if tracing fails
|
||||
logger.debug(f"Failed to emit trace event: {trace_error}")
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
Session and persistence management functionality for Agent class.
|
||||
|
||||
This module contains methods related to session management, persistence, and state handling.
|
||||
Split from the main agent.py file for better maintainability.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
|
||||
class SessionManagerMixin:
|
||||
"""Mixin class containing session management methods for the Agent class."""
|
||||
|
||||
@property
|
||||
def session_id(self) -> Optional[str]:
|
||||
"""Get the current session ID."""
|
||||
return getattr(self, '_session_id', None)
|
||||
|
||||
def _init_db_session(self) -> None:
|
||||
"""Initialize database session for persistent storage."""
|
||||
# Session initialization logic would go here
|
||||
# This is extracted from the main agent.py file
|
||||
raise NotImplementedError("Database session initialization moved from main Agent class")
|
||||
|
||||
def _init_session_store(self) -> None:
|
||||
"""Initialize session store for state persistence."""
|
||||
# Session store initialization logic would go here
|
||||
raise NotImplementedError("Session store initialization moved from main Agent class")
|
||||
|
||||
def _start_run(self, input_content: str) -> None:
|
||||
"""Start a new run/session with input tracking."""
|
||||
# Logic to start a new execution run
|
||||
logging.info(f"{self.name}: Starting new run with input: {input_content[:100]}...")
|
||||
|
||||
def _end_run(self, output_content: str, status: str = "completed",
|
||||
metrics: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""End the current run with output tracking."""
|
||||
# Logic to end the current execution run
|
||||
logging.info(f"{self.name}: Ending run with status: {status}")
|
||||
|
||||
def _persist_message(self, role: str, content: str) -> None:
|
||||
"""Persist a message to the session store."""
|
||||
# Logic to persist messages
|
||||
if hasattr(self, 'chat_history'):
|
||||
self.chat_history.append({"role": role, "content": content})
|
||||
|
||||
def _auto_save_session(self) -> None:
|
||||
"""Auto-save the current session state."""
|
||||
# Auto-save logic would go here
|
||||
if hasattr(self, '_auto_save_enabled') and self._auto_save_enabled:
|
||||
logging.debug(f"{self.name}: Auto-saving session")
|
||||
|
||||
def _load_history_context(self) -> str:
|
||||
"""Load historical context from previous sessions."""
|
||||
# Logic to load context from previous sessions
|
||||
return ""
|
||||
|
||||
def _process_auto_memory(self, user_message: str, assistant_response: str) -> None:
|
||||
"""Process and store automatic memory based on conversation."""
|
||||
# Auto-memory processing logic
|
||||
if hasattr(self, '_memory_instance') and self._memory_instance:
|
||||
try:
|
||||
# Store interaction in memory
|
||||
memory_content = f"User: {user_message}\nAssistant: {assistant_response}"
|
||||
self._memory_instance.store_short_term(memory_content)
|
||||
logging.debug(f"{self.name}: Stored interaction in auto-memory")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to store auto-memory: {e}")
|
||||
|
||||
def _process_auto_learning(self) -> None:
|
||||
"""Process automatic learning from recent interactions."""
|
||||
# Auto-learning processing logic
|
||||
if hasattr(self, '_learn_enabled') and self._learn_enabled:
|
||||
logging.debug(f"{self.name}: Processing auto-learning")
|
||||
|
||||
def _save_output_to_file(self, content: str) -> bool:
|
||||
"""Save output content to a file if configured."""
|
||||
# File output saving logic
|
||||
if hasattr(self, '_output_file') and self._output_file:
|
||||
try:
|
||||
with open(self._output_file, 'a', encoding='utf-8') as f:
|
||||
f.write(content + '\n')
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to save output to file: {e}")
|
||||
return False
|
||||
@@ -6,11 +6,11 @@ Reuses existing telemetry/token_collector for token tracking.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
from typing import Optional, List, Dict, Any, Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@dataclass
|
||||
class SummarizationConfig:
|
||||
@@ -22,7 +22,6 @@ class SummarizationConfig:
|
||||
preserve_system: bool = True # Keep system message
|
||||
preserve_recent: int = 2 # Keep last N message pairs
|
||||
|
||||
|
||||
class SummarizationManager:
|
||||
"""
|
||||
Manages automatic conversation summarization.
|
||||
@@ -180,7 +179,6 @@ SUMMARY:"""
|
||||
"""Number of times summarization has been performed."""
|
||||
return self._summarization_count
|
||||
|
||||
|
||||
async def summarize_conversation(
|
||||
messages: List[Dict[str, str]],
|
||||
llm_call: Callable,
|
||||
@@ -210,7 +208,6 @@ async def summarize_conversation(
|
||||
|
||||
return new_history
|
||||
|
||||
|
||||
def summarize_conversation_sync(
|
||||
messages: List[Dict[str, str]],
|
||||
llm_call: Callable,
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
Tool execution functionality for Agent class.
|
||||
|
||||
This module contains methods related to tool execution, validation, and calling.
|
||||
Split from the main agent.py file for better maintainability.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
|
||||
class ToolExecutionMixin:
|
||||
"""Mixin class containing tool execution methods for the Agent class."""
|
||||
|
||||
def execute_tool(self, function_name: str, arguments: Dict[str, Any], tool_call_id: Optional[str] = None) -> Any:
|
||||
"""
|
||||
Execute a tool dynamically based on the function name and arguments.
|
||||
Injects agent state for tools with Injected[T] parameters.
|
||||
|
||||
Args:
|
||||
function_name: Name of the tool function to execute
|
||||
arguments: Dictionary of arguments to pass to the tool
|
||||
tool_call_id: Optional ID from the LLM's tool_call (e.g., 'call_xxxxx')
|
||||
Used for correlating TOOL_CALL_START/RESULT stream events
|
||||
"""
|
||||
logging.debug(f"{self.name} executing tool {function_name} with arguments: {arguments}")
|
||||
|
||||
# NOTE: tool_call callback is triggered by display_tool_call in openai_client.py
|
||||
# Do NOT call it here to avoid duplicate output
|
||||
|
||||
# Set up injection context for tools with Injected parameters
|
||||
from ..tools.injected import AgentState
|
||||
state = AgentState(
|
||||
agent_id=self.name,
|
||||
run_id=getattr(self, '_current_run_id', 'unknown'),
|
||||
session_id=getattr(self, '_session_id', None) or 'default',
|
||||
last_user_message=self.chat_history[-1].get('content') if self.chat_history else None,
|
||||
memory=getattr(self, '_memory_instance', None),
|
||||
learn_manager=getattr(getattr(self, '_memory_instance', None), 'learn', None),
|
||||
metadata={'agent_name': self.name}
|
||||
)
|
||||
|
||||
# Execute within injection context
|
||||
return self._execute_tool_with_context(function_name, arguments, state, tool_call_id)
|
||||
|
||||
def _execute_tool_with_context(self, function_name: str, arguments: Dict[str, Any],
|
||||
state: Any, tool_call_id: Optional[str] = None) -> Any:
|
||||
"""Execute tool within injection context, with optional output truncation.
|
||||
|
||||
Args:
|
||||
function_name: Name of the tool function to execute
|
||||
arguments: Dictionary of arguments to pass to the tool
|
||||
state: AgentState for injection context
|
||||
tool_call_id: Optional ID from the LLM's tool_call (e.g., 'call_xxxxx')
|
||||
"""
|
||||
from ..tools.injected import with_injection_context
|
||||
from ..trace.context_events import get_context_emitter
|
||||
from ..streaming.events import StreamEvent, StreamEventType
|
||||
import time as _time
|
||||
|
||||
# Emit tool call start event (zero overhead when not set)
|
||||
_trace_emitter = get_context_emitter()
|
||||
_trace_emitter.tool_call_start(self.name, function_name, arguments)
|
||||
_tool_start_time = _time.time()
|
||||
_tool_start_perf = _time.perf_counter()
|
||||
|
||||
# Emit TOOL_CALL_START to stream_emitter (for AIUI/AG-UI consumers)
|
||||
# Zero overhead when no callbacks registered
|
||||
if hasattr(self, '_Agent__stream_emitter') and self.__stream_emitter is not None and self.__stream_emitter.has_callbacks:
|
||||
self.__stream_emitter.emit(StreamEvent(
|
||||
type=StreamEventType.TOOL_CALL_START,
|
||||
timestamp=_tool_start_perf,
|
||||
tool_call={
|
||||
"name": function_name,
|
||||
"arguments": arguments, # PARSED DICT, not JSON string
|
||||
"id": tool_call_id, # Now properly threaded through
|
||||
},
|
||||
agent_id=self.name,
|
||||
))
|
||||
|
||||
try:
|
||||
# Trigger BEFORE_TOOL hook
|
||||
from ..hooks import HookEvent, BeforeToolInput
|
||||
before_tool_input = BeforeToolInput(
|
||||
session_id=getattr(self, '_session_id', 'default'),
|
||||
cwd=os.getcwd(),
|
||||
event_name=HookEvent.BEFORE_TOOL,
|
||||
timestamp=str(_time.time()),
|
||||
agent_name=self.name,
|
||||
tool_name=function_name,
|
||||
tool_input=arguments
|
||||
)
|
||||
tool_hook_results = self._hook_runner.execute_sync(HookEvent.BEFORE_TOOL, before_tool_input, target=function_name)
|
||||
if self._hook_runner.is_blocked(tool_hook_results):
|
||||
logging.warning(f"Tool {function_name} execution blocked by BEFORE_TOOL hook")
|
||||
return f"Execution of {function_name} was blocked by security policy."
|
||||
|
||||
# Update arguments if modified by hooks
|
||||
for res in tool_hook_results:
|
||||
if res.output and res.output.modified_data:
|
||||
arguments.update(res.output.modified_data)
|
||||
|
||||
with with_injection_context(state):
|
||||
# P8/G11: Apply tool timeout if configured
|
||||
tool_timeout = getattr(self, '_tool_timeout', None)
|
||||
if tool_timeout and tool_timeout > 0:
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
future = executor.submit(self._execute_tool_impl, function_name, arguments)
|
||||
try:
|
||||
result = future.result(timeout=tool_timeout)
|
||||
except concurrent.futures.TimeoutError:
|
||||
logging.warning(f"Tool {function_name} timed out after {tool_timeout}s")
|
||||
result = {"error": f"Tool timed out after {tool_timeout}s", "timeout": True}
|
||||
else:
|
||||
result = self._execute_tool_impl(function_name, arguments)
|
||||
|
||||
except Exception as e:
|
||||
# Apply consistent error logging and formatting
|
||||
error_msg = f"Error executing tool {function_name}: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
result = {"error": error_msg}
|
||||
|
||||
# Trigger AFTER_TOOL hook and trace events
|
||||
try:
|
||||
from ..hooks import AfterToolInput
|
||||
after_tool_input = AfterToolInput(
|
||||
session_id=getattr(self, '_session_id', 'default'),
|
||||
cwd=os.getcwd(),
|
||||
event_name=HookEvent.AFTER_TOOL,
|
||||
timestamp=str(_time.time()),
|
||||
agent_name=self.name,
|
||||
tool_name=function_name,
|
||||
tool_input=arguments,
|
||||
tool_output=result
|
||||
)
|
||||
self._hook_runner.execute_sync(HookEvent.AFTER_TOOL, after_tool_input, target=function_name)
|
||||
except Exception as hook_error:
|
||||
logging.warning(f"Error in AFTER_TOOL hook: {hook_error}")
|
||||
|
||||
# Emit TOOL_CALL_RESULT to stream_emitter
|
||||
if hasattr(self, '_Agent__stream_emitter') and self.__stream_emitter is not None and self.__stream_emitter.has_callbacks:
|
||||
self.__stream_emitter.emit(StreamEvent(
|
||||
type=StreamEventType.TOOL_CALL_RESULT,
|
||||
timestamp=_time.perf_counter(),
|
||||
tool_call={
|
||||
"name": function_name,
|
||||
"arguments": arguments,
|
||||
"id": tool_call_id,
|
||||
"result": result,
|
||||
},
|
||||
agent_id=self.name,
|
||||
))
|
||||
|
||||
# Emit trace event
|
||||
_trace_emitter.tool_call_end(self.name, function_name, result, _time.time() - _tool_start_time)
|
||||
|
||||
return result
|
||||
|
||||
def _execute_tool_impl(self, function_name: str, arguments: Dict[str, Any]) -> Any:
|
||||
"""Core tool execution implementation."""
|
||||
# This method would contain the actual tool execution logic
|
||||
# For now, just return a placeholder to demonstrate the pattern
|
||||
raise NotImplementedError("Tool execution implementation moved from main Agent class")
|
||||
|
||||
async def execute_tool_async(self, function_name: str, arguments: Dict[str, Any]) -> Any:
|
||||
"""Async tool execution for non-blocking operations."""
|
||||
# Async version of execute_tool
|
||||
raise NotImplementedError("Async tool execution implementation moved from main Agent class")
|
||||
@@ -12,17 +12,15 @@ Follows the Agent() class patterns:
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
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)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# VideoConfig - Configuration dataclass following feature_configs.py patterns
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -81,7 +79,6 @@ class VideoConfig:
|
||||
"api_version": self.api_version,
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# VideoAgent Class - Agent-centric video generation
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -212,9 +209,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
|
||||
|
||||
@@ -266,12 +264,12 @@ class VideoAgent:
|
||||
"""Configure logging levels based on verbose setting."""
|
||||
if isinstance(verbose, int) and verbose >= 10:
|
||||
# Debug mode
|
||||
logging.getLogger("litellm").setLevel(logging.DEBUG)
|
||||
get_logger("litellm").setLevel(logging.DEBUG)
|
||||
else:
|
||||
# Suppress debug logging
|
||||
logging.getLogger("litellm").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
get_logger("litellm").setLevel(logging.WARNING)
|
||||
get_logger("httpx").setLevel(logging.WARNING)
|
||||
get_logger("httpcore").setLevel(logging.WARNING)
|
||||
|
||||
def _get_model_params(self) -> Dict[str, Any]:
|
||||
"""Build parameters for LiteLLM video calls."""
|
||||
@@ -342,6 +340,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 +490,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}"),
|
||||
|
||||
@@ -12,12 +12,12 @@ Follows the Agent() class patterns with:
|
||||
|
||||
import os
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Dict, Any, Union, List
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@dataclass
|
||||
class VisionConfig:
|
||||
@@ -44,7 +44,6 @@ class VisionConfig:
|
||||
"api_key": self.api_key,
|
||||
}
|
||||
|
||||
|
||||
class VisionAgent:
|
||||
"""
|
||||
A specialized agent for image analysis and understanding.
|
||||
@@ -166,10 +165,10 @@ class VisionAgent:
|
||||
def _configure_logging(self, verbose: Union[bool, int]) -> None:
|
||||
"""Configure logging levels."""
|
||||
if isinstance(verbose, int) and verbose >= 10:
|
||||
logging.getLogger("litellm").setLevel(logging.DEBUG)
|
||||
get_logger("litellm").setLevel(logging.DEBUG)
|
||||
else:
|
||||
logging.getLogger("litellm").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
get_logger("litellm").setLevel(logging.WARNING)
|
||||
get_logger("httpx").setLevel(logging.WARNING)
|
||||
|
||||
def _get_model_params(self, model: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Build parameters for LiteLLM calls."""
|
||||
|
||||
@@ -2,8 +2,8 @@ import os
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
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
|
||||
@@ -28,9 +28,11 @@ class TaskStatus(Enum):
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
# Set up logger
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Global variables for managing the shared servers
|
||||
# Global variables for managing the shared servers with thread-safety
|
||||
import threading
|
||||
_agents_server_lock = threading.Lock() # Protect all global server state mutations
|
||||
_agents_server_started = {} # Dict of port -> started boolean
|
||||
_agents_registered_endpoints = {} # Dict of port -> Dict of path -> endpoint_id
|
||||
_agents_shared_apps = {} # Dict of port -> FastAPI app
|
||||
@@ -62,7 +64,6 @@ def process_video(video_path: str, seconds_per_frame=2):
|
||||
video.release()
|
||||
return base64_frames
|
||||
|
||||
|
||||
def get_multimodal_message(text_prompt: str, images: list) -> list:
|
||||
"""
|
||||
Build multimodal message content for LLM with text and images.
|
||||
@@ -107,7 +108,6 @@ def get_multimodal_message(text_prompt: str, images: list) -> list:
|
||||
})
|
||||
return content
|
||||
|
||||
|
||||
def process_task_context(context_item, verbose=0, user_id=None):
|
||||
"""
|
||||
Process a single context item for task execution.
|
||||
@@ -431,6 +431,8 @@ class AgentTeam:
|
||||
_max_retries = 3
|
||||
self.completion_checker = _completion_checker if _completion_checker else self.default_completion_checker
|
||||
self.task_id_counter = 0
|
||||
self._task_id_lock = threading.Lock() # Thread-safe task ID assignment
|
||||
self._state_lock = threading.Lock() # Thread-safe state mutations
|
||||
self.verbose = _verbose
|
||||
self.max_retries = _max_retries
|
||||
self.process = process
|
||||
@@ -447,17 +449,18 @@ class AgentTeam:
|
||||
|
||||
# Set logger level based on verbose
|
||||
if _verbose >= 5:
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.setLevel(10) # DEBUG
|
||||
elif _verbose >= 3:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logger.setLevel(20) # INFO
|
||||
else:
|
||||
logger.setLevel(logging.WARNING)
|
||||
|
||||
logger.setLevel(30) # WARNING
|
||||
|
||||
# Also set third-party loggers to WARNING
|
||||
logging.getLogger('chromadb').setLevel(logging.WARNING)
|
||||
logging.getLogger('openai').setLevel(logging.WARNING)
|
||||
logging.getLogger('httpx').setLevel(logging.WARNING)
|
||||
logging.getLogger('httpcore').setLevel(logging.WARNING)
|
||||
import logging as _logging
|
||||
get_logger('chromadb').setLevel(_logging.WARNING)
|
||||
get_logger('openai').setLevel(_logging.WARNING)
|
||||
get_logger('httpx').setLevel(_logging.WARNING)
|
||||
get_logger('httpcore').setLevel(_logging.WARNING)
|
||||
|
||||
if self.verbose:
|
||||
logger.info(f"Using model {self.manager_llm} for manager")
|
||||
@@ -559,11 +562,12 @@ class AgentTeam:
|
||||
self._telemetry = None
|
||||
|
||||
def add_task(self, task):
|
||||
task_id = self.task_id_counter
|
||||
task.id = task_id
|
||||
self.tasks[task_id] = task
|
||||
self.task_id_counter += 1
|
||||
return task_id
|
||||
with self._task_id_lock:
|
||||
task_id = self.task_id_counter
|
||||
task.id = task_id
|
||||
self.tasks[task_id] = task
|
||||
self.task_id_counter += 1
|
||||
return task_id
|
||||
|
||||
def clean_json_output(self, output: str) -> str:
|
||||
cleaned = output.strip()
|
||||
@@ -1287,6 +1291,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
|
||||
|
||||
@@ -1413,7 +1418,8 @@ Context:
|
||||
|
||||
def set_state(self, key: str, value: Any) -> None:
|
||||
"""Set a state value"""
|
||||
self._state[key] = value
|
||||
with self._state_lock:
|
||||
self._state[key] = value
|
||||
|
||||
def get_state(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a state value"""
|
||||
@@ -1421,11 +1427,13 @@ Context:
|
||||
|
||||
def update_state(self, updates: Dict) -> None:
|
||||
"""Update multiple state values"""
|
||||
self._state.update(updates)
|
||||
with self._state_lock:
|
||||
self._state.update(updates)
|
||||
|
||||
def clear_state(self) -> None:
|
||||
"""Clear all state values"""
|
||||
self._state.clear()
|
||||
with self._state_lock:
|
||||
self._state.clear()
|
||||
|
||||
# Convenience methods for enhanced state management
|
||||
def has_state(self, key: str) -> bool:
|
||||
@@ -1438,19 +1446,21 @@ Context:
|
||||
|
||||
def delete_state(self, key: str) -> bool:
|
||||
"""Delete a state key if it exists. Returns True if deleted, False if key didn't exist."""
|
||||
if key in self._state:
|
||||
del self._state[key]
|
||||
return True
|
||||
return False
|
||||
with self._state_lock:
|
||||
if key in self._state:
|
||||
del self._state[key]
|
||||
return True
|
||||
return False
|
||||
|
||||
def increment_state(self, key: str, amount: float = 1, default: float = 0) -> float:
|
||||
"""Increment a numeric state value. Creates the key with default if it doesn't exist."""
|
||||
current = self._state.get(key, default)
|
||||
if not isinstance(current, (int, float)):
|
||||
raise TypeError(f"Cannot increment non-numeric value at key '{key}': {type(current).__name__}")
|
||||
new_value = current + amount
|
||||
self._state[key] = new_value
|
||||
return new_value
|
||||
with self._state_lock:
|
||||
current = self._state.get(key, default)
|
||||
if not isinstance(current, (int, float)):
|
||||
raise TypeError(f"Cannot increment non-numeric value at key '{key}': {type(current).__name__}")
|
||||
new_value = current + amount
|
||||
self._state[key] = new_value
|
||||
return new_value
|
||||
|
||||
def append_to_state(self, key: str, value: Any, max_length: Optional[int] = None) -> List[Any]:
|
||||
"""Append a value to a list state. Creates the list if it doesn't exist.
|
||||
@@ -1466,20 +1476,21 @@ Context:
|
||||
Raises:
|
||||
TypeError: If the existing value is not a list and convert_to_list=False
|
||||
"""
|
||||
if key not in self._state:
|
||||
self._state[key] = []
|
||||
elif not isinstance(self._state[key], list):
|
||||
# Be explicit about type conversion for better user experience
|
||||
current_value = self._state[key]
|
||||
self._state[key] = [current_value]
|
||||
|
||||
self._state[key].append(value)
|
||||
|
||||
# Trim list if max_length is specified
|
||||
if max_length and len(self._state[key]) > max_length:
|
||||
self._state[key] = self._state[key][-max_length:]
|
||||
|
||||
return self._state[key]
|
||||
with self._state_lock:
|
||||
if key not in self._state:
|
||||
self._state[key] = []
|
||||
elif not isinstance(self._state[key], list):
|
||||
# Be explicit about type conversion for better user experience
|
||||
current_value = self._state[key]
|
||||
self._state[key] = [current_value]
|
||||
|
||||
self._state[key].append(value)
|
||||
|
||||
# Trim list if max_length is specified
|
||||
if max_length and len(self._state[key]) > max_length:
|
||||
self._state[key] = self._state[key][-max_length:]
|
||||
|
||||
return self._state[key]
|
||||
|
||||
def save_session_state(self, session_id: str, include_memory: bool = True) -> None:
|
||||
"""Save current state to memory for session persistence"""
|
||||
@@ -1521,7 +1532,8 @@ Context:
|
||||
state_data = metadata.get("state_data", {})
|
||||
if "state" in state_data:
|
||||
# Merge with existing state instead of replacing
|
||||
self._state.update(state_data["state"])
|
||||
with self._state_lock:
|
||||
self._state.update(state_data["state"])
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -1645,16 +1657,18 @@ Context:
|
||||
print("pip install 'praisonaiagents[api]'")
|
||||
return None
|
||||
|
||||
# Initialize port-specific collections if needed
|
||||
if port not in _agents_registered_endpoints:
|
||||
_agents_registered_endpoints[port] = {}
|
||||
|
||||
# Initialize shared FastAPI app if not already created for this port
|
||||
if _agents_shared_apps.get(port) is None:
|
||||
_agents_shared_apps[port] = FastAPI(
|
||||
title=f"PraisonAI Agents API (Port {port})",
|
||||
description="API for interacting with multiple PraisonAI Agents"
|
||||
)
|
||||
# Thread-safe initialization of port-specific collections
|
||||
with _agents_server_lock:
|
||||
# Initialize port-specific collections if needed
|
||||
if port not in _agents_registered_endpoints:
|
||||
_agents_registered_endpoints[port] = {}
|
||||
|
||||
# Initialize shared FastAPI app if not already created for this port
|
||||
if _agents_shared_apps.get(port) is None:
|
||||
_agents_shared_apps[port] = FastAPI(
|
||||
title=f"PraisonAI Agents API (Port {port})",
|
||||
description="API for interacting with multiple PraisonAI Agents"
|
||||
)
|
||||
|
||||
# Add a root endpoint with a welcome message
|
||||
@_agents_shared_apps[port].get("/")
|
||||
@@ -1676,20 +1690,22 @@ Context:
|
||||
if not path.startswith('/'):
|
||||
path = f'/{path}'
|
||||
|
||||
# Check if path is already registered for this port
|
||||
if path in _agents_registered_endpoints[port]:
|
||||
logging.warning(f"Path '{path}' is already registered on port {port}. Please use a different path.")
|
||||
print(f"⚠️ Warning: Path '{path}' is already registered on port {port}.")
|
||||
# Use a modified path to avoid conflicts
|
||||
original_path = path
|
||||
instance_id = str(uuid.uuid4())[:6]
|
||||
path = f"{path}_{instance_id}"
|
||||
logging.warning(f"Using '{path}' instead of '{original_path}'")
|
||||
print(f"🔄 Using '{path}' instead")
|
||||
|
||||
# Generate a unique ID for this agent group's endpoint
|
||||
endpoint_id = str(uuid.uuid4())
|
||||
_agents_registered_endpoints[port][path] = endpoint_id
|
||||
# Thread-safe path registration
|
||||
with _agents_server_lock:
|
||||
# Check if path is already registered for this port
|
||||
if path in _agents_registered_endpoints[port]:
|
||||
logging.warning(f"Path '{path}' is already registered on port {port}. Please use a different path.")
|
||||
print(f"⚠️ Warning: Path '{path}' is already registered on port {port}.")
|
||||
# Use a modified path to avoid conflicts
|
||||
original_path = path
|
||||
instance_id = str(uuid.uuid4())[:6]
|
||||
path = f"{path}_{instance_id}"
|
||||
logging.warning(f"Using '{path}' instead of '{original_path}'")
|
||||
print(f"🔄 Using '{path}' instead")
|
||||
|
||||
# Generate a unique ID for this agent group's endpoint
|
||||
endpoint_id = str(uuid.uuid4())
|
||||
_agents_registered_endpoints[port][path] = endpoint_id
|
||||
|
||||
# Define the endpoint handler
|
||||
@_agents_shared_apps[port].post(path)
|
||||
@@ -1817,16 +1833,23 @@ Context:
|
||||
)
|
||||
return handle_single_agent
|
||||
|
||||
# Register the endpoint
|
||||
# Register the endpoint with thread safety
|
||||
_agents_shared_apps[port].post(agent_path)(create_agent_handler(agent_instance))
|
||||
_agents_registered_endpoints[port][agent_path] = f"{endpoint_id}_{agent_id}"
|
||||
with _agents_server_lock:
|
||||
_agents_registered_endpoints[port][agent_path] = f"{endpoint_id}_{agent_id}"
|
||||
|
||||
print(f"🔗 Per-agent endpoints: {', '.join([f'{path}/{aid}' for aid in agents_dict.keys()])}")
|
||||
|
||||
# Start the server if it's not already running for this port
|
||||
if not _agents_server_started.get(port, False):
|
||||
# Mark the server as started first to prevent duplicate starts
|
||||
_agents_server_started[port] = True
|
||||
with _agents_server_lock:
|
||||
if not _agents_server_started.get(port, False):
|
||||
# Mark the server as started first to prevent duplicate starts
|
||||
_agents_server_started[port] = True
|
||||
should_start_server = True
|
||||
else:
|
||||
should_start_server = False
|
||||
|
||||
if should_start_server:
|
||||
|
||||
# Start the server in a separate thread
|
||||
def run_server():
|
||||
@@ -2305,7 +2328,8 @@ Context:
|
||||
# Store original tasks and create new tasks from plan
|
||||
original_tasks = self.tasks.copy()
|
||||
self.tasks = {}
|
||||
self.task_id_counter = 0
|
||||
with self._task_id_lock:
|
||||
self.task_id_counter = 0
|
||||
|
||||
# Create Task objects from plan steps
|
||||
plan_tasks = []
|
||||
@@ -2411,7 +2435,6 @@ Context:
|
||||
self._plan_tasks = self.tasks.copy()
|
||||
# Keep plan tasks for results but note original tasks are preserved in _plan_tasks
|
||||
|
||||
|
||||
# Backward compatibility aliases (silent - no deprecation warnings)
|
||||
# AgentTeam is the primary name (v1.0+)
|
||||
# AgentManager, Agents, PraisonAIAgents are silent aliases
|
||||
|
||||
@@ -18,6 +18,7 @@ Usage:
|
||||
"""
|
||||
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
@@ -27,8 +28,7 @@ if TYPE_CHECKING:
|
||||
from praisonaiagents.agent import Agent
|
||||
from praisonaiagents.rag import RAG, ContextPack
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class RetrievalPolicy(Enum):
|
||||
"""Policy for when to perform RAG retrieval."""
|
||||
@@ -36,7 +36,6 @@ class RetrievalPolicy(Enum):
|
||||
ALWAYS = "always" # Always retrieve
|
||||
NEVER = "never" # Never retrieve (direct chat only)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AutoRagConfig:
|
||||
"""Configuration for AutoRagAgent."""
|
||||
@@ -101,7 +100,6 @@ class AutoRagConfig:
|
||||
|
||||
return cls(**kwargs)
|
||||
|
||||
|
||||
class AutoRagAgent:
|
||||
"""
|
||||
Agent wrapper with automatic RAG retrieval decision.
|
||||
|
||||
@@ -10,6 +10,7 @@ from ..agent.agent import Agent
|
||||
from ..task.task import Task
|
||||
from typing import List, Any, Optional, Dict, Tuple
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
import os
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from ..main import display_instruction, display_tool_call, display_interaction
|
||||
@@ -358,9 +359,9 @@ DO NOT use strings for tasks. Each task MUST be a complete object with all four
|
||||
if self.llm and supports_structured_outputs(self.llm):
|
||||
client = get_openai_client()
|
||||
use_openai_structured = True
|
||||
except:
|
||||
except Exception as e:
|
||||
# If OpenAI client is not available, we'll use the LLM class
|
||||
pass
|
||||
get_logger(__name__).debug(f"OpenAI client not available, falling back to LLM class: {e}")
|
||||
|
||||
try:
|
||||
if use_openai_structured and client:
|
||||
|
||||
@@ -6,6 +6,7 @@ and context. Inspired by Gemini CLI's delegate-to-agent pattern.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Callable
|
||||
@@ -13,8 +14,7 @@ from enum import Enum
|
||||
|
||||
from .profiles import AgentProfile, AgentMode, get_profile, BUILTIN_PROFILES
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class DelegationStatus(Enum):
|
||||
"""Status of a delegated task."""
|
||||
@@ -25,7 +25,6 @@ class DelegationStatus(Enum):
|
||||
CANCELLED = "cancelled"
|
||||
TIMEOUT = "timeout"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DelegationConfig:
|
||||
"""Configuration for subagent delegation."""
|
||||
@@ -49,7 +48,6 @@ class DelegationConfig:
|
||||
auto_cancel_on_parent_cancel: bool = True
|
||||
collect_results: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class DelegationTask:
|
||||
"""A task delegated to a subagent."""
|
||||
@@ -75,7 +73,6 @@ class DelegationTask:
|
||||
# Metadata
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DelegationResult:
|
||||
"""Result of a delegation operation."""
|
||||
@@ -88,7 +85,6 @@ class DelegationResult:
|
||||
tokens_used: int = 0
|
||||
duration_seconds: float = 0.0
|
||||
|
||||
|
||||
class SubagentDelegator:
|
||||
"""
|
||||
Manages delegation of tasks to subagents.
|
||||
@@ -448,7 +444,6 @@ class SubagentDelegator:
|
||||
self._task_counter += 1
|
||||
return f"task_{self._task_counter}"
|
||||
|
||||
|
||||
# Convenience function for quick delegation
|
||||
async def delegate_to_agent(
|
||||
agent_name: str,
|
||||
|
||||
@@ -33,6 +33,7 @@ import asyncio
|
||||
import contextvars
|
||||
import json
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from functools import wraps
|
||||
@@ -45,13 +46,12 @@ from .protocols import ApprovalDecision # noqa: F401
|
||||
from .backends import AutoApproveBackend, ConsoleBackend, CallbackBackend, AgentApproval # noqa: F401
|
||||
from .registry import ApprovalRegistry, DEFAULT_DANGEROUS_TOOLS # noqa: F401
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# ── Singleton registry ───────────────────────────────────────────────────────
|
||||
|
||||
_registry: Optional[ApprovalRegistry] = None
|
||||
|
||||
|
||||
def get_approval_registry() -> ApprovalRegistry:
|
||||
"""Return the global singleton :class:`ApprovalRegistry`."""
|
||||
global _registry
|
||||
@@ -59,7 +59,6 @@ def get_approval_registry() -> ApprovalRegistry:
|
||||
_registry = ApprovalRegistry()
|
||||
return _registry
|
||||
|
||||
|
||||
# ── Backward-compatible API (delegates to registry) ─────────────────────────
|
||||
|
||||
# These globals are kept for code that imports them directly.
|
||||
@@ -69,7 +68,6 @@ TOOL_RISK_LEVELS: Dict[str, str] = get_approval_registry()._risk_levels
|
||||
# Legacy global callback holder — set_approval_callback wraps it into a backend
|
||||
approval_callback: Optional[Callable] = None
|
||||
|
||||
|
||||
def set_approval_callback(callback_fn: Optional[Callable]) -> None:
|
||||
"""Set a custom approval callback (legacy API).
|
||||
|
||||
@@ -84,67 +82,53 @@ def set_approval_callback(callback_fn: Optional[Callable]) -> None:
|
||||
else:
|
||||
reg.remove_backend()
|
||||
|
||||
|
||||
def get_approval_callback() -> Optional[Callable]:
|
||||
"""Get the current approval callback function (legacy API)."""
|
||||
return approval_callback
|
||||
|
||||
|
||||
def mark_approved(tool_name: str) -> None:
|
||||
get_approval_registry().mark_approved(tool_name)
|
||||
|
||||
|
||||
def is_already_approved(tool_name: str) -> bool:
|
||||
return get_approval_registry().is_already_approved(tool_name)
|
||||
|
||||
|
||||
def is_yaml_approved(tool_name: str) -> bool:
|
||||
return get_approval_registry().is_yaml_approved(tool_name)
|
||||
|
||||
|
||||
def is_env_auto_approve() -> bool:
|
||||
return ApprovalRegistry.is_env_auto_approve()
|
||||
|
||||
|
||||
def set_yaml_approved_tools(tools: List[str]) -> contextvars.Token:
|
||||
return get_approval_registry().set_yaml_approved_tools(tools)
|
||||
|
||||
|
||||
def reset_yaml_approved_tools(token: contextvars.Token) -> None:
|
||||
get_approval_registry().reset_yaml_approved_tools(token)
|
||||
|
||||
|
||||
def clear_approval_context() -> None:
|
||||
get_approval_registry().clear_approved()
|
||||
|
||||
|
||||
def configure_default_approvals() -> None:
|
||||
"""Re-configure default dangerous tools (idempotent)."""
|
||||
reg = get_approval_registry()
|
||||
for tool_name, risk_level in DEFAULT_DANGEROUS_TOOLS.items():
|
||||
reg.add_requirement(tool_name, risk_level)
|
||||
|
||||
|
||||
def add_approval_requirement(tool_name: str, risk_level: str = "high") -> None:
|
||||
get_approval_registry().add_requirement(tool_name, risk_level)
|
||||
APPROVAL_REQUIRED_TOOLS.add(tool_name)
|
||||
TOOL_RISK_LEVELS[tool_name] = risk_level
|
||||
|
||||
|
||||
def remove_approval_requirement(tool_name: str) -> None:
|
||||
get_approval_registry().remove_requirement(tool_name)
|
||||
APPROVAL_REQUIRED_TOOLS.discard(tool_name)
|
||||
TOOL_RISK_LEVELS.pop(tool_name, None)
|
||||
|
||||
|
||||
def is_approval_required(tool_name: str) -> bool:
|
||||
return get_approval_registry().is_required(tool_name)
|
||||
|
||||
|
||||
def get_risk_level(tool_name: str) -> Optional[str]:
|
||||
return get_approval_registry().get_risk_level(tool_name)
|
||||
|
||||
|
||||
# ── Legacy console callback (kept for direct callers) ───────────────────────
|
||||
|
||||
def console_approval_callback(function_name: str, arguments: Dict, risk_level: str) -> ApprovalDecision:
|
||||
@@ -157,19 +141,16 @@ def console_approval_callback(function_name: str, arguments: Dict, risk_level: s
|
||||
)
|
||||
return backend.request_approval_sync(request)
|
||||
|
||||
|
||||
# ── Legacy async request_approval ────────────────────────────────────────────
|
||||
|
||||
async def request_approval(function_name: str, arguments: Dict) -> ApprovalDecision:
|
||||
"""Request approval for a tool execution (legacy async API)."""
|
||||
return await get_approval_registry().approve_async(None, function_name, arguments)
|
||||
|
||||
|
||||
# ── require_approval decorator (unchanged semantics) ─────────────────────────
|
||||
|
||||
RiskLevel = Literal["critical", "high", "medium", "low"]
|
||||
|
||||
|
||||
def require_approval(risk_level: RiskLevel = "high"):
|
||||
"""Decorator to mark a tool as requiring human approval."""
|
||||
def decorator(func):
|
||||
@@ -228,10 +209,8 @@ def require_approval(risk_level: RiskLevel = "high"):
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
# ── PermissionAllowlist (kept as-is) ─────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolPermission:
|
||||
"""Permission entry for a tool."""
|
||||
@@ -239,7 +218,6 @@ class ToolPermission:
|
||||
allowed_paths: List[str] = field(default_factory=list)
|
||||
session_only: bool = False
|
||||
|
||||
|
||||
class PermissionAllowlist:
|
||||
"""Persistent permission allowlist for tools."""
|
||||
|
||||
@@ -309,17 +287,14 @@ class PermissionAllowlist:
|
||||
allowlist.add_tool(tool_name, paths=tool_data.get("allowed_paths", []), session_only=tool_data.get("session_only", False))
|
||||
return allowlist
|
||||
|
||||
|
||||
_permission_allowlist: Optional[PermissionAllowlist] = None
|
||||
|
||||
|
||||
def get_permission_allowlist() -> PermissionAllowlist:
|
||||
global _permission_allowlist
|
||||
if _permission_allowlist is None:
|
||||
_permission_allowlist = PermissionAllowlist()
|
||||
return _permission_allowlist
|
||||
|
||||
|
||||
def set_permission_allowlist(allowlist: PermissionAllowlist) -> None:
|
||||
global _permission_allowlist
|
||||
_permission_allowlist = allowlist
|
||||
@@ -12,18 +12,18 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
from typing import Any
|
||||
|
||||
from .protocols import ApprovalDecision, ApprovalRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Lazy Rich imports (same pattern as old approval.py)
|
||||
_rich_console = None
|
||||
_rich_panel = None
|
||||
_rich_confirm = None
|
||||
|
||||
|
||||
def _get_rich_console():
|
||||
global _rich_console
|
||||
if _rich_console is None:
|
||||
@@ -31,7 +31,6 @@ def _get_rich_console():
|
||||
_rich_console = Console
|
||||
return _rich_console
|
||||
|
||||
|
||||
def _get_rich_panel():
|
||||
global _rich_panel
|
||||
if _rich_panel is None:
|
||||
@@ -39,7 +38,6 @@ def _get_rich_panel():
|
||||
_rich_panel = Panel
|
||||
return _rich_panel
|
||||
|
||||
|
||||
def _get_rich_confirm():
|
||||
global _rich_confirm
|
||||
if _rich_confirm is None:
|
||||
@@ -47,7 +45,6 @@ def _get_rich_confirm():
|
||||
_rich_confirm = Confirm
|
||||
return _rich_confirm
|
||||
|
||||
|
||||
class AutoApproveBackend:
|
||||
"""Always approves. Use for bots or trusted unattended environments."""
|
||||
|
||||
@@ -57,7 +54,6 @@ class AutoApproveBackend:
|
||||
def request_approval_sync(self, request: ApprovalRequest) -> ApprovalDecision:
|
||||
return ApprovalDecision(approved=True, reason="auto-approved", approver="system")
|
||||
|
||||
|
||||
class ConsoleBackend:
|
||||
"""Interactive Rich terminal prompt. Default for CLI usage."""
|
||||
|
||||
@@ -116,10 +112,9 @@ class ConsoleBackend:
|
||||
|
||||
async def request_approval(self, request: ApprovalRequest) -> ApprovalDecision:
|
||||
"""Async wrapper — runs the sync prompt in an executor."""
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, self.request_approval_sync, request)
|
||||
|
||||
|
||||
class AgentApproval:
|
||||
"""Delegates approval decisions to another AI agent.
|
||||
|
||||
@@ -210,7 +205,7 @@ class AgentApproval:
|
||||
if hasattr(approver, "achat"):
|
||||
response = await approver.achat(prompt)
|
||||
elif hasattr(approver, "chat"):
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
response = await loop.run_in_executor(None, approver.chat, prompt)
|
||||
else:
|
||||
return ApprovalDecision(
|
||||
@@ -237,19 +232,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:
|
||||
"""Wraps a legacy ``(function_name, arguments, risk_level) -> ApprovalDecision`` callback
|
||||
@@ -274,7 +258,7 @@ class CallbackBackend:
|
||||
if asyncio.iscoroutinefunction(self._callback):
|
||||
result = await self._callback(request.tool_name, request.arguments, request.risk_level)
|
||||
else:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
result = await loop.run_in_executor(
|
||||
None, self._callback, request.tool_name, request.arguments, request.risk_level,
|
||||
)
|
||||
|
||||
@@ -19,12 +19,13 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import contextvars
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
import os
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
from .protocols import ApprovalDecision, ApprovalRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Default dangerous tools — same as old approval.py
|
||||
DEFAULT_DANGEROUS_TOOLS: Dict[str, str] = {
|
||||
@@ -45,6 +46,18 @@ 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)
|
||||
@@ -6,14 +6,14 @@ Manages background task execution.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
from typing import Optional, List, Dict, Any, Callable, Union
|
||||
from datetime import datetime
|
||||
|
||||
from .task import BackgroundTask, TaskStatus
|
||||
from .config import BackgroundConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class BackgroundRunner:
|
||||
"""
|
||||
@@ -392,7 +392,6 @@ class BackgroundRunner:
|
||||
)
|
||||
return future.result(timeout=5)
|
||||
|
||||
|
||||
# ── Module-level background event loop (lazy, daemon) ───────────────────
|
||||
|
||||
import threading as _threading
|
||||
@@ -400,7 +399,6 @@ import threading as _threading
|
||||
_bg_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
_bg_lock = _threading.Lock()
|
||||
|
||||
|
||||
def _get_bg_loop() -> asyncio.AbstractEventLoop:
|
||||
"""Return a running event loop on a background daemon thread.
|
||||
|
||||
|
||||
@@ -7,13 +7,13 @@ Represents a task running in the background.
|
||||
import uuid
|
||||
import asyncio
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Any, Callable, Dict
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class TaskStatus(str, Enum):
|
||||
"""Status of a background task."""
|
||||
@@ -23,7 +23,6 @@ class TaskStatus(str, Enum):
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackgroundTask:
|
||||
"""
|
||||
|
||||
@@ -6,13 +6,13 @@ Provides publish/subscribe functionality for typed events.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
import threading
|
||||
from typing import Any, Callable, Dict, List, Optional, Set, Union
|
||||
from dataclasses import dataclass, field
|
||||
from .event import Event, EventType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@dataclass
|
||||
class Subscriber:
|
||||
@@ -36,7 +36,6 @@ class Subscriber:
|
||||
return True
|
||||
return event_type in self.event_types
|
||||
|
||||
|
||||
class EventBus:
|
||||
"""
|
||||
Event bus for publish/subscribe communication.
|
||||
@@ -324,12 +323,10 @@ class EventBus:
|
||||
def __repr__(self) -> str:
|
||||
return f"EventBus(subscribers={self.subscriber_count})"
|
||||
|
||||
|
||||
# Global default bus
|
||||
_default_bus: Optional[EventBus] = None
|
||||
_bus_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_default_bus() -> EventBus:
|
||||
"""Get the default global event bus."""
|
||||
global _default_bus
|
||||
@@ -338,7 +335,6 @@ def get_default_bus() -> EventBus:
|
||||
_default_bus = EventBus()
|
||||
return _default_bus
|
||||
|
||||
|
||||
def set_default_bus(bus: EventBus):
|
||||
"""Set the default global event bus."""
|
||||
global _default_bus
|
||||
|
||||
@@ -7,6 +7,7 @@ Implements shadow git repository for file-level checkpointing.
|
||||
import os
|
||||
import asyncio
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
import shutil
|
||||
from typing import Optional, List, Dict, Any, Callable
|
||||
from datetime import datetime
|
||||
@@ -17,8 +18,7 @@ from .types import (
|
||||
CheckpointResult, CheckpointEvent
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
def _parse_iso_timestamp(timestamp: str) -> datetime:
|
||||
"""Parse ISO format timestamp, handling 'Z' suffix for Python 3.9 compatibility."""
|
||||
@@ -39,7 +39,6 @@ PROTECTED_PATHS = [
|
||||
"/etc",
|
||||
]
|
||||
|
||||
|
||||
class CheckpointService:
|
||||
"""
|
||||
Shadow Git Checkpoint Service.
|
||||
|
||||
@@ -9,10 +9,10 @@ to enable DRY condition handling across the codebase.
|
||||
"""
|
||||
import re
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class ExpressionCondition:
|
||||
"""
|
||||
@@ -62,7 +62,6 @@ class ExpressionCondition:
|
||||
def __repr__(self) -> str:
|
||||
return f"ExpressionCondition({self.expression!r})"
|
||||
|
||||
|
||||
class DictCondition:
|
||||
"""
|
||||
Dict-based condition evaluator for routing decisions.
|
||||
@@ -127,7 +126,6 @@ class DictCondition:
|
||||
def __repr__(self) -> str:
|
||||
return f"DictCondition(routes={self.routes!r}, key={self.key!r})"
|
||||
|
||||
|
||||
def evaluate_condition(
|
||||
condition: str,
|
||||
variables: Dict[str, Any],
|
||||
|
||||
@@ -78,6 +78,18 @@ __all__ = [
|
||||
"CONTEXT_PRESETS",
|
||||
"AUTONOMY_PRESETS",
|
||||
"CACHING_PRESETS",
|
||||
# Unified config resolver
|
||||
"get_config",
|
||||
"set_config",
|
||||
"has_config",
|
||||
"list_config_keys",
|
||||
"validate_config",
|
||||
"ConfigRegistry",
|
||||
"ConfigScope",
|
||||
"ConfigType",
|
||||
"ConfigEntry",
|
||||
"getenv_cached",
|
||||
"clear_env_cache",
|
||||
]
|
||||
|
||||
# Module mapping for lazy loading
|
||||
@@ -139,6 +151,18 @@ _MODULE_MAP = {
|
||||
"CONTEXT_PRESETS": "presets",
|
||||
"AUTONOMY_PRESETS": "presets",
|
||||
"CACHING_PRESETS": "presets",
|
||||
# resolver
|
||||
"get_config": "resolver",
|
||||
"set_config": "resolver",
|
||||
"has_config": "resolver",
|
||||
"list_config_keys": "resolver",
|
||||
"validate_config": "resolver",
|
||||
"ConfigRegistry": "resolver",
|
||||
"ConfigScope": "resolver",
|
||||
"ConfigType": "resolver",
|
||||
"ConfigEntry": "resolver",
|
||||
"getenv_cached": "resolver",
|
||||
"clear_env_cache": "resolver",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -696,9 +702,29 @@ class ExecutionConfig:
|
||||
code_execution: bool = False
|
||||
code_mode: str = "safe" # "safe" or "unsafe"
|
||||
|
||||
# Code execution sandbox mode - "sandbox" (default) uses subprocess isolation
|
||||
# "direct" runs in current process (legacy, less secure)
|
||||
code_sandbox_mode: str = "sandbox" # "sandbox" or "direct"
|
||||
|
||||
# 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 +734,10 @@ class ExecutionConfig:
|
||||
"max_retry_limit": self.max_retry_limit,
|
||||
"code_execution": self.code_execution,
|
||||
"code_mode": self.code_mode,
|
||||
"code_sandbox_mode": self.code_sandbox_mode,
|
||||
"context_compaction": self.context_compaction,
|
||||
"max_context_tokens": self.max_context_tokens,
|
||||
"max_budget": self.max_budget,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
"""
|
||||
Unified Configuration Resolver for PraisonAI.
|
||||
|
||||
This module provides a centralized, protocol-driven configuration system that:
|
||||
- Unifies all environment variable access
|
||||
- Provides caching for performance
|
||||
- Implements clear precedence rules
|
||||
- Maintains backward compatibility
|
||||
- Enables configuration validation and discovery
|
||||
|
||||
Design Philosophy (from AGENTS.md):
|
||||
- Protocol-driven core: Use protocols for extension points
|
||||
- No performance impact: Lazy loading, caching
|
||||
- Agent-centric: All decisions prioritize agents and workflows
|
||||
- Backward compatible: Existing env var access continues to work
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.config.resolver import get_config, ConfigRegistry
|
||||
|
||||
# Get a configuration value with fallback
|
||||
api_key = get_config("OPENAI_API_KEY", default=None)
|
||||
|
||||
# Register a new configuration
|
||||
ConfigRegistry.register("MY_CONFIG", default="default_value", doc="My config setting")
|
||||
"""
|
||||
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from functools import lru_cache
|
||||
from typing import Any, Dict, List, Optional, Protocol, Set, Type, Union
|
||||
import warnings
|
||||
|
||||
|
||||
class ConfigScope(str, Enum):
|
||||
"""Configuration scopes for organizing settings."""
|
||||
CORE = "core" # Core SDK settings
|
||||
LLM = "llm" # LLM provider settings
|
||||
MEMORY = "memory" # Memory and persistence
|
||||
TOOLS = "tools" # Tool configurations
|
||||
BOTS = "bots" # Bot platform settings
|
||||
GATEWAY = "gateway" # Gateway server settings
|
||||
INTEGRATIONS = "integrations" # External integrations
|
||||
|
||||
|
||||
class ConfigType(str, Enum):
|
||||
"""Configuration value types for validation."""
|
||||
STRING = "string"
|
||||
INTEGER = "integer"
|
||||
FLOAT = "float"
|
||||
BOOLEAN = "boolean"
|
||||
LIST = "list"
|
||||
DICT = "dict"
|
||||
PATH = "path"
|
||||
URL = "url"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConfigEntry:
|
||||
"""Configuration entry with metadata."""
|
||||
key: str
|
||||
scope: ConfigScope = ConfigScope.CORE
|
||||
type: ConfigType = ConfigType.STRING
|
||||
default: Any = None
|
||||
description: str = ""
|
||||
env_var: Optional[str] = None # Override env var name if different from key
|
||||
deprecated: bool = False
|
||||
aliases: List[str] = field(default_factory=list)
|
||||
validation: Optional[callable] = None
|
||||
|
||||
def __post_init__(self):
|
||||
"""Set env_var to key if not provided."""
|
||||
if self.env_var is None:
|
||||
self.env_var = self.key
|
||||
|
||||
|
||||
class ConfigProtocol(Protocol):
|
||||
"""Protocol for configuration providers."""
|
||||
|
||||
@abstractmethod
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get configuration value by key."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
"""Set configuration value."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def has(self, key: str) -> bool:
|
||||
"""Check if configuration key exists."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def list_keys(self) -> List[str]:
|
||||
"""List all available configuration keys."""
|
||||
...
|
||||
|
||||
|
||||
class EnvironmentConfigProvider:
|
||||
"""Configuration provider that reads from environment variables."""
|
||||
|
||||
def __init__(self):
|
||||
self._cache: Dict[str, Any] = {}
|
||||
self._cache_enabled = True
|
||||
|
||||
def get(self, key: str, default: Any = None, config_type: ConfigType = ConfigType.STRING) -> Any:
|
||||
"""Get value from environment with type conversion and caching."""
|
||||
if self._cache_enabled and key in self._cache:
|
||||
return self._cache[key]
|
||||
|
||||
env_value = os.getenv(key)
|
||||
if env_value is None:
|
||||
return default
|
||||
|
||||
# Type conversion
|
||||
try:
|
||||
converted_value = self._convert_type(env_value, config_type)
|
||||
if self._cache_enabled:
|
||||
self._cache[key] = converted_value
|
||||
return converted_value
|
||||
except (ValueError, TypeError) as e:
|
||||
warnings.warn(f"Failed to convert env var {key}={env_value} to {config_type}: {e}")
|
||||
return default
|
||||
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
"""Set environment variable and clear cache."""
|
||||
os.environ[key] = str(value)
|
||||
self._cache.pop(key, None)
|
||||
|
||||
def has(self, key: str) -> bool:
|
||||
"""Check if environment variable exists."""
|
||||
return key in os.environ
|
||||
|
||||
def list_keys(self) -> List[str]:
|
||||
"""List all environment variable keys."""
|
||||
return list(os.environ.keys())
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear the configuration cache."""
|
||||
self._cache.clear()
|
||||
|
||||
def disable_cache(self) -> None:
|
||||
"""Disable caching (useful for testing)."""
|
||||
self._cache_enabled = False
|
||||
self._cache.clear()
|
||||
|
||||
def enable_cache(self) -> None:
|
||||
"""Enable caching."""
|
||||
self._cache_enabled = True
|
||||
|
||||
def _convert_type(self, value: str, config_type: ConfigType) -> Any:
|
||||
"""Convert string value to specified type."""
|
||||
if config_type == ConfigType.STRING:
|
||||
return value
|
||||
elif config_type == ConfigType.INTEGER:
|
||||
return int(value)
|
||||
elif config_type == ConfigType.FLOAT:
|
||||
return float(value)
|
||||
elif config_type == ConfigType.BOOLEAN:
|
||||
return value.lower() in ('true', '1', 'yes', 'on')
|
||||
elif config_type == ConfigType.LIST:
|
||||
# Support comma-separated values
|
||||
return [item.strip() for item in value.split(',') if item.strip()]
|
||||
elif config_type == ConfigType.DICT:
|
||||
# Support JSON-like format (simplified)
|
||||
import json
|
||||
return json.loads(value)
|
||||
elif config_type in (ConfigType.PATH, ConfigType.URL):
|
||||
return value # No conversion needed, but could add validation
|
||||
else:
|
||||
return value
|
||||
|
||||
|
||||
class ConfigRegistry:
|
||||
"""Central registry for all configuration entries."""
|
||||
|
||||
_entries: Dict[str, ConfigEntry] = {}
|
||||
_aliases: Dict[str, str] = {} # alias -> canonical key mapping
|
||||
_provider = EnvironmentConfigProvider()
|
||||
|
||||
@classmethod
|
||||
def register(
|
||||
self,
|
||||
key: str,
|
||||
scope: ConfigScope = ConfigScope.CORE,
|
||||
type: ConfigType = ConfigType.STRING,
|
||||
default: Any = None,
|
||||
description: str = "",
|
||||
env_var: Optional[str] = None,
|
||||
deprecated: bool = False,
|
||||
aliases: Optional[List[str]] = None,
|
||||
validation: Optional[callable] = None,
|
||||
) -> ConfigEntry:
|
||||
"""Register a configuration entry."""
|
||||
entry = ConfigEntry(
|
||||
key=key,
|
||||
scope=scope,
|
||||
type=type,
|
||||
default=default,
|
||||
description=description,
|
||||
env_var=env_var,
|
||||
deprecated=deprecated,
|
||||
aliases=aliases or [],
|
||||
validation=validation,
|
||||
)
|
||||
|
||||
self._entries[key] = entry
|
||||
|
||||
# Register aliases
|
||||
for alias in entry.aliases:
|
||||
self._aliases[alias] = key
|
||||
|
||||
return entry
|
||||
|
||||
@classmethod
|
||||
def get_entry(cls, key: str) -> Optional[ConfigEntry]:
|
||||
"""Get configuration entry by key or alias."""
|
||||
# Check direct key first
|
||||
if key in cls._entries:
|
||||
return cls._entries[key]
|
||||
|
||||
# Check aliases
|
||||
canonical_key = cls._aliases.get(key)
|
||||
if canonical_key:
|
||||
return cls._entries[canonical_key]
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def list_entries(cls, scope: Optional[ConfigScope] = None) -> List[ConfigEntry]:
|
||||
"""List all registered configuration entries."""
|
||||
entries = list(cls._entries.values())
|
||||
if scope:
|
||||
entries = [e for e in entries if e.scope == scope]
|
||||
return entries
|
||||
|
||||
@classmethod
|
||||
def get_unknown_env_vars(cls) -> Set[str]:
|
||||
"""Get environment variables that are not registered."""
|
||||
all_env_vars = set(os.environ.keys())
|
||||
registered_vars = {entry.env_var for entry in cls._entries.values()}
|
||||
|
||||
# Filter out common system env vars
|
||||
system_vars = {
|
||||
'PATH', 'HOME', 'USER', 'SHELL', 'PWD', 'OLDPWD', 'TERM', 'LANG',
|
||||
'LC_ALL', 'TZ', 'TMPDIR', 'PYTHONPATH', 'VIRTUAL_ENV', 'CONDA_PREFIX'
|
||||
}
|
||||
|
||||
unknown_vars = all_env_vars - registered_vars - system_vars
|
||||
|
||||
# Filter PraisonAI-related vars only (to reduce noise)
|
||||
praison_vars = {var for var in unknown_vars if 'PRAISON' in var or any(
|
||||
provider in var for provider in ['OPENAI', 'ANTHROPIC', 'GOOGLE', 'GEMINI', 'CLAUDE']
|
||||
)}
|
||||
|
||||
return praison_vars
|
||||
|
||||
@classmethod
|
||||
def clear(cls) -> None:
|
||||
"""Clear all registered entries (for testing)."""
|
||||
cls._entries.clear()
|
||||
cls._aliases.clear()
|
||||
cls._provider.clear_cache()
|
||||
|
||||
|
||||
# Main configuration access functions
|
||||
|
||||
def get_config(key: str, default: Any = None, type: ConfigType = ConfigType.STRING) -> Any:
|
||||
"""
|
||||
Get configuration value with unified resolution.
|
||||
|
||||
Resolution order:
|
||||
1. Registered configuration entry (with type conversion)
|
||||
2. Direct environment variable lookup
|
||||
3. Default value
|
||||
|
||||
Args:
|
||||
key: Configuration key or environment variable name
|
||||
default: Default value if not found
|
||||
type: Expected configuration type for conversion
|
||||
|
||||
Returns:
|
||||
Configuration value with appropriate type conversion
|
||||
"""
|
||||
# Check if this is a registered configuration
|
||||
entry = ConfigRegistry.get_entry(key)
|
||||
if entry:
|
||||
if entry.deprecated:
|
||||
warnings.warn(f"Configuration {key} is deprecated: {entry.description}")
|
||||
|
||||
value = ConfigRegistry._provider.get(
|
||||
entry.env_var,
|
||||
default=entry.default if default is None else default,
|
||||
config_type=entry.type
|
||||
)
|
||||
|
||||
# Apply validation if provided
|
||||
if entry.validation and value is not None:
|
||||
try:
|
||||
entry.validation(value)
|
||||
except Exception as e:
|
||||
warnings.warn(f"Validation failed for {key}={value}: {e}")
|
||||
|
||||
return value
|
||||
|
||||
# Fall back to direct environment variable lookup (backward compatibility)
|
||||
return ConfigRegistry._provider.get(key, default=default, config_type=type)
|
||||
|
||||
|
||||
def set_config(key: str, value: Any) -> None:
|
||||
"""Set configuration value."""
|
||||
entry = ConfigRegistry.get_entry(key)
|
||||
env_var = entry.env_var if entry else key
|
||||
ConfigRegistry._provider.set(env_var, value)
|
||||
|
||||
|
||||
def has_config(key: str) -> bool:
|
||||
"""Check if configuration key exists."""
|
||||
entry = ConfigRegistry.get_entry(key)
|
||||
env_var = entry.env_var if entry else key
|
||||
return ConfigRegistry._provider.has(env_var)
|
||||
|
||||
|
||||
def list_config_keys() -> List[str]:
|
||||
"""List all registered configuration keys."""
|
||||
return list(ConfigRegistry._entries.keys())
|
||||
|
||||
|
||||
def validate_config() -> Dict[str, List[str]]:
|
||||
"""
|
||||
Validate current configuration and return warnings.
|
||||
|
||||
Returns:
|
||||
Dictionary with validation results:
|
||||
- "unknown_vars": Unknown environment variables
|
||||
- "deprecated_vars": Deprecated configuration in use
|
||||
- "validation_errors": Configuration values that failed validation
|
||||
"""
|
||||
results = {
|
||||
"unknown_vars": [],
|
||||
"deprecated_vars": [],
|
||||
"validation_errors": []
|
||||
}
|
||||
|
||||
# Check for unknown variables
|
||||
unknown_vars = ConfigRegistry.get_unknown_env_vars()
|
||||
results["unknown_vars"] = sorted(unknown_vars)
|
||||
|
||||
# Check for deprecated vars in use
|
||||
for entry in ConfigRegistry.list_entries():
|
||||
if entry.deprecated and ConfigRegistry._provider.has(entry.env_var):
|
||||
results["deprecated_vars"].append(entry.key)
|
||||
|
||||
# Check validation errors
|
||||
for entry in ConfigRegistry.list_entries():
|
||||
if entry.validation and ConfigRegistry._provider.has(entry.env_var):
|
||||
try:
|
||||
value = get_config(entry.key)
|
||||
entry.validation(value)
|
||||
except Exception as e:
|
||||
results["validation_errors"].append(f"{entry.key}: {e}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# Register core PraisonAI configuration entries
|
||||
def _register_core_configs():
|
||||
"""Register core configuration entries."""
|
||||
# LLM Provider Settings
|
||||
ConfigRegistry.register(
|
||||
"OPENAI_API_KEY",
|
||||
scope=ConfigScope.LLM,
|
||||
type=ConfigType.STRING,
|
||||
description="OpenAI API key for GPT models"
|
||||
)
|
||||
|
||||
ConfigRegistry.register(
|
||||
"OPENAI_BASE_URL",
|
||||
scope=ConfigScope.LLM,
|
||||
type=ConfigType.URL,
|
||||
aliases=["OPENAI_API_BASE"],
|
||||
description="OpenAI API base URL"
|
||||
)
|
||||
|
||||
ConfigRegistry.register(
|
||||
"ANTHROPIC_API_KEY",
|
||||
scope=ConfigScope.LLM,
|
||||
type=ConfigType.STRING,
|
||||
aliases=["CLAUDE_API_KEY"],
|
||||
description="Anthropic API key for Claude models"
|
||||
)
|
||||
|
||||
ConfigRegistry.register(
|
||||
"GOOGLE_API_KEY",
|
||||
scope=ConfigScope.LLM,
|
||||
type=ConfigType.STRING,
|
||||
aliases=["GEMINI_API_KEY"],
|
||||
description="Google API key for Gemini models"
|
||||
)
|
||||
|
||||
# Memory Configuration
|
||||
ConfigRegistry.register(
|
||||
"PRAISONAI_MEMORY_BACKEND",
|
||||
scope=ConfigScope.MEMORY,
|
||||
type=ConfigType.STRING,
|
||||
default="file",
|
||||
description="Memory storage backend (file, sqlite, redis, postgres)"
|
||||
)
|
||||
|
||||
# Bot Platform Tokens
|
||||
ConfigRegistry.register(
|
||||
"TELEGRAM_BOT_TOKEN",
|
||||
scope=ConfigScope.BOTS,
|
||||
type=ConfigType.STRING,
|
||||
description="Telegram bot token"
|
||||
)
|
||||
|
||||
ConfigRegistry.register(
|
||||
"DISCORD_BOT_TOKEN",
|
||||
scope=ConfigScope.BOTS,
|
||||
type=ConfigType.STRING,
|
||||
description="Discord bot token"
|
||||
)
|
||||
|
||||
# Logging and Debug
|
||||
ConfigRegistry.register(
|
||||
"LITELLM_TELEMETRY",
|
||||
scope=ConfigScope.CORE,
|
||||
type=ConfigType.BOOLEAN,
|
||||
default=False,
|
||||
description="Enable LiteLLM telemetry"
|
||||
)
|
||||
|
||||
ConfigRegistry.register(
|
||||
"PRAISONAI_DEBUG",
|
||||
scope=ConfigScope.CORE,
|
||||
type=ConfigType.BOOLEAN,
|
||||
default=False,
|
||||
description="Enable debug logging"
|
||||
)
|
||||
|
||||
|
||||
# Initialize core configurations
|
||||
_register_core_configs()
|
||||
|
||||
|
||||
# Backward compatibility functions - these maintain existing behavior
|
||||
# while routing through the new unified system
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def getenv_cached(key: str, default: Any = None) -> Any:
|
||||
"""Backward compatible cached getenv function."""
|
||||
return get_config(key, default)
|
||||
|
||||
|
||||
def clear_env_cache() -> None:
|
||||
"""Clear the environment variable cache."""
|
||||
getenv_cached.cache_clear()
|
||||
ConfigRegistry._provider.clear_cache()
|
||||
@@ -7,11 +7,11 @@ Inspired by CrewAI's ContextualMemory pattern.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@dataclass
|
||||
class AggregatedContext:
|
||||
@@ -30,7 +30,6 @@ class AggregatedContext:
|
||||
"fetch_times": self.fetch_times,
|
||||
}
|
||||
|
||||
|
||||
class ContextAggregator:
|
||||
"""
|
||||
Aggregates context from multiple memory sources concurrently.
|
||||
@@ -268,25 +267,26 @@ class ContextAggregator:
|
||||
AggregatedContext with merged results
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
# Create a new loop for sync execution
|
||||
# Check if we're in an async context
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
# We're in an async context - need to run in thread
|
||||
import concurrent.futures
|
||||
|
||||
def _run_async_in_thread():
|
||||
"""Run async method in new event loop within thread."""
|
||||
return asyncio.run(self.aggregate(query, sources, max_tokens))
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
future = pool.submit(
|
||||
asyncio.run,
|
||||
self.aggregate(query, sources, max_tokens)
|
||||
)
|
||||
future = pool.submit(_run_async_in_thread)
|
||||
return future.result()
|
||||
else:
|
||||
return loop.run_until_complete(
|
||||
self.aggregate(query, sources, max_tokens)
|
||||
)
|
||||
except RuntimeError:
|
||||
# No event loop, create one
|
||||
except RuntimeError:
|
||||
# No running event loop - safe to use asyncio.run
|
||||
return asyncio.run(self.aggregate(query, sources, max_tokens))
|
||||
except Exception:
|
||||
# Fallback - create new event loop
|
||||
return asyncio.run(self.aggregate(query, sources, max_tokens))
|
||||
|
||||
|
||||
def create_aggregator_from_config(
|
||||
memory = None,
|
||||
knowledge = None,
|
||||
|
||||
@@ -13,14 +13,14 @@ Design principles:
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
from typing import Optional, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Lazy availability check
|
||||
_AIOFILES_AVAILABLE: Optional[bool] = None
|
||||
|
||||
|
||||
def _check_aiofiles() -> bool:
|
||||
"""Lazy check for aiofiles availability.
|
||||
|
||||
@@ -38,7 +38,6 @@ def _check_aiofiles() -> bool:
|
||||
logger.debug("aiofiles not available, using sync fallback")
|
||||
return _AIOFILES_AVAILABLE
|
||||
|
||||
|
||||
def _sync_read_file(
|
||||
path: str,
|
||||
encoding: str = 'utf-8',
|
||||
@@ -48,7 +47,6 @@ def _sync_read_file(
|
||||
with open(path, 'r', encoding=encoding, errors=errors) as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _sync_read_lines(
|
||||
path: str,
|
||||
start_line: Optional[int] = None,
|
||||
@@ -74,7 +72,6 @@ def _sync_read_lines(
|
||||
else:
|
||||
return [line.rstrip('\n\r') for line in lines]
|
||||
|
||||
|
||||
async def async_read_file(
|
||||
path: str,
|
||||
encoding: str = 'utf-8',
|
||||
@@ -104,7 +101,6 @@ async def async_read_file(
|
||||
lambda: _sync_read_file(path, encoding, errors)
|
||||
)
|
||||
|
||||
|
||||
async def async_read_lines(
|
||||
path: str,
|
||||
start_line: Optional[int] = None,
|
||||
@@ -149,7 +145,6 @@ async def async_read_lines(
|
||||
lambda: _sync_read_lines(path, start_line, end_line, encoding, errors)
|
||||
)
|
||||
|
||||
|
||||
def is_aiofiles_available() -> bool:
|
||||
"""Check if aiofiles is available for async operations.
|
||||
|
||||
|
||||
@@ -13,15 +13,15 @@ Design principles:
|
||||
"""
|
||||
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
from typing import Protocol, Optional, List
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Lazy availability check for llmlingua
|
||||
_LLMLINGUA_AVAILABLE: Optional[bool] = None
|
||||
|
||||
|
||||
def _check_llmlingua() -> bool:
|
||||
"""Lazy check for llmlingua availability."""
|
||||
global _LLMLINGUA_AVAILABLE
|
||||
@@ -33,7 +33,6 @@ def _check_llmlingua() -> bool:
|
||||
_LLMLINGUA_AVAILABLE = False
|
||||
return _LLMLINGUA_AVAILABLE
|
||||
|
||||
|
||||
class ContextCompressor(Protocol):
|
||||
"""Protocol for context compression."""
|
||||
|
||||
@@ -49,7 +48,6 @@ class ContextCompressor(Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompressionConfig:
|
||||
"""Configuration for context compression."""
|
||||
@@ -58,7 +56,6 @@ class CompressionConfig:
|
||||
preserve_end_lines: int = 3 # Preserve last N lines
|
||||
truncation_marker: str = "\n\n... [content truncated] ...\n\n"
|
||||
|
||||
|
||||
class TruncateCompressor:
|
||||
"""Simple token-aware truncation compressor.
|
||||
|
||||
@@ -126,7 +123,6 @@ class TruncateCompressor:
|
||||
|
||||
return start_text + '\n' + middle_truncated + self.config.truncation_marker + end_text
|
||||
|
||||
|
||||
class SmartCompressor:
|
||||
"""Smart compressor that preserves important content.
|
||||
|
||||
@@ -214,7 +210,6 @@ class SmartCompressor:
|
||||
|
||||
return '\n'.join(result_lines)
|
||||
|
||||
|
||||
def get_compressor(compressor_type: str = "truncate") -> ContextCompressor:
|
||||
"""Get a context compressor by type.
|
||||
|
||||
@@ -244,7 +239,6 @@ def get_compressor(compressor_type: str = "truncate") -> ContextCompressor:
|
||||
else:
|
||||
raise ValueError(f"Invalid compressor type: {compressor_type}. Must be 'truncate', 'smart', or 'llmlingua'")
|
||||
|
||||
|
||||
def is_llmlingua_available() -> bool:
|
||||
"""Check if llmlingua is available.
|
||||
|
||||
|
||||
@@ -8,13 +8,13 @@ Injects FastContext results into the main agent with:
|
||||
"""
|
||||
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
from typing import Optional, List, Dict, Any
|
||||
from dataclasses import dataclass
|
||||
|
||||
from praisonaiagents.context.fast.result import FastContextResult, FileMatch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@dataclass
|
||||
class InjectionConfig:
|
||||
@@ -40,7 +40,6 @@ class InjectionConfig:
|
||||
# Approximate tokens per character (conservative estimate)
|
||||
chars_per_token: float = 4.0
|
||||
|
||||
|
||||
class ContextInjector:
|
||||
"""Injects FastContext results into agent context.
|
||||
|
||||
@@ -355,7 +354,6 @@ class ContextInjector:
|
||||
# Prepend context
|
||||
return f"{context}\n\n---\n\n{user_message}"
|
||||
|
||||
|
||||
def inject_fast_context(
|
||||
result: FastContextResult,
|
||||
system_prompt: Optional[str] = None,
|
||||
|
||||
@@ -26,13 +26,13 @@ import os
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from praisonaiagents.context.fast.result import FastContextResult, FileMatch, LineRange
|
||||
from praisonaiagents.context.fast.fast_context_agent import FastContextAgent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class FastContext:
|
||||
"""High-level API for fast parallel code search.
|
||||
@@ -408,7 +408,6 @@ class FastContext:
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
|
||||
|
||||
# Convenience function for quick searches
|
||||
def fast_search(
|
||||
query: str,
|
||||
|
||||
@@ -11,6 +11,7 @@ This agent is designed for fast context retrieval using:
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from praisonaiagents.context.fast.result import (
|
||||
@@ -30,8 +31,7 @@ from praisonaiagents.context.fast.parallel_executor import (
|
||||
ParallelSearchCoordinator
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class FastContextAgent:
|
||||
"""Specialized agent for fast parallel code search.
|
||||
|
||||