mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-12 13:09:10 -10:00
Compare commits
199
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1260ede088 | ||
|
|
2a463d6b39 | ||
|
|
10068583bb | ||
|
|
beb3e0a644 | ||
|
|
6a90cc07ee | ||
|
|
ffb9bd157e | ||
|
|
dd3ccb8b5f | ||
|
|
da8786520d | ||
|
|
ed02fc660f | ||
|
|
3c45e6f474 | ||
|
|
9b85a22b4c | ||
|
|
9a79f2a15d | ||
|
|
11e449bf43 | ||
|
|
d9581f39f8 | ||
|
|
88806ce469 | ||
|
|
468dffa99d | ||
|
|
c1947b9e4b | ||
|
|
21a3774b63 | ||
|
|
2c9b4a5c34 | ||
|
|
bc0a2a3765 | ||
|
|
b6c10e4040 | ||
|
|
f5220973f8 | ||
|
|
a924a55245 | ||
|
|
dcd072dc0c | ||
|
|
8173350f79 | ||
|
|
5b9a046d4b | ||
|
|
27aafddaa4 | ||
|
|
4a0b34ea20 | ||
|
|
19f5a16766 | ||
|
|
20cb053181 | ||
|
|
41ce1de079 | ||
|
|
36920ff8fe | ||
|
|
e428393e0a | ||
|
|
95fd857212 | ||
|
|
c260519bf1 | ||
|
|
2921103ddd | ||
|
|
73800f3509 | ||
|
|
7371ed7048 | ||
|
|
129174257f | ||
|
|
efcdc42fbb | ||
|
|
df79c5acb9 | ||
|
|
d9da7cc59c | ||
|
|
11d9f290c7 | ||
|
|
f796d12908 | ||
|
|
9c4adcee89 | ||
|
|
1d0ffed030 | ||
|
|
3be6df4de8 | ||
|
|
b65de3bf06 | ||
|
|
f949814af8 | ||
|
|
7449ccac76 | ||
|
|
ae26edd3f8 | ||
|
|
6349c88ad9 | ||
|
|
1d4721d039 | ||
|
|
90915fe790 | ||
|
|
29facc8f89 | ||
|
|
401521848f | ||
|
|
ab97eae954 | ||
|
|
5dda0643ca | ||
|
|
1b8d7a02d9 | ||
|
|
dbcd36edce | ||
|
|
9ef5391c83 | ||
|
|
7385adbd60 | ||
|
|
78d7883fb7 | ||
|
|
6122531a6a | ||
|
|
d704cf1349 | ||
|
|
8523ef076e | ||
|
|
72889d2f73 | ||
|
|
f17bd7369c | ||
|
|
ed31e9b280 | ||
|
|
69e9e712c7 | ||
|
|
7617b10c70 | ||
|
|
7939a4f12e | ||
|
|
8cbad0c997 | ||
|
|
48ed131577 | ||
|
|
f8c6f32d00 | ||
|
|
69ff8e7f03 | ||
|
|
feaa81b68f | ||
|
|
39406e904f | ||
|
|
2bb37b1076 | ||
|
|
cc36ac1cf8 | ||
|
|
a041a76f0d | ||
|
|
f77cca9931 | ||
|
|
ef176298b9 | ||
|
|
31f5cbb1cf | ||
|
|
85ec6cba90 | ||
|
|
c77259eda7 | ||
|
|
e69e726ca6 | ||
|
|
3130ae4e19 | ||
|
|
c872be20ed | ||
|
|
4548783a40 | ||
|
|
5b1c39d496 | ||
|
|
1e3933f5f3 | ||
|
|
203ebe4960 | ||
|
|
e144d840bb | ||
|
|
47bff65413 | ||
|
|
66bd9ee2a9 | ||
|
|
1dd5d7e706 | ||
|
|
fee74fab53 | ||
|
|
003f9371a1 | ||
|
|
7234cd5682 | ||
|
|
4373fb3adb | ||
|
|
e87dd8e37a | ||
|
|
872cd87638 | ||
|
|
8ea0065f83 | ||
|
|
e922eb1e64 | ||
|
|
e364aa7519 | ||
|
|
e67a11db1f | ||
|
|
9071af74ea | ||
|
|
7821916c56 | ||
|
|
dd1b9a7f1f | ||
|
|
3b006be069 | ||
|
|
4d80c60ecb | ||
|
|
0cdfde2c04 | ||
|
|
98e2fcccf6 | ||
|
|
1a42ca92d3 | ||
|
|
c75d087591 | ||
|
|
2f6f496e5d | ||
|
|
42ab69c142 | ||
|
|
db62f4368c | ||
|
|
fd3daa27fa | ||
|
|
875da3dfe0 | ||
|
|
620e21b599 | ||
|
|
79ad6b621f | ||
|
|
cbe57c9d52 | ||
|
|
fa264e1314 | ||
|
|
e71e73cca4 | ||
|
|
9861520be6 | ||
|
|
710624bd69 | ||
|
|
a35a6690c4 | ||
|
|
1b9d569de8 | ||
|
|
956e4eeb91 | ||
|
|
654e1e2aec | ||
|
|
2870aa9861 | ||
|
|
7bec22ce77 | ||
|
|
520a46019a | ||
|
|
7ee1c642d4 | ||
|
|
397f499dda | ||
|
|
d4dac28434 | ||
|
|
dbca6e0047 | ||
|
|
ed3da59b2c | ||
|
|
55ab4acf8d | ||
|
|
e0c1301965 | ||
|
|
79c4002e3c | ||
|
|
2e5a8c5fe5 | ||
|
|
99c811da21 | ||
|
|
0fb773adcd | ||
|
|
d95d3854db | ||
|
|
1383db2fba | ||
|
|
75adcfed68 | ||
|
|
3793ef0387 | ||
|
|
bf3b7f8e9d | ||
|
|
a4da8c6ab6 | ||
|
|
f59ba1b83d | ||
|
|
8c88766900 | ||
|
|
a6eaa854fe | ||
|
|
e36911e712 | ||
|
|
c3a6a39ee1 | ||
|
|
15146ff6a7 | ||
|
|
0d0c2fa762 | ||
|
|
c9010fca9f | ||
|
|
7a36352196 | ||
|
|
6ae4ebedcb | ||
|
|
3377347087 | ||
|
|
f47aa248ea | ||
|
|
0379e9f196 | ||
|
|
c841d46ae9 | ||
|
|
cd6fdd0107 | ||
|
|
d840407b12 | ||
|
|
b99ca3f0ae | ||
|
|
23c48f6cd5 | ||
|
|
d0fa48b110 | ||
|
|
0d2c1bd961 | ||
|
|
ef6b1a8913 | ||
|
|
dc9c250adb | ||
|
|
d1fb090a07 | ||
|
|
8479fffa8c | ||
|
|
8555b5ad69 | ||
|
|
ad8ee02b5a | ||
|
|
02b9c64c0a | ||
|
|
16f9325176 | ||
|
|
fc9f8c5e1d | ||
|
|
f5ee866e6c | ||
|
|
af1c26c471 | ||
|
|
f20d5d95e4 | ||
|
|
ac89f8a17c | ||
|
|
abc7f09046 | ||
|
|
a0287a9919 | ||
|
|
5046e9cf88 | ||
|
|
89624c1580 | ||
|
|
cc719e5379 | ||
|
|
e854a50fd2 | ||
|
|
99fde9b9d9 | ||
|
|
3c73ca95b6 | ||
|
|
d7c274489e | ||
|
|
23f6c00e21 | ||
|
|
a293999e8b | ||
|
|
0b14ae49ef | ||
|
|
b33c649de5 | ||
|
|
02a1977651 |
No files matched your search
@@ -22,6 +22,12 @@ inputs:
|
||||
To enable observability with Google Cloud, include:
|
||||
{"telemetry": {"enabled": true, "target": "gcp"}, "sandbox": false}
|
||||
required: false
|
||||
model:
|
||||
description: |
|
||||
The Gemini model to use. Options: gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-lite.
|
||||
Use flash-lite for highest free-tier quota (1,000 RPD).
|
||||
required: false
|
||||
default: 'gemini-3.1-flash-lite-preview'
|
||||
version:
|
||||
description: |
|
||||
The version of @google/gemini-cli to execute.
|
||||
@@ -70,10 +76,11 @@ runs:
|
||||
|
||||
- name: Run Gemini CLI
|
||||
run: |
|
||||
gemini --yolo --prompt "$PROMPT"
|
||||
gemini --yolo --model "$GEMINI_MODEL" --prompt "$PROMPT"
|
||||
shell: bash
|
||||
env:
|
||||
GEMINI_API_KEY: ${{ inputs.GEMINI_API_KEY }}
|
||||
GEMINI_MODEL: ${{ inputs.model }}
|
||||
PROMPT: ${{ inputs.prompt }}
|
||||
|
||||
branding:
|
||||
|
||||
@@ -1,26 +1,45 @@
|
||||
name: Auto Issue Comment with PR Creation
|
||||
name: Auto Issue Comment
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
add-comment:
|
||||
# Trigger Claude by adding the 'claude' label (only if not already labeled)
|
||||
# Note: @claude in comments only works from human users (not bots)
|
||||
# The claude.yml workflow listens for issues:labeled event with 'claude' label
|
||||
trigger-claude:
|
||||
# Skip if issue was already created with 'claude' label to avoid double-trigger
|
||||
if: |
|
||||
!contains(toJSON(github.event.issue.labels), 'claude')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
actions: read
|
||||
steps:
|
||||
- name: Add automated comment
|
||||
- name: Add claude label to trigger analysis
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
// Check if 'claude' label already exists on this issue
|
||||
const labels = context.payload.issue.labels.map(l => l.name);
|
||||
if (labels.includes('claude')) {
|
||||
console.log('Issue already has claude label, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add label to trigger claude.yml via issues:labeled event
|
||||
await github.rest.issues.addLabels({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude review this issue and do a detailed analysis and fix this if the existing code doesn\'t have the solution implemented. Making sure it has backward compatibility, no existing features removed. After making those changes, again review the applied changes. Use @web to search if you dont know any information or to find the latest documentation or to find the latest version. Run the code if you think you need to run it to test it. Minimal code change to start with if required any changes. MAINLY IT SHOULD NOT IMPACT ON THE CURRENT SPEED OF EXECUTION of existing features, not increasing the overhead. **Please create a PR using gh tool with your changes**.'
|
||||
})
|
||||
labels: ['claude']
|
||||
});
|
||||
|
||||
// Also post instructions as a comment for context
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '🤖 Auto-triaging with Claude. Analyzing issue and implementing a fix...\n\nClaude will:\n1. Classify this issue\n2. Search codebase for root cause\n3. Implement a minimal fix following AGENTS.md\n4. Create a PR with the changes'
|
||||
});
|
||||
@@ -1,23 +1,138 @@
|
||||
name: Auto PR Comment
|
||||
|
||||
# Review chain: CodeRabbit/Qodo/Gemini review first → THEN trigger Copilot
|
||||
# For human PRs: CodeRabbit/Qodo auto-review → this workflow triggers Copilot
|
||||
# For bot PRs: CodeRabbit/Qodo skip bots, so we trigger them explicitly first
|
||||
#
|
||||
# IMPORTANT: @copilot ignores bot comments. All @copilot mentions must use
|
||||
# PAT_TOKEN so the comment posts as MervinPraison (a real user).
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
pull_request:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
add-comment:
|
||||
# ================================================================
|
||||
# HUMAN PRs: Trigger @copilot AFTER CodeRabbit posts its summary
|
||||
# ================================================================
|
||||
copilot-after-coderabbit:
|
||||
if: |
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
github.event.comment.user.login == 'coderabbitai[bot]' &&
|
||||
contains(github.event.comment.body, 'summary by coderabbit')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Add automated comment
|
||||
- name: Post Copilot review request (as user via PAT)
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
github-token: ${{ secrets.PAT_TOKEN }}
|
||||
script: |
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
// Check for duplicates — posted by MervinPraison (PAT) or github-actions[bot] (legacy)
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@copilot') &&
|
||||
(c.user.login === 'MervinPraison' || c.user.login === 'github-actions[bot]')
|
||||
);
|
||||
if (alreadyPosted) {
|
||||
console.log('Copilot already triggered, skipping');
|
||||
return;
|
||||
}
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude review this pull request and do a detailed analysis and fix this if the existing code doesn\'t have the solution implemented. Making sure it has backward compatibility, no existing features removed. After making those changes, again review the applied changes. Use @web to search if you dont know any information or to find the latest documentation or to find the latest version. Run the code if you think you need to run it to test it. Minimal code change to start with if required any changes. MAINLY IT SHOULD NOT IMPACT ON THE CURRENT SPEED OF EXECUTION of existing features, not increasing the overhead.'
|
||||
})
|
||||
body: '@copilot Do a thorough review of this PR. Read ALL existing reviewer comments above from Qodo, Coderabbit, and Gemini first — incorporate their findings.\n\nReview areas:\n1. **Bloat check**: Are changes minimal and focused? Any unnecessary code or scope creep?\n2. **Security**: Any hardcoded secrets, unsafe eval/exec, missing input validation?\n3. **Performance**: Any module-level heavy imports? Hot-path regressions?\n4. **Tests**: Are tests included? Do they cover the changes adequately?\n5. **Backward compat**: Any public API changes without deprecation?\n6. **Code quality**: DRY violations, naming conventions, error handling?\n7. **Address reviewer feedback**: If Qodo, Coderabbit, or Gemini flagged valid issues, include them in your review\n8. Suggest specific improvements with code examples where possible'
|
||||
})
|
||||
|
||||
# Fallback: Trigger @copilot after Qodo review (if Coderabbit doesn't fire)
|
||||
copilot-after-qodo:
|
||||
if: |
|
||||
github.event_name == 'pull_request_review' &&
|
||||
github.event.review.user.login == 'qodo-code-review[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Post Copilot review request (as user via PAT)
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.PAT_TOKEN }}
|
||||
script: |
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@copilot') &&
|
||||
(c.user.login === 'MervinPraison' || c.user.login === 'github-actions[bot]')
|
||||
);
|
||||
if (alreadyPosted) {
|
||||
console.log('Copilot already triggered, skipping');
|
||||
return;
|
||||
}
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@copilot Do a thorough review of this PR. Read ALL existing reviewer comments above first.\n\nReview areas:\n1. **Bloat check**: Are changes minimal and focused?\n2. **Security**: Any hardcoded secrets, unsafe eval/exec, missing input validation?\n3. **Performance**: Any module-level heavy imports? Hot-path regressions?\n4. **Tests**: Are tests included? Do they cover the changes adequately?\n5. **Backward compat**: Any public API changes without deprecation?\n6. **Code quality**: DRY violations, naming conventions, error handling?\n7. Suggest specific improvements with code examples where possible'
|
||||
})
|
||||
|
||||
# ================================================================
|
||||
# BOT PRs: CodeRabbit/Qodo skip bot-authored PRs by default.
|
||||
# Trigger them explicitly. Do NOT trigger Copilot here — it will
|
||||
# be triggered by copilot-after-coderabbit/qodo when they finish.
|
||||
# ================================================================
|
||||
bot-pr-trigger-reviews:
|
||||
if: |
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.action == 'opened' &&
|
||||
github.event.pull_request.user.login == 'github-actions[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Trigger CodeRabbit, Qodo, and Gemini reviews
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.PAT_TOKEN }}
|
||||
script: |
|
||||
const pr = context.payload.pull_request.number;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
// 1. Trigger CodeRabbit review
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr, owner, repo,
|
||||
body: '@coderabbitai review'
|
||||
});
|
||||
console.log(`Triggered CodeRabbit for bot PR #${pr}`);
|
||||
|
||||
// 2. Trigger Qodo review
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr, owner, repo,
|
||||
body: '/review'
|
||||
});
|
||||
console.log(`Triggered Qodo for bot PR #${pr}`);
|
||||
|
||||
// 3. Trigger Gemini review
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr, owner, repo,
|
||||
body: '@gemini review this PR'
|
||||
});
|
||||
console.log(`Triggered Gemini for bot PR #${pr}`);
|
||||
|
||||
// NOTE: @copilot is NOT triggered here.
|
||||
// It will be triggered by copilot-after-coderabbit or copilot-after-qodo
|
||||
// once those bots finish, ensuring Copilot always reviews LAST.
|
||||
console.log('Copilot will be triggered after CodeRabbit/Qodo complete.');
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Chain Claude After Reviewers
|
||||
|
||||
# Event-driven: triggers when Copilot finishes reviewing a PR
|
||||
on:
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
# Future: re-enable issue_comment trigger for Gemini→Claude chain
|
||||
# issue_comment:
|
||||
# types: [created]
|
||||
|
||||
jobs:
|
||||
# Trigger Claude after Copilot finishes on a PR — Claude is the FINAL reviewer
|
||||
# Claude reads ALL previous reviews (Qodo, Coderabbit, Copilot) and fixes any valid issues
|
||||
claude-final-review:
|
||||
if: |
|
||||
github.event_name == 'pull_request_review' &&
|
||||
github.event.review.user.login == 'copilot-pull-request-reviewer[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Post Claude final review + fix request
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the FINAL architecture reviewer. Read ALL comments above from Qodo, Coderabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: heavy code in wrapper, not core SDK?\n2. Backward compatible: no public API removed without deprecation?\n3. Performance: no hot-path regressions, lazy imports for optional deps?\n4. DRY: no duplication, reuses existing abstractions?\n5. Async-safe and multi-agent safe?\n\n**Phase 2: Fix valid issues from other reviewers**\n6. Read Qodo, Coderabbit, and Copilot reviews above\n7. For each VALID suggestion they raised: implement the fix\n8. Push all fixes to THIS branch (do NOT create a new PR)\n9. Comment a summary of what you fixed and what you intentionally skipped (with reasoning)\n\n**Phase 3: Final verdict**\n10. If all issues are resolved and tests pass, approve the PR\n11. If blocking issues remain, request changes with clear action items'
|
||||
})
|
||||
|
||||
# Future: Trigger Claude after Gemini finishes on an Issue (commented out)
|
||||
# claude-after-gemini-on-issue:
|
||||
# if: |
|
||||
# github.event_name == 'issue_comment' &&
|
||||
# !github.event.issue.pull_request &&
|
||||
# github.event.comment.user.login == 'gemini-code-assist[bot]'
|
||||
# runs-on: ubuntu-latest
|
||||
# permissions:
|
||||
# issues: write
|
||||
# steps:
|
||||
# - name: Post Claude analysis request (final reviewer)
|
||||
# uses: actions/github-script@v7
|
||||
# with:
|
||||
# github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# script: |
|
||||
# github.rest.issues.createComment({
|
||||
# issue_number: context.issue.number,
|
||||
# owner: context.repo.owner,
|
||||
# repo: context.repo.repo,
|
||||
# body: '@claude You are the lead engineer. Read Gemini\'s analysis above first.\n\nAnalyze this issue and implement a fix following AGENTS.md:\n1. Ensure backward compatibility — no features removed\n2. Zero performance regression on hot paths\n3. Minimal, focused changes only\n4. Run tests to verify the fix\n5. Create a PR with your changes using gh'
|
||||
# })
|
||||
+18
-108
@@ -6,18 +6,22 @@ on:
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned, labeled]
|
||||
types: [assigned, labeled]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude-response:
|
||||
# Prevent certain bots from triggering this action (but allow github-actions[bot] for auto-comments)
|
||||
# Allow: human users, github-actions[bot] (auto-comments/labels)
|
||||
# Block: dependabot, cursor, renovate, other bots
|
||||
if: |
|
||||
(
|
||||
!contains(github.actor, '[bot]') ||
|
||||
github.actor == 'github-actions[bot]'
|
||||
) &&
|
||||
github.actor != 'dependabot[bot]' &&
|
||||
github.actor != 'cursor[bot]' &&
|
||||
github.actor != 'renovate[bot]' &&
|
||||
!(contains(github.actor, '[bot]') && github.actor != 'github-actions[bot]')
|
||||
github.actor != 'renovate[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -32,9 +36,18 @@ jobs:
|
||||
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_ACCESS_TOKEN }}
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
trigger_phrase: "@claude"
|
||||
label_trigger: "claude"
|
||||
direct_prompt: |
|
||||
Analyze this issue and implement a fix following AGENTS.md:
|
||||
1. Read AGENTS.md for repo guidelines
|
||||
2. Search the codebase and identify the root cause
|
||||
3. Create a fix branch and implement a minimal, focused fix
|
||||
4. Run tests to verify
|
||||
5. Commit, push, and create a PR using `gh pr create`
|
||||
CRITICAL: You MUST create the PR automatically using `gh pr create`. Do NOT just provide a link.
|
||||
allowed_tools: |
|
||||
Bash(git:*)
|
||||
Bash(python:*)
|
||||
@@ -55,106 +68,3 @@ jobs:
|
||||
mcp__github__get_issue_comments
|
||||
mcp__github__update_issue
|
||||
timeout_minutes: 30
|
||||
|
||||
post-review-comment:
|
||||
needs: claude-response
|
||||
if: success() && needs.claude-response.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
steps:
|
||||
- name: Check for existing review comment
|
||||
id: check-comment
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const reviewText = '@claude review if the changes made in the above branch are as per the requirements and make changes to the above branch if issues still exist.';
|
||||
let comments = [];
|
||||
let issueNumber;
|
||||
|
||||
try {
|
||||
// Determine issue/PR number based on event type
|
||||
if (context.eventName === 'pull_request_review_comment' || context.eventName === 'pull_request_review') {
|
||||
issueNumber = context.payload.pull_request?.number || context.issue.number;
|
||||
} else if (context.eventName === 'issue_comment' && context.payload.issue.pull_request) {
|
||||
// This is a comment on a PR (not an issue)
|
||||
issueNumber = context.payload.issue.number;
|
||||
} else {
|
||||
// This is a regular issue
|
||||
issueNumber = context.issue.number;
|
||||
}
|
||||
|
||||
comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber
|
||||
});
|
||||
|
||||
// Look for Claude's completed review comments
|
||||
const claudeReviewComments = comments.data.filter(comment =>
|
||||
(comment.user.login === 'claude[bot]' || comment.body.includes('✅') || comment.body.includes('## Summary')) &&
|
||||
!comment.body.includes('⏳') &&
|
||||
!comment.body.includes('🔄') &&
|
||||
!comment.body.includes('Loading...')
|
||||
);
|
||||
|
||||
// Check if our review request comment already exists
|
||||
const existingReviewRequest = comments.data.find(comment =>
|
||||
comment.body.includes(reviewText)
|
||||
);
|
||||
|
||||
// Post if Claude has completed a review AND we haven't posted the review request yet
|
||||
const shouldPost = claudeReviewComments.length > 0 && !existingReviewRequest;
|
||||
core.setOutput('should-post', shouldPost);
|
||||
console.log(`Claude review completed: ${claudeReviewComments.length > 0}`);
|
||||
console.log(`Review request exists: ${!!existingReviewRequest}`);
|
||||
console.log(`Should post comment: ${shouldPost}`);
|
||||
|
||||
} catch (error) {
|
||||
console.log('Error checking comments:', error);
|
||||
core.setOutput('should-post', false); // If we can't check, don't post
|
||||
}
|
||||
|
||||
- name: Wait 30 seconds before posting comment
|
||||
if: steps.check-comment.outputs.should-post == 'true'
|
||||
run: sleep 30
|
||||
|
||||
- name: Post review comment
|
||||
if: steps.check-comment.outputs.should-post == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
let issueNumber;
|
||||
let isPR = false;
|
||||
|
||||
// Determine issue/PR number and type based on event type
|
||||
if (context.eventName === 'pull_request_review_comment' || context.eventName === 'pull_request_review') {
|
||||
issueNumber = context.payload.pull_request?.number || context.issue.number;
|
||||
isPR = true;
|
||||
} else if (context.eventName === 'issue_comment' && context.payload.issue.pull_request) {
|
||||
// This is a comment on a PR (not an issue)
|
||||
issueNumber = context.payload.issue.number;
|
||||
isPR = true;
|
||||
} else {
|
||||
// This is a regular issue - don't post review comment
|
||||
issueNumber = context.issue.number;
|
||||
isPR = false;
|
||||
}
|
||||
|
||||
// Only post review comment on PRs, not regular issues
|
||||
if (isPR) {
|
||||
console.log(`Posting review comment on PR #${issueNumber}`);
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: issueNumber,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude review if the changes made in the above branch are as per the requirements and make changes to the above branch if issues still exist. MAINLY IT SHOULD NOT IMPACT ON THE CURRENT SPEED OF EXECUTION of existing features, not increasing the overhead.'
|
||||
});
|
||||
} else {
|
||||
console.log(`Skipping review comment for regular issue #${issueNumber}`);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
name: Run tests and upload coverage
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
pull_request:
|
||||
branches: [ main, develop ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run tests and collect coverage
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.11
|
||||
|
||||
- name: Install UV
|
||||
run: |
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd src/praisonai
|
||||
uv pip install --system ."[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
|
||||
uv pip install --system duckduckgo_search
|
||||
uv pip install --system pytest pytest-cov pytest-asyncio
|
||||
# Install knowledge dependencies from praisonai-agents
|
||||
uv pip install --system "praisonaiagents[knowledge]"
|
||||
|
||||
- name: Set environment variables
|
||||
run: |
|
||||
echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY || 'sk-test-key-for-github-actions-testing-only-not-real' }}" >> $GITHUB_ENV
|
||||
echo "OPENAI_API_BASE=${{ secrets.OPENAI_API_BASE || 'https://api.openai.com/v1' }}" >> $GITHUB_ENV
|
||||
echo "OPENAI_MODEL_NAME=${{ secrets.OPENAI_MODEL_NAME || 'gpt-5-nano' }}" >> $GITHUB_ENV
|
||||
echo "LOGLEVEL=DEBUG" >> $GITHUB_ENV
|
||||
echo "PYTHONPATH=${{ github.workspace }}/src/praisonai-agents:$PYTHONPATH" >> $GITHUB_ENV
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
cd src/praisonai
|
||||
# Run tests - allow some failures for now while we fix remaining issues
|
||||
pytest --cov=praisonai --cov-branch --cov-report=xml tests/unit/ --tb=no -q || echo "Some tests failed, but coverage was collected"
|
||||
|
||||
- name: Upload results to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
file: src/praisonai/coverage.xml
|
||||
flags: unit-tests
|
||||
name: unit-tests-coverage
|
||||
fail_ci_if_error: false
|
||||
verbose: true
|
||||
@@ -20,7 +20,8 @@ jobs:
|
||||
!contains(github.event.comment.body, '@gemini triage') &&
|
||||
(github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR'))
|
||||
github.event.comment.author_association == 'COLLABORATOR' ||
|
||||
github.event.comment.author_association == 'CONTRIBUTOR'))
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
@@ -39,8 +40,8 @@ jobs:
|
||||
|
||||
- name: Set up Git
|
||||
run: |
|
||||
git config --local user.email "gemini-bot@github.com"
|
||||
git config --local user.name "Gemini Bot"
|
||||
git config --local user.email "praison.ms@gmail.com"
|
||||
git config --local user.name "MervinPraison"
|
||||
|
||||
- name: Get issue data
|
||||
id: issue_data
|
||||
@@ -75,16 +76,16 @@ jobs:
|
||||
echo "ADDITIONAL_INSTRUCTIONS=$ADDITIONAL_INSTRUCTIONS" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Run Gemini CLI
|
||||
uses: ./.github/actions/gemini
|
||||
uses: google-github-actions/run-gemini-cli@v0
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||
ISSUE_NUMBER: ${{ steps.issue_data.outputs.ISSUE_NUMBER }}
|
||||
ADDITIONAL_INSTRUCTIONS: ${{ steps.issue_data.outputs.ADDITIONAL_INSTRUCTIONS }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
with:
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
OTLP_GCP_WIF_PROVIDER: ${{ secrets.OTLP_GCP_WIF_PROVIDER }}
|
||||
OTLP_GOOGLE_CLOUD_PROJECT: ${{ secrets.OTLP_GOOGLE_CLOUD_PROJECT }}
|
||||
gemini_api_key: ${{ secrets.GEMINI_API_KEY }}
|
||||
gemini_model: 'gemini-2.5-flash'
|
||||
prompt: |
|
||||
You are a helpful GitHub issue assistant. Your task is to analyze and help with GitHub issues.
|
||||
|
||||
@@ -161,7 +162,7 @@ jobs:
|
||||
|
||||
Please proceed with helping resolve this issue following this exact workflow.
|
||||
|
||||
settings_json: |
|
||||
settings: |
|
||||
{
|
||||
"coreTools": [
|
||||
"run_shell_command(echo)",
|
||||
@@ -193,10 +194,5 @@ jobs:
|
||||
"web_fetch",
|
||||
"google_web_search",
|
||||
"save_memory"
|
||||
],
|
||||
"telemetry": {
|
||||
"enabled": false,
|
||||
"otlpGcpWifProvider": "${{ secrets.OTLP_GCP_WIF_PROVIDER }}",
|
||||
"googleCloudProject": "${{ secrets.OTLP_GOOGLE_CLOUD_PROJECT }}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,25 +1,21 @@
|
||||
# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
|
||||
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
|
||||
|
||||
name: Python package
|
||||
name: Python Package (Multi-Version)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "develop" ]
|
||||
pull_request:
|
||||
branches: [ "develop" ]
|
||||
schedule:
|
||||
- cron: '0 4 * * 1' # Weekly Monday 4am UTC
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11"]
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
@@ -30,39 +26,16 @@ jobs:
|
||||
echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY || 'sk-test-key-for-github-actions-testing-only-not-real' }}" >> $GITHUB_ENV
|
||||
echo "OPENAI_API_BASE=${{ secrets.OPENAI_API_BASE || 'https://api.openai.com/v1' }}" >> $GITHUB_ENV
|
||||
echo "OPENAI_MODEL_NAME=${{ secrets.OPENAI_MODEL_NAME || 'gpt-5-nano' }}" >> $GITHUB_ENV
|
||||
echo "LOGLEVEL=DEBUG" >> $GITHUB_ENV
|
||||
echo "PYTHONPATH=${{ github.workspace }}/src/praisonai-agents:$PYTHONPATH" >> $GITHUB_ENV
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd src/praisonai
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install flake8 pytest
|
||||
python -m pip install ."[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
|
||||
python -m pip install duckduckgo_search
|
||||
python -m pip install pytest-asyncio pytest-cov
|
||||
# Install knowledge dependencies from praisonai-agents
|
||||
python -m pip install duckduckgo_search pytest pytest-asyncio pytest-cov
|
||||
python -m pip install "praisonaiagents[knowledge]"
|
||||
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
|
||||
|
||||
- name: Debug API Key Status
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
echo "🔍 Checking API key availability..."
|
||||
if [ -n "${{ secrets.OPENAI_API_KEY }}" ]; then
|
||||
echo "✅ GitHub secret OPENAI_API_KEY is available"
|
||||
echo "🔑 API key starts with: $(echo "$OPENAI_API_KEY" | cut -c1-7)..."
|
||||
else
|
||||
echo "⚠️ GitHub secret OPENAI_API_KEY is NOT set - using fallback"
|
||||
echo "🔑 Using fallback key: sk-test-key..."
|
||||
fi
|
||||
echo "🌐 API Base: $OPENAI_API_BASE"
|
||||
echo "🤖 Model: $OPENAI_MODEL_NAME"
|
||||
|
||||
# - name: Lint with flake8
|
||||
# run: |
|
||||
# # stop the build if there are Python syntax errors or undefined names
|
||||
# flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||
# # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
|
||||
# flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
||||
- name: Test with pytest
|
||||
run: |
|
||||
cd src/praisonai && python tests/test_runner.py --pattern fast
|
||||
cd src/praisonai && python -m pytest tests/unit/ -v --tb=short --disable-warnings --timeout=60 || echo "Some tests failed"
|
||||
@@ -51,8 +51,8 @@ jobs:
|
||||
# Sync with upstream commit
|
||||
- name: Sync with upstream
|
||||
run: |
|
||||
git config --global user.name "GitHub Actions"
|
||||
git config --global user.email "actions@github.com"
|
||||
git config --global user.name "MervinPraison"
|
||||
git config --global user.email "praison.ms@gmail.com"
|
||||
|
||||
# Add upstream remote
|
||||
git remote add upstream https://github.com/MervinPraison/PraisonAI.git
|
||||
@@ -473,8 +473,8 @@ jobs:
|
||||
# Create tag at the same commit
|
||||
- name: Create tag at commit
|
||||
run: |
|
||||
git config --global user.name "GitHub Actions"
|
||||
git config --global user.email "actions@github.com"
|
||||
git config --global user.name "MervinPraison"
|
||||
git config --global user.email "praison.ms@gmail.com"
|
||||
|
||||
# Remove tag if it exists locally
|
||||
git tag -d ${{ github.event.inputs.tag }} || true
|
||||
|
||||
+27
-243
@@ -1,41 +1,44 @@
|
||||
name: Core Tests
|
||||
|
||||
on:
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/test-core.yml'
|
||||
pull_request:
|
||||
branches: [ main, develop ]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/test-core.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test-core:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.11]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
timeout-minutes: 10
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install UV
|
||||
run: |
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd src/praisonai
|
||||
uv pip install --system ."[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
|
||||
uv pip install --system duckduckgo_search
|
||||
uv pip install --system pytest pytest-asyncio pytest-cov pytest-timeout
|
||||
# Install knowledge dependencies from praisonai-agents
|
||||
uv pip install --system "praisonaiagents[knowledge]"
|
||||
|
||||
- name: Set environment variables
|
||||
@@ -43,246 +46,27 @@ jobs:
|
||||
echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY || 'sk-test-key-for-github-actions-testing-only-not-real' }}" >> $GITHUB_ENV
|
||||
echo "OPENAI_API_BASE=${{ secrets.OPENAI_API_BASE || 'https://api.openai.com/v1' }}" >> $GITHUB_ENV
|
||||
echo "OPENAI_MODEL_NAME=${{ secrets.OPENAI_MODEL_NAME || 'gpt-5-nano' }}" >> $GITHUB_ENV
|
||||
echo "LOGLEVEL=DEBUG" >> $GITHUB_ENV
|
||||
echo "PYTHONPATH=${{ github.workspace }}/src/praisonai-agents:$PYTHONPATH" >> $GITHUB_ENV
|
||||
# Also export to current shell session for immediate availability
|
||||
export OPENAI_API_KEY="${{ secrets.OPENAI_API_KEY || 'sk-test-key-for-github-actions-testing-only-not-real' }}"
|
||||
export OPENAI_API_BASE="${{ secrets.OPENAI_API_BASE || 'https://api.openai.com/v1' }}"
|
||||
export OPENAI_MODEL_NAME="${{ secrets.OPENAI_MODEL_NAME || 'gpt-5-nano' }}"
|
||||
export LOGLEVEL=DEBUG
|
||||
# Verify immediate availability
|
||||
echo "🔧 Immediate verification in same step:"
|
||||
echo " OPENAI_API_KEY length in current session: ${#OPENAI_API_KEY}"
|
||||
echo " OPENAI_API_KEY starts with sk-: $(echo "$OPENAI_API_KEY" | grep -q '^sk-' && echo 'YES' || echo 'NO')"
|
||||
|
||||
- name: Debug API Key Status
|
||||
run: |
|
||||
echo "🔍 Checking API key availability..."
|
||||
if [ -n "${{ secrets.OPENAI_API_KEY }}" ]; then
|
||||
echo "✅ GitHub secret OPENAI_API_KEY is available"
|
||||
echo "🔑 API key starts with: $(echo "$OPENAI_API_KEY" | cut -c1-7)..."
|
||||
else
|
||||
echo "⚠️ GitHub secret OPENAI_API_KEY is NOT set - using fallback"
|
||||
echo "🔑 Using fallback key: sk-test-key..."
|
||||
fi
|
||||
echo "🌐 API Base: $OPENAI_API_BASE"
|
||||
echo "🤖 Model: $OPENAI_MODEL_NAME"
|
||||
echo "🐛 Log Level: $LOGLEVEL"
|
||||
echo "📊 Environment Check:"
|
||||
echo " - OPENAI_API_KEY length: ${#OPENAI_API_KEY}"
|
||||
echo " - OPENAI_API_BASE: $OPENAI_API_BASE"
|
||||
echo " - OPENAI_MODEL_NAME: $OPENAI_MODEL_NAME"
|
||||
echo " - LOGLEVEL: $LOGLEVEL"
|
||||
echo "🔍 Is API key actually set?"
|
||||
echo " - API key starts with sk-: $(echo "$OPENAI_API_KEY" | grep -q '^sk-' && echo 'YES' || echo 'NO')"
|
||||
echo " - API key is not test key: $([ "$OPENAI_API_KEY" != 'sk-test-key-for-github-actions-testing-only-not-real' ] && echo 'YES' || echo 'NO')"
|
||||
|
||||
- name: Debug Environment Variables Raw
|
||||
run: |
|
||||
echo "🔧 Raw environment variable check:"
|
||||
echo "OPENAI_API_KEY set: $(if [ -n "$OPENAI_API_KEY" ]; then echo 'YES'; else echo 'NO'; fi)"
|
||||
echo "OPENAI_API_KEY length: ${#OPENAI_API_KEY}"
|
||||
echo "OPENAI_API_KEY first 10 chars: ${OPENAI_API_KEY:0:10}"
|
||||
echo "OPENAI_API_KEY last 5 chars: ${OPENAI_API_KEY: -5}"
|
||||
printenv | grep OPENAI || echo "No OPENAI env vars found"
|
||||
|
||||
- name: Debug Python Environment Variables
|
||||
run: |
|
||||
python -c "
|
||||
import os
|
||||
print('🐍 Python Environment Variable Check:')
|
||||
api_key = os.environ.get('OPENAI_API_KEY', 'NOT_SET')
|
||||
if api_key != 'NOT_SET':
|
||||
print(f' ✅ OPENAI_API_KEY: {api_key[:7]}... (length: {len(api_key)})')
|
||||
else:
|
||||
print(' ❌ OPENAI_API_KEY: NOT_SET')
|
||||
print(f' 🌐 OPENAI_API_BASE: {os.environ.get(\"OPENAI_API_BASE\", \"NOT_SET\")}')
|
||||
print(f' 🤖 OPENAI_MODEL_NAME: {os.environ.get(\"OPENAI_MODEL_NAME\", \"NOT_SET\")}')
|
||||
print(f' 📋 All OPENAI env vars:')
|
||||
for key, value in os.environ.items():
|
||||
if key.startswith('OPENAI'):
|
||||
print(f' {key}: {value[:10] if len(value) > 10 else value}...')
|
||||
"
|
||||
|
||||
- name: Validate API Key
|
||||
run: |
|
||||
echo "🔑 Testing API key validity with minimal OpenAI call..."
|
||||
python -c "
|
||||
import os
|
||||
try:
|
||||
from openai import OpenAI
|
||||
client = OpenAI(api_key=os.environ.get('OPENAI_API_KEY'))
|
||||
response = client.models.list()
|
||||
print('✅ API Key is VALID - OpenAI responded successfully')
|
||||
print(f'📊 Available models: {len(list(response.data))} models found')
|
||||
except Exception as e:
|
||||
print(f'❌ API Key is INVALID - Error: {e}')
|
||||
print('🔍 This explains why all API-dependent tests are failing')
|
||||
print('💡 The GitHub secret OPENAI_API_KEY needs to be updated with a valid key')
|
||||
"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Debug PraisonAI API Key Usage
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY || 'sk-test-key-for-github-actions-testing-only-not-real' }}
|
||||
OPENAI_API_BASE: ${{ secrets.OPENAI_API_BASE || 'https://api.openai.com/v1' }}
|
||||
OPENAI_MODEL_NAME: ${{ secrets.OPENAI_MODEL_NAME || 'gpt-5-nano' }}
|
||||
LOGLEVEL: DEBUG
|
||||
run: |
|
||||
echo "🔍 Testing PraisonAI API key usage directly..."
|
||||
cd src/praisonai
|
||||
python -c "
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, '.')
|
||||
|
||||
# Attempt to import SecretStr, otherwise use a dummy class
|
||||
try:
|
||||
from pydantic.types import SecretStr
|
||||
except ImportError:
|
||||
class SecretStr: # Dummy class if pydantic is not available in this minimal context
|
||||
def __init__(self, value): self._value = value
|
||||
def get_secret_value(self): return self._value
|
||||
|
||||
def get_key_display_value(key_value):
|
||||
if isinstance(key_value, SecretStr):
|
||||
return key_value.get_secret_value()[:10] if key_value.get_secret_value() else 'EMPTY_SECRET'
|
||||
elif isinstance(key_value, str):
|
||||
return key_value[:10] if key_value != 'nokey' and key_value != 'NOT_SET' else key_value
|
||||
return 'INVALID_TYPE'
|
||||
|
||||
print('🔧 Direct PraisonAI API Key Check:')
|
||||
env_api_key = os.environ.get(\"OPENAI_API_KEY\", \"NOT_SET\")
|
||||
print(f'Environment OPENAI_API_KEY: {get_key_display_value(env_api_key)}...')
|
||||
|
||||
from praisonai import PraisonAI
|
||||
praisonai = PraisonAI()
|
||||
|
||||
print(f'PraisonAI config_list: {praisonai.config_list}')
|
||||
api_key_from_config = praisonai.config_list[0].get('api_key', 'NOT_SET')
|
||||
print(f'API key from PraisonAI config: {get_key_display_value(api_key_from_config)}...')
|
||||
|
||||
from praisonai.inc.models import PraisonAIModel
|
||||
|
||||
print('\\n🧪 Testing PraisonAIModel with explicit API key (CrewAI method):')
|
||||
model_with_explicit_key = PraisonAIModel(
|
||||
model='openai/gpt-5-nano',
|
||||
base_url=praisonai.config_list[0].get('base_url'),
|
||||
api_key=api_key_from_config # This will be a string from praisonai.config_list
|
||||
)
|
||||
print(f' Model: {model_with_explicit_key.model}')
|
||||
print(f' Model name: {model_with_explicit_key.model_name}')
|
||||
print(f' API key var: {model_with_explicit_key.api_key_var}')
|
||||
# model_with_explicit_key.api_key is now a string, or 'nokey'
|
||||
print(f' API key (explicitly passed to PraisonAIModel): {get_key_display_value(model_with_explicit_key.api_key)}...')
|
||||
print(f' Base URL: {model_with_explicit_key.base_url}')
|
||||
|
||||
try:
|
||||
llm_instance = model_with_explicit_key.get_model()
|
||||
print(f' ✅ LLM instance created successfully: {type(llm_instance).__name__}')
|
||||
|
||||
# langchain_openai.ChatOpenAI stores the key in openai_api_key as SecretStr
|
||||
llm_api_key_attr = getattr(llm_instance, 'openai_api_key', 'NOT_FOUND')
|
||||
if llm_api_key_attr != 'NOT_FOUND':
|
||||
print(f' LLM instance API key: {get_key_display_value(llm_api_key_attr)}...')
|
||||
else:
|
||||
print(f' LLM instance API key attribute not found.')
|
||||
except Exception as e:
|
||||
print(f' ❌ Failed to create LLM instance: {e}')
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run Unit Tests
|
||||
run: |
|
||||
cd src/praisonai && python -m pytest tests/unit/ -v --tb=short --disable-warnings --cov=praisonai --cov-report=term-missing --cov-report=xml --cov-branch || echo "Some tests failed - see output above"
|
||||
cd src/praisonai && python -m pytest tests/unit/ -v --tb=short --disable-warnings \
|
||||
--cov=praisonai --cov-report=xml --cov-branch \
|
||||
--timeout=60 || echo "Some unit tests failed"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run Integration Tests
|
||||
- name: Run Integration Tests
|
||||
run: |
|
||||
cd src/praisonai && python -m pytest tests/integration/ -v --tb=short --disable-warnings
|
||||
cd src/praisonai && python -m pytest tests/integration/ -v --tb=short --disable-warnings \
|
||||
--timeout=60 \
|
||||
--ignore=tests/integration/test_tracker_complex_tasks.py \
|
||||
--ignore=tests/integration/test_serve_integration.py \
|
||||
|| echo "Some integration tests failed"
|
||||
|
||||
- name: Debug Directory Structure
|
||||
run: |
|
||||
echo "🔍 Debugging directory structure for CrewAI tests..."
|
||||
cd src/praisonai
|
||||
echo "Current working directory: $(pwd)"
|
||||
echo "📁 Contents of current directory:"
|
||||
ls -la
|
||||
echo ""
|
||||
echo "📁 Contents of tests directory:"
|
||||
ls -la tests/ || echo "❌ tests/ directory not found"
|
||||
echo ""
|
||||
echo "📁 Contents of tests/integration:"
|
||||
ls -la tests/integration/ || echo "❌ tests/integration/ directory not found"
|
||||
echo ""
|
||||
echo "📁 Looking for crewai directory:"
|
||||
find . -name "crewai" -type d 2>/dev/null || echo "❌ No crewai directories found"
|
||||
echo ""
|
||||
echo "📁 Full directory tree of tests:"
|
||||
tree tests/ || find tests/ -type d 2>/dev/null || echo "❌ Cannot explore tests directory"
|
||||
|
||||
- name: Test AutoGen Framework
|
||||
run: |
|
||||
echo "🤖 Testing AutoGen Framework Integration..."
|
||||
cd src/praisonai && python tests/test_runner.py --pattern autogen --verbose
|
||||
|
||||
- name: Test CrewAI Framework
|
||||
run: |
|
||||
echo "⛵ Testing CrewAI Framework Integration..."
|
||||
cd src/praisonai
|
||||
echo "🔍 Trying test runner first..."
|
||||
python tests/test_runner.py --pattern crewai --verbose || {
|
||||
echo "❌ Test runner failed, trying direct pytest..."
|
||||
echo "📁 Current directory: $(pwd)"
|
||||
echo "📁 Looking for CrewAI tests..."
|
||||
find . -name "*crewai*" -type f 2>/dev/null
|
||||
echo "🧪 Trying direct pytest on integration/crewai..."
|
||||
python -m pytest tests/integration/crewai/ -v --tb=short --disable-warnings || {
|
||||
echo "❌ Direct path failed, trying relative path..."
|
||||
python -m pytest integration/crewai/ -v --tb=short --disable-warnings || {
|
||||
echo "❌ All CrewAI test attempts failed"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- name: Run Legacy Tests with API Key
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY || 'sk-test-key-for-github-actions-testing-only-not-real' }}
|
||||
OPENAI_API_BASE: ${{ secrets.OPENAI_API_BASE || 'https://api.openai.com/v1' }}
|
||||
OPENAI_MODEL_NAME: ${{ secrets.OPENAI_MODEL_NAME || 'gpt-5-nano' }}
|
||||
LOGLEVEL: DEBUG
|
||||
run: |
|
||||
echo "🧪 Running legacy tests with real API key..."
|
||||
echo "🔧 Final environment check before pytest:"
|
||||
echo " OPENAI_API_KEY set: $([ -n "$OPENAI_API_KEY" ] && echo 'YES' || echo 'NO')"
|
||||
echo " OPENAI_API_KEY length: ${#OPENAI_API_KEY}"
|
||||
echo " OPENAI_API_KEY starts with sk-: $(echo "$OPENAI_API_KEY" | grep -q '^sk-' && echo 'YES' || echo 'NO')"
|
||||
export OPENAI_API_KEY="$OPENAI_API_KEY"
|
||||
export OPENAI_API_BASE="$OPENAI_API_BASE"
|
||||
export OPENAI_MODEL_NAME="$OPENAI_MODEL_NAME"
|
||||
cd src/praisonai && python -m pytest tests/test.py -v --tb=short --disable-warnings -m "not real" --timeout=60
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.python-version == '3.11'
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
file: src/praisonai/coverage.xml
|
||||
flags: core-tests
|
||||
name: core-tests-coverage
|
||||
fail_ci_if_error: false
|
||||
verbose: true
|
||||
|
||||
- name: Upload Coverage Reports (Artifacts)
|
||||
if: matrix.python-version == '3.11'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-reports
|
||||
path: |
|
||||
src/praisonai/.coverage
|
||||
src/praisonai/htmlcov/
|
||||
src/praisonai/coverage.xml
|
||||
retention-days: 7
|
||||
fail_ci_if_error: false
|
||||
@@ -1,14 +1,9 @@
|
||||
name: Extended Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
schedule:
|
||||
# Run nightly at 2 AM UTC
|
||||
- cron: '0 2 * * *'
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test-examples:
|
||||
|
||||
@@ -3,8 +3,16 @@ name: Optimized Test Suite
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/test-optimized.yml'
|
||||
pull_request:
|
||||
branches: [ main, develop ]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/test-optimized.yml'
|
||||
schedule:
|
||||
# Nightly extended tests at 3 AM UTC
|
||||
- cron: '0 3 * * *'
|
||||
|
||||
@@ -3,8 +3,16 @@ name: Windows Tests
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/test-windows.yml'
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/test-windows.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -14,6 +22,7 @@ jobs:
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_MODEL_NAME: gpt-4o-mini
|
||||
PYTHONUTF8: 1
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -27,13 +36,13 @@ jobs:
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
cd src/praisonai-agents
|
||||
pip install .
|
||||
pip install -e .
|
||||
shell: bash
|
||||
|
||||
- name: Install praisonai wrapper
|
||||
run: |
|
||||
cd src/praisonai
|
||||
pip install .[dev]
|
||||
pip install -e .
|
||||
shell: bash
|
||||
|
||||
# ==================== CORE IMPORTS ====================
|
||||
@@ -41,7 +50,7 @@ jobs:
|
||||
run: |
|
||||
python -c "from praisonaiagents import Agent, Agents, Workflow; print('Core imports OK')"
|
||||
python -c "from praisonaiagents import tool, Tools; print('Tool imports OK')"
|
||||
python -c "from praisonaiagents import Task, WorkflowStep; print('Task/Step imports OK')"
|
||||
python -c "from praisonaiagents import Task; print('Task imports OK')"
|
||||
|
||||
# ==================== AGENT FEATURES ====================
|
||||
- name: Test Agent instantiation
|
||||
@@ -66,21 +75,13 @@ jobs:
|
||||
"
|
||||
|
||||
# ==================== WORKFLOW FEATURES ====================
|
||||
- name: Test Workflow and WorkflowStep
|
||||
- name: Test Workflow
|
||||
run: |
|
||||
python -c "
|
||||
from praisonaiagents import Workflow, WorkflowStep
|
||||
from praisonaiagents.workflows import WorkflowOutputConfig
|
||||
from praisonaiagents import Workflow
|
||||
|
||||
w = Workflow(name='test')
|
||||
s1 = WorkflowStep(name='step1')
|
||||
s2 = WorkflowStep(name='step2', context=['step1'])
|
||||
print('Workflow:', w.name)
|
||||
print('Steps:', s1.name, s2.name)
|
||||
|
||||
cfg = WorkflowOutputConfig(verbose=True, stream=False)
|
||||
w2 = Workflow(name='configured', output=cfg)
|
||||
print('Configured workflow verbose:', w2.verbose)
|
||||
"
|
||||
|
||||
# ==================== MEMORY FEATURES ====================
|
||||
|
||||
@@ -1,516 +0,0 @@
|
||||
name: Quick Validation Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
quick-test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.11
|
||||
|
||||
- name: Install UV
|
||||
run: |
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd src/praisonai
|
||||
uv pip install --system ."[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
|
||||
uv pip install --system duckduckgo_search
|
||||
uv pip install --system pytest pytest-asyncio pytest-cov pytest-timeout
|
||||
# Install knowledge dependencies from praisonai-agents
|
||||
uv pip install --system "praisonaiagents[knowledge]"
|
||||
|
||||
- name: Set environment variables
|
||||
run: |
|
||||
echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY || 'sk-test-key-for-github-actions-testing-only-not-real' }}" >> $GITHUB_ENV
|
||||
echo "OPENAI_API_BASE=${{ secrets.OPENAI_API_BASE || 'https://api.openai.com/v1' }}" >> $GITHUB_ENV
|
||||
echo "OPENAI_MODEL_NAME=${{ secrets.OPENAI_MODEL_NAME || 'gpt-5-nano' }}" >> $GITHUB_ENV
|
||||
echo "LOGLEVEL=DEBUG" >> $GITHUB_ENV
|
||||
echo "PYTHONPATH=${{ github.workspace }}/src/praisonai-agents:$PYTHONPATH" >> $GITHUB_ENV
|
||||
|
||||
- name: Backup Root Config Files
|
||||
run: |
|
||||
echo "🔄 Backing up root configuration files to prevent default file resolution interference..."
|
||||
echo "ℹ️ PraisonAI automatically uses 'agents.yaml' in working directory when no file specified"
|
||||
echo "ℹ️ This can interfere with test files that specify their own YAML files"
|
||||
if [ -f "agents.yaml" ]; then
|
||||
mv agents.yaml agents.yaml.backup
|
||||
echo "✅ Moved root agents.yaml to agents.yaml.backup"
|
||||
echo " - This prevents default file fallback during tests"
|
||||
fi
|
||||
if [ -f "tools.py" ]; then
|
||||
mv tools.py tools.py.backup
|
||||
echo "✅ Moved tools.py to tools.py.backup"
|
||||
fi
|
||||
|
||||
- name: Debug API Key Status
|
||||
run: |
|
||||
echo "🔍 Checking API key availability..."
|
||||
if [ -n "${{ secrets.OPENAI_API_KEY }}" ]; then
|
||||
echo "✅ GitHub secret OPENAI_API_KEY is available"
|
||||
echo "🔑 API key starts with: $(echo "$OPENAI_API_KEY" | cut -c1-7)..."
|
||||
else
|
||||
echo "⚠️ GitHub secret OPENAI_API_KEY is NOT set - using fallback"
|
||||
echo "🔑 Using fallback key: sk-test-key..."
|
||||
fi
|
||||
echo "🌐 API Base: $OPENAI_API_BASE"
|
||||
echo "🤖 Model: $OPENAI_MODEL_NAME"
|
||||
echo "🐛 Log Level: $LOGLEVEL"
|
||||
echo "📊 Environment Check:"
|
||||
echo " - OPENAI_API_KEY length: ${#OPENAI_API_KEY}"
|
||||
echo " - OPENAI_API_BASE: $OPENAI_API_BASE"
|
||||
echo " - OPENAI_MODEL_NAME: $OPENAI_MODEL_NAME"
|
||||
echo " - LOGLEVEL: $LOGLEVEL"
|
||||
|
||||
- name: Debug Python Environment Variables
|
||||
run: |
|
||||
python -c "
|
||||
import os
|
||||
print('🐍 Python Environment Variable Check:')
|
||||
api_key = os.environ.get('OPENAI_API_KEY', 'NOT_SET')
|
||||
if api_key != 'NOT_SET':
|
||||
print(f' ✅ OPENAI_API_KEY: {api_key[:7]}... (length: {len(api_key)})')
|
||||
else:
|
||||
print(' ❌ OPENAI_API_KEY: NOT_SET')
|
||||
print(f' 🌐 OPENAI_API_BASE: {os.environ.get(\"OPENAI_API_BASE\", \"NOT_SET\")}')
|
||||
print(f' 🤖 OPENAI_MODEL_NAME: {os.environ.get(\"OPENAI_MODEL_NAME\", \"NOT_SET\")}')
|
||||
print(f' 📋 All OPENAI env vars:')
|
||||
for key, value in os.environ.items():
|
||||
if key.startswith('OPENAI'):
|
||||
print(f' {key}: {value[:10] if len(value) > 10 else value}...')
|
||||
"
|
||||
|
||||
- name: Find Researcher Role Source
|
||||
run: |
|
||||
echo "🔍 Hunting for the mysterious 'Researcher' role..."
|
||||
cd src/praisonai
|
||||
python -c "
|
||||
import os
|
||||
import yaml
|
||||
import glob
|
||||
|
||||
print('📋 Searching for Researcher role in all YAML files:')
|
||||
yaml_files = glob.glob('src/praisonai/tests/*.yaml')
|
||||
|
||||
for yaml_file in yaml_files:
|
||||
try:
|
||||
with open(yaml_file, 'r') as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
# Check if any role contains 'researcher'
|
||||
roles = config.get('roles', {})
|
||||
for role_key, role_data in roles.items():
|
||||
role_name = role_data.get('role', '')
|
||||
if 'researcher' in role_key.lower() or 'researcher' in role_name.lower():
|
||||
print(f' 🎯 FOUND in {yaml_file}:')
|
||||
print(f' Framework: {config.get(\"framework\", \"NOT_SET\")}')
|
||||
print(f' Role key: {role_key}')
|
||||
print(f' Role name: {role_name}')
|
||||
print(f' All roles: {list(roles.keys())}')
|
||||
print()
|
||||
except Exception as e:
|
||||
print(f' ❌ Error reading {yaml_file}: {e}')
|
||||
|
||||
print('🔍 Checking for default configurations...')
|
||||
# Check if there are any default configs or hardcoded roles
|
||||
try:
|
||||
import praisonai
|
||||
print(f' PraisonAI package location: {praisonai.__file__}')
|
||||
|
||||
# Check if there are any example YAML files in the package
|
||||
package_dir = os.path.dirname(praisonai.__file__)
|
||||
for root, dirs, files in os.walk(package_dir):
|
||||
for file in files:
|
||||
if file.endswith(('.yaml', '.yml')):
|
||||
file_path = os.path.join(root, file)
|
||||
print(f' 📁 Found YAML in package: {file_path}')
|
||||
except Exception as e:
|
||||
print(f' ❌ Error checking package: {e}')
|
||||
"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Trace AutoGen Execution Path
|
||||
run: |
|
||||
echo "🔍 Tracing AutoGen execution to find where it diverges..."
|
||||
cd src/praisonai
|
||||
python -c "
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, '.')
|
||||
|
||||
try:
|
||||
from praisonai import PraisonAI
|
||||
from praisonai.agents_generator import AgentsGenerator
|
||||
|
||||
# Test the exact execution path
|
||||
print('🎯 Testing AutoGen execution path:')
|
||||
|
||||
praisonai = PraisonAI(agent_file='tests/autogen-agents.yaml')
|
||||
print(f' 1. PraisonAI framework: \"{praisonai.framework}\"')
|
||||
|
||||
agents_gen = AgentsGenerator(
|
||||
agent_file='tests/autogen-agents.yaml',
|
||||
framework=praisonai.framework,
|
||||
config_list=praisonai.config_list
|
||||
)
|
||||
print(f' 2. AgentsGenerator framework: \"{agents_gen.framework}\"')
|
||||
|
||||
# Load the YAML to check what it contains
|
||||
import yaml
|
||||
with open('src/praisonai/tests/autogen-agents.yaml', 'r') as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
framework = agents_gen.framework or config.get('framework')
|
||||
print(f' 3. Final framework decision: \"{framework}\"')
|
||||
print(f' 4. Available frameworks:')
|
||||
|
||||
# Check framework availability
|
||||
try:
|
||||
import autogen
|
||||
print(f' ✅ AutoGen available')
|
||||
except ImportError:
|
||||
print(f' ❌ AutoGen NOT available')
|
||||
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
print(f' ✅ PraisonAI agents available')
|
||||
except ImportError:
|
||||
print(f' ❌ PraisonAI agents NOT available')
|
||||
|
||||
try:
|
||||
from crewai import Agent
|
||||
print(f' ✅ CrewAI available')
|
||||
except ImportError:
|
||||
print(f' ❌ CrewAI NOT available')
|
||||
|
||||
print(f' 5. Roles in YAML: {list(config.get(\"roles\", {}).keys())}')
|
||||
|
||||
# Now test the actual framework execution
|
||||
if framework == 'autogen':
|
||||
print(f' 6. ✅ Should execute _run_autogen')
|
||||
elif framework == 'praisonai':
|
||||
print(f' 6. ❌ Would execute _run_praisonai (WRONG!)')
|
||||
else:
|
||||
print(f' 6. ❌ Would execute _run_crewai (DEFAULT FALLBACK)')
|
||||
|
||||
except Exception as e:
|
||||
print(f'❌ Error tracing execution: {e}')
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Debug YAML Loading and Roles
|
||||
run: |
|
||||
echo "🔍 Tracing YAML file loading and role creation..."
|
||||
cd src/praisonai
|
||||
python -c "
|
||||
import os
|
||||
import sys
|
||||
import yaml
|
||||
sys.path.insert(0, '.')
|
||||
|
||||
print('📁 Available YAML files in src/praisonai/tests/:')
|
||||
import glob
|
||||
yaml_files = glob.glob('tests/*.yaml')
|
||||
for f in yaml_files:
|
||||
print(f' {f}')
|
||||
|
||||
print()
|
||||
print('📋 Content of autogen-agents.yaml:')
|
||||
with open('tests/autogen-agents.yaml', 'r') as f:
|
||||
config = yaml.safe_load(f)
|
||||
print(f' Framework: {config.get(\"framework\")}')
|
||||
print(f' Topic: {config.get(\"topic\")}')
|
||||
print(f' Roles: {list(config.get(\"roles\", {}).keys())}')
|
||||
for role_key, role_data in config.get('roles', {}).items():
|
||||
print(f' {role_key} -> {role_data.get(\"role\", \"NO_ROLE\")}')
|
||||
|
||||
print()
|
||||
print('🔍 Checking if execution uses a different YAML:')
|
||||
|
||||
# Check other YAML files for 'Researcher' role
|
||||
for yaml_file in yaml_files:
|
||||
try:
|
||||
with open(yaml_file, 'r') as f:
|
||||
test_config = yaml.safe_load(f)
|
||||
roles = test_config.get('roles', {})
|
||||
for role_key, role_data in roles.items():
|
||||
if 'researcher' in role_key.lower() or 'researcher' in role_data.get('role', '').lower():
|
||||
print(f' 🎯 FOUND Researcher in {yaml_file}!')
|
||||
print(f' Framework: {test_config.get(\"framework\")}')
|
||||
print(f' Role key: {role_key} -> {role_data.get(\"role\")}')
|
||||
except:
|
||||
pass
|
||||
"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Debug Framework Detection
|
||||
run: |
|
||||
echo "🔍 Testing framework detection and config flow..."
|
||||
python -c "
|
||||
import os
|
||||
import sys
|
||||
import yaml
|
||||
sys.path.insert(0, '.')
|
||||
|
||||
print('🔧 Testing framework detection:')
|
||||
|
||||
# Load the YAML file
|
||||
with open('tests/autogen-agents.yaml', 'r') as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
print(f' 📋 YAML framework: {config.get(\"framework\", \"NOT_SET\")}')
|
||||
print(f' 📋 YAML topic: {config.get(\"topic\", \"NOT_SET\")}')
|
||||
|
||||
try:
|
||||
from praisonai import PraisonAI
|
||||
from praisonai.agents_generator import AgentsGenerator
|
||||
|
||||
# Test PraisonAI initialization
|
||||
praisonai = PraisonAI(agent_file='tests/autogen-agents.yaml')
|
||||
print(f' 🎯 PraisonAI framework: {praisonai.framework}')
|
||||
|
||||
# Test AgentsGenerator initialization
|
||||
agents_gen = AgentsGenerator(
|
||||
agent_file='tests/autogen-agents.yaml',
|
||||
framework=praisonai.framework,
|
||||
config_list=praisonai.config_list
|
||||
)
|
||||
print(f' ⚙️ AgentsGenerator framework: {agents_gen.framework}')
|
||||
print(f' ⚙️ Final framework decision: {agents_gen.framework or config.get(\"framework\")}')
|
||||
|
||||
# Check config_list
|
||||
print(f' 🔑 Config list model: {praisonai.config_list[0].get(\"model\")}')
|
||||
print(f' 🔑 Config list API key: {praisonai.config_list[0].get(\"api_key\", \"NOT_SET\")[:10]}...')
|
||||
|
||||
except Exception as e:
|
||||
print(f'❌ Error in framework detection: {e}')
|
||||
"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Debug PraisonAIModel API Key Flow
|
||||
run: |
|
||||
echo "🔍 Testing PraisonAIModel API key handling..."
|
||||
cd src/praisonai
|
||||
python -c "
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, '.')
|
||||
|
||||
print('🔑 Environment API Key Check:')
|
||||
env_key = os.environ.get('OPENAI_API_KEY', 'NOT_FOUND')
|
||||
print(f' OPENAI_API_KEY: {env_key[:10] if env_key != \"NOT_FOUND\" else \"NOT_FOUND\"}...')
|
||||
|
||||
try:
|
||||
from praisonai.inc.models import PraisonAIModel
|
||||
|
||||
# Test PraisonAIModel with openai/gpt-5-nano (from YAML)
|
||||
model = PraisonAIModel(model='openai/gpt-5-nano')
|
||||
|
||||
print('🤖 PraisonAIModel Configuration:')
|
||||
print(f' model: {model.model}')
|
||||
print(f' model_name: {model.model_name}')
|
||||
print(f' api_key_var: {model.api_key_var}')
|
||||
print(f' api_key: {model.api_key[:10] if model.api_key != \"nokey\" else \"DEFAULT_NOKEY\"}...')
|
||||
print(f' base_url: {model.base_url}')
|
||||
|
||||
if model.api_key == 'nokey':
|
||||
print('❌ FOUND THE ISSUE: PraisonAIModel is using default \"nokey\" instead of environment variable!')
|
||||
else:
|
||||
print('✅ PraisonAIModel has valid API key from environment')
|
||||
|
||||
except Exception as e:
|
||||
print(f'❌ Error testing PraisonAIModel: {e}')
|
||||
"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Validate API Key
|
||||
run: |
|
||||
echo "🔑 Testing API key validity with minimal OpenAI call..."
|
||||
python -c "
|
||||
import os
|
||||
try:
|
||||
from openai import OpenAI
|
||||
client = OpenAI(api_key=os.environ.get('OPENAI_API_KEY'))
|
||||
# Make a minimal API call to test key validity
|
||||
response = client.models.list()
|
||||
print('✅ API Key is VALID - OpenAI responded successfully')
|
||||
print(f'📊 Available models: {len(list(response.data))} models found')
|
||||
except Exception as e:
|
||||
print(f'❌ API Key is INVALID - Error: {e}')
|
||||
print('🔍 This explains why all API-dependent tests are failing')
|
||||
print('💡 The GitHub secret OPENAI_API_KEY needs to be updated with a valid key')
|
||||
"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Test Direct PraisonAI Execution
|
||||
run: |
|
||||
echo "🧪 Testing direct PraisonAI execution (what works locally)..."
|
||||
python -m praisonai src/praisonai/tests/autogen-agents.yaml
|
||||
continue-on-error: true
|
||||
|
||||
- name: Comprehensive Execution Debug
|
||||
run: |
|
||||
echo "🔍 Comprehensive debugging of PraisonAI execution path..."
|
||||
cd src/praisonai
|
||||
python -c "
|
||||
import os
|
||||
import sys
|
||||
import yaml
|
||||
sys.path.insert(0, '.')
|
||||
|
||||
print('=' * 60)
|
||||
print('🔍 COMPREHENSIVE EXECUTION DEBUG')
|
||||
print('=' * 60)
|
||||
|
||||
# Check current working directory
|
||||
print(f'📁 Current working directory: {os.getcwd()}')
|
||||
|
||||
# List files in current directory
|
||||
print('📋 Files in current directory:')
|
||||
for f in os.listdir('.'):
|
||||
print(f' {f}')
|
||||
|
||||
print()
|
||||
print('📋 Files in src/praisonai/tests/ directory:')
|
||||
for f in os.listdir('tests'):
|
||||
if f.endswith('.yaml'):
|
||||
print(f' src/praisonai/tests/{f}')
|
||||
|
||||
# Check if root agents.yaml exists
|
||||
print()
|
||||
if os.path.exists('agents.yaml'):
|
||||
print('❌ ROOT agents.yaml EXISTS (this is the problem!)')
|
||||
with open('agents.yaml', 'r') as f:
|
||||
root_config = yaml.safe_load(f)
|
||||
print(f' Root framework: {root_config.get(\"framework\")}')
|
||||
print(f' Root roles: {list(root_config.get(\"roles\", {}).keys())}')
|
||||
else:
|
||||
print('✅ ROOT agents.yaml does NOT exist (good!)')
|
||||
|
||||
# Test the actual execution path
|
||||
print()
|
||||
print('🎯 Testing EXACT execution path:')
|
||||
|
||||
try:
|
||||
from praisonai import PraisonAI
|
||||
from praisonai.agents_generator import AgentsGenerator
|
||||
|
||||
# Test with full path
|
||||
test_file = 'src/praisonai/tests/autogen-agents.yaml'
|
||||
print(f' Using test file: {test_file}')
|
||||
print(f' File exists: {os.path.exists(test_file)}')
|
||||
|
||||
# Load the test file directly
|
||||
with open(test_file, 'r') as f:
|
||||
test_config = yaml.safe_load(f)
|
||||
print(f' Test file framework: {test_config.get(\"framework\")}')
|
||||
print(f' Test file roles: {list(test_config.get(\"roles\", {}).keys())}')
|
||||
|
||||
# Create PraisonAI instance
|
||||
praisonai = PraisonAI(agent_file=test_file)
|
||||
print(f' PraisonAI.agent_file: {praisonai.agent_file}')
|
||||
print(f' PraisonAI.framework: {praisonai.framework}')
|
||||
|
||||
# Create AgentsGenerator
|
||||
agents_gen = AgentsGenerator(
|
||||
agent_file=test_file,
|
||||
framework=praisonai.framework,
|
||||
config_list=praisonai.config_list
|
||||
)
|
||||
print(f' AgentsGenerator.agent_file: {agents_gen.agent_file}')
|
||||
print(f' AgentsGenerator.framework: {agents_gen.framework}')
|
||||
|
||||
# Test what would happen in generate_crew_and_kickoff
|
||||
print()
|
||||
print('🔧 Testing generate_crew_and_kickoff logic:')
|
||||
|
||||
# Simulate the loading logic
|
||||
if agents_gen.agent_yaml:
|
||||
loaded_config = yaml.safe_load(agents_gen.agent_yaml)
|
||||
print(' Would load from agent_yaml')
|
||||
else:
|
||||
if agents_gen.agent_file == '/app/api:app' or agents_gen.agent_file == 'api:app':
|
||||
agents_gen.agent_file = 'agents.yaml'
|
||||
print(f' Would change agent_file to: {agents_gen.agent_file}')
|
||||
try:
|
||||
with open(agents_gen.agent_file, 'r') as f:
|
||||
loaded_config = yaml.safe_load(f)
|
||||
print(f' Successfully loaded: {agents_gen.agent_file}')
|
||||
except FileNotFoundError:
|
||||
print(f' FileNotFoundError: {agents_gen.agent_file}')
|
||||
loaded_config = None
|
||||
|
||||
if loaded_config:
|
||||
final_framework = agents_gen.framework or loaded_config.get('framework')
|
||||
print(f' Final framework decision: {final_framework}')
|
||||
print(f' Loaded roles: {list(loaded_config.get(\"roles\", {}).keys())}')
|
||||
|
||||
if 'researcher' in loaded_config.get('roles', {}):
|
||||
print(' ❌ FOUND Researcher role in loaded config!')
|
||||
else:
|
||||
print(' ✅ No Researcher role in loaded config')
|
||||
|
||||
except Exception as e:
|
||||
print(f'❌ Error during execution debug: {e}')
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run Fast Tests
|
||||
run: |
|
||||
# Run the fastest, most essential tests with coverage
|
||||
cd src/praisonai && python -m pytest tests/unit/test_core_agents.py -v --tb=short --disable-warnings --cov=praisonai --cov-report=xml --cov-branch
|
||||
|
||||
- name: Run Real API Tests
|
||||
run: |
|
||||
echo "🔑 Running real API tests with actual OpenAI API key..."
|
||||
cd src/praisonai && python -m pytest tests/test_agents_playbook.py -v --tb=short --disable-warnings -m real
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run E2E Real Tests
|
||||
run: |
|
||||
echo "🧪 Running E2E real tests..."
|
||||
cd src/praisonai && python -m pytest tests/e2e/ -v --tb=short --disable-warnings -m real
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run Legacy Example Tests
|
||||
run: |
|
||||
cd src/praisonai && python -m pytest tests/test.py -v --tb=short --disable-warnings -m "not real" --timeout=60
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
file: src/praisonai/coverage.xml
|
||||
flags: quick-validation
|
||||
name: quick-validation-coverage
|
||||
fail_ci_if_error: false
|
||||
verbose: true
|
||||
|
||||
- name: Restore Root Config Files
|
||||
run: |
|
||||
echo "🔄 Restoring root configuration files..."
|
||||
if [ -f "agents.yaml.backup" ]; then
|
||||
mv agents.yaml.backup agents.yaml
|
||||
echo "✅ Restored agents.yaml"
|
||||
fi
|
||||
if [ -f "tools.py.backup" ]; then
|
||||
mv tools.py.backup tools.py
|
||||
echo "✅ Restored tools.py"
|
||||
fi
|
||||
if: always() # Always run this step, even if previous steps failed
|
||||
@@ -52,6 +52,6 @@ jobs:
|
||||
with:
|
||||
commit_message: "docs: auto-update api.md [skip ci]"
|
||||
file_pattern: 'api.md'
|
||||
commit_user_name: "github-actions[bot]"
|
||||
commit_user_email: "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
commit_author: "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>"
|
||||
commit_user_name: "MervinPraison"
|
||||
commit_user_email: "praison.ms@gmail.com"
|
||||
commit_author: "MervinPraison <praison.ms@gmail.com>"
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
with:
|
||||
repository: MervinPraison/PraisonAIDocs
|
||||
path: PraisonAIDocs
|
||||
token: ${{ secrets.DOCS_PAT }}
|
||||
token: ${{ secrets.DOCS_PAT || secrets.PAT_TOKEN }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
@@ -65,16 +65,16 @@ jobs:
|
||||
with:
|
||||
commit_message: "docs: auto-update documentation parity trackers [skip ci]"
|
||||
file_pattern: 'src/praisonai-agents/DOCS_PARITY.md src/praisonai-ts/DOCS_PARITY.md src/praisonai-rust/DOCS_PARITY.md'
|
||||
commit_user_name: "github-actions[bot]"
|
||||
commit_user_email: "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
commit_author: "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>"
|
||||
commit_user_name: "MervinPraison"
|
||||
commit_user_email: "praison.ms@gmail.com"
|
||||
commit_author: "MervinPraison <praison.ms@gmail.com>"
|
||||
|
||||
- name: Commit and push changes to PraisonAIDocs
|
||||
if: steps.check_changes.outputs.changes == 'true'
|
||||
run: |
|
||||
cd PraisonAIDocs
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git config user.name "MervinPraison"
|
||||
git config user.email "praison.ms@gmail.com"
|
||||
git add docs/features/DOCS_PARITY.md docs/js/DOCS_PARITY.md docs/rust/DOCS_PARITY.md
|
||||
git diff --staged --quiet || git commit -m "docs: auto-update documentation parity trackers [skip ci]"
|
||||
git push
|
||||
@@ -29,6 +29,9 @@ jobs:
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install pyyaml
|
||||
|
||||
- name: Generate parity trackers
|
||||
run: |
|
||||
cd $GITHUB_WORKSPACE
|
||||
@@ -52,7 +55,7 @@ jobs:
|
||||
with:
|
||||
commit_message: "docs: auto-update feature parity trackers [skip ci]"
|
||||
file_pattern: 'src/praisonai-ts/FEATURE_PARITY_TRACKER.json src/praisonai-ts/PARITY.md src/praisonai-rust/FEATURE_PARITY_TRACKER.json src/praisonai-rust/PARITY.md'
|
||||
commit_user_name: "github-actions[bot]"
|
||||
commit_user_email: "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
commit_author: "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>"
|
||||
commit_user_name: "MervinPraison"
|
||||
commit_user_email: "praison.ms@gmail.com"
|
||||
commit_author: "MervinPraison <praison.ms@gmail.com>"
|
||||
|
||||
+6
-1
@@ -21,6 +21,7 @@ docs.sh
|
||||
other
|
||||
output
|
||||
!src/praisonai/praisonai/cli/output/
|
||||
!src/praisonai-agents/praisonaiagents/output/
|
||||
|
||||
.files
|
||||
threads.db
|
||||
@@ -91,4 +92,8 @@ issue_details.json
|
||||
target/
|
||||
Cargo.lock
|
||||
**/*.rs.bk
|
||||
*.pdb
|
||||
*.pdb
|
||||
.windsurf
|
||||
.agent
|
||||
|
||||
src/praisonai/build
|
||||
@@ -49,13 +49,14 @@ Methods:
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">console</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">context_manager</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">context_manager</a>(value)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">cost_summary</a>() -> dict</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">delete_history</a>(index: int) -> bool</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">delete_history_matching</a>(pattern: str) -> int</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">diff</a>(from_hash: Optional[str] = None)</code>
|
||||
* <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)</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">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>
|
||||
@@ -99,6 +100,7 @@ Methods:
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">switch_model</a>(new_model: str) -> None</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">thinking_budget</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">thinking_budget</a>(value)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">total_cost</a>() -> float</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">undo</a>() -> bool</code>
|
||||
* <code title="class AutoAgents">AutoAgents.<a href="./src/praisonai-agents/praisonaiagents/agents/autoagents.py">astart</a>()</code>
|
||||
* <code title="class AutoAgents">AutoAgents.<a href="./src/praisonai-agents/praisonaiagents/agents/autoagents.py">start</a>()</code>
|
||||
@@ -250,6 +252,7 @@ Methods:
|
||||
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">build_context_for_task</a>(task_descr: str, user_id: Optional[str] = None, additional: str = '', max_items: int = 3, include_in_output: Optional[bool] = None) -> str</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">calculate_quality_metrics</a>(output: str, expected_output: str, llm: Optional[str] = None, custom_prompt: Optional[str] = None) -> Dict[str, float]</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">close_connections</a>()</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">compute_quality_score</a>(completeness: float, relevance: float, clarity: float, accuracy: float, weights: Dict[str, float] = None) -> float</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">delete_long_term</a>(memory_id: str) -> bool</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">delete_memories</a>(memory_ids: List[str]) -> int</code>
|
||||
@@ -381,7 +384,7 @@ from praisonaiagents import GuardrailAction, GuardrailConfig, LLMGuardrail
|
||||
Methods:
|
||||
|
||||
* <code title="class GuardrailConfig">GuardrailConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">to_dict</a>() -> Dict[str, Any]</code>
|
||||
* <code title="class LLMGuardrail">LLMGuardrail.<a href="./src/praisonai-agents/praisonaiagents/guardrails/llm_guardrail.py">__call__</a>(task_output: TaskOutput) -> Tuple[bool, Union[str, TaskOutput]]</code>
|
||||
* <code title="class LLMGuardrail">LLMGuardrail.<a href="./src/praisonai-agents/praisonaiagents/guardrails/llm_guardrail.py">__call__</a>(task_output) -> Tuple[bool, Union[str, 'TaskOutput']]</code>
|
||||
|
||||
# Planning
|
||||
|
||||
@@ -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)
|
||||
@@ -784,8 +785,10 @@ Methods:
|
||||
* <code title="cli">praisonai benchmark profile <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
|
||||
* <code title="cli">praisonai benchmark sdk <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
|
||||
* <code title="cli">praisonai benchmark workflow <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot agentmail <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot bot-callback <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot discord <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot email <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot slack <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot start <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot telegram <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
@@ -802,6 +805,7 @@ Methods:
|
||||
* <code title="cli">praisonai call call-main <a href="./src/praisonai/praisonai/cli/commands/call.py">--help</a></code>
|
||||
* <code title="cli">praisonai chat <a href="./src/praisonai/praisonai/cli/main.py">--help</a></code>
|
||||
* <code title="cli">praisonai chat chat-main <a href="./src/praisonai/praisonai/cli/commands/chat.py">--help</a></code>
|
||||
* <code title="cli">praisonai claw claw <a href="./src/praisonai/praisonai/cli/commands/claw.py">--help</a></code>
|
||||
* <code title="cli">praisonai code <a href="./src/praisonai/praisonai/cli/main.py">--help</a></code>
|
||||
* <code title="cli">praisonai code code-main <a href="./src/praisonai/praisonai/cli/commands/code.py">--help</a></code>
|
||||
* <code title="cli">praisonai commit commit-main <a href="./src/praisonai/praisonai/cli/commands/commit.py">--help</a></code>
|
||||
@@ -868,7 +872,9 @@ 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 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>
|
||||
* <code title="cli">praisonai gateway start <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway status <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai hooks add <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
|
||||
@@ -906,6 +912,7 @@ Methods:
|
||||
* <code title="cli">praisonai package install <a href="./src/praisonai/praisonai/cli/commands/package.py">--help</a></code>
|
||||
* <code title="cli">praisonai package list <a href="./src/praisonai/praisonai/cli/commands/package.py">--help</a></code>
|
||||
* <code title="cli">praisonai package uninstall <a href="./src/praisonai/praisonai/cli/commands/package.py">--help</a></code>
|
||||
* <code title="cli">praisonai paths show <a href="./src/praisonai/praisonai/cli/commands/paths.py">--help</a></code>
|
||||
* <code title="cli">praisonai plugins create <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
|
||||
* <code title="cli">praisonai plugins disable <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
|
||||
* <code title="cli">praisonai plugins discover <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
|
||||
@@ -923,6 +930,8 @@ Methods:
|
||||
* <code title="cli">praisonai profile snapshot <a href="./src/praisonai/praisonai/cli/commands/profile.py">--help</a></code>
|
||||
* <code title="cli">praisonai profile startup <a href="./src/praisonai/praisonai/cli/commands/profile.py">--help</a></code>
|
||||
* <code title="cli">praisonai profile suite <a href="./src/praisonai/praisonai/cli/commands/profile.py">--help</a></code>
|
||||
* <code title="cli">praisonai publish publish-main <a href="./src/praisonai/praisonai/cli/commands/publish.py">--help</a></code>
|
||||
* <code title="cli">praisonai publish pypi <a href="./src/praisonai/praisonai/cli/commands/publish.py">--help</a></code>
|
||||
* <code title="cli">praisonai rag chat <a href="./src/praisonai/praisonai/cli/commands/rag.py">--help</a></code>
|
||||
* <code title="cli">praisonai rag eval <a href="./src/praisonai/praisonai/cli/commands/rag.py">--help</a></code>
|
||||
* <code title="cli">praisonai rag index <a href="./src/praisonai/praisonai/cli/commands/rag.py">--help</a></code>
|
||||
@@ -961,6 +970,7 @@ Methods:
|
||||
* <code title="cli">praisonai sandbox recreate <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
|
||||
* <code title="cli">praisonai sandbox sandbox-callback <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
|
||||
* <code title="cli">praisonai sandbox status <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule add <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule delete <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule describe <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule list <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.5.15" \
|
||||
"praisonai>=4.5.87" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# PraisonAI 🦞 Claw — All-in-one: dashboard + bots + gateway + agents
|
||||
#
|
||||
# Build: docker build -f docker/Dockerfile.claw -t praisonai:claw ..
|
||||
# Run: docker run -p 8082:8082 -e OPENAI_API_KEY=sk-xxx praisonai:claw
|
||||
# Health: curl http://localhost:8082/health
|
||||
#
|
||||
# Override CMD for other modes:
|
||||
# Bot: docker run -e ... praisonai:claw praisonai bot telegram
|
||||
# Gateway: docker run -e ... praisonai:claw praisonai gateway start
|
||||
# Serve: docker run -e ... praisonai:claw praisonai serve agents.yaml
|
||||
# Shell: docker run --rm -it praisonai:claw bash
|
||||
|
||||
FROM python:3.12-slim AS base
|
||||
|
||||
# ── Environment ──────────────────────────────────────────────────────────────
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
AIUI_PORT=8082 \
|
||||
AIUI_HOST=0.0.0.0 \
|
||||
AIUI_DATA_DIR=/data
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# ── System dependencies ─────────────────────────────────────────────────────
|
||||
# tini: PID 1 signal handling (graceful shutdown on Fly/Railway/k8s)
|
||||
# curl: HEALTHCHECK probe
|
||||
# build-essential: compile native Python wheels (chromadb, etc.)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
tini \
|
||||
curl \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── Install PraisonAI 🦞 ────────────────────────────────────────────────────
|
||||
# Single command installs: AIUI dashboard, agents SDK (memory/knowledge/llm/mcp),
|
||||
# bot channels (Telegram/Discord/Slack), gateway, search tools, and more.
|
||||
RUN pip install --no-cache-dir "praisonai[claw]"
|
||||
|
||||
# ── Data volume ──────────────────────────────────────────────────────────────
|
||||
# Persistence for sessions, config, memory, knowledge stores
|
||||
RUN mkdir -p /data
|
||||
VOLUME /data
|
||||
|
||||
# ── Entrypoint ───────────────────────────────────────────────────────────────
|
||||
# Ensures /data exists and forwards all signals via tini
|
||||
RUN printf '#!/bin/sh\n\
|
||||
set -e\n\
|
||||
mkdir -p /data 2>/dev/null || true\n\
|
||||
exec "$@"\n' > /usr/local/bin/entrypoint.sh \
|
||||
&& chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
EXPOSE 8082
|
||||
|
||||
# ── Health check ─────────────────────────────────────────────────────────────
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD curl -f http://localhost:8082/health || exit 1
|
||||
|
||||
# ── Run ──────────────────────────────────────────────────────────────────────
|
||||
# Default: AIUI dashboard via praisonai claw. Override CMD for bot/gateway/serve.
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint.sh"]
|
||||
CMD ["praisonai", "claw", "--port", "8082"]
|
||||
@@ -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.15" \
|
||||
"praisonai>=4.5.87" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.5.15" \
|
||||
"praisonai>=4.5.87" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# PraisonAI 🦞 Claw — Recommended all-in-one service
|
||||
claw:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile.claw
|
||||
volumes:
|
||||
- claw_data:/data
|
||||
ports:
|
||||
- "8082:8082"
|
||||
environment:
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: [ "CMD", "curl", "-f", "http://localhost:8082/health" ]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 20s
|
||||
retries: 3
|
||||
networks:
|
||||
- praison_network
|
||||
|
||||
# ─── Legacy services (kept for backward compatibility) ─────────────────
|
||||
|
||||
# PraisonAI UI Service
|
||||
ui:
|
||||
build:
|
||||
@@ -92,6 +115,8 @@ services:
|
||||
# For bot services (Slack, Discord, Telegram), see docker/bots/
|
||||
|
||||
volumes:
|
||||
claw_data:
|
||||
driver: local
|
||||
praison_data:
|
||||
driver: local
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Calculator with Tests - Editor Output Example.
|
||||
|
||||
Builds a calculator module, writes unit tests, runs them, generates a
|
||||
coverage-style report, and reads it back.
|
||||
"""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools import (
|
||||
write_file, read_file, execute_command
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
instructions="You are a helpful coding assistant.",
|
||||
output="editor",
|
||||
tools=[write_file, read_file, execute_command],
|
||||
approval=True,
|
||||
)
|
||||
agent.start(
|
||||
"Build a mini calculator - create /tmp/calculator.py with add, subtract, "
|
||||
"multiply, divide functions, write unit tests in /tmp/test_calculator.py "
|
||||
"using unittest, run the tests to make sure they pass, then generate a "
|
||||
"coverage-style report in /tmp/calc_report.md listing which functions were "
|
||||
"tested and read it back"
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Fibonacci Generator - Editor Output Example.
|
||||
|
||||
Creates a Fibonacci script, runs it, writes a summary, and reads back
|
||||
both files to verify everything looks good.
|
||||
"""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools import (
|
||||
write_file, read_file, execute_command, list_directory
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
instructions="You are a helpful coding assistant.",
|
||||
output="editor",
|
||||
tools=[write_file, read_file, execute_command, list_directory],
|
||||
approval=True,
|
||||
)
|
||||
agent.start(
|
||||
"Create a Python file at /tmp/fibonacci.py that generates the first 20 "
|
||||
"Fibonacci numbers, run it to verify the output is correct, then write "
|
||||
"a summary to /tmp/fib_summary.md explaining the algorithm you used and "
|
||||
"the output, finally read back both files to confirm everything looks good"
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Multi-Step Research - Editor Output Example.
|
||||
|
||||
Researches Python frameworks, creates comparison code, runs it, writes a
|
||||
markdown report, reads it back, lists files, and gets system info.
|
||||
"""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools import (
|
||||
write_file, read_file, execute_command, list_directory, get_system_info
|
||||
)
|
||||
|
||||
try:
|
||||
from praisonaiagents.tools import web_search
|
||||
tools = [web_search, write_file, read_file, execute_command,
|
||||
list_directory, get_system_info]
|
||||
except ImportError:
|
||||
tools = [write_file, read_file, execute_command,
|
||||
list_directory, get_system_info]
|
||||
|
||||
agent = Agent(
|
||||
instructions="You are a helpful research assistant.",
|
||||
output="editor",
|
||||
tools=tools,
|
||||
approval=True,
|
||||
)
|
||||
agent.start(
|
||||
"Research the top 3 Python web frameworks (Django, FastAPI, Flask), then: "
|
||||
"1) Search the web for each framework's latest version and key features, "
|
||||
"2) Create a file called /tmp/framework_comparison.py that contains a "
|
||||
"Python dictionary with the comparison data, "
|
||||
"3) Execute the code to verify the dictionary is valid, "
|
||||
"4) Write a markdown report to /tmp/framework_report.md summarizing your "
|
||||
"findings in a table format, "
|
||||
"5) Read back the report file to verify it was written correctly, "
|
||||
"6) List the files in /tmp to confirm both files exist, "
|
||||
"7) Get system info to note what OS this report was generated on"
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""System Audit - Editor Output Example.
|
||||
|
||||
Runs system commands, gathers info, writes a report, and reads it back.
|
||||
"""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools import (
|
||||
write_file, read_file, execute_command
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
instructions="You are a helpful system administration assistant.",
|
||||
output="editor",
|
||||
tools=[write_file, read_file, execute_command],
|
||||
approval=True,
|
||||
)
|
||||
agent.start(
|
||||
"Investigate my system - get the current date, check disk usage, find out "
|
||||
"what Python version is installed, list running processes sorted by memory, "
|
||||
"then write all findings to /tmp/system_audit.md as a neat report and read "
|
||||
"it back to verify"
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Weather Search - Editor Output Example.
|
||||
|
||||
Searches for weather, creates JSON data, writes a parser script, executes
|
||||
it, and lists created files.
|
||||
"""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools import (
|
||||
write_file, execute_command, list_directory
|
||||
)
|
||||
|
||||
try:
|
||||
from praisonaiagents.tools import web_search
|
||||
tools = [web_search, write_file, execute_command, list_directory]
|
||||
except ImportError:
|
||||
tools = [write_file, execute_command, list_directory]
|
||||
|
||||
agent = Agent(
|
||||
instructions="You are a helpful research assistant.",
|
||||
output="editor",
|
||||
tools=tools,
|
||||
approval=True,
|
||||
)
|
||||
agent.start(
|
||||
"Search the web for the current weather in Tokyo, create a JSON file at "
|
||||
"/tmp/weather_data.json with the findings, then write a Python script at "
|
||||
"/tmp/parse_weather.py that reads the JSON and prints a formatted weather "
|
||||
"report, execute the script, and list all files you created in /tmp"
|
||||
)
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Calculator with tests: code + unit tests + test execution + coverage report
|
||||
# Tests: multiple file creation, test execution, report generation, file reading
|
||||
|
||||
praisonai "Build a mini calculator - create /tmp/calculator.py with add, subtract, multiply, divide functions, write unit tests in /tmp/test_calculator.py using unittest, run the tests to make sure they pass, then generate a coverage-style report in /tmp/calc_report.md listing which functions were tested and read it back" --trust
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Fibonacci generator: code creation, execution, documentation, verification
|
||||
# Tests: file creation, code execution, markdown writing, file reading
|
||||
|
||||
praisonai "Create a Python file at /tmp/fibonacci.py that generates the first 20 Fibonacci numbers, run it to verify the output is correct, then write a summary to /tmp/fib_summary.md explaining the algorithm you used and the output, finally read back both files to confirm everything looks good" --trust
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Multi-step research: web search, file creation, code execution, reporting
|
||||
# Tests: web search, file write, code execution, markdown report, file read, directory listing, system info
|
||||
|
||||
praisonai "Research the top 3 Python web frameworks (Django, FastAPI, Flask), then: 1) Search the web for each framework's latest version and key features, 2) Create a file called /tmp/framework_comparison.py that contains a Python dictionary with the comparison data, 3) Execute the code to verify the dictionary is valid, 4) Write a markdown report to /tmp/framework_report.md summarizing your findings in a table format, 5) Read back the report file to verify it was written correctly, 6) List the files in /tmp to confirm both files exist, 7) Get system info to note what OS this report was generated on, 8) Analyze the Python code you wrote for any issues, 9) Format the Python code properly, 10) Finally search for any recent news about Python 3.13 features" --trust
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
# System audit: multiple command executions, report generation
|
||||
# Tests: command execution (date, disk, python, processes), file creation, file reading
|
||||
|
||||
praisonai "Investigate my system - get the current date, check disk usage, find out what Python version is installed, list running processes sorted by memory, then write all findings to /tmp/system_audit.md as a neat report and read it back to verify" --trust
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Todo app: multi-file app creation, execution, documentation, verification
|
||||
# Tests: multiple file creation, code execution, markdown docs, directory listing
|
||||
|
||||
praisonai "Write a todo list app at /tmp/todo.py with functions to add, remove, and list tasks stored in /tmp/todos.json, run the script with some sample tasks added, then create documentation at /tmp/todo_docs.md explaining usage with examples, and verify all three files exist" --trust
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Weather search: web search, JSON creation, Python script, execution
|
||||
# Tests: web search, JSON file creation, Python scripting, code execution, directory listing
|
||||
|
||||
praisonai "Search the web for the current weather in Tokyo, create a JSON file at /tmp/weather_data.json with the findings, then write a Python script at /tmp/parse_weather.py that reads the JSON and prints a formatted weather report, execute the script, and list all files you created in /tmp" --trust
|
||||
@@ -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,216 @@
|
||||
"""
|
||||
Email Bot Example — Deploy an AI Agent to Your Mailbox
|
||||
|
||||
This example shows how to deploy PraisonAI agents as email bots
|
||||
using IMAP/SMTP. The bot monitors an inbox, processes incoming emails,
|
||||
and replies intelligently using an LLM-powered agent.
|
||||
|
||||
Supported providers:
|
||||
- Gmail (with App Password)
|
||||
- Outlook/Microsoft 365
|
||||
- Any IMAP/SMTP server
|
||||
|
||||
Environment Variables:
|
||||
export OPENAI_API_KEY=your_key
|
||||
export EMAIL_ADDRESS=support@example.com
|
||||
export EMAIL_APP_PASSWORD=your_app_password
|
||||
export EMAIL_IMAP_SERVER=imap.gmail.com # optional, default: imap.gmail.com
|
||||
export EMAIL_SMTP_SERVER=smtp.gmail.com # optional, default: smtp.gmail.com
|
||||
|
||||
Usage:
|
||||
python bot_email_example.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from praisonaiagents import Agent
|
||||
|
||||
|
||||
# ── Example 1: Minimal — Bot() wrapper (recommended) ──────────────
|
||||
|
||||
def example_minimal():
|
||||
"""Minimal email bot — 3 lines to start."""
|
||||
from praisonai.bots import Bot
|
||||
|
||||
agent = Agent(
|
||||
name="email_assistant",
|
||||
instructions="You are a helpful email assistant. Reply concisely and professionally.",
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
|
||||
# Token and server resolved from env vars automatically:
|
||||
# EMAIL_APP_PASSWORD, EMAIL_ADDRESS, EMAIL_IMAP_SERVER, EMAIL_SMTP_SERVER
|
||||
bot = Bot("email", agent=agent)
|
||||
bot.run() # Blocks until Ctrl+C
|
||||
|
||||
|
||||
# ── Example 2: Custom configuration ───────────────────────────────
|
||||
|
||||
def example_with_config():
|
||||
"""Email bot with explicit configuration."""
|
||||
from praisonai.bots import Bot
|
||||
from praisonaiagents import BotConfig
|
||||
|
||||
config = BotConfig(
|
||||
polling_interval=30.0, # Check inbox every 30s
|
||||
allowed_users=[ # Only respond to these senders
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
],
|
||||
max_message_length=10000, # Allow long email replies
|
||||
session_ttl=86400, # Clear session after 24h inactivity
|
||||
metadata={
|
||||
"imap_folder": "INBOX", # Which folder to monitor
|
||||
},
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
name="support_agent",
|
||||
instructions="""You are a technical support agent for ExampleCo.
|
||||
|
||||
Guidelines:
|
||||
- Be professional and empathetic
|
||||
- Provide clear step-by-step solutions
|
||||
- If you don't know the answer, escalate to [email protected]
|
||||
- Keep replies under 500 words
|
||||
""",
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
|
||||
bot = Bot("email", agent=agent, config=config)
|
||||
bot.run()
|
||||
|
||||
|
||||
# ── Example 3: Direct EmailBot with custom handlers ──────────────
|
||||
|
||||
def example_direct():
|
||||
"""Direct EmailBot usage with custom message handler and commands."""
|
||||
from praisonai.bots import EmailBot
|
||||
|
||||
agent = Agent(
|
||||
name="smart_inbox",
|
||||
instructions="You help triage and respond to support emails.",
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
|
||||
bot = EmailBot(
|
||||
token=os.environ.get("EMAIL_APP_PASSWORD", ""),
|
||||
agent=agent,
|
||||
email_address=os.environ.get("EMAIL_ADDRESS", ""),
|
||||
imap_server=os.environ.get("EMAIL_IMAP_SERVER", "imap.gmail.com"),
|
||||
smtp_server=os.environ.get("EMAIL_SMTP_SERVER", "smtp.gmail.com"),
|
||||
)
|
||||
|
||||
# Custom message handler — runs on every incoming email
|
||||
@bot.on_message
|
||||
async def log_incoming(message):
|
||||
print(f"📧 New email from: {message.sender.username}")
|
||||
print(f" Subject: {message.metadata.get('subject', '(no subject)')}")
|
||||
|
||||
# Custom command — triggered when subject starts with "/status"
|
||||
@bot.on_command("status")
|
||||
async def handle_status(message):
|
||||
await bot.send_message(
|
||||
channel_id=message.sender.user_id,
|
||||
content={
|
||||
"subject": "Re: Bot Status",
|
||||
"body": f"🤖 Bot is running!\nProcessed emails: {bot._emails_processed}",
|
||||
},
|
||||
reply_to=message.message_id,
|
||||
)
|
||||
|
||||
asyncio.run(bot.start())
|
||||
|
||||
|
||||
# ── Example 4: Multi-platform with BotOS ─────────────────────────
|
||||
|
||||
def example_multi_platform():
|
||||
"""Run email bot alongside Telegram and Discord bots."""
|
||||
from praisonai.bots import BotOS, Bot
|
||||
|
||||
agent = Agent(
|
||||
name="omni_assistant",
|
||||
instructions="You are a helpful assistant available on email, Telegram, and Discord.",
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
|
||||
botos = BotOS(agent=agent, platforms=["email", "telegram", "discord"])
|
||||
botos.run() # All three bots run concurrently
|
||||
|
||||
|
||||
# ── Example 5: Probe connectivity ─────────────────────────────────
|
||||
|
||||
async def example_probe():
|
||||
"""Test email server connectivity without starting the bot."""
|
||||
from praisonai.bots import Bot
|
||||
|
||||
bot = Bot("email")
|
||||
result = await bot.probe()
|
||||
|
||||
print(f"Connection OK: {result.ok}")
|
||||
print(f"Platform: {result.platform}")
|
||||
if result.bot_username:
|
||||
print(f"Email: {result.bot_username}")
|
||||
if result.error:
|
||||
print(f"Error: {result.error}")
|
||||
print(f"Latency: {result.elapsed_ms:.0f}ms")
|
||||
|
||||
|
||||
# ── Example 6: YAML config (for praisonai bot CLI) ───────────────
|
||||
|
||||
YAML_EXAMPLE = """
|
||||
# bot.yaml — Save this file and run: praisonai bot --config bot.yaml
|
||||
agent:
|
||||
name: email_support
|
||||
instructions: >
|
||||
You are a professional support agent.
|
||||
Reply concisely and helpfully.
|
||||
llm: gpt-4o-mini
|
||||
tools:
|
||||
- search_web
|
||||
|
||||
platforms:
|
||||
email:
|
||||
token: ${EMAIL_APP_PASSWORD}
|
||||
"""
|
||||
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
examples = {
|
||||
"minimal": ("Minimal email bot", example_minimal),
|
||||
"config": ("With custom config", example_with_config),
|
||||
"direct": ("Direct EmailBot with handlers", example_direct),
|
||||
"multi": ("Multi-platform (email+telegram+discord)", example_multi_platform),
|
||||
"probe": ("Test connectivity", lambda: asyncio.run(example_probe())),
|
||||
}
|
||||
|
||||
if len(sys.argv) > 1 and sys.argv[1] in examples:
|
||||
name = sys.argv[1]
|
||||
desc, fn = examples[name]
|
||||
print(f"Running: {desc}")
|
||||
fn()
|
||||
else:
|
||||
print("PraisonAI Email Bot Examples")
|
||||
print("=" * 40)
|
||||
print()
|
||||
print("Usage: python bot_email_example.py <example>")
|
||||
print()
|
||||
print("Available examples:")
|
||||
for key, (desc, _) in examples.items():
|
||||
print(f" {key:10s} {desc}")
|
||||
print()
|
||||
print("Required environment variables:")
|
||||
print(" OPENAI_API_KEY Your OpenAI API key")
|
||||
print(" EMAIL_ADDRESS Bot's email address")
|
||||
print(" EMAIL_APP_PASSWORD App password (Gmail: myaccount.google.com/apppasswords)")
|
||||
print()
|
||||
print("Optional:")
|
||||
print(" EMAIL_IMAP_SERVER IMAP server (default: imap.gmail.com)")
|
||||
print(" EMAIL_SMTP_SERVER SMTP server (default: smtp.gmail.com)")
|
||||
print()
|
||||
print("YAML config example:")
|
||||
print(YAML_EXAMPLE)
|
||||
@@ -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,41 @@
|
||||
"""
|
||||
Example file-based action: line-count
|
||||
|
||||
Counts lines in files matching a pattern. Demonstrates how actions
|
||||
receive step config and flags from the workflow YAML.
|
||||
|
||||
Usage in workflow:
|
||||
- name: Count lines
|
||||
action: line-count
|
||||
pattern: "*.py"
|
||||
directory: src/
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run(step, flags, cwd):
|
||||
"""Count lines in files matching a glob pattern."""
|
||||
pattern = step.get("pattern", "*.py")
|
||||
directory = step.get("directory", ".")
|
||||
target = Path(cwd) / directory
|
||||
|
||||
if not target.exists():
|
||||
return {"ok": False, "error": f"Directory not found: {target}"}
|
||||
|
||||
total_lines = 0
|
||||
file_count = 0
|
||||
|
||||
for filepath in target.rglob(pattern):
|
||||
if filepath.is_file():
|
||||
try:
|
||||
total_lines += len(filepath.read_text().splitlines())
|
||||
file_count += 1
|
||||
except (UnicodeDecodeError, PermissionError):
|
||||
continue
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"output": f"{file_count} files, {total_lines} lines ({pattern} in {directory})",
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
Example file-based action: system-info
|
||||
|
||||
Collects basic system information. Demonstrates the action file contract:
|
||||
- Must define run(step, flags, cwd) → dict
|
||||
- Return {"ok": True, "output": "..."} on success
|
||||
- Return {"ok": False, "error": "..."} on failure
|
||||
|
||||
Place in: actions/system_info.py (next to workflow)
|
||||
or: .praison/actions/system_info.py (project-level)
|
||||
"""
|
||||
|
||||
import platform
|
||||
import os
|
||||
|
||||
|
||||
def run(step, flags, cwd):
|
||||
"""Collect system information."""
|
||||
info_parts = [
|
||||
f"OS: {platform.system()} {platform.release()}",
|
||||
f"Python: {platform.python_version()}",
|
||||
f"Machine: {platform.machine()}",
|
||||
f"CWD: {cwd}",
|
||||
]
|
||||
|
||||
# Optional: include env vars if requested via step config
|
||||
if step.get("show_env"):
|
||||
env_vars = step.get("show_env", "").split(",")
|
||||
for var in env_vars:
|
||||
var = var.strip()
|
||||
info_parts.append(f"{var}: {os.environ.get(var, '(not set)')}")
|
||||
|
||||
return {"ok": True, "output": " | ".join(info_parts)}
|
||||
@@ -0,0 +1,33 @@
|
||||
# Example: File-Based Actions (Option A)
|
||||
#
|
||||
# Actions loaded from .py files in the actions/ directory next to this workflow.
|
||||
# Each action file exports: run(step, flags, cwd) → dict
|
||||
#
|
||||
# Directory structure:
|
||||
# examples/workflows/
|
||||
# ├── file-actions-demo.yaml ← this file
|
||||
# └── actions/
|
||||
# ├── system_info.py ← action: system-info
|
||||
# └── line_count.py ← action: line-count
|
||||
#
|
||||
# Usage:
|
||||
# praisonai workflow run file-actions-demo.yaml
|
||||
# praisonai workflow run file-actions-demo.yaml --dry-run
|
||||
|
||||
type: job
|
||||
name: file-actions-demo
|
||||
description: Demonstrates file-based custom actions from actions/ directory
|
||||
|
||||
steps:
|
||||
- name: System info
|
||||
action: system-info
|
||||
|
||||
- name: Count Python files
|
||||
action: line-count
|
||||
pattern: "*.py"
|
||||
directory: "."
|
||||
|
||||
- name: Count YAML files
|
||||
action: line-count
|
||||
pattern: "*.yaml"
|
||||
directory: "."
|
||||
@@ -0,0 +1,40 @@
|
||||
# Example Step Workflow: Publish to PyPI
|
||||
# Usage:
|
||||
# praisonai workflow run publish-pypi.yaml --dry-run
|
||||
# praisonai workflow run publish-pypi.yaml
|
||||
# praisonai workflow run publish-pypi.yaml --major
|
||||
# praisonai workflow run publish-pypi.yaml --minor
|
||||
# praisonai workflow run publish-pypi.yaml --no-bump
|
||||
|
||||
type: job
|
||||
name: publish-pypi
|
||||
description: Publish package to PyPI with automatic version bump
|
||||
|
||||
vars:
|
||||
token_var:
|
||||
default: PYPI_TOKEN
|
||||
description: Environment variable for PyPI token
|
||||
|
||||
flags:
|
||||
major: { description: "Bump major version (X.0.0)" }
|
||||
minor: { description: "Bump minor version (X.Y.0)" }
|
||||
no-bump: { description: "Skip version bump" }
|
||||
|
||||
steps:
|
||||
- name: Bump version
|
||||
action: bump-version
|
||||
file: pyproject.toml
|
||||
strategy: patch
|
||||
if: "{{ not flags.no_bump }}"
|
||||
|
||||
- name: Clean dist
|
||||
run: rm -rf dist
|
||||
|
||||
- name: Lock dependencies
|
||||
run: uv lock
|
||||
|
||||
- name: Build package
|
||||
run: uv build
|
||||
|
||||
- name: Publish to PyPI
|
||||
run: uv publish --token ${{ env.PYPI_TOKEN }}
|
||||
@@ -0,0 +1,40 @@
|
||||
# Example: YAML-Defined Actions (Option B)
|
||||
#
|
||||
# Actions defined inline in the workflow — self-contained, portable.
|
||||
# No external files needed.
|
||||
#
|
||||
# Usage:
|
||||
# praisonai workflow run yaml-actions-demo.yaml
|
||||
# praisonai workflow run yaml-actions-demo.yaml --dry-run
|
||||
|
||||
type: job
|
||||
name: yaml-actions-demo
|
||||
description: Demonstrates YAML-defined custom actions
|
||||
|
||||
# Define reusable actions inline
|
||||
actions:
|
||||
check-python:
|
||||
run: python3 --version
|
||||
|
||||
count-files:
|
||||
script: |
|
||||
import os
|
||||
cwd = os.getcwd()
|
||||
py_files = [f for f in os.listdir(cwd) if f.endswith('.py')]
|
||||
yaml_files = [f for f in os.listdir(cwd) if f.endswith('.yaml') or f.endswith('.yml')]
|
||||
result = f"Python: {len(py_files)}, YAML: {len(yaml_files)}"
|
||||
|
||||
timestamp:
|
||||
script: |
|
||||
from datetime import datetime
|
||||
result = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
steps:
|
||||
- name: Check Python version
|
||||
action: check-python
|
||||
|
||||
- name: Count project files
|
||||
action: count-files
|
||||
|
||||
- name: Get timestamp
|
||||
action: timestamp
|
||||
@@ -0,0 +1,70 @@
|
||||
# Agent-Centric Job Workflow Example
|
||||
#
|
||||
# This workflow demonstrates the new agent-centric step types:
|
||||
# - agent: AI agent execution
|
||||
# - judge: AI quality gate with threshold
|
||||
# - approve: AI or human approval gate
|
||||
#
|
||||
# Run with: praisonai workflow run agent-centric-job-workflow.yaml
|
||||
# Dry run: praisonai workflow run agent-centric-job-workflow.yaml --dry-run
|
||||
|
||||
type: job
|
||||
name: smart-release
|
||||
description: Intelligent release workflow with AI-powered steps
|
||||
|
||||
flags:
|
||||
major:
|
||||
description: Bump major version
|
||||
minor:
|
||||
description: Bump minor version
|
||||
skip-approval:
|
||||
description: Skip approval gate (auto-approve)
|
||||
|
||||
steps:
|
||||
# Step 1: Deterministic - Bump version
|
||||
- name: Bump version
|
||||
action: bump-version
|
||||
strategy: patch
|
||||
|
||||
# Step 2: Agent - Generate changelog
|
||||
- name: Generate changelog
|
||||
agent:
|
||||
role: Technical Writer
|
||||
instructions: |
|
||||
You are a technical writer who creates clear, concise changelogs.
|
||||
Focus on user-facing changes, bug fixes, and new features.
|
||||
prompt: |
|
||||
Generate a changelog entry for the latest release.
|
||||
Include sections for: Features, Bug Fixes, and Breaking Changes.
|
||||
Keep it concise and professional.
|
||||
model: gpt-4o-mini
|
||||
output_file: CHANGELOG_ENTRY.md
|
||||
|
||||
# Step 3: Judge - Quality check
|
||||
- name: Quality check
|
||||
judge:
|
||||
input_file: CHANGELOG_ENTRY.md
|
||||
criteria: |
|
||||
- Completeness: All sections present (Features, Bug Fixes, Breaking Changes)
|
||||
- Clarity: Easy to understand for end users
|
||||
- Formatting: Proper markdown structure
|
||||
- Conciseness: Not overly verbose
|
||||
threshold: 7.0
|
||||
on_fail: warn
|
||||
model: gpt-4o-mini
|
||||
|
||||
# Step 4: Approve - Human approval gate
|
||||
- name: Approve release
|
||||
approve:
|
||||
description: Review and approve the release
|
||||
risk_level: medium
|
||||
auto_approve: "{{ flags.skip_approval }}"
|
||||
|
||||
# Step 5: Deterministic - Build
|
||||
- name: Build package
|
||||
run: echo "Building package..."
|
||||
|
||||
# Step 6: Deterministic - Publish (conditional)
|
||||
- name: Publish to PyPI
|
||||
run: echo "Publishing to PyPI..."
|
||||
if: "{{ not flags.skip_approval }}"
|
||||
@@ -0,0 +1,27 @@
|
||||
# PraisonAI AgentMail Bot Configuration (Zero-Code)
|
||||
#
|
||||
# AgentMail = API-first email (no IMAP/SMTP). Programmatic inboxes.
|
||||
#
|
||||
# Usage:
|
||||
# praisonai bot start --config agentmail-bot.yaml
|
||||
#
|
||||
# Requires:
|
||||
# pip install praisonai agentmail
|
||||
# export AGENTMAIL_API_KEY=am_...
|
||||
#
|
||||
# Optional env vars:
|
||||
# AGENTMAIL_INBOX_ID=inbox_... (reuse existing inbox)
|
||||
# AGENTMAIL_DOMAIN=mycompany.com (custom domain for new inboxes)
|
||||
|
||||
# Agent configuration
|
||||
agent:
|
||||
name: "Email Assistant"
|
||||
instructions: "You are a helpful email assistant. Keep replies concise and professional."
|
||||
model: "gpt-4o-mini"
|
||||
memory: true
|
||||
|
||||
# Channel configuration (multi-channel format)
|
||||
channels:
|
||||
agentmail:
|
||||
token: "${AGENTMAIL_API_KEY}"
|
||||
# Optional: inbox_id, domain are resolved from env vars automatically
|
||||
@@ -0,0 +1,89 @@
|
||||
# Hybrid Workflow Example
|
||||
#
|
||||
# This workflow demonstrates the `type: hybrid` feature that combines:
|
||||
# - Deterministic steps (run, action) from job workflows
|
||||
# - Agent-centric steps (agent, judge, approve) from job workflows
|
||||
# - Multi-agent workflow steps (workflow, parallel)
|
||||
#
|
||||
# Run with: praisonai workflow run hybrid-workflow.yaml
|
||||
# Dry run: praisonai workflow run hybrid-workflow.yaml --dry-run
|
||||
|
||||
type: hybrid
|
||||
name: hybrid-release-pipeline
|
||||
description: Hybrid workflow combining shell commands, AI agents, and multi-agent steps
|
||||
|
||||
# Define agents for multi-agent workflow steps
|
||||
agents:
|
||||
researcher:
|
||||
name: Researcher
|
||||
role: Research Analyst
|
||||
goal: Research and gather information
|
||||
instructions: Provide concise, factual research findings
|
||||
model: gpt-4o-mini
|
||||
|
||||
writer:
|
||||
name: Writer
|
||||
role: Content Writer
|
||||
goal: Write clear, engaging content
|
||||
instructions: Write professional documentation
|
||||
model: gpt-4o-mini
|
||||
|
||||
flags:
|
||||
skip-tests:
|
||||
description: Skip test execution
|
||||
auto-approve:
|
||||
description: Auto-approve deployment
|
||||
|
||||
steps:
|
||||
# Step 1: Deterministic - Check environment
|
||||
- name: Check environment
|
||||
run: echo "Checking environment..." && python --version
|
||||
|
||||
# Step 2: Agent - Generate release notes
|
||||
- name: Generate release notes
|
||||
agent:
|
||||
role: Technical Writer
|
||||
instructions: You write clear, concise release notes
|
||||
prompt: Generate release notes for version 1.2.0 with bug fixes and new features
|
||||
model: gpt-4o-mini
|
||||
output_file: RELEASE_NOTES.md
|
||||
|
||||
# Step 3: Parallel - Run multiple checks simultaneously
|
||||
- name: Parallel checks
|
||||
parallel:
|
||||
- run: echo "Running lint check..."
|
||||
- run: echo "Running type check..."
|
||||
- run: echo "Running security scan..."
|
||||
|
||||
# Step 4: Multi-agent workflow step
|
||||
- name: Research and document
|
||||
workflow:
|
||||
agent: researcher
|
||||
action: Research best practices for release management
|
||||
|
||||
# Step 5: Judge - Quality gate
|
||||
- name: Quality check
|
||||
judge:
|
||||
input_file: RELEASE_NOTES.md
|
||||
criteria: |
|
||||
- Completeness: All sections present
|
||||
- Clarity: Easy to understand
|
||||
- Professional tone
|
||||
threshold: 7.0
|
||||
on_fail: warn
|
||||
|
||||
# Step 6: Approve - Human approval gate
|
||||
- name: Approve release
|
||||
approve:
|
||||
description: Review and approve the release
|
||||
risk_level: medium
|
||||
auto_approve: "{{ flags.auto_approve }}"
|
||||
|
||||
# Step 7: Deterministic - Build
|
||||
- name: Build package
|
||||
run: echo "Building package..."
|
||||
if: "{{ not flags.skip_tests }}"
|
||||
|
||||
# Step 8: Final notification
|
||||
- name: Notify completion
|
||||
run: echo "✅ Hybrid workflow completed successfully!"
|
||||
@@ -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
|
||||
|
||||
@@ -123,6 +136,8 @@ Pattern: Each major module has a protocols.py file
|
||||
- Protocols define WHAT (interface contract)
|
||||
- Adapters implement HOW (concrete implementation)
|
||||
- Naming: XProtocol for interfaces, XAdapter for implementations
|
||||
(ALWAYS suffix with "Protocol" — never use bare concept names like TraceSink)
|
||||
- File placement: All protocols live in protocols.py within their module
|
||||
- Users can implement any protocol for custom behavior
|
||||
```
|
||||
|
||||
@@ -286,9 +301,19 @@ save() / load() # Disk persistence (NOT store)
|
||||
# Configuration
|
||||
class XConfig: # Configuration dataclass (MemoryConfig, HooksConfig)
|
||||
|
||||
# Protocols
|
||||
class XProtocol(Protocol): # Abstract interface
|
||||
class XAdapter: # Implementation
|
||||
# Protocols — ALWAYS suffix with "Protocol"
|
||||
class XProtocol(Protocol): # Abstract interface (TraceSinkProtocol, MemoryProtocol)
|
||||
class XAdapter: # Concrete implementation (ChromaAdapter, FileAdapter)
|
||||
|
||||
# ✅ CORRECT protocol naming:
|
||||
class MemoryProtocol(Protocol): ... # Clear it's a protocol
|
||||
class SessionStoreProtocol(Protocol): ... # Descriptive + Protocol suffix
|
||||
class TraceSinkProtocol(Protocol): ... # In protocols.py + suffixed
|
||||
|
||||
# ❌ WRONG protocol naming:
|
||||
class TraceSink(Protocol): ... # Missing Protocol suffix — ambiguous
|
||||
class IMemory(Protocol): ... # TypeScript convention, not Python
|
||||
class MemoryBase(Protocol): ... # "Base" implies ABC, not Protocol
|
||||
|
||||
# Execution
|
||||
run() # Synchronous, blocking
|
||||
@@ -500,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
|
||||
@@ -624,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
|
||||
@@ -634,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
|
||||
@@ -841,7 +892,7 @@ platforms:
|
||||
|
||||
---
|
||||
|
||||
## 13. Paid Value Path
|
||||
## 14. Paid Value Path
|
||||
|
||||
The core remains **free and open source**. Clear upgrade paths to paid value:
|
||||
|
||||
|
||||
@@ -59,18 +59,7 @@ _logging.configure_root_logger()
|
||||
# Import configuration (lightweight, no heavy deps)
|
||||
from . import _config
|
||||
|
||||
# Lightweight imports that don't trigger heavy dependency chains
|
||||
from .tools.tools import Tools
|
||||
from .tools.base import BaseTool, ToolResult, ToolValidationError, validate_tool
|
||||
from .tools.decorator import tool, FunctionTool
|
||||
from .tools.registry import get_registry, register_tool, get_tool, ToolRegistry
|
||||
# db and obs are lazy-loaded via __getattr__ for performance
|
||||
|
||||
# Sub-packages for organized imports (pa.config, pa.tools, etc.)
|
||||
# These enable: import praisonaiagents as pa; pa.config.MemoryConfig
|
||||
from . import config
|
||||
from . import tools
|
||||
# Note: db, obs, knowledge and mcp are lazy-loaded via __getattr__ due to heavy deps
|
||||
# Note: tools, config, memory, workflows, db, obs, knowledge and mcp are lazy-loaded via __getattr__ due to heavy deps
|
||||
|
||||
# Embedding API - LAZY LOADED via __getattr__ for performance
|
||||
# Supports: embedding, embeddings, aembedding, aembeddings, EmbeddingResult, get_dimensions
|
||||
@@ -125,6 +114,19 @@ def _get_lazy_cache():
|
||||
# ============================================================================
|
||||
|
||||
_LAZY_IMPORTS = {
|
||||
# Tools (moved from eager imports for lazy loading)
|
||||
'Tools': ('praisonaiagents.tools.tools', 'Tools'),
|
||||
'BaseTool': ('praisonaiagents.tools.base', 'BaseTool'),
|
||||
'ToolResult': ('praisonaiagents.tools.base', 'ToolResult'),
|
||||
'ToolValidationError': ('praisonaiagents.tools.base', 'ToolValidationError'),
|
||||
'validate_tool': ('praisonaiagents.tools.base', 'validate_tool'),
|
||||
'tool': ('praisonaiagents.tools.decorator', 'tool'),
|
||||
'FunctionTool': ('praisonaiagents.tools.decorator', 'FunctionTool'),
|
||||
'get_registry': ('praisonaiagents.tools.registry', 'get_registry'),
|
||||
'register_tool': ('praisonaiagents.tools.registry', 'register_tool'),
|
||||
'get_tool': ('praisonaiagents.tools.registry', 'get_tool'),
|
||||
'ToolRegistry': ('praisonaiagents.tools.registry', 'ToolRegistry'),
|
||||
|
||||
# Main display utilities (imports rich)
|
||||
'TaskOutput': ('praisonaiagents.main', 'TaskOutput'),
|
||||
'ReflectionOutput': ('praisonaiagents.main', 'ReflectionOutput'),
|
||||
@@ -179,9 +181,7 @@ _LAZY_IMPORTS = {
|
||||
'HandoffDepthError': ('praisonaiagents.agent.handoff', 'HandoffDepthError'),
|
||||
'HandoffTimeoutError': ('praisonaiagents.agent.handoff', 'HandoffTimeoutError'),
|
||||
|
||||
# Embedding API
|
||||
'embedding': ('praisonaiagents.embedding.embed', 'embedding'),
|
||||
'embeddings': ('praisonaiagents.embedding.embed', 'embedding'),
|
||||
# Embedding API (Note: embedding/embeddings handled in custom_handler to override subpackage)
|
||||
'aembedding': ('praisonaiagents.embedding.embed', 'aembedding'),
|
||||
'aembeddings': ('praisonaiagents.embedding.embed', 'aembedding'),
|
||||
'embed': ('praisonaiagents.embedding.embed', 'embed'),
|
||||
@@ -203,6 +203,9 @@ _LAZY_IMPORTS = {
|
||||
|
||||
# Agent classes
|
||||
'Agent': ('praisonaiagents.agent.agent', 'Agent'),
|
||||
'BudgetExceededError': ('praisonaiagents.agent.agent', 'BudgetExceededError'),
|
||||
'Heartbeat': ('praisonaiagents.agent.heartbeat', 'Heartbeat'),
|
||||
'HeartbeatConfig': ('praisonaiagents.agent.heartbeat', 'HeartbeatConfig'),
|
||||
'ImageAgent': ('praisonaiagents.agent.image_agent', 'ImageAgent'),
|
||||
'VideoAgent': ('praisonaiagents.agent.video_agent', 'VideoAgent'),
|
||||
'VideoConfig': ('praisonaiagents.agent.video_agent', 'VideoConfig'),
|
||||
@@ -344,6 +347,14 @@ _LAZY_IMPORTS = {
|
||||
'ObservabilityEventType': ('praisonaiagents.escalation.observability', 'EventType'),
|
||||
'DoomLoopDetector': ('praisonaiagents.escalation.doom_loop', 'DoomLoopDetector'),
|
||||
'MemoryBackend': ('praisonaiagents.config.feature_configs', 'MemoryBackend'),
|
||||
'LearnConfig': ('praisonaiagents.config.feature_configs', 'LearnConfig'),
|
||||
'LearnScope': ('praisonaiagents.config.feature_configs', 'LearnScope'),
|
||||
'LearnMode': ('praisonaiagents.config.feature_configs', 'LearnMode'),
|
||||
'LearnBackend': ('praisonaiagents.config.feature_configs', 'LearnBackend'),
|
||||
'LearnProtocol': ('praisonaiagents.memory.learn.protocols', 'LearnProtocol'),
|
||||
'AsyncLearnProtocol': ('praisonaiagents.memory.learn.protocols', 'AsyncLearnProtocol'),
|
||||
'LearnManagerProtocol': ('praisonaiagents.memory.learn.protocols', 'LearnManagerProtocol'),
|
||||
'LearnManager': ('praisonaiagents.memory.learn', 'LearnManager'),
|
||||
'ChunkingStrategy': ('praisonaiagents.config.feature_configs', 'ChunkingStrategy'),
|
||||
'GuardrailAction': ('praisonaiagents.config.feature_configs', 'GuardrailAction'),
|
||||
'WebSearchProvider': ('praisonaiagents.config.feature_configs', 'WebSearchProvider'),
|
||||
@@ -495,21 +506,27 @@ def _custom_handler(name, cache):
|
||||
cache['Agents'] = value
|
||||
return value
|
||||
|
||||
# Task removed in v4.0.0 - use Task instead
|
||||
if name == "Task":
|
||||
raise ImportError(
|
||||
"Task has been removed in v4.0.0. Use Task instead.\n"
|
||||
"Migration: Replace 'from praisonaiagents import Task' with 'from praisonaiagents import Task'\n"
|
||||
"Task supports all Task features including action, handler, loop_over, etc."
|
||||
)
|
||||
|
||||
# Module imports (return the module itself)
|
||||
if name == 'tools':
|
||||
import importlib
|
||||
mod = importlib.import_module('.tools', 'praisonaiagents')
|
||||
cache['tools'] = mod
|
||||
return mod
|
||||
if name == 'config':
|
||||
import importlib
|
||||
mod = importlib.import_module('.config', 'praisonaiagents')
|
||||
cache['config'] = mod
|
||||
return mod
|
||||
if name == 'memory':
|
||||
import importlib
|
||||
return importlib.import_module('.memory', 'praisonaiagents')
|
||||
mod = importlib.import_module('.memory', 'praisonaiagents')
|
||||
cache['memory'] = mod
|
||||
return mod
|
||||
if name == 'workflows':
|
||||
import importlib
|
||||
return importlib.import_module('.workflows', 'praisonaiagents')
|
||||
mod = importlib.import_module('.workflows', 'praisonaiagents')
|
||||
cache['workflows'] = mod
|
||||
return mod
|
||||
|
||||
raise AttributeError(f"Not handled by custom_handler: {name}")
|
||||
|
||||
@@ -521,12 +538,49 @@ def _custom_handler(name, cache):
|
||||
# Override them here to return the function instead of the module.
|
||||
# ============================================================================
|
||||
|
||||
# Override 'embedding' to return the function, not the subpackage
|
||||
from .embedding.embed import embedding as _embedding_func
|
||||
embedding = _embedding_func
|
||||
# Override 'embedding' and 'embeddings' at module level to prevent subpackage import
|
||||
# These need to be set after _LAZY_IMPORTS is defined but before __getattr__ is created
|
||||
def _get_embedding_func():
|
||||
"""Lazy getter for embedding function."""
|
||||
# Import with alias to avoid overwriting the module proxy
|
||||
from .embedding.embed import embedding as _embedding_func
|
||||
return _embedding_func
|
||||
|
||||
# Also provide embeddings alias
|
||||
embeddings = _embedding_func
|
||||
# Create lazy properties that override the submodule
|
||||
class _EmbeddingProxy:
|
||||
"""Proxy object that loads embedding function on first access."""
|
||||
def __init__(self):
|
||||
self._func = None
|
||||
|
||||
def _load(self):
|
||||
"""Load the actual embedding function if not already loaded."""
|
||||
if self._func is None:
|
||||
self._func = _get_embedding_func()
|
||||
return self._func
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
return self._load()(*args, **kwargs)
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._load(), name)
|
||||
|
||||
@property
|
||||
def __wrapped__(self):
|
||||
"""Support for inspect.signature() and functools.wraps."""
|
||||
return self._load()
|
||||
|
||||
@property
|
||||
def __signature__(self):
|
||||
"""Support for inspect.signature()."""
|
||||
import inspect
|
||||
return inspect.signature(self._load())
|
||||
|
||||
def __repr__(self):
|
||||
return f"<lazy proxy for {self._load()!r}>"
|
||||
|
||||
# Override the submodule with our function proxy
|
||||
embedding = _EmbeddingProxy()
|
||||
embeddings = embedding # embeddings is an alias
|
||||
|
||||
|
||||
# Create the __getattr__ function using centralized utility
|
||||
@@ -534,7 +588,7 @@ __getattr__ = create_lazy_getattr_with_fallback(
|
||||
mapping=_LAZY_IMPORTS,
|
||||
module_name=__name__,
|
||||
cache=_lazy_cache,
|
||||
fallback_modules=['tools', 'memory', 'config', 'workflows'],
|
||||
fallback_modules=[], # Note: 'embedding' excluded to avoid conflict with embedding() function
|
||||
custom_handler=_custom_handler
|
||||
)
|
||||
|
||||
|
||||
@@ -13,6 +13,18 @@ def __getattr__(name):
|
||||
from .agent import Agent
|
||||
_lazy_cache[name] = Agent
|
||||
return Agent
|
||||
if name == 'BudgetExceededError':
|
||||
from .agent import BudgetExceededError
|
||||
_lazy_cache[name] = BudgetExceededError
|
||||
return BudgetExceededError
|
||||
if name == 'Heartbeat':
|
||||
from .heartbeat import Heartbeat
|
||||
_lazy_cache[name] = Heartbeat
|
||||
return Heartbeat
|
||||
if name == 'HeartbeatConfig':
|
||||
from .heartbeat import HeartbeatConfig
|
||||
_lazy_cache[name] = HeartbeatConfig
|
||||
return HeartbeatConfig
|
||||
|
||||
# Specialized agents - lazy loaded (import rich)
|
||||
if name == 'ImageAgent':
|
||||
@@ -145,6 +157,9 @@ def __getattr__(name):
|
||||
|
||||
__all__ = [
|
||||
'Agent',
|
||||
'BudgetExceededError',
|
||||
'Heartbeat',
|
||||
'HeartbeatConfig',
|
||||
'ImageAgent',
|
||||
'VideoAgent',
|
||||
'VideoConfig',
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -73,6 +73,17 @@ _ESCALATION_TO_AUTONOMY_SIGNAL = {
|
||||
_AUTONOMY_TO_ESCALATION_SIGNAL = {v: k for k, v in _ESCALATION_TO_AUTONOMY_SIGNAL.items()}
|
||||
|
||||
|
||||
# Valid autonomy modes
|
||||
VALID_AUTONOMY_MODES = {"caller", "iterative"}
|
||||
|
||||
# Smart default mode based on level
|
||||
_LEVEL_TO_DEFAULT_MODE = {
|
||||
"suggest": "caller",
|
||||
"auto_edit": "caller",
|
||||
"full_auto": "iterative",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AutonomyConfig:
|
||||
"""Configuration for Agent autonomy features.
|
||||
@@ -83,6 +94,10 @@ class AutonomyConfig:
|
||||
Attributes:
|
||||
enabled: Whether autonomy is enabled
|
||||
level: Autonomy level (suggest, auto_edit, full_auto)
|
||||
mode: Execution mode — "caller" (single chat call, wrapper-equivalent)
|
||||
or "iterative" (internal loop with completion detection).
|
||||
Defaults based on level: suggest/auto_edit → "caller",
|
||||
full_auto → "iterative".
|
||||
max_iterations: Maximum iterations before stopping
|
||||
doom_loop_threshold: Number of repeated actions to trigger doom loop
|
||||
auto_escalate: Whether to automatically escalate complexity
|
||||
@@ -99,18 +114,21 @@ class AutonomyConfig:
|
||||
|
||||
Usage::
|
||||
|
||||
# Simple — full_auto auto-enables tracking + sandbox
|
||||
# Simple — default caller mode (wrapper-equivalent, no wasteful loop)
|
||||
Agent(autonomy=True) # level=suggest, mode=caller
|
||||
|
||||
# Full auto — iterative loop with completion detection
|
||||
Agent(autonomy="full_auto")
|
||||
|
||||
# Advanced — explicit config
|
||||
# Ralph Loop — explicit iterative mode with completion promise
|
||||
Agent(autonomy=AutonomyConfig(
|
||||
level="full_auto",
|
||||
track_changes=True,
|
||||
sandbox=SandboxConfig.native(writable_paths=["./src"]),
|
||||
mode="iterative",
|
||||
completion_promise="COMPLETE",
|
||||
))
|
||||
"""
|
||||
enabled: bool = True
|
||||
level: str = "suggest"
|
||||
mode: Optional[str] = None # None = smart default based on level
|
||||
max_iterations: int = 20
|
||||
doom_loop_threshold: int = 3
|
||||
auto_escalate: bool = True
|
||||
@@ -122,6 +140,8 @@ class AutonomyConfig:
|
||||
track_changes: Optional[bool] = None # None = auto (True for full_auto)
|
||||
sandbox: Optional[Any] = None # SandboxConfig (lazy import to avoid circular)
|
||||
snapshot_dir: Optional[str] = None # Defaults to ~/.praisonai/snapshots
|
||||
# Default tools for autonomy mode (auto-populated with code intelligence tools)
|
||||
default_tools: Optional[List[Any]] = None # None = auto (uses get_autonomy_default_tools())
|
||||
|
||||
def __post_init__(self):
|
||||
if self.level not in VALID_AUTONOMY_LEVELS:
|
||||
@@ -129,15 +149,21 @@ class AutonomyConfig:
|
||||
f"Invalid autonomy level: {self.level!r}. "
|
||||
f"Must be one of {sorted(VALID_AUTONOMY_LEVELS)}"
|
||||
)
|
||||
# Smart default mode based on level
|
||||
if self.mode is None:
|
||||
self.mode = _LEVEL_TO_DEFAULT_MODE.get(self.level, "caller")
|
||||
elif self.mode not in VALID_AUTONOMY_MODES:
|
||||
raise ValueError(
|
||||
f"Invalid autonomy mode: {self.mode!r}. "
|
||||
f"Must be one of {sorted(VALID_AUTONOMY_MODES)}"
|
||||
)
|
||||
# Auto-enable track_changes for full_auto if not explicitly set
|
||||
if self.track_changes is None:
|
||||
self.track_changes = (self.level == "full_auto")
|
||||
# Default snapshot directory
|
||||
if self.snapshot_dir is None:
|
||||
import os
|
||||
self.snapshot_dir = os.path.join(
|
||||
os.path.expanduser("~"), ".praisonai", "snapshots"
|
||||
)
|
||||
from ..paths import get_snapshots_dir
|
||||
self.snapshot_dir = str(get_snapshots_dir())
|
||||
|
||||
@property
|
||||
def effective_track_changes(self) -> bool:
|
||||
@@ -156,6 +182,7 @@ class AutonomyConfig:
|
||||
return cls(
|
||||
enabled=data.get("enabled", True),
|
||||
level=level,
|
||||
mode=data.get("mode"), # None = smart default in __post_init__
|
||||
max_iterations=data.get("max_iterations", 20),
|
||||
doom_loop_threshold=data.get("doom_loop_threshold", 3),
|
||||
auto_escalate=data.get("auto_escalate", True),
|
||||
@@ -166,6 +193,7 @@ class AutonomyConfig:
|
||||
track_changes=data.get("track_changes"),
|
||||
sandbox=data.get("sandbox"),
|
||||
snapshot_dir=data.get("snapshot_dir"),
|
||||
default_tools=data.get("default_tools"),
|
||||
)
|
||||
|
||||
|
||||
@@ -377,6 +405,14 @@ class DoomLoopTracker:
|
||||
"""
|
||||
self._delegate.mark_progress(marker)
|
||||
|
||||
def record_response(self, text: str) -> None:
|
||||
"""Record model response text for content streaming loop detection.
|
||||
|
||||
Args:
|
||||
text: The model's response text
|
||||
"""
|
||||
self._delegate.record_response(text)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset the tracker completely."""
|
||||
self._delegate.start_session()
|
||||
|
||||
@@ -162,7 +162,8 @@ class ContextAgent(Agent):
|
||||
|
||||
# Enhanced logging and output management
|
||||
self.debug_mode = os.getenv('LOGLEVEL', '').lower() == 'debug'
|
||||
self.output_dir = Path(".praisonai/prp") # Save in .praisonai/prp folder
|
||||
from ..paths import get_project_prp_dir
|
||||
self.output_dir = get_project_prp_dir() # Save in .praisonai/prp folder
|
||||
self.setup_output_directories() # Create directories first
|
||||
self.setup_logging() # Then setup logging
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Cost persistence — saves agent cost_summary to disk after runs.
|
||||
|
||||
Writes JSON files to ``~/.praisonai/costs/`` for CLI dashboard consumption.
|
||||
Zero overhead when not used (this module is never imported unless explicitly called).
|
||||
|
||||
Usage::
|
||||
|
||||
from praisonaiagents.agent.cost_persistence import save_cost_report, load_cost_report
|
||||
|
||||
save_cost_report(agent, session_name="my_run")
|
||||
report = load_cost_report("my_run")
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
COST_DIR = Path.home() / ".praisonai" / "costs"
|
||||
|
||||
|
||||
def save_cost_report(
|
||||
agent,
|
||||
session_name: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Persist an agent's cost_summary to disk.
|
||||
|
||||
Args:
|
||||
agent: Agent instance with cost_summary property.
|
||||
session_name: Optional session identifier. Auto-generated if None.
|
||||
|
||||
Returns:
|
||||
Path to the saved JSON file.
|
||||
"""
|
||||
COST_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
name = session_name or f"{agent.name}_{int(time.time())}"
|
||||
safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in name)
|
||||
|
||||
summary = getattr(agent, "cost_summary", None)
|
||||
if summary is None:
|
||||
summary = {
|
||||
"tokens_in": 0,
|
||||
"tokens_out": 0,
|
||||
"cost": 0.0,
|
||||
"llm_calls": 0,
|
||||
}
|
||||
|
||||
report = {
|
||||
"session": name,
|
||||
"agent_name": getattr(agent, "name", "unknown"),
|
||||
"timestamp": time.time(),
|
||||
"iso_time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
**summary,
|
||||
}
|
||||
|
||||
filepath = COST_DIR / f"{safe_name}.json"
|
||||
with open(filepath, "w") as f:
|
||||
json.dump(report, f, indent=2)
|
||||
|
||||
logger.info(f"[costs] Saved cost report to {filepath}")
|
||||
return str(filepath)
|
||||
|
||||
|
||||
def load_cost_report(session_name: str) -> Optional[Dict[str, Any]]:
|
||||
"""Load a cost report by session name.
|
||||
|
||||
Args:
|
||||
session_name: Session identifier.
|
||||
|
||||
Returns:
|
||||
Parsed JSON dict, or None if not found.
|
||||
"""
|
||||
safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in session_name)
|
||||
filepath = COST_DIR / f"{safe_name}.json"
|
||||
if filepath.exists():
|
||||
with open(filepath) as f:
|
||||
return json.load(f)
|
||||
return None
|
||||
|
||||
|
||||
def list_cost_reports(limit: int = 20) -> List[Dict[str, Any]]:
|
||||
"""List recent cost reports, sorted by timestamp (newest first).
|
||||
|
||||
Args:
|
||||
limit: Max number of reports to return.
|
||||
|
||||
Returns:
|
||||
List of parsed JSON report dicts.
|
||||
"""
|
||||
if not COST_DIR.exists():
|
||||
return []
|
||||
|
||||
reports = []
|
||||
for fpath in sorted(COST_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
|
||||
try:
|
||||
with open(fpath) as f:
|
||||
reports.append(json.load(f))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
if len(reports) >= limit:
|
||||
break
|
||||
return reports
|
||||
|
||||
|
||||
def format_cost_table(reports: List[Dict[str, Any]], as_json: bool = False) -> str:
|
||||
"""Format cost reports for terminal display.
|
||||
|
||||
Args:
|
||||
reports: List of report dicts from list_cost_reports().
|
||||
as_json: If True, returns JSON string instead of table.
|
||||
|
||||
Returns:
|
||||
Formatted string for terminal output.
|
||||
"""
|
||||
if as_json:
|
||||
return json.dumps(reports, indent=2)
|
||||
|
||||
if not reports:
|
||||
return "No cost reports found."
|
||||
|
||||
lines = []
|
||||
lines.append(f"{'Session':<30} {'Agent':<20} {'Cost ($)':<12} {'Tokens In':<12} {'Tokens Out':<12} {'LLM Calls':<10}")
|
||||
lines.append("-" * 96)
|
||||
for r in reports:
|
||||
lines.append(
|
||||
f"{r.get('session', '?'):<30} "
|
||||
f"{r.get('agent_name', '?'):<20} "
|
||||
f"${r.get('cost', 0):<11.4f} "
|
||||
f"{r.get('tokens_in', 0):<12} "
|
||||
f"{r.get('tokens_out', 0):<12} "
|
||||
f"{r.get('llm_calls', 0):<10}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Heartbeat — runs an agent on a schedule, delivers results via callback.
|
||||
|
||||
Standalone wrapper class. Does NOT modify the Agent class.
|
||||
|
||||
Usage::
|
||||
|
||||
from praisonaiagents import Agent, Heartbeat
|
||||
|
||||
agent = Agent(instructions="Check server status")
|
||||
hb = Heartbeat(agent, schedule="every 30m", prompt="Report status")
|
||||
hb.start() # blocking loop
|
||||
# hb.start(blocking=False) # background thread
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Optional, Union
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HeartbeatConfig:
|
||||
"""Configuration for Heartbeat scheduler.
|
||||
|
||||
Attributes:
|
||||
schedule: Human-friendly schedule expression. Supports:
|
||||
- Keywords: "hourly", "daily", "weekly"
|
||||
- Intervals: "every 30m", "every 6h", "*/30m", "*/10s"
|
||||
- Cron: "cron:0 7 * * *"
|
||||
- One-shot: "at:2026-03-01T09:00:00"
|
||||
prompt: Override prompt sent to agent each tick. None = use agent's default.
|
||||
on_result: Callback receiving (result_text: str). Default: log to stdout.
|
||||
on_error: "retry" | "skip" | callable(error). Default: "retry".
|
||||
max_retries: Max consecutive retries before skipping. Default: 3.
|
||||
"""
|
||||
schedule: str = "hourly"
|
||||
prompt: Optional[str] = None
|
||||
on_result: Optional[Callable] = None
|
||||
on_error: Union[str, Callable] = "retry"
|
||||
max_retries: int = 3
|
||||
|
||||
|
||||
class Heartbeat:
|
||||
"""Standalone heartbeat coordinator.
|
||||
|
||||
Runs an Agent on a schedule and delivers results via callback.
|
||||
Does NOT add any params to the Agent class.
|
||||
|
||||
Usage::
|
||||
|
||||
from praisonaiagents import Agent, Heartbeat
|
||||
|
||||
agent = Agent(instructions="Monitor server health")
|
||||
hb = Heartbeat(agent, schedule="hourly")
|
||||
hb.start()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent,
|
||||
schedule: str = "hourly",
|
||||
prompt: Optional[str] = None,
|
||||
on_result: Optional[Callable] = None,
|
||||
on_error: Union[str, Callable] = "retry",
|
||||
max_retries: int = 3,
|
||||
):
|
||||
self.agent = agent
|
||||
self.config = HeartbeatConfig(
|
||||
schedule=schedule,
|
||||
prompt=prompt,
|
||||
on_result=on_result,
|
||||
on_error=on_error,
|
||||
max_retries=max_retries,
|
||||
)
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._interval_seconds = self._resolve_interval(schedule)
|
||||
|
||||
def start(self, blocking: bool = True) -> None:
|
||||
"""Start the heartbeat loop.
|
||||
|
||||
Args:
|
||||
blocking: If True, blocks the calling thread. If False,
|
||||
runs in a background daemon thread.
|
||||
"""
|
||||
self._running = True
|
||||
if blocking:
|
||||
self._loop()
|
||||
else:
|
||||
self._thread = threading.Thread(target=self._loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the heartbeat loop."""
|
||||
self._running = False
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._running
|
||||
|
||||
# ── internals ────────────────────────────────────────────────────
|
||||
|
||||
def _loop(self) -> None:
|
||||
"""Main polling loop."""
|
||||
retries = 0
|
||||
while self._running:
|
||||
try:
|
||||
result = self._tick()
|
||||
retries = 0 # Reset on success
|
||||
self._deliver(result)
|
||||
except Exception as e:
|
||||
retries += 1
|
||||
logger.error(f"[heartbeat] {self.agent.name}: error on tick #{retries}: {e}")
|
||||
if callable(self.config.on_error):
|
||||
self.config.on_error(e)
|
||||
elif self.config.on_error == "skip":
|
||||
logger.warning(f"[heartbeat] Skipping error: {e}")
|
||||
else: # "retry"
|
||||
if retries >= self.config.max_retries:
|
||||
logger.error(
|
||||
f"[heartbeat] {self.agent.name}: max retries "
|
||||
f"({self.config.max_retries}) reached, skipping."
|
||||
)
|
||||
retries = 0
|
||||
time.sleep(self._interval_seconds)
|
||||
|
||||
def _tick(self) -> str:
|
||||
"""Execute one heartbeat tick — run the agent and return result."""
|
||||
prompt = self.config.prompt or "Run your scheduled check."
|
||||
result = self.agent.start(prompt)
|
||||
return str(result) if result else ""
|
||||
|
||||
def _deliver(self, result: str) -> None:
|
||||
"""Deliver result via callback or log."""
|
||||
if self.config.on_result:
|
||||
self.config.on_result(result)
|
||||
else:
|
||||
logger.info(f"[heartbeat] {self.agent.name}: {result[:200]}")
|
||||
|
||||
@staticmethod
|
||||
def _resolve_interval(schedule: str) -> float:
|
||||
"""Convert schedule string to interval in seconds.
|
||||
|
||||
Uses the scheduler parser if available, otherwise falls back to
|
||||
simple keyword parsing.
|
||||
"""
|
||||
try:
|
||||
from ..scheduler.parser import parse_schedule
|
||||
sched = parse_schedule(schedule)
|
||||
if sched.every_seconds:
|
||||
return float(sched.every_seconds)
|
||||
except (ImportError, Exception):
|
||||
pass
|
||||
|
||||
# Fallback keyword resolution
|
||||
keywords = {
|
||||
"hourly": 3600,
|
||||
"daily": 86400,
|
||||
"weekly": 604800,
|
||||
}
|
||||
lower = schedule.lower().strip()
|
||||
if lower in keywords:
|
||||
return float(keywords[lower])
|
||||
|
||||
# Try parsing "every Xm/h/s" patterns
|
||||
import re
|
||||
match = re.match(r"(?:every\s+)?(\d+)\s*(s|m|h)", lower)
|
||||
if match:
|
||||
val, unit = int(match.group(1)), match.group(2)
|
||||
multipliers = {"s": 1, "m": 60, "h": 3600}
|
||||
return float(val * multipliers.get(unit, 60))
|
||||
|
||||
return 3600.0 # Default to hourly
|
||||
@@ -10,9 +10,6 @@ import logging
|
||||
import warnings
|
||||
# Filter out Pydantic warning about fields
|
||||
warnings.filterwarnings("ignore", "Valid config keys have changed in V2", UserWarning)
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn
|
||||
class ImageGenerationConfig(BaseModel):
|
||||
"""Configuration for image generation settings."""
|
||||
style: str = Field(default="natural", description="Style of the generated image")
|
||||
@@ -155,6 +152,7 @@ class ImageAgent(Agent):
|
||||
# Gemini provider doesn't support response_format parameter
|
||||
# Apply this filter if provider is explicitly 'gemini' or as fallback for gemini models
|
||||
config.pop('response_format', None)
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
"""
|
||||
Tool Loop Detection for PraisonAI Agents.
|
||||
|
||||
Detects when an agent is stuck calling the same tool repeatedly with
|
||||
identical arguments and no progress.
|
||||
|
||||
Inspired by OpenClaw's tool-loop-detection.ts (624 lines, 4 detectors).
|
||||
Adapted to Python with PraisonAI's protocol-driven, zero-dep philosophy.
|
||||
|
||||
Zero Performance Impact:
|
||||
- Disabled by default (enabled=False)
|
||||
- Uses stdlib only: hashlib, json
|
||||
- History is per-agent-instance (no global mutable state)
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.agent.loop_detection import (
|
||||
LoopDetectionConfig, detect_tool_loop, record_tool_call, record_tool_outcome
|
||||
)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Literal, Optional, TypedDict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class LoopDetectionConfig:
|
||||
"""
|
||||
Configuration for tool loop detection.
|
||||
|
||||
Attributes:
|
||||
enabled: Whether loop detection is active. Default False (opt-in).
|
||||
history_size: Sliding window size for tool call history.
|
||||
warn_threshold: Number of identical (tool, args) calls before warning.
|
||||
critical_threshold: Number before critical/circuit-break. Must > warn.
|
||||
detectors: Which detectors are active.
|
||||
"""
|
||||
enabled: bool = False
|
||||
history_size: int = 30
|
||||
warn_threshold: int = 10
|
||||
critical_threshold: int = 20
|
||||
detectors: Dict[str, bool] = field(default_factory=lambda: {
|
||||
"generic_repeat": True,
|
||||
"poll_no_progress": True,
|
||||
"ping_pong": True,
|
||||
})
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Auto-correct: critical must always be > warn
|
||||
if self.critical_threshold <= self.warn_threshold:
|
||||
self.critical_threshold = self.warn_threshold + 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Result type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LoopDetectionResult(TypedDict, total=False):
|
||||
stuck: bool
|
||||
level: Optional[Literal["warning", "critical"]]
|
||||
detector: Optional[str]
|
||||
message: Optional[str]
|
||||
count: int
|
||||
|
||||
|
||||
_NOT_STUCK: LoopDetectionResult = {"stuck": False}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# History record
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ToolCallRecord(TypedDict, total=False):
|
||||
tool_name: str
|
||||
args_hash: str
|
||||
result_hash: Optional[str]
|
||||
timestamp: float
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hashing helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _stable_json(value: Any) -> str:
|
||||
"""Deterministic JSON serialization (sorted keys, str fallback)."""
|
||||
if value is None or isinstance(value, (bool, int, float, str)):
|
||||
return json.dumps(value)
|
||||
if isinstance(value, (list, tuple)):
|
||||
return "[" + ",".join(_stable_json(v) for v in value) + "]"
|
||||
if isinstance(value, dict):
|
||||
keys = sorted(value.keys())
|
||||
parts = [json.dumps(k) + ":" + _stable_json(value[k]) for k in keys]
|
||||
return "{" + ",".join(parts) + "}"
|
||||
# Fallback for non-serializable objects
|
||||
try:
|
||||
return json.dumps(str(value))
|
||||
except Exception:
|
||||
return '"<unserializable>"'
|
||||
|
||||
|
||||
def _sha256_hex(text: str, prefix_len: int = 16) -> str:
|
||||
return hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()[:prefix_len]
|
||||
|
||||
|
||||
def hash_tool_call(tool_name: str, args: Any) -> str:
|
||||
"""
|
||||
Compute a stable hash for a (tool_name, args) pair.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool being called
|
||||
args: Arguments dict or any serializable value
|
||||
|
||||
Returns:
|
||||
16-character hex string
|
||||
"""
|
||||
stable = _stable_json({"t": tool_name, "a": args})
|
||||
return _sha256_hex(stable)
|
||||
|
||||
|
||||
def _hash_result(result: Any) -> Optional[str]:
|
||||
"""Hash a tool result for no-progress detection."""
|
||||
if result is None:
|
||||
return None
|
||||
try:
|
||||
stable = _stable_json(result)
|
||||
return _sha256_hex(stable)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Record tool call / outcome
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def record_tool_call(
|
||||
history: List[ToolCallRecord],
|
||||
tool_name: str,
|
||||
args: Any,
|
||||
config: Optional[LoopDetectionConfig] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Append a tool call to the history (before execution).
|
||||
|
||||
Args:
|
||||
history: Mutable list of ToolCallRecord
|
||||
tool_name: Tool being called
|
||||
args: Tool arguments
|
||||
config: Loop detection config (for history_size)
|
||||
"""
|
||||
max_size = config.history_size if config else 30
|
||||
history.append(ToolCallRecord(
|
||||
tool_name=tool_name,
|
||||
args_hash=hash_tool_call(tool_name, args),
|
||||
result_hash=None,
|
||||
timestamp=time.time(),
|
||||
))
|
||||
# Trim to sliding window
|
||||
if len(history) > max_size:
|
||||
del history[: len(history) - max_size]
|
||||
|
||||
|
||||
def record_tool_outcome(
|
||||
history: List[ToolCallRecord],
|
||||
tool_name: str,
|
||||
args: Any,
|
||||
result: Any,
|
||||
config: Optional[LoopDetectionConfig] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Update the most recent matching record with result hash (after execution).
|
||||
|
||||
Args:
|
||||
history: Mutable list of ToolCallRecord
|
||||
tool_name: Tool that was called
|
||||
args: Arguments that were passed
|
||||
result: The return value of the tool
|
||||
config: Loop detection config
|
||||
"""
|
||||
args_hash = hash_tool_call(tool_name, args)
|
||||
result_hash = _hash_result(result)
|
||||
if result_hash is None:
|
||||
return
|
||||
|
||||
# Find the latest matching record without a result_hash
|
||||
for i in range(len(history) - 1, -1, -1):
|
||||
rec = history[i]
|
||||
if rec["tool_name"] == tool_name and rec["args_hash"] == args_hash and rec.get("result_hash") is None:
|
||||
history[i] = ToolCallRecord(
|
||||
tool_name=rec["tool_name"],
|
||||
args_hash=rec["args_hash"],
|
||||
result_hash=result_hash,
|
||||
timestamp=rec["timestamp"],
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Detectors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _known_poll_tool(tool_name: str) -> bool:
|
||||
"""Heuristic: tools whose names suggest polling/waiting."""
|
||||
_poll_keywords = ("status", "poll", "check", "wait", "ping", "health", "command_status")
|
||||
name_lower = tool_name.lower()
|
||||
return any(kw in name_lower for kw in _poll_keywords)
|
||||
|
||||
|
||||
def _count_generic_repeat(
|
||||
history: List[ToolCallRecord],
|
||||
args_hash: str,
|
||||
tool_name: str,
|
||||
) -> int:
|
||||
"""Count how many times this exact (tool, args) appears in history."""
|
||||
return sum(
|
||||
1 for rec in history
|
||||
if rec["tool_name"] == tool_name and rec["args_hash"] == args_hash
|
||||
)
|
||||
|
||||
|
||||
def _no_progress_streak(
|
||||
history: List[ToolCallRecord],
|
||||
args_hash: str,
|
||||
tool_name: str,
|
||||
) -> int:
|
||||
"""
|
||||
Count consecutive tail records with identical (args_hash, result_hash).
|
||||
Returns 0 if any record has no result_hash or a different result_hash.
|
||||
"""
|
||||
streak = 0
|
||||
first_result_hash: Optional[str] = None
|
||||
|
||||
for rec in reversed(history):
|
||||
if rec["tool_name"] != tool_name or rec["args_hash"] != args_hash:
|
||||
continue
|
||||
rh = rec.get("result_hash")
|
||||
if rh is None:
|
||||
continue
|
||||
if first_result_hash is None:
|
||||
first_result_hash = rh
|
||||
streak = 1
|
||||
elif rh == first_result_hash:
|
||||
streak += 1
|
||||
else:
|
||||
break # result changed — no longer a streak
|
||||
|
||||
return streak
|
||||
|
||||
|
||||
def _ping_pong_streak(
|
||||
history: List[ToolCallRecord],
|
||||
current_hash: str,
|
||||
) -> int:
|
||||
"""
|
||||
Detect alternating pattern: A, B, A, B, ...
|
||||
Returns count of consecutive alternating tail entries, 0 if not ping-pong.
|
||||
"""
|
||||
if len(history) < 2:
|
||||
return 0
|
||||
|
||||
last = history[-1]
|
||||
# Find the "other" hash in history (most recent != last)
|
||||
other_hash: Optional[str] = None
|
||||
for rec in reversed(history[:-1]):
|
||||
if rec["args_hash"] != last["args_hash"]:
|
||||
other_hash = rec["args_hash"]
|
||||
break
|
||||
|
||||
if other_hash is None:
|
||||
return 0
|
||||
|
||||
# Count tail alternation
|
||||
count = 0
|
||||
for i, rec in enumerate(reversed(history)):
|
||||
expected = last["args_hash"] if i % 2 == 0 else other_hash
|
||||
if rec["args_hash"] != expected:
|
||||
break
|
||||
count += 1
|
||||
|
||||
# Current call must continue the ping-pong pattern
|
||||
if current_hash != (other_hash if count % 2 == 1 else last["args_hash"]):
|
||||
return 0
|
||||
|
||||
return count + 1 # include current call
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main detection function
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def detect_tool_loop(
|
||||
history: List[ToolCallRecord],
|
||||
tool_name: str,
|
||||
args: Any,
|
||||
config: Optional[LoopDetectionConfig] = None,
|
||||
) -> LoopDetectionResult:
|
||||
"""
|
||||
Detect if agent is stuck in a tool call loop.
|
||||
|
||||
Args:
|
||||
history: Mutable sliding window of ToolCallRecord
|
||||
tool_name: Tool being called now
|
||||
args: Arguments for this call
|
||||
config: Loop detection configuration
|
||||
|
||||
Returns:
|
||||
LoopDetectionResult with stuck=False (safe) or stuck=True (warning/critical)
|
||||
"""
|
||||
if config is None or not config.enabled:
|
||||
return _NOT_STUCK
|
||||
|
||||
args_hash = hash_tool_call(tool_name, args)
|
||||
detectors = config.detectors or {}
|
||||
warn = config.warn_threshold
|
||||
critical = config.critical_threshold
|
||||
|
||||
# --- poll_no_progress detector ---
|
||||
if detectors.get("poll_no_progress", True) and _known_poll_tool(tool_name):
|
||||
streak = _no_progress_streak(history, args_hash, tool_name)
|
||||
if streak >= critical:
|
||||
msg = (
|
||||
f"CRITICAL: '{tool_name}' called {streak} times with identical args "
|
||||
f"and identical result. Detected stuck polling loop. Stop and report failure."
|
||||
)
|
||||
logger.error(f"[loop_detection] {msg}")
|
||||
return LoopDetectionResult(
|
||||
stuck=True, level="critical",
|
||||
detector="poll_no_progress", message=msg, count=streak,
|
||||
)
|
||||
if streak >= warn:
|
||||
msg = (
|
||||
f"WARNING: '{tool_name}' has returned identical results {streak} times. "
|
||||
f"This looks like a stuck poll. Increase wait time or report failure."
|
||||
)
|
||||
logger.warning(f"[loop_detection] {msg}")
|
||||
return LoopDetectionResult(
|
||||
stuck=True, level="warning",
|
||||
detector="poll_no_progress", message=msg, count=streak,
|
||||
)
|
||||
|
||||
# --- ping_pong detector ---
|
||||
if detectors.get("ping_pong", True):
|
||||
pp_count = _ping_pong_streak(history, args_hash)
|
||||
if pp_count >= critical:
|
||||
msg = (
|
||||
f"CRITICAL: Ping-pong loop detected ({pp_count} alternating calls). "
|
||||
f"Agent is oscillating between two tool states. Stop and report failure."
|
||||
)
|
||||
logger.error(f"[loop_detection] {msg}")
|
||||
return LoopDetectionResult(
|
||||
stuck=True, level="critical",
|
||||
detector="ping_pong", message=msg, count=pp_count,
|
||||
)
|
||||
if pp_count >= warn:
|
||||
msg = (
|
||||
f"WARNING: Alternating tool pattern detected ({pp_count} calls). "
|
||||
f"Stop ping-pong and try a different approach."
|
||||
)
|
||||
logger.warning(f"[loop_detection] {msg}")
|
||||
return LoopDetectionResult(
|
||||
stuck=True, level="warning",
|
||||
detector="ping_pong", message=msg, count=pp_count,
|
||||
)
|
||||
|
||||
# --- generic_repeat detector ---
|
||||
if detectors.get("generic_repeat", True) and not _known_poll_tool(tool_name):
|
||||
count = _count_generic_repeat(history, args_hash, tool_name)
|
||||
if count >= critical:
|
||||
msg = (
|
||||
f"CRITICAL: '{tool_name}' called {count} times with identical args. "
|
||||
f"Agent is stuck. Execution blocked."
|
||||
)
|
||||
logger.error(f"[loop_detection] {msg}")
|
||||
return LoopDetectionResult(
|
||||
stuck=True, level="critical",
|
||||
detector="generic_repeat", message=msg, count=count,
|
||||
)
|
||||
if count >= warn:
|
||||
msg = (
|
||||
f"WARNING: '{tool_name}' called {count} times with identical args. "
|
||||
f"If not making progress, stop and report failure."
|
||||
)
|
||||
logger.warning(f"[loop_detection] {msg}")
|
||||
return LoopDetectionResult(
|
||||
stuck=True, level="warning",
|
||||
detector="generic_repeat", message=msg, count=count,
|
||||
)
|
||||
|
||||
return _NOT_STUCK
|
||||
@@ -16,8 +16,6 @@ import asyncio
|
||||
import warnings
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Any, Dict, Union, List, Generator
|
||||
from rich.console import Console
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn
|
||||
|
||||
# Filter out Pydantic warning about fields
|
||||
warnings.filterwarnings("ignore", "Valid config keys have changed in V2", UserWarning)
|
||||
@@ -212,9 +210,10 @@ class VideoAgent:
|
||||
return VideoConfig()
|
||||
|
||||
@property
|
||||
def console(self) -> Console:
|
||||
def console(self):
|
||||
"""Lazily initialize Rich Console."""
|
||||
if self._console is None:
|
||||
from rich.console import Console
|
||||
self._console = Console()
|
||||
return self._console
|
||||
|
||||
@@ -342,6 +341,7 @@ class VideoAgent:
|
||||
params.pop("poll_interval", None)
|
||||
params.pop("max_wait_time", None)
|
||||
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
@@ -491,7 +491,8 @@ class VideoAgent:
|
||||
max_wait_time = max_wait_time or self._video_config.max_wait_time
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
|
||||
@@ -3,7 +3,6 @@ import time
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, Optional, List
|
||||
from rich.console import Console
|
||||
from ..main import display_error, TaskOutput
|
||||
from ..agent.agent import Agent
|
||||
from ..task.task import Task
|
||||
@@ -220,6 +219,7 @@ class AgentTeam:
|
||||
web: Optional[Any] = None, # Union[bool, WebConfig] - web search/fetch
|
||||
reflection: Optional[Any] = None, # Union[bool, ReflectionConfig] - self-reflection
|
||||
caching: Optional[Any] = None, # Union[bool, CachingConfig] - caching
|
||||
learn: Optional[Any] = None, # Union[bool, LearnConfig] - continuous learning
|
||||
):
|
||||
"""
|
||||
Initialize AgentManager with consolidated feature parameters.
|
||||
@@ -243,8 +243,10 @@ class AgentTeam:
|
||||
guardrails: Guardrails configuration (bool | Callable | GuardrailConfig)
|
||||
web: Web search/fetch configuration (bool | WebConfig)
|
||||
reflection: Self-reflection configuration (bool | ReflectionConfig)
|
||||
learn: Continuous learning configuration (bool | LearnConfig)
|
||||
"""
|
||||
# Store new params for propagation to agents
|
||||
self._learn = learn
|
||||
self.llm = llm # Store default LLM for API consistency
|
||||
self._autonomy = autonomy
|
||||
self._knowledge = knowledge
|
||||
@@ -512,14 +514,16 @@ class AgentTeam:
|
||||
if not mem_cfg:
|
||||
mem_cfg = next((t.config.get('memory_config') for t in tasks if hasattr(t, 'config') and t.config), None)
|
||||
if not mem_cfg:
|
||||
from ..paths import get_project_data_dir
|
||||
_pd = str(get_project_data_dir())
|
||||
mem_cfg = {
|
||||
"provider": "rag",
|
||||
"use_embedding": True,
|
||||
"storage": {
|
||||
"type": "sqlite",
|
||||
"path": "./.praisonai/memory.db"
|
||||
"path": f"{_pd}/memory.db"
|
||||
},
|
||||
"rag_db_path": "./.praisonai/chroma_db"
|
||||
"rag_db_path": f"{_pd}/chroma_db"
|
||||
}
|
||||
if _embedder:
|
||||
if isinstance(_embedder, dict):
|
||||
@@ -1282,6 +1286,7 @@ Context:
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
if show_verbose and is_tty:
|
||||
from rich.panel import Panel
|
||||
from rich.console import Console
|
||||
console = Console()
|
||||
import time as time_module
|
||||
|
||||
|
||||
@@ -237,18 +237,8 @@ class AgentApproval:
|
||||
|
||||
def request_approval_sync(self, request: ApprovalRequest) -> ApprovalDecision:
|
||||
"""Synchronous wrapper."""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
|
||||
if loop and loop.is_running():
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
future = pool.submit(asyncio.run, self.request_approval(request))
|
||||
return future.result(timeout=60)
|
||||
else:
|
||||
return asyncio.run(self.request_approval(request))
|
||||
from .utils import run_coroutine_safely
|
||||
return run_coroutine_safely(self.request_approval(request), timeout=60)
|
||||
|
||||
|
||||
class CallbackBackend:
|
||||
|
||||
@@ -45,6 +45,19 @@ DEFAULT_DANGEROUS_TOOLS: Dict[str, str] = {
|
||||
"scrape_page": "medium",
|
||||
}
|
||||
|
||||
# Permission presets — resolved to deny frozensets at Agent.__init__ time.
|
||||
# Usage: Agent(approval="safe")
|
||||
PERMISSION_PRESETS = {
|
||||
# "safe" — blocks all dangerous tools (file writes, shell exec, etc.)
|
||||
"safe": frozenset(DEFAULT_DANGEROUS_TOOLS.keys()),
|
||||
# "read_only" — blocks dangerous tools + write operations
|
||||
"read_only": frozenset(DEFAULT_DANGEROUS_TOOLS.keys()) | frozenset({
|
||||
"write_file", "copy_file", "move_file",
|
||||
}),
|
||||
# "full" — no restrictions
|
||||
"full": frozenset(),
|
||||
}
|
||||
|
||||
|
||||
class ApprovalRegistry:
|
||||
"""Per-agent approval configuration.
|
||||
@@ -197,15 +210,12 @@ class ApprovalRegistry:
|
||||
if hasattr(backend, "request_approval_sync"):
|
||||
decision = backend.request_approval_sync(request)
|
||||
else:
|
||||
# Fallback: run async method
|
||||
try:
|
||||
decision = asyncio.run(backend.request_approval(request))
|
||||
except RuntimeError:
|
||||
# Already in an event loop — fall back to thread
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
future = pool.submit(asyncio.run, backend.request_approval(request))
|
||||
decision = future.result(timeout=self.timeout)
|
||||
# Use shared utility for consistent async-to-sync bridging
|
||||
from .utils import run_coroutine_safely
|
||||
decision = run_coroutine_safely(
|
||||
backend.request_approval(request),
|
||||
timeout=self.timeout
|
||||
)
|
||||
|
||||
if decision.approved:
|
||||
self.mark_approved(tool_name)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Utility functions for approval handling.
|
||||
|
||||
Provides reusable async-to-sync bridging logic to prevent code duplication
|
||||
across the approval system.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
from typing import Any, Awaitable, Callable, Optional, TypeVar
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
def run_coroutine_safely(
|
||||
coro: Awaitable[T],
|
||||
timeout: Optional[float] = None
|
||||
) -> T:
|
||||
"""
|
||||
Run a coroutine safely, handling both sync and async contexts.
|
||||
|
||||
This function detects if an event loop is already running and uses a
|
||||
ThreadPoolExecutor as a fallback to avoid RuntimeError. It respects
|
||||
timeout semantics consistently across both code paths.
|
||||
|
||||
Args:
|
||||
coro: The coroutine to execute
|
||||
timeout: Timeout in seconds. None means indefinite wait.
|
||||
|
||||
Returns:
|
||||
The result of the coroutine
|
||||
|
||||
Raises:
|
||||
TimeoutError: If the operation times out
|
||||
Any exception raised by the coroutine
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
|
||||
if loop and loop.is_running():
|
||||
# We're in an async context - use thread pool to avoid RuntimeError
|
||||
pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
||||
|
||||
# Wrap the coroutine with timeout handling inside the thread
|
||||
def run_with_timeout():
|
||||
if timeout is not None and timeout > 0:
|
||||
return asyncio.run(asyncio.wait_for(coro, timeout=timeout))
|
||||
else:
|
||||
return asyncio.run(coro)
|
||||
|
||||
future = pool.submit(run_with_timeout)
|
||||
try:
|
||||
# Don't use timeout on Future.result() since we handle timeout
|
||||
# inside the coroutine via asyncio.wait_for
|
||||
result = future.result(timeout=None if timeout is None or timeout == 0 else timeout)
|
||||
return result
|
||||
finally:
|
||||
# Properly shut down the executor without waiting for threads
|
||||
pool.shutdown(wait=False, cancel_futures=True)
|
||||
else:
|
||||
# No running event loop - use asyncio.run directly
|
||||
if timeout is not None and timeout > 0:
|
||||
return asyncio.run(asyncio.wait_for(coro, timeout=timeout))
|
||||
else:
|
||||
return asyncio.run(coro)
|
||||
@@ -319,3 +319,106 @@ class BackgroundRunner:
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._cleanup_task = None
|
||||
|
||||
# ── Sync-friendly wrappers ──────────────────────────────────────────
|
||||
|
||||
def submit_sync(
|
||||
self,
|
||||
func: Callable,
|
||||
args: tuple = (),
|
||||
kwargs: Optional[Dict[str, Any]] = None,
|
||||
name: Optional[str] = None,
|
||||
timeout: Optional[float] = None,
|
||||
on_complete: Optional[Callable[[BackgroundTask], None]] = None,
|
||||
) -> BackgroundTask:
|
||||
"""Submit a task from synchronous code.
|
||||
|
||||
Creates a background event loop (once) and schedules the task
|
||||
there. Returns the ``BackgroundTask`` immediately so callers
|
||||
can poll ``task.status`` / ``task.result`` without blocking.
|
||||
|
||||
Args:
|
||||
func: Function to execute (sync or async)
|
||||
args: Positional arguments for *func*
|
||||
kwargs: Keyword arguments for *func*
|
||||
name: Optional human-readable task name
|
||||
timeout: Optional timeout in seconds
|
||||
on_complete: Callback when task completes
|
||||
|
||||
Returns:
|
||||
BackgroundTask for tracking
|
||||
"""
|
||||
loop = _get_bg_loop()
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self.submit(
|
||||
func, args=args, kwargs=kwargs,
|
||||
name=name, timeout=timeout, on_complete=on_complete,
|
||||
),
|
||||
loop,
|
||||
)
|
||||
# Block briefly to get the BackgroundTask object (not the task result).
|
||||
return future.result(timeout=5)
|
||||
|
||||
def submit_agent_sync(
|
||||
self,
|
||||
agent: Any,
|
||||
prompt: str,
|
||||
name: Optional[str] = None,
|
||||
timeout: Optional[float] = None,
|
||||
on_complete: Optional[Callable[[BackgroundTask], None]] = None,
|
||||
) -> BackgroundTask:
|
||||
"""Submit an agent task from synchronous code.
|
||||
|
||||
Convenience wrapper around :meth:`submit_sync` that resolves
|
||||
the agent's callable automatically.
|
||||
|
||||
Args:
|
||||
agent: Agent instance (must have ``start``, ``chat``, or ``run``)
|
||||
prompt: Prompt to send to the agent
|
||||
name: Optional task name
|
||||
timeout: Optional timeout in seconds
|
||||
on_complete: Callback when task completes
|
||||
|
||||
Returns:
|
||||
BackgroundTask for tracking
|
||||
"""
|
||||
loop = _get_bg_loop()
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self.submit_agent(
|
||||
agent, prompt,
|
||||
name=name, timeout=timeout, on_complete=on_complete,
|
||||
),
|
||||
loop,
|
||||
)
|
||||
return future.result(timeout=5)
|
||||
|
||||
|
||||
# ── Module-level background event loop (lazy, daemon) ───────────────────
|
||||
|
||||
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.
|
||||
|
||||
Created once on first call; reused thereafter. The thread is a
|
||||
daemon so it won't prevent process exit.
|
||||
"""
|
||||
global _bg_loop
|
||||
if _bg_loop is not None and _bg_loop.is_running():
|
||||
return _bg_loop
|
||||
|
||||
with _bg_lock:
|
||||
# Double-check after acquiring lock.
|
||||
if _bg_loop is not None and _bg_loop.is_running():
|
||||
return _bg_loop
|
||||
|
||||
_bg_loop = asyncio.new_event_loop()
|
||||
|
||||
t = _threading.Thread(target=_bg_loop.run_forever, daemon=True)
|
||||
t.start()
|
||||
|
||||
return _bg_loop
|
||||
@@ -22,6 +22,8 @@ if TYPE_CHECKING:
|
||||
ChatCommandProtocol,
|
||||
ProbeResult,
|
||||
HealthResult,
|
||||
EmailProtocol,
|
||||
EmailInbox,
|
||||
)
|
||||
from .protocols import (
|
||||
BotProtocol,
|
||||
@@ -37,6 +39,8 @@ from .protocols import (
|
||||
BotOSProtocol,
|
||||
ProbeResult,
|
||||
HealthResult,
|
||||
EmailProtocol,
|
||||
EmailInbox,
|
||||
)
|
||||
from .config import BotConfig, BotOSConfig
|
||||
|
||||
@@ -56,4 +60,6 @@ __all__ = [
|
||||
"BotOSConfig",
|
||||
"ProbeResult",
|
||||
"HealthResult",
|
||||
"EmailProtocol",
|
||||
"EmailInbox",
|
||||
]
|
||||
@@ -29,8 +29,10 @@ class BotConfig:
|
||||
"""
|
||||
|
||||
token: str = ""
|
||||
mode: str = "poll" # poll | ws | webhook | hybrid
|
||||
webhook_url: Optional[str] = None
|
||||
webhook_path: str = "/webhook"
|
||||
webhook_port: int = 8080
|
||||
polling_interval: float = 1.0
|
||||
allowed_users: List[str] = field(default_factory=list)
|
||||
allowed_channels: List[str] = field(default_factory=list)
|
||||
@@ -47,7 +49,12 @@ class BotConfig:
|
||||
group_policy: str = "mention_only" # respond_all, mention_only, command_only
|
||||
|
||||
# Default tools enabled for all bots
|
||||
default_tools: List[str] = field(default_factory=lambda: ["execute_command", "search_web", "schedule_add", "schedule_list", "schedule_remove"])
|
||||
default_tools: List[str] = field(default_factory=lambda: [
|
||||
"execute_command", "search_web", "web_crawl",
|
||||
"schedule_add", "schedule_list", "schedule_remove",
|
||||
"store_memory", "search_memory",
|
||||
"store_learning", "search_learning",
|
||||
])
|
||||
|
||||
# Auto-approve tool calls (useful for trusted environments)
|
||||
auto_approve_tools: bool = False # If True, skip confirmation for tool execution
|
||||
@@ -72,8 +79,10 @@ class BotConfig:
|
||||
"""Convert to dictionary (hides sensitive data)."""
|
||||
return {
|
||||
"token": "***" if self.token else None,
|
||||
"mode": self.mode,
|
||||
"webhook_url": self.webhook_url,
|
||||
"webhook_path": self.webhook_path,
|
||||
"webhook_port": self.webhook_port,
|
||||
"polling_interval": self.polling_interval,
|
||||
"allowed_users": self.allowed_users,
|
||||
"allowed_channels": self.allowed_channels,
|
||||
@@ -97,7 +106,17 @@ class BotConfig:
|
||||
@property
|
||||
def is_webhook_mode(self) -> bool:
|
||||
"""Whether bot is configured for webhook mode."""
|
||||
return bool(self.webhook_url)
|
||||
return self.mode == "webhook" or bool(self.webhook_url)
|
||||
|
||||
@property
|
||||
def is_ws_mode(self) -> bool:
|
||||
"""Whether bot is configured for WebSocket mode."""
|
||||
return self.mode == "ws"
|
||||
|
||||
@property
|
||||
def is_hybrid_mode(self) -> bool:
|
||||
"""Whether bot is configured for hybrid mode (WS + slow poll)."""
|
||||
return self.mode == "hybrid"
|
||||
|
||||
def is_user_allowed(self, user_id: str) -> bool:
|
||||
"""Check if a user is allowed to interact with the bot."""
|
||||
|
||||
@@ -613,6 +613,96 @@ class ChatCommandProtocol(Protocol):
|
||||
...
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# EmailProtocol — email-specific bot capabilities
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmailInbox:
|
||||
"""Information about an email inbox.
|
||||
|
||||
Attributes:
|
||||
id: Unique inbox identifier
|
||||
email_address: The inbox's email address
|
||||
domain: Domain of the inbox
|
||||
created_at: When the inbox was created (ISO format)
|
||||
"""
|
||||
|
||||
id: str
|
||||
email_address: str
|
||||
domain: str = ""
|
||||
created_at: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"email_address": self.email_address,
|
||||
"domain": self.domain,
|
||||
"created_at": self.created_at,
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class EmailProtocol(Protocol):
|
||||
"""Protocol for email bots with inbox lifecycle management.
|
||||
|
||||
Extends BotProtocol with email-specific capabilities like
|
||||
programmatic inbox creation, listing, and deletion.
|
||||
|
||||
This protocol is optional — only AgentMail-style bots that
|
||||
support API-based inbox management need to implement it.
|
||||
Traditional IMAP/SMTP bots (EmailBot) do not implement this.
|
||||
|
||||
Example:
|
||||
# Check if a bot supports inbox management
|
||||
if isinstance(bot, EmailProtocol):
|
||||
inbox = await bot.create_inbox(domain="mycompany.com")
|
||||
print(f"Created: {inbox['email_address']}")
|
||||
"""
|
||||
|
||||
@property
|
||||
def email_address(self) -> Optional[str]:
|
||||
"""The bot's current email address."""
|
||||
...
|
||||
|
||||
async def create_inbox(
|
||||
self,
|
||||
domain: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new email inbox programmatically.
|
||||
|
||||
Args:
|
||||
domain: Optional custom domain for the inbox
|
||||
**kwargs: Additional provider-specific parameters
|
||||
|
||||
Returns:
|
||||
Dict with at least 'id' and 'email_address' keys
|
||||
"""
|
||||
...
|
||||
|
||||
async def list_inboxes(self) -> List[Dict[str, Any]]:
|
||||
"""List all inboxes accessible to this bot.
|
||||
|
||||
Returns:
|
||||
List of inbox dicts with 'id' and 'email_address' keys
|
||||
"""
|
||||
...
|
||||
|
||||
async def delete_inbox(self, inbox_id: str) -> bool:
|
||||
"""Delete an inbox.
|
||||
|
||||
Args:
|
||||
inbox_id: ID of the inbox to delete
|
||||
|
||||
Returns:
|
||||
True if deleted successfully
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# BotOS Protocol — multi-platform bot orchestration
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
@@ -57,6 +57,34 @@ class LearnScope(str, Enum):
|
||||
SHARED = "shared" # Shared with all agents
|
||||
|
||||
|
||||
class LearnMode(str, Enum):
|
||||
"""Learning mode for automatic learning extraction.
|
||||
|
||||
DISABLED: No automatic learning (manual capture only)
|
||||
AGENTIC: Agent autonomously extracts and stores learnings after each conversation
|
||||
PROPOSE: (Future) Agent proposes learnings for user approval before storing.
|
||||
Note: PROPOSE mode is defined but not yet implemented. Using it will
|
||||
behave the same as DISABLED until the approval workflow is added.
|
||||
"""
|
||||
DISABLED = "disabled"
|
||||
AGENTIC = "agentic"
|
||||
PROPOSE = "propose"
|
||||
|
||||
|
||||
class LearnBackend(str, Enum):
|
||||
"""Storage backend for learning data.
|
||||
|
||||
FILE: JSON files (default, zero dependencies)
|
||||
SQLITE: SQLite database
|
||||
REDIS: Redis (requires redis package)
|
||||
MONGODB: MongoDB (requires pymongo)
|
||||
"""
|
||||
FILE = "file"
|
||||
SQLITE = "sqlite"
|
||||
REDIS = "redis"
|
||||
MONGODB = "mongodb"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LearnConfig:
|
||||
"""
|
||||
@@ -66,16 +94,21 @@ class LearnConfig:
|
||||
to improve future responses. All learning data is stored within the memory system.
|
||||
|
||||
Usage:
|
||||
# Simple enable
|
||||
Agent(memory=MemoryConfig(learn=True))
|
||||
# Simple enable (top-level param)
|
||||
Agent(learn=True)
|
||||
|
||||
# With specific capabilities
|
||||
Agent(memory=MemoryConfig(
|
||||
learn=LearnConfig(
|
||||
persona=True, # User preferences
|
||||
insights=True, # Observations
|
||||
patterns=True, # Reusable knowledge
|
||||
)
|
||||
Agent(learn=LearnConfig(
|
||||
persona=True, # User preferences
|
||||
insights=True, # Observations
|
||||
patterns=True, # Reusable knowledge
|
||||
mode="agentic", # Auto-extract learnings
|
||||
))
|
||||
|
||||
# With database backend
|
||||
Agent(learn=LearnConfig(
|
||||
backend="sqlite",
|
||||
db_url="sqlite:///learn.db",
|
||||
))
|
||||
"""
|
||||
# Learning capabilities
|
||||
@@ -87,15 +120,19 @@ class LearnConfig:
|
||||
feedback: bool = False # Outcome signals
|
||||
improvements: bool = False # Self-improvement proposals
|
||||
|
||||
# Learning mode
|
||||
mode: Union[str, LearnMode] = LearnMode.DISABLED # How to extract learnings
|
||||
|
||||
# Scope configuration
|
||||
scope: Union[str, LearnScope] = LearnScope.PRIVATE
|
||||
|
||||
# Storage configuration
|
||||
store_path: Optional[str] = None # Custom storage path
|
||||
# Storage backend configuration
|
||||
backend: Union[str, LearnBackend] = LearnBackend.FILE # Storage backend
|
||||
db_url: Optional[str] = None # Database connection URL (for non-file backends)
|
||||
store_path: Optional[str] = None # Custom storage path (for file backend)
|
||||
|
||||
# Maintenance settings
|
||||
auto_consolidate: bool = True # Auto-consolidate learnings
|
||||
retention_days: Optional[int] = None # Days to retain (None = forever)
|
||||
# LLM for learning extraction
|
||||
llm: Optional[str] = None # LLM model for extracting learnings (defaults to agent's LLM)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
@@ -107,10 +144,12 @@ class LearnConfig:
|
||||
"decisions": self.decisions,
|
||||
"feedback": self.feedback,
|
||||
"improvements": self.improvements,
|
||||
"mode": self.mode.value if isinstance(self.mode, LearnMode) else self.mode,
|
||||
"scope": self.scope.value if isinstance(self.scope, LearnScope) else self.scope,
|
||||
"backend": self.backend.value if isinstance(self.backend, LearnBackend) else self.backend,
|
||||
"db_url": self.db_url,
|
||||
"store_path": self.store_path,
|
||||
"auto_consolidate": self.auto_consolidate,
|
||||
"retention_days": self.retention_days,
|
||||
"llm": self.llm,
|
||||
}
|
||||
|
||||
|
||||
@@ -574,6 +613,10 @@ class OutputConfig:
|
||||
# Shows: [timestamp] Calling LLM..., Executing tool..., Response: ...
|
||||
status_trace: bool = False
|
||||
|
||||
# Editor output mode - beginner-friendly numbered steps
|
||||
# Shows: Step 1: 📄 Creating file: path → ✓ Done
|
||||
editor_output: bool = False
|
||||
|
||||
# Output file - save agent response to file automatically
|
||||
# When set, saves the response to the specified file path
|
||||
output_file: Optional[str] = None
|
||||
@@ -596,6 +639,7 @@ class OutputConfig:
|
||||
"simple_output": self.simple_output,
|
||||
"show_parameters": self.show_parameters,
|
||||
"status_trace": self.status_trace,
|
||||
"editor_output": self.editor_output,
|
||||
"output_file": self.output_file,
|
||||
"template": self.template,
|
||||
}
|
||||
@@ -635,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
|
||||
@@ -654,7 +704,23 @@ class ExecutionConfig:
|
||||
|
||||
# Rate limiter instance (consolidated from standalone rate_limiter param)
|
||||
rate_limiter: Optional[Any] = None
|
||||
|
||||
|
||||
# Context compaction: automatically compact chat_history when approaching token limit.
|
||||
# Opt-in only (safe default: False). Uses existing praisonaiagents.compaction module.
|
||||
# Usage: Agent(execution=ExecutionConfig(context_compaction=True, max_context_tokens=8000))
|
||||
context_compaction: bool = False
|
||||
|
||||
# Token limit before compaction triggers. None = auto-detect from model metadata.
|
||||
max_context_tokens: Optional[int] = None
|
||||
|
||||
# Token budget guard — hard dollar limit per agent run.
|
||||
# None = disabled (zero overhead). Float = max USD spend.
|
||||
max_budget: Optional[float] = None
|
||||
|
||||
# Action when budget exceeded: "stop" (default) raises BudgetExceededError,
|
||||
# "warn" logs warning but continues, or callable(total_cost, max_budget).
|
||||
on_budget_exceeded: Any = "stop"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
@@ -664,6 +730,9 @@ class ExecutionConfig:
|
||||
"max_retry_limit": self.max_retry_limit,
|
||||
"code_execution": self.code_execution,
|
||||
"code_mode": self.code_mode,
|
||||
"context_compaction": self.context_compaction,
|
||||
"max_context_tokens": self.max_context_tokens,
|
||||
"max_budget": self.max_budget,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -85,14 +85,32 @@ _config_cache: Optional[Dict[str, Any]] = None
|
||||
_config_loaded: bool = False
|
||||
|
||||
|
||||
def _default_project_plugins_dir():
|
||||
"""Return project-local plugins directory via paths.py."""
|
||||
from ..paths import get_project_data_dir
|
||||
return get_project_data_dir() / "plugins"
|
||||
|
||||
|
||||
def _default_global_plugins_dir():
|
||||
"""Return user-global plugins directory via paths.py."""
|
||||
from ..paths import get_plugins_dir
|
||||
return get_plugins_dir()
|
||||
|
||||
|
||||
def get_project_data_dir():
|
||||
"""Re-export for local use in config search."""
|
||||
from ..paths import get_project_data_dir as _gpd
|
||||
return _gpd()
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginsConfig:
|
||||
"""Configuration for the plugin system."""
|
||||
enabled: Union[bool, List[str]] = False # True, False, or list of plugin names
|
||||
auto_discover: bool = True
|
||||
directories: List[str] = field(default_factory=lambda: [
|
||||
"./.praisonai/plugins/",
|
||||
"~/.praisonai/plugins/"
|
||||
str(_default_project_plugins_dir()),
|
||||
str(_default_global_plugins_dir())
|
||||
])
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
@@ -174,7 +192,7 @@ def _find_config_file() -> Optional[Path]:
|
||||
# Project-local locations
|
||||
cwd = Path.cwd()
|
||||
local_paths = [
|
||||
cwd / ".praisonai" / "config.toml",
|
||||
get_project_data_dir() / "config.toml",
|
||||
cwd / "praisonai.toml",
|
||||
]
|
||||
|
||||
@@ -183,8 +201,8 @@ def _find_config_file() -> Optional[Path]:
|
||||
return path
|
||||
|
||||
# User global location
|
||||
home = Path.home()
|
||||
global_path = home / ".praisonai" / "config.toml"
|
||||
from ..paths import get_data_dir
|
||||
global_path = get_data_dir() / "config.toml"
|
||||
if global_path.exists():
|
||||
return global_path
|
||||
|
||||
@@ -245,8 +263,8 @@ def _dict_to_plugins_config(data: Dict[str, Any]) -> PluginsConfig:
|
||||
enabled=data.get("enabled", False),
|
||||
auto_discover=data.get("auto_discover", True),
|
||||
directories=data.get("directories", [
|
||||
"./.praisonai/plugins/",
|
||||
"~/.praisonai/plugins/"
|
||||
str(_default_project_plugins_dir()),
|
||||
str(_default_global_plugins_dir())
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
@@ -133,6 +133,17 @@ OUTPUT_PRESETS: Dict[str, Dict[str, Any]] = {
|
||||
"actions_trace": True,
|
||||
"json_output": True, # Output as JSONL
|
||||
},
|
||||
# Editor preset - beginner-friendly numbered steps
|
||||
# Shows: Step 1: 📄 Creating file: path → ✓ Done
|
||||
# Best for non-programmers and interactive scripts
|
||||
"editor": {
|
||||
"verbose": False,
|
||||
"markdown": False,
|
||||
"stream": False,
|
||||
"metrics": False,
|
||||
"reasoning_steps": False,
|
||||
"editor_output": True, # Enable editor display mode
|
||||
},
|
||||
|
||||
# ==========================================================================
|
||||
# ALIASES (for backward compatibility)
|
||||
|
||||
@@ -10,13 +10,25 @@ from .models import BudgetAllocation
|
||||
|
||||
# Model token limits (context window sizes)
|
||||
MODEL_LIMITS: Dict[str, int] = {
|
||||
# OpenAI
|
||||
# OpenAI — gpt-5 family (1M context window)
|
||||
"gpt-5": 1047576,
|
||||
"gpt-5-mini": 1047576,
|
||||
"gpt-5-nano": 1047576,
|
||||
# OpenAI — gpt-4.1 family (1M context window)
|
||||
"gpt-4.1": 1047576,
|
||||
"gpt-4.1-mini": 1047576,
|
||||
"gpt-4.1-nano": 1047576,
|
||||
# OpenAI — gpt-4o family
|
||||
"gpt-4o": 128000,
|
||||
"gpt-4o-mini": 128000,
|
||||
"gpt-4-turbo": 128000,
|
||||
"gpt-4": 8192,
|
||||
"gpt-3.5-turbo": 16385,
|
||||
"gpt-3.5-turbo-16k": 16385,
|
||||
# OpenAI — o-series
|
||||
"o3-mini": 200000,
|
||||
"o3": 200000,
|
||||
"o4-mini": 200000,
|
||||
# Anthropic
|
||||
"claude-3-5-sonnet": 200000,
|
||||
"claude-3-5-haiku": 200000,
|
||||
@@ -33,11 +45,15 @@ MODEL_LIMITS: Dict[str, int] = {
|
||||
|
||||
# Default output reserves by model family
|
||||
OUTPUT_RESERVES: Dict[str, int] = {
|
||||
"gpt-5": 32768,
|
||||
"gpt-4.1": 32768,
|
||||
"gpt-4o": 16384,
|
||||
"gpt-4o-mini": 16384,
|
||||
"gpt-4-turbo": 4096,
|
||||
"gpt-4": 4096,
|
||||
"gpt-3.5-turbo": 4096,
|
||||
"o3": 100000,
|
||||
"o4-mini": 100000,
|
||||
"claude-3": 8192,
|
||||
"gemini": 8192,
|
||||
"default": 8000,
|
||||
|
||||
@@ -99,7 +99,6 @@ def test_db_hook_lifecycle():
|
||||
instructions="Test agent",
|
||||
db=mock_db,
|
||||
session_id="test-session-001",
|
||||
verbose=False
|
||||
)
|
||||
|
||||
# Verify db is stored
|
||||
@@ -125,7 +124,6 @@ def test_db_none_unchanged_behavior():
|
||||
agent = Agent(
|
||||
name="TestAgent",
|
||||
instructions="Test agent",
|
||||
verbose=False
|
||||
)
|
||||
|
||||
# Verify db is None
|
||||
@@ -157,7 +155,6 @@ def test_session_id_auto_generation():
|
||||
instructions="Test agent",
|
||||
db=mock_db,
|
||||
# No session_id provided
|
||||
verbose=False
|
||||
)
|
||||
|
||||
# Trigger db initialization
|
||||
|
||||
@@ -22,6 +22,7 @@ class DoomLoopType(Enum):
|
||||
NO_PROGRESS = "no_progress" # No meaningful progress
|
||||
CIRCULAR_PLAN = "circular_plan" # Plan loops back to start
|
||||
RESOURCE_EXHAUSTION = "resource_exhaustion" # Budget exceeded
|
||||
REPEATED_OUTPUT = "repeated_output" # Same output text repeated (content chanting)
|
||||
|
||||
|
||||
class RecoveryAction(Enum):
|
||||
@@ -58,6 +59,10 @@ class DoomLoopConfig:
|
||||
initial_backoff: float = 1.0 # Initial backoff in seconds
|
||||
backoff_multiplier: float = 2.0 # Backoff multiplier
|
||||
max_backoff: float = 30.0 # Maximum backoff
|
||||
|
||||
# Content streaming detection
|
||||
max_repeated_chunks: int = 8 # Max identical output chunks before flagging
|
||||
content_chunk_size: int = 50 # Sliding window chunk size (chars)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -116,6 +121,7 @@ class DoomLoopDetector:
|
||||
self._start_time: Optional[float] = None
|
||||
self._progress_markers: List[str] = []
|
||||
self._current_backoff: float = self.config.initial_backoff
|
||||
self._content_chunk_counts: Dict[str, int] = {} # hash -> count
|
||||
|
||||
def start_session(self):
|
||||
"""Start a new detection session."""
|
||||
@@ -125,6 +131,7 @@ class DoomLoopDetector:
|
||||
self._recovery_attempts = 0
|
||||
self._progress_markers.clear()
|
||||
self._current_backoff = self.config.initial_backoff
|
||||
self._content_chunk_counts.clear()
|
||||
|
||||
def record_action(
|
||||
self,
|
||||
@@ -162,6 +169,25 @@ class DoomLoopDetector:
|
||||
if self.is_doom_loop():
|
||||
self._handle_loop_detection()
|
||||
|
||||
def record_response(self, text: str):
|
||||
"""
|
||||
Record model response text for content streaming loop detection.
|
||||
|
||||
Hashes sliding window chunks and tracks repetition counts.
|
||||
Inspired by gemini-cli's sliding window approach.
|
||||
|
||||
Args:
|
||||
text: The model's response text
|
||||
"""
|
||||
if not text or len(text) < self.config.content_chunk_size:
|
||||
return
|
||||
|
||||
chunk_size = self.config.content_chunk_size
|
||||
for i in range(0, len(text) - chunk_size + 1, chunk_size):
|
||||
chunk = text[i:i + chunk_size]
|
||||
chunk_hash = hashlib.md5(chunk.encode()).hexdigest()[:16]
|
||||
self._content_chunk_counts[chunk_hash] = self._content_chunk_counts.get(chunk_hash, 0) + 1
|
||||
|
||||
def mark_progress(self, marker: str):
|
||||
"""
|
||||
Mark meaningful progress.
|
||||
@@ -178,6 +204,10 @@ class DoomLoopDetector:
|
||||
Returns:
|
||||
True if doom loop detected
|
||||
"""
|
||||
# Content streaming loop can occur even without recorded actions
|
||||
if self._check_content_loop():
|
||||
return True
|
||||
|
||||
if len(self._actions) < 2:
|
||||
return False
|
||||
|
||||
@@ -201,6 +231,10 @@ class DoomLoopDetector:
|
||||
if self._check_resource_exhaustion():
|
||||
return True
|
||||
|
||||
# Check for content streaming loops (repeated output text)
|
||||
if self._check_content_loop():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_loop_type(self) -> Optional[DoomLoopType]:
|
||||
@@ -215,6 +249,8 @@ class DoomLoopDetector:
|
||||
return DoomLoopType.NO_PROGRESS
|
||||
if self._check_resource_exhaustion():
|
||||
return DoomLoopType.RESOURCE_EXHAUSTION
|
||||
if self._check_content_loop():
|
||||
return DoomLoopType.REPEATED_OUTPUT
|
||||
return None
|
||||
|
||||
def get_loop_event(self) -> Optional[DoomLoopEvent]:
|
||||
@@ -371,6 +407,19 @@ class DoomLoopDetector:
|
||||
elapsed = time.time() - self._start_time
|
||||
return elapsed > self.config.max_total_time
|
||||
|
||||
def _check_content_loop(self) -> bool:
|
||||
"""Check for repeated output text (content streaming loop).
|
||||
|
||||
Uses sliding window chunk hashing. If any chunk hash appears
|
||||
more than max_repeated_chunks times, it indicates the model
|
||||
is generating repetitive output (chanting).
|
||||
"""
|
||||
if not self._content_chunk_counts:
|
||||
return False
|
||||
|
||||
threshold = self.config.max_repeated_chunks
|
||||
return any(count >= threshold for count in self._content_chunk_counts.values())
|
||||
|
||||
def _determine_recovery_action(self, loop_type: DoomLoopType) -> RecoveryAction:
|
||||
"""Determine appropriate recovery action."""
|
||||
# If max recovery attempts reached, abort
|
||||
@@ -408,6 +457,9 @@ class DoomLoopDetector:
|
||||
DoomLoopType.RESOURCE_EXHAUSTION: (
|
||||
f"Exceeded time limit of {self.config.max_total_time}s"
|
||||
),
|
||||
DoomLoopType.REPEATED_OUTPUT: (
|
||||
f"Model output repeating — {self.config.max_repeated_chunks}+ identical chunks detected"
|
||||
),
|
||||
}
|
||||
return descriptions.get(loop_type, "Unknown loop type")
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ implementations that coordinate multi-agent deployments.
|
||||
|
||||
This module contains only protocols and lightweight utilities.
|
||||
Heavy implementations live in the praisonai wrapper package.
|
||||
|
||||
Gap S2: WebSocketGateway is re-exported here for convenience but requires
|
||||
the praisonai wrapper package to be installed.
|
||||
"""
|
||||
|
||||
from .protocols import (
|
||||
@@ -18,15 +21,59 @@ from .protocols import (
|
||||
)
|
||||
from .config import GatewayConfig, SessionConfig, ChannelRouteConfig, MultiChannelGatewayConfig
|
||||
|
||||
# Lazy loading cache
|
||||
_lazy_cache = {}
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy load heavy gateway implementations from praisonai wrapper.
|
||||
|
||||
Gap S2: Re-export WebSocketGateway from praisonai wrapper for convenience.
|
||||
This allows downstream packages to import from praisonaiagents.gateway
|
||||
without needing to know about the praisonai wrapper.
|
||||
"""
|
||||
if name in _lazy_cache:
|
||||
return _lazy_cache[name]
|
||||
|
||||
if name == "WebSocketGateway":
|
||||
try:
|
||||
from praisonai.gateway import WebSocketGateway
|
||||
_lazy_cache[name] = WebSocketGateway
|
||||
return WebSocketGateway
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"WebSocketGateway requires the praisonai wrapper package. "
|
||||
"Install with: pip install praisonai"
|
||||
)
|
||||
|
||||
if name == "GatewaySession":
|
||||
try:
|
||||
from praisonai.gateway import GatewaySession
|
||||
_lazy_cache[name] = GatewaySession
|
||||
return GatewaySession
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"GatewaySession requires the praisonai wrapper package. "
|
||||
"Install with: pip install praisonai"
|
||||
)
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
__all__ = [
|
||||
# Protocols (always available)
|
||||
"GatewayProtocol",
|
||||
"GatewaySessionProtocol",
|
||||
"GatewayClientProtocol",
|
||||
"GatewayEvent",
|
||||
"GatewayMessage",
|
||||
"EventType",
|
||||
# Config (always available)
|
||||
"GatewayConfig",
|
||||
"SessionConfig",
|
||||
"ChannelRouteConfig",
|
||||
"MultiChannelGatewayConfig",
|
||||
# Implementations (lazy loaded from praisonai wrapper)
|
||||
"WebSocketGateway",
|
||||
"GatewaySession",
|
||||
]
|
||||
@@ -67,16 +67,22 @@ class LLMGuardrail:
|
||||
self.logger.warning(f"Unknown LLM type: {type(llm)}, treating as-is")
|
||||
return llm
|
||||
|
||||
def __call__(self, task_output: TaskOutput) -> Tuple[bool, Union[str, TaskOutput]]:
|
||||
def __call__(self, task_output) -> Tuple[bool, Union[str, "TaskOutput"]]:
|
||||
"""Validate the task output using the LLM.
|
||||
|
||||
Args:
|
||||
task_output: The task output to validate
|
||||
task_output: The task output to validate (TaskOutput or plain str)
|
||||
|
||||
Returns:
|
||||
Tuple of (success, result) where result is the output or error message
|
||||
"""
|
||||
try:
|
||||
# Accept plain str for convenience (e.g. chat-input screening)
|
||||
if isinstance(task_output, str):
|
||||
raw_text = task_output
|
||||
else:
|
||||
raw_text = task_output.raw
|
||||
|
||||
if not self.llm:
|
||||
self.logger.warning("No LLM provided for guardrail validation")
|
||||
return True, task_output
|
||||
@@ -88,7 +94,7 @@ You are a quality assurance validator. Your task is to evaluate the following ou
|
||||
Validation Criteria: {self.description}
|
||||
|
||||
Output to Validate:
|
||||
{task_output.raw}
|
||||
{raw_text}
|
||||
|
||||
Please evaluate if this output meets the criteria. Respond with:
|
||||
1. "PASS" if the output meets all criteria
|
||||
|
||||
@@ -20,34 +20,59 @@ class Chunking:
|
||||
@cached_property
|
||||
def SUPPORTED_CHUNKERS(self) -> Dict[str, Any]:
|
||||
"""Lazy load chunker classes."""
|
||||
chunkers = {}
|
||||
try:
|
||||
from chonkie import (
|
||||
TokenChunker,
|
||||
SentenceChunker,
|
||||
SemanticChunker,
|
||||
SDPMChunker,
|
||||
LateChunker,
|
||||
RecursiveChunker,
|
||||
CodeChunker,
|
||||
NeuralChunker,
|
||||
SlumberChunker
|
||||
)
|
||||
from chonkie import TokenChunker
|
||||
chunkers['token'] = TokenChunker
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from chonkie import SentenceChunker
|
||||
chunkers['sentence'] = SentenceChunker
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from chonkie import SemanticChunker
|
||||
chunkers['semantic'] = SemanticChunker
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from chonkie import SDPMChunker
|
||||
chunkers['sdpm'] = SDPMChunker
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from chonkie import LateChunker
|
||||
chunkers['late'] = LateChunker
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from chonkie import RecursiveChunker
|
||||
chunkers['recursive'] = RecursiveChunker
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from chonkie import CodeChunker
|
||||
chunkers['code'] = CodeChunker
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from chonkie import NeuralChunker
|
||||
chunkers['neural'] = NeuralChunker
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from chonkie import SlumberChunker
|
||||
chunkers['slumber'] = SlumberChunker
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if not chunkers:
|
||||
raise ImportError(
|
||||
"chonkie package not found. Please install it using: pip install 'praisonaiagents[knowledge]'"
|
||||
)
|
||||
|
||||
return {
|
||||
'token': TokenChunker,
|
||||
'sentence': SentenceChunker,
|
||||
'semantic': SemanticChunker,
|
||||
'sdpm': SDPMChunker,
|
||||
'late': LateChunker,
|
||||
'recursive': RecursiveChunker,
|
||||
'code': CodeChunker,
|
||||
'neural': NeuralChunker,
|
||||
'slumber': SlumberChunker
|
||||
}
|
||||
|
||||
return chunkers
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -116,7 +141,18 @@ class Chunking:
|
||||
if self._chunker is None:
|
||||
chunker_cls = self.SUPPORTED_CHUNKERS[self.chunker_type]
|
||||
common_params = self._get_chunker_params()
|
||||
self._chunker = chunker_cls(**common_params)
|
||||
try:
|
||||
self._chunker = chunker_cls(**common_params)
|
||||
except TypeError:
|
||||
# Chonkie API may have changed — retry with just chunk_size
|
||||
fallback_params = {'chunk_size': self.chunk_size}
|
||||
if 'embedding_model' in common_params:
|
||||
fallback_params['embedding_model'] = common_params['embedding_model']
|
||||
try:
|
||||
self._chunker = chunker_cls(**fallback_params)
|
||||
except TypeError:
|
||||
# Last resort — no params
|
||||
self._chunker = chunker_cls()
|
||||
|
||||
return self._chunker
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import os
|
||||
import logging
|
||||
import uuid
|
||||
import time
|
||||
from datetime import datetime
|
||||
from .chunking import Chunking
|
||||
from functools import cached_property
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
|
||||
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -414,9 +413,11 @@ class Knowledge:
|
||||
|
||||
@cached_property
|
||||
def config(self):
|
||||
# Generate unique collection name for each instance (only if not provided in config)
|
||||
default_collection = f"test_{int(time.time())}_{str(uuid.uuid4())[:8]}"
|
||||
persist_dir = ".praisonai"
|
||||
# Use deterministic collection name for persistence across restarts
|
||||
# Only generate unique name if explicitly requested via config
|
||||
default_collection = "praisonai_knowledge"
|
||||
from ..paths import get_project_data_dir
|
||||
persist_dir = str(get_project_data_dir())
|
||||
|
||||
# Create persistent client config
|
||||
base_config = {
|
||||
@@ -786,6 +787,7 @@ class Knowledge:
|
||||
memories = [self.normalize_content(input_path)]
|
||||
|
||||
# Create progress display
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
|
||||
progress = Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
@@ -862,7 +864,8 @@ class Knowledge:
|
||||
result = IndexResult()
|
||||
|
||||
# Initialize file tracker for incremental indexing
|
||||
state_dir = os.path.join(os.path.dirname(path) if os.path.isfile(path) else path, ".praisonai")
|
||||
from ..paths import DEFAULT_DIR_NAME
|
||||
state_dir = os.path.join(os.path.dirname(path) if os.path.isfile(path) else path, DEFAULT_DIR_NAME)
|
||||
os.makedirs(state_dir, exist_ok=True)
|
||||
state_file = os.path.join(state_dir, ".index_state.json")
|
||||
|
||||
|
||||
@@ -71,7 +71,9 @@ except ImportError:
|
||||
# Logging is already configured in _logging.py via __init__.py
|
||||
|
||||
# TODO: Include in-build tool calling in LLM class
|
||||
# TODO: Restructure so that duplicate calls are not made (Sync with agent.py)
|
||||
# NOTE: The custom-LLM path (Agent.chat → get_response) and OpenAI path
|
||||
# (Agent.chat → _chat_completion) are separate code paths, not duplicate
|
||||
# API calls per request. This is a DRY/maintenance concern, not a billing issue.
|
||||
class LLMContextLengthExceededException(Exception):
|
||||
"""Raised when LLM context length is exceeded"""
|
||||
def __init__(self, message: str):
|
||||
@@ -107,7 +109,7 @@ class LLM:
|
||||
MODEL_WINDOWS = {
|
||||
# OpenAI
|
||||
"gpt-4": 6144, # 8,192 actual
|
||||
"gpt-4o-mini": 96000, # 128,000 actual
|
||||
"gpt-4o": 96000, # 128,000 actual
|
||||
"gpt-4o-mini": 96000, # 128,000 actual
|
||||
"gpt-4-turbo": 96000, # 128,000 actual
|
||||
"o1-preview": 96000, # 128,000 actual
|
||||
@@ -592,6 +594,18 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
f"waiting {retry_delay:.1f}s before retry..."
|
||||
)
|
||||
|
||||
# P5: Emit retry callback for CLI visibility
|
||||
try:
|
||||
from ..main import execute_sync_callback
|
||||
execute_sync_callback('retry',
|
||||
attempt=attempt + 1,
|
||||
max_attempts=self._max_retries + 1,
|
||||
error=error_str[:200],
|
||||
retry_in_seconds=retry_delay
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if self._rate_limiter is not None:
|
||||
self._rate_limiter.wait_for_retry(retry_delay)
|
||||
else:
|
||||
@@ -1673,6 +1687,167 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
# Initial callback is in agent._chat_completion, so this triggers for all loop iterations
|
||||
_get_display_functions()['execute_sync_callback']('llm_start', model=self.model, agent_name=agent_name)
|
||||
|
||||
# ── Responses API path ──────────────────────────────────
|
||||
# OpenAI models use the Responses API which returns text
|
||||
# and tool calls as separate output items — no content:null.
|
||||
if self._supports_responses_api():
|
||||
responses_params = self._build_responses_params(
|
||||
messages=messages,
|
||||
tools=formatted_tools,
|
||||
temperature=temperature,
|
||||
output_json=output_json,
|
||||
output_pydantic=output_pydantic,
|
||||
**{k: v for k, v in kwargs.items() if k != 'reasoning_steps'},
|
||||
)
|
||||
|
||||
if stream:
|
||||
response_text, tool_calls = self._stream_responses_api(
|
||||
responses_params,
|
||||
verbose=verbose,
|
||||
stream_callback=stream_callback,
|
||||
emit_events=emit_events,
|
||||
current_time=current_time,
|
||||
)
|
||||
else:
|
||||
resp = self._call_responses_api(**responses_params)
|
||||
response_text, tool_calls, _reasoning = self._extract_from_responses_output(resp)
|
||||
|
||||
# Track token usage
|
||||
if self.metrics and hasattr(resp, 'usage') and resp.usage:
|
||||
usage = resp.usage
|
||||
tokens_in = getattr(usage, 'input_tokens', 0) or 0
|
||||
tokens_out = getattr(usage, 'output_tokens', 0) or 0
|
||||
# Emit llm_end with metrics
|
||||
llm_latency_ms = (time.time() - current_time) * 1000
|
||||
_get_display_functions()['execute_sync_callback'](
|
||||
'llm_end', model=self.model,
|
||||
tokens_in=tokens_in, tokens_out=tokens_out,
|
||||
cost=None, latency_ms=llm_latency_ms,
|
||||
)
|
||||
|
||||
# Emit reasoning if present
|
||||
if _emit and _reasoning:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
timestamp=time.perf_counter(),
|
||||
content=_reasoning, is_reasoning=True,
|
||||
))
|
||||
# Emit text content as stream event
|
||||
if _emit and response_text:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
timestamp=time.perf_counter(),
|
||||
content=response_text, is_reasoning=False,
|
||||
))
|
||||
|
||||
# Build a Chat-Completions-compatible final_response
|
||||
# so all downstream code (display, callbacks) works unchanged
|
||||
final_response = {
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": response_text,
|
||||
"tool_calls": tool_calls,
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
# Display / callback handling (same as Chat Completions path)
|
||||
if verbose and not interaction_displayed:
|
||||
_get_display_functions()['display_interaction'](
|
||||
original_prompt, response_text,
|
||||
markdown=markdown,
|
||||
generation_time=time.time() - current_time,
|
||||
console=self.console,
|
||||
agent_name=agent_name, agent_role=agent_role,
|
||||
agent_tools=agent_tools, task_name=task_name,
|
||||
task_description=task_description, task_id=task_id,
|
||||
)
|
||||
interaction_displayed = True
|
||||
callback_executed = True
|
||||
elif not callback_executed:
|
||||
_get_display_functions()['execute_sync_callback'](
|
||||
'interaction',
|
||||
message=original_prompt, response=response_text,
|
||||
markdown=markdown,
|
||||
generation_time=time.time() - current_time,
|
||||
agent_name=agent_name, agent_role=agent_role,
|
||||
agent_tools=agent_tools, task_name=task_name,
|
||||
task_description=task_description, task_id=task_id,
|
||||
)
|
||||
callback_executed = True
|
||||
|
||||
# Emit llm_content for intermediate narrative display
|
||||
if response_text and response_text.strip():
|
||||
_get_display_functions()['execute_sync_callback'](
|
||||
'llm_content',
|
||||
content=response_text.strip(),
|
||||
agent_name=agent_name,
|
||||
)
|
||||
|
||||
# ── Handle tool calls ───────────────────────────────
|
||||
if tool_calls and execute_tool_fn:
|
||||
serializable_tool_calls = self._serialize_tool_calls(tool_calls)
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": response_text,
|
||||
"tool_calls": serializable_tool_calls,
|
||||
})
|
||||
|
||||
tool_results = []
|
||||
for tool_call in tool_calls:
|
||||
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call)
|
||||
|
||||
logging.debug(f"[RESPONSES_API] Executing tool {function_name} with args: {arguments}")
|
||||
tool_result = execute_tool_fn(function_name, arguments, tool_call_id=tool_call_id)
|
||||
tool_results.append(tool_result)
|
||||
accumulated_tool_results.append(tool_result)
|
||||
|
||||
if verbose:
|
||||
display_message = f"Agent {agent_name} called function '{function_name}' with arguments: {arguments}\n"
|
||||
display_message += f"Function returned: {tool_result}" if tool_result else "Function returned no output"
|
||||
_get_display_functions()['display_tool_call'](display_message, console=self.console)
|
||||
|
||||
result_str = json.dumps(tool_result) if tool_result else "empty"
|
||||
_get_display_functions()['execute_sync_callback'](
|
||||
'tool_call',
|
||||
message=f"Calling function: {function_name}",
|
||||
tool_name=function_name,
|
||||
tool_input=arguments,
|
||||
tool_output=result_str[:200] if result_str else None,
|
||||
)
|
||||
|
||||
# Append tool result as standard tool message
|
||||
if tool_result is None:
|
||||
content = "Function returned an empty output"
|
||||
elif isinstance(tool_result, dict) and 'error' in tool_result:
|
||||
content = f"Error: {tool_result.get('error', 'Unknown error')}. Please inform the user."
|
||||
elif isinstance(tool_result, list) and tool_result and isinstance(tool_result[0], dict) and 'error' in tool_result[0]:
|
||||
content = f"Error: {tool_result[0].get('error', 'Unknown error')}. Please inform the user."
|
||||
else:
|
||||
content = json.dumps(tool_result)
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"content": content,
|
||||
})
|
||||
|
||||
# Safety: break after 5 iterations
|
||||
if iteration_count >= 5:
|
||||
final_response_text = response_text.strip() if response_text else "Task completed."
|
||||
break
|
||||
|
||||
iteration_count += 1
|
||||
response_text = ""
|
||||
continue
|
||||
else:
|
||||
# No tool calls — we have a final answer
|
||||
if iteration_count > 0:
|
||||
final_response_text = response_text.strip() if response_text else ""
|
||||
else:
|
||||
final_response_text = response_text.strip() if response_text else ""
|
||||
break
|
||||
|
||||
# ── Chat Completions path (unchanged) ───────────────────
|
||||
# If reasoning_steps is True, do a single non-streaming call
|
||||
if reasoning_steps:
|
||||
resp = litellm.completion(
|
||||
@@ -2057,18 +2232,59 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
}]
|
||||
}
|
||||
else:
|
||||
# For non-streaming + non-verbose: no display_generating (per user requirements)
|
||||
final_response = litellm.completion(
|
||||
**self._build_completion_params(
|
||||
messages=messages,
|
||||
tools=formatted_tools,
|
||||
temperature=temperature,
|
||||
stream=False,
|
||||
output_json=output_json,
|
||||
output_pydantic=output_pydantic,
|
||||
**kwargs
|
||||
# For non-streaming + non-verbose path
|
||||
# When editor output is enabled, use silent streaming
|
||||
# to capture intermediate text (OpenAI returns content:null
|
||||
# on tool-call turns in non-streaming mode, but streaming
|
||||
# delivers text tokens before tool call tokens).
|
||||
_use_silent_stream = False
|
||||
try:
|
||||
from ..output.editor import is_editor_output_enabled
|
||||
_use_silent_stream = is_editor_output_enabled()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if _use_silent_stream:
|
||||
# Silent streaming: no display, just accumulate
|
||||
response_text = ""
|
||||
tool_calls = []
|
||||
for chunk in litellm.completion(
|
||||
**self._build_completion_params(
|
||||
messages=messages,
|
||||
tools=formatted_tools,
|
||||
temperature=temperature,
|
||||
stream=True,
|
||||
output_json=output_json,
|
||||
output_pydantic=output_pydantic,
|
||||
**kwargs
|
||||
)
|
||||
):
|
||||
if chunk and chunk.choices and chunk.choices[0].delta:
|
||||
delta = chunk.choices[0].delta
|
||||
response_text, tool_calls = self._process_stream_delta(
|
||||
delta, response_text, tool_calls, formatted_tools
|
||||
)
|
||||
final_response = {
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": response_text,
|
||||
"tool_calls": tool_calls if tool_calls else None
|
||||
}
|
||||
}]
|
||||
}
|
||||
else:
|
||||
# Standard non-streaming path
|
||||
final_response = litellm.completion(
|
||||
**self._build_completion_params(
|
||||
messages=messages,
|
||||
tools=formatted_tools,
|
||||
temperature=temperature,
|
||||
stream=False,
|
||||
output_json=output_json,
|
||||
output_pydantic=output_pydantic,
|
||||
**kwargs
|
||||
)
|
||||
)
|
||||
)
|
||||
# Handle None content from Gemini
|
||||
response_content = final_response["choices"][0]["message"].get("content")
|
||||
response_text = response_content if response_content is not None else ""
|
||||
@@ -2259,6 +2475,16 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
# Reset repair count on successful tool call
|
||||
self._current_repair_count = 0
|
||||
|
||||
# Emit intermediate LLM narrative for display
|
||||
# This fires for ALL responses with text, not just tool-call turns.
|
||||
# Competitors (Gemini CLI, Codex, OpenCode) display this inline.
|
||||
if response_text and response_text.strip():
|
||||
_get_display_functions()['execute_sync_callback'](
|
||||
'llm_content',
|
||||
content=response_text.strip(),
|
||||
agent_name=agent_name,
|
||||
)
|
||||
|
||||
# Handle tool calls - Sequential tool calling logic
|
||||
if tool_calls and execute_tool_fn:
|
||||
# Convert tool_calls to a serializable format for all providers
|
||||
@@ -2301,7 +2527,7 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
arguments = self._validate_and_filter_ollama_arguments(function_name, arguments, tools)
|
||||
|
||||
logging.debug(f"[TOOL_EXEC_DEBUG] About to execute tool {function_name} with args: {arguments}")
|
||||
tool_result = execute_tool_fn(function_name, arguments)
|
||||
tool_result = execute_tool_fn(function_name, arguments, tool_call_id=tool_call_id)
|
||||
logging.debug(f"[TOOL_EXEC_DEBUG] Tool execution result: {tool_result}")
|
||||
tool_results.append(tool_result) # Store the result
|
||||
accumulated_tool_results.append(tool_result) # Accumulate across iterations
|
||||
@@ -2947,6 +3173,15 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
logging.error(f"Non-recoverable stream iterator error: {iterator_error}")
|
||||
raise iterator_error
|
||||
|
||||
# Emit intermediate LLM narrative for display
|
||||
# (parity with non-streaming path above)
|
||||
if response_text and response_text.strip():
|
||||
_get_display_functions()['execute_sync_callback'](
|
||||
'llm_content',
|
||||
content=response_text.strip(),
|
||||
agent_name=agent_name,
|
||||
)
|
||||
|
||||
# After streaming completes, handle tool calls if present
|
||||
if tool_calls and execute_tool_fn:
|
||||
# Add assistant message with tool calls to conversation
|
||||
@@ -2969,8 +3204,8 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call, is_ollama)
|
||||
|
||||
try:
|
||||
# Execute the tool
|
||||
tool_result = execute_tool_fn(function_name, arguments)
|
||||
# Execute the tool (pass tool_call_id for event correlation)
|
||||
tool_result = execute_tool_fn(function_name, arguments, tool_call_id=tool_call_id)
|
||||
|
||||
# Add tool result to messages
|
||||
tool_message = self._create_tool_message(function_name, tool_result, tool_call_id, is_ollama)
|
||||
@@ -3185,6 +3420,104 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
reasoning_content = None
|
||||
tool_calls = []
|
||||
|
||||
# ── Responses API path (async) ──────────────────────────
|
||||
if self._supports_responses_api():
|
||||
responses_params = self._build_responses_params(
|
||||
messages=messages,
|
||||
tools=formatted_tools,
|
||||
temperature=temperature,
|
||||
output_json=output_json,
|
||||
output_pydantic=output_pydantic,
|
||||
**{k: v for k, v in kwargs.items() if k != 'reasoning_steps'},
|
||||
)
|
||||
current_time = time.time()
|
||||
|
||||
if stream:
|
||||
response_text, tool_calls = await self._stream_responses_api_async(
|
||||
responses_params,
|
||||
stream_callback=kwargs.get('stream_callback'),
|
||||
emit_events=kwargs.get('emit_events', False),
|
||||
)
|
||||
else:
|
||||
resp = await self._call_responses_api_async(**responses_params)
|
||||
response_text, tool_calls, _reasoning = self._extract_from_responses_output(resp)
|
||||
|
||||
# Build Chat-Completions-compatible final_response
|
||||
final_response = {
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": response_text,
|
||||
"tool_calls": tool_calls,
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
# Display / callback handling
|
||||
if verbose and not interaction_displayed:
|
||||
_get_display_functions()['display_interaction'](
|
||||
original_prompt, response_text,
|
||||
markdown=markdown,
|
||||
generation_time=time.time() - current_time,
|
||||
console=self.console,
|
||||
agent_name=agent_name, agent_role=agent_role,
|
||||
agent_tools=agent_tools, task_name=task_name,
|
||||
task_description=task_description, task_id=task_id,
|
||||
)
|
||||
interaction_displayed = True
|
||||
callback_executed = True
|
||||
|
||||
# Emit llm_content for narrative display
|
||||
if response_text and response_text.strip():
|
||||
_get_display_functions()['execute_sync_callback'](
|
||||
'llm_content',
|
||||
content=response_text.strip(),
|
||||
agent_name=agent_name,
|
||||
)
|
||||
|
||||
# ── Handle tool calls ────────────────────────────
|
||||
if tool_calls and execute_tool_fn:
|
||||
serializable_tool_calls = self._serialize_tool_calls(tool_calls)
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": response_text,
|
||||
"tool_calls": serializable_tool_calls,
|
||||
})
|
||||
|
||||
for tool_call in tool_calls:
|
||||
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call)
|
||||
logging.debug(f"[RESPONSES_API_ASYNC] Executing tool {function_name}")
|
||||
tool_result = execute_tool_fn(function_name, arguments, tool_call_id=tool_call_id)
|
||||
accumulated_tool_results.append(tool_result)
|
||||
|
||||
if verbose:
|
||||
display_message = f"Agent {agent_name} called function '{function_name}' with arguments: {arguments}\n"
|
||||
display_message += f"Function returned: {tool_result}" if tool_result else "Function returned no output"
|
||||
_get_display_functions()['display_tool_call'](display_message, console=self.console)
|
||||
|
||||
# Append tool result
|
||||
if tool_result is None:
|
||||
content = "Function returned an empty output"
|
||||
elif isinstance(tool_result, dict) and 'error' in tool_result:
|
||||
content = f"Error: {tool_result.get('error', 'Unknown error')}. Please inform the user."
|
||||
else:
|
||||
content = json.dumps(tool_result)
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"content": content,
|
||||
})
|
||||
|
||||
if iteration_count >= 5:
|
||||
final_response_text = response_text.strip() if response_text else "Task completed."
|
||||
break
|
||||
|
||||
iteration_count += 1
|
||||
continue
|
||||
else:
|
||||
final_response_text = response_text.strip() if response_text else ""
|
||||
break
|
||||
|
||||
# ── Chat Completions path (unchanged) ───────────────
|
||||
if reasoning_steps and iteration_count == 0:
|
||||
# Non-streaming call to capture reasoning
|
||||
resp = await litellm.acompletion(
|
||||
@@ -4057,6 +4390,501 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
return params
|
||||
|
||||
# ── Responses API support ───────────────────────────────────────────
|
||||
# These methods add a Responses API call path alongside the existing
|
||||
# Chat Completions path. Model detection gates which path is used.
|
||||
|
||||
def _supports_responses_api(self) -> bool:
|
||||
"""
|
||||
Check if the current model supports the OpenAI Responses API.
|
||||
|
||||
Returns True for OpenAI models that support the Responses API
|
||||
(gpt-4o, gpt-4-turbo, o1, o3, chatgpt-4o, etc.).
|
||||
Returns False for Anthropic, Gemini, Ollama, local, and all other models.
|
||||
"""
|
||||
if not self.model:
|
||||
return False
|
||||
model_lower = self.model.lower()
|
||||
|
||||
# Exclude non-OpenAI providers that happen to use similar model names
|
||||
if self._is_ollama_provider():
|
||||
return False
|
||||
if self._is_gemini_model():
|
||||
return False
|
||||
if self._is_anthropic_model():
|
||||
return False
|
||||
|
||||
# Explicit prefix check for OpenAI & Azure OpenAI
|
||||
# Note: Azure models often start with the deployment name, but LiteLLM
|
||||
# normalises them to "azure/<model>" – the prefixes below handle both.
|
||||
_openai_prefixes = (
|
||||
"gpt-4o", "gpt-4-turbo", "gpt-4.1", "gpt-4.5",
|
||||
"o1", "o3", "o4",
|
||||
"chatgpt-4o",
|
||||
"azure/gpt-4o", "azure/gpt-4-turbo", "azure/gpt-4.1",
|
||||
"azure/o1", "azure/o3", "azure/o4",
|
||||
)
|
||||
# Match on prefix (gpt-4o, gpt-4o-mini, gpt-4o-2024-08-06, etc.)
|
||||
for prefix in _openai_prefixes:
|
||||
if model_lower.startswith(prefix):
|
||||
return True
|
||||
|
||||
# Any model routed through openai/ prefix
|
||||
if model_lower.startswith("openai/"):
|
||||
remainder = model_lower[len("openai/"):]
|
||||
for prefix in _openai_prefixes:
|
||||
if remainder.startswith(prefix):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _build_responses_params(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
tools: Optional[List[Dict]] = None,
|
||||
temperature: Optional[float] = None,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Build parameters for ``litellm.responses()`` from the Chat Completions-
|
||||
style message list that PraisonAI already constructs.
|
||||
|
||||
Mapping:
|
||||
- system message → ``instructions``
|
||||
- remaining messages → ``input`` (list of role/content items)
|
||||
- tools → ``tools`` (Responses API uses same function schema)
|
||||
- temperature, max_tokens, etc. → direct pass-through
|
||||
"""
|
||||
params: Dict[str, Any] = {"model": self.model}
|
||||
|
||||
# ── Extract system instructions ─────────────────────────────────
|
||||
instructions = None
|
||||
input_items: List[Dict[str, Any]] = []
|
||||
for msg in messages:
|
||||
role = msg.get("role", "")
|
||||
if role in ("system", "developer"):
|
||||
# Accumulate system / developer messages as instructions
|
||||
content = msg.get("content", "")
|
||||
if instructions is None:
|
||||
instructions = content
|
||||
else:
|
||||
instructions += "\n" + content
|
||||
else:
|
||||
# user / assistant / tool messages become input items
|
||||
# Special handling for Chat Completions→Responses API format:
|
||||
if role == "assistant" and msg.get("tool_calls"):
|
||||
# Assistant message with tool_calls → emit text (if any)
|
||||
# then emit function_call items for each tool call
|
||||
content = msg.get("content")
|
||||
if content and content.strip():
|
||||
input_items.append({"role": "assistant", "content": content})
|
||||
for tc in msg["tool_calls"]:
|
||||
fn = tc.get("function", tc) if isinstance(tc, dict) else tc
|
||||
fn_name = fn.get("name", "") if isinstance(fn, dict) else getattr(fn, "name", "")
|
||||
fn_args = fn.get("arguments", "{}") if isinstance(fn, dict) else getattr(fn, "arguments", "{}")
|
||||
tc_id = tc.get("id", "") if isinstance(tc, dict) else getattr(tc, "id", "")
|
||||
input_items.append({
|
||||
"type": "function_call",
|
||||
"call_id": tc_id,
|
||||
"name": fn_name,
|
||||
"arguments": fn_args if isinstance(fn_args, str) else json.dumps(fn_args),
|
||||
})
|
||||
elif role == "tool":
|
||||
# Tool result → function_call_output
|
||||
input_items.append({
|
||||
"type": "function_call_output",
|
||||
"call_id": msg.get("tool_call_id", ""),
|
||||
"output": msg.get("content", ""),
|
||||
})
|
||||
else:
|
||||
input_items.append(msg)
|
||||
|
||||
if instructions:
|
||||
params["instructions"] = instructions
|
||||
params["input"] = input_items
|
||||
|
||||
# ── Tools ───────────────────────────────────────────────────────
|
||||
if tools:
|
||||
# Responses API tool format differs from Chat Completions:
|
||||
# Chat Completions: {"type": "function", "function": {"name": ..., "parameters": ...}}
|
||||
# Responses API: {"type": "function", "name": ..., "parameters": ...}
|
||||
# Transform Chat Completions format → Responses API format
|
||||
responses_tools = []
|
||||
for tool in tools:
|
||||
if isinstance(tool, dict) and "function" in tool:
|
||||
fn = tool["function"]
|
||||
responses_tool = {
|
||||
"type": "function",
|
||||
"name": fn.get("name", ""),
|
||||
}
|
||||
if "description" in fn:
|
||||
responses_tool["description"] = fn["description"]
|
||||
if "parameters" in fn:
|
||||
responses_tool["parameters"] = fn["parameters"]
|
||||
if fn.get("strict") is not None:
|
||||
responses_tool["strict"] = fn["strict"]
|
||||
responses_tools.append(responses_tool)
|
||||
else:
|
||||
# Already in Responses API format or unknown — pass through
|
||||
responses_tools.append(tool)
|
||||
params["tools"] = responses_tools
|
||||
if "tool_choice" not in kwargs:
|
||||
params["tool_choice"] = "auto"
|
||||
|
||||
# ── Scalar params ───────────────────────────────────────────────
|
||||
if temperature is not None:
|
||||
params["temperature"] = temperature
|
||||
if self.max_tokens:
|
||||
params["max_output_tokens"] = self.max_tokens
|
||||
if self.top_p:
|
||||
params["top_p"] = self.top_p
|
||||
if self.base_url:
|
||||
params["base_url"] = self.base_url
|
||||
if self.api_key:
|
||||
params["api_key"] = self.api_key
|
||||
if self.timeout:
|
||||
params["timeout"] = self.timeout
|
||||
|
||||
# ── Structured output ───────────────────────────────────────────
|
||||
output_json = kwargs.pop("output_json", None)
|
||||
output_pydantic = kwargs.pop("output_pydantic", None)
|
||||
schema_model = output_json or output_pydantic
|
||||
if schema_model:
|
||||
from .model_capabilities import supports_structured_outputs
|
||||
if supports_structured_outputs(self.model):
|
||||
if hasattr(schema_model, 'model_json_schema'):
|
||||
schema = schema_model.model_json_schema()
|
||||
params['text'] = {
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"name": getattr(schema_model, '__name__', 'response'),
|
||||
"schema": schema,
|
||||
"strict": True,
|
||||
}
|
||||
}
|
||||
|
||||
# ── Pass through remaining kwargs (stream, etc.) ────────────────
|
||||
# Filter out internal PraisonAI params that are not Responses API params
|
||||
_internal = {
|
||||
'output_json', 'output_pydantic',
|
||||
'reflection', 'self_reflect', 'max_reflect', 'min_reflect', 'reflect_llm',
|
||||
'verbose', 'markdown',
|
||||
'agent_name', 'agent_role', 'agent_tools',
|
||||
'task_name', 'task_description', 'task_id',
|
||||
'execute_tool_fn', 'stream_callback', 'emit_events',
|
||||
'console', 'reasoning_steps',
|
||||
}
|
||||
for k, v in kwargs.items():
|
||||
if k not in _internal and v is not None:
|
||||
params[k] = v
|
||||
|
||||
return params
|
||||
|
||||
def _call_responses_api(self, **params) -> Any:
|
||||
"""
|
||||
Call ``litellm.responses()`` synchronously with retry support.
|
||||
Returns a ``ResponsesAPIResponse`` object.
|
||||
"""
|
||||
import litellm
|
||||
return self._call_with_retry(litellm.responses, **params)
|
||||
|
||||
async def _call_responses_api_async(self, **params) -> Any:
|
||||
"""
|
||||
Call ``litellm.aresponses()`` asynchronously with retry support.
|
||||
Returns a ``ResponsesAPIResponse`` object.
|
||||
"""
|
||||
import litellm
|
||||
return await self._call_with_retry_async(litellm.aresponses, **params)
|
||||
|
||||
def _extract_from_responses_output(self, response) -> tuple:
|
||||
"""
|
||||
Parse a ``ResponsesAPIResponse`` into the same (text, tool_calls,
|
||||
reasoning_content) tuple that the Chat Completions path produces.
|
||||
|
||||
The Responses API returns an ``output`` list where:
|
||||
- items with type="message" contain text in content[].text
|
||||
- items with type="function_call" contain tool call info
|
||||
Unlike Chat Completions, text and tool calls are *always* separate
|
||||
items, so ``content`` is never null when text is present.
|
||||
"""
|
||||
response_text = ""
|
||||
tool_calls: List[Dict[str, Any]] = []
|
||||
reasoning_content = None
|
||||
|
||||
output_items = getattr(response, 'output', None) or []
|
||||
for item in output_items:
|
||||
# Handle both dict and object access
|
||||
if isinstance(item, dict):
|
||||
item_type = item.get("type", "")
|
||||
else:
|
||||
item_type = getattr(item, "type", "")
|
||||
|
||||
if item_type == "message":
|
||||
# Extract text from content blocks
|
||||
if isinstance(item, dict):
|
||||
content_list = item.get("content", [])
|
||||
else:
|
||||
content_list = getattr(item, "content", [])
|
||||
for content_block in content_list:
|
||||
if isinstance(content_block, dict):
|
||||
block_type = content_block.get("type", "")
|
||||
block_text = content_block.get("text", "") or ""
|
||||
else:
|
||||
block_type = getattr(content_block, "type", "")
|
||||
block_text = getattr(content_block, "text", "") or ""
|
||||
|
||||
if block_type == "output_text":
|
||||
response_text += block_text
|
||||
elif block_type == "reasoning":
|
||||
reasoning_content = (reasoning_content or "") + block_text
|
||||
|
||||
elif item_type == "function_call":
|
||||
# Extract tool call information
|
||||
if isinstance(item, dict):
|
||||
call_id = item.get("call_id", item.get("id", f"tool_{len(tool_calls)}"))
|
||||
fn_name = item.get("name", "")
|
||||
fn_args = item.get("arguments", "{}")
|
||||
else:
|
||||
call_id = getattr(item, "call_id", None) or getattr(item, "id", f"tool_{len(tool_calls)}")
|
||||
fn_name = getattr(item, "name", "")
|
||||
fn_args = getattr(item, "arguments", "{}")
|
||||
|
||||
tool_calls.append({
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": fn_name,
|
||||
"arguments": fn_args if isinstance(fn_args, str) else json.dumps(fn_args),
|
||||
}
|
||||
})
|
||||
|
||||
elif item_type == "reasoning":
|
||||
# Some models return top-level reasoning items
|
||||
if isinstance(item, dict):
|
||||
reasoning_text = item.get("summary", "") or item.get("text", "")
|
||||
else:
|
||||
reasoning_text = getattr(item, "summary", "") or getattr(item, "text", "")
|
||||
if reasoning_text:
|
||||
reasoning_content = (reasoning_content or "") + str(reasoning_text)
|
||||
|
||||
return response_text, tool_calls if tool_calls else None, reasoning_content
|
||||
|
||||
def _stream_responses_api(
|
||||
self,
|
||||
params: Dict[str, Any],
|
||||
verbose: bool = True,
|
||||
stream_callback: Optional[Callable] = None,
|
||||
emit_events: bool = False,
|
||||
current_time: Optional[float] = None,
|
||||
) -> tuple:
|
||||
"""
|
||||
Stream a Responses API call, accumulating text and tool calls.
|
||||
|
||||
Returns the same (response_text, tool_calls) tuple as the Chat
|
||||
Completions streaming path. Emits StreamEvents when configured.
|
||||
"""
|
||||
import litellm
|
||||
|
||||
params["stream"] = True
|
||||
response_text = ""
|
||||
# tool_calls accumulator: {output_index: {"id":..., "name":..., "arguments":...}}
|
||||
_pending_tools: Dict[int, Dict[str, Any]] = {}
|
||||
_emit = emit_events and stream_callback is not None
|
||||
|
||||
if _emit:
|
||||
from ..streaming.events import StreamEvent, StreamEventType
|
||||
|
||||
for event in litellm.responses(**params):
|
||||
# Access event type — handle both object and dict
|
||||
if isinstance(event, dict):
|
||||
evt_type = event.get("type", "")
|
||||
else:
|
||||
evt_type = getattr(event, "type", "")
|
||||
|
||||
# ── Text delta ──────────────────────────────────────────
|
||||
if evt_type == "response.output_text.delta":
|
||||
delta_text = (event.get("delta", "") if isinstance(event, dict)
|
||||
else getattr(event, "delta", ""))
|
||||
response_text += delta_text
|
||||
if _emit and delta_text:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
timestamp=time.perf_counter(),
|
||||
content=delta_text,
|
||||
is_reasoning=False,
|
||||
))
|
||||
|
||||
# ── Reasoning delta ─────────────────────────────────────
|
||||
elif evt_type == "response.reasoning_summary_text.delta":
|
||||
delta_text = (event.get("delta", "") if isinstance(event, dict)
|
||||
else getattr(event, "delta", ""))
|
||||
if _emit and delta_text:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
timestamp=time.perf_counter(),
|
||||
content=delta_text,
|
||||
is_reasoning=True,
|
||||
))
|
||||
|
||||
# ── Function call arguments delta ───────────────────────
|
||||
elif evt_type == "response.function_call_arguments.delta":
|
||||
idx = (event.get("output_index", 0) if isinstance(event, dict)
|
||||
else getattr(event, "output_index", 0))
|
||||
delta_args = (event.get("delta", "") if isinstance(event, dict)
|
||||
else getattr(event, "delta", ""))
|
||||
if idx not in _pending_tools:
|
||||
item_id = (event.get("item_id", f"tool_{idx}") if isinstance(event, dict)
|
||||
else getattr(event, "item_id", f"tool_{idx}"))
|
||||
_pending_tools[idx] = {
|
||||
"id": item_id,
|
||||
"name": "",
|
||||
"arguments": "",
|
||||
}
|
||||
_pending_tools[idx]["arguments"] += delta_args
|
||||
|
||||
# ── Output item done — finalise tool call metadata ──────
|
||||
elif evt_type == "response.output_item.done":
|
||||
if isinstance(event, dict):
|
||||
item = event.get("item", {})
|
||||
idx = event.get("output_index", 0)
|
||||
else:
|
||||
item = getattr(event, "item", None) or {}
|
||||
idx = getattr(event, "output_index", 0)
|
||||
|
||||
if isinstance(item, dict):
|
||||
i_type = item.get("type", "")
|
||||
else:
|
||||
i_type = getattr(item, "type", "")
|
||||
|
||||
if i_type == "function_call":
|
||||
if isinstance(item, dict):
|
||||
call_id = item.get("call_id", item.get("id", f"tool_{idx}"))
|
||||
fn_name = item.get("name", "")
|
||||
fn_args = item.get("arguments", "")
|
||||
else:
|
||||
call_id = getattr(item, "call_id", None) or getattr(item, "id", f"tool_{idx}")
|
||||
fn_name = getattr(item, "name", "")
|
||||
fn_args = getattr(item, "arguments", "")
|
||||
|
||||
if idx in _pending_tools:
|
||||
_pending_tools[idx]["id"] = call_id
|
||||
_pending_tools[idx]["name"] = fn_name
|
||||
# Prefer the accumulated delta args if available, else use final
|
||||
if not _pending_tools[idx]["arguments"]:
|
||||
_pending_tools[idx]["arguments"] = fn_args
|
||||
else:
|
||||
_pending_tools[idx] = {
|
||||
"id": call_id,
|
||||
"name": fn_name,
|
||||
"arguments": fn_args,
|
||||
}
|
||||
|
||||
# ── Build tool_calls list in output_index order ─────────────
|
||||
tool_calls: List[Dict[str, Any]] = []
|
||||
for idx in sorted(_pending_tools.keys()):
|
||||
tc = _pending_tools[idx]
|
||||
tool_calls.append({
|
||||
"id": tc["id"],
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc["name"],
|
||||
"arguments": tc["arguments"],
|
||||
},
|
||||
})
|
||||
|
||||
return response_text, tool_calls if tool_calls else None
|
||||
|
||||
async def _stream_responses_api_async(
|
||||
self,
|
||||
params: Dict[str, Any],
|
||||
stream_callback: Optional[Callable] = None,
|
||||
emit_events: bool = False,
|
||||
) -> tuple:
|
||||
"""
|
||||
Async streaming for Responses API.
|
||||
Returns (response_text, tool_calls).
|
||||
"""
|
||||
import litellm
|
||||
|
||||
params["stream"] = True
|
||||
response_text = ""
|
||||
_pending_tools: Dict[int, Dict[str, Any]] = {}
|
||||
_emit = emit_events and stream_callback is not None
|
||||
|
||||
if _emit:
|
||||
from ..streaming.events import StreamEvent, StreamEventType
|
||||
|
||||
async for event in await litellm.aresponses(**params):
|
||||
if isinstance(event, dict):
|
||||
evt_type = event.get("type", "")
|
||||
else:
|
||||
evt_type = getattr(event, "type", "")
|
||||
|
||||
if evt_type == "response.output_text.delta":
|
||||
delta_text = (event.get("delta", "") if isinstance(event, dict)
|
||||
else getattr(event, "delta", ""))
|
||||
response_text += delta_text
|
||||
if _emit and delta_text:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
timestamp=time.perf_counter(),
|
||||
content=delta_text,
|
||||
is_reasoning=False,
|
||||
))
|
||||
|
||||
elif evt_type == "response.function_call_arguments.delta":
|
||||
idx = (event.get("output_index", 0) if isinstance(event, dict)
|
||||
else getattr(event, "output_index", 0))
|
||||
delta_args = (event.get("delta", "") if isinstance(event, dict)
|
||||
else getattr(event, "delta", ""))
|
||||
if idx not in _pending_tools:
|
||||
item_id = (event.get("item_id", f"tool_{idx}") if isinstance(event, dict)
|
||||
else getattr(event, "item_id", f"tool_{idx}"))
|
||||
_pending_tools[idx] = {"id": item_id, "name": "", "arguments": ""}
|
||||
_pending_tools[idx]["arguments"] += delta_args
|
||||
|
||||
elif evt_type == "response.output_item.done":
|
||||
if isinstance(event, dict):
|
||||
item = event.get("item", {})
|
||||
idx = event.get("output_index", 0)
|
||||
else:
|
||||
item = getattr(event, "item", None) or {}
|
||||
idx = getattr(event, "output_index", 0)
|
||||
|
||||
if isinstance(item, dict):
|
||||
i_type = item.get("type", "")
|
||||
else:
|
||||
i_type = getattr(item, "type", "")
|
||||
|
||||
if i_type == "function_call":
|
||||
if isinstance(item, dict):
|
||||
call_id = item.get("call_id", item.get("id", f"tool_{idx}"))
|
||||
fn_name = item.get("name", "")
|
||||
fn_args = item.get("arguments", "")
|
||||
else:
|
||||
call_id = getattr(item, "call_id", None) or getattr(item, "id", f"tool_{idx}")
|
||||
fn_name = getattr(item, "name", "")
|
||||
fn_args = getattr(item, "arguments", "")
|
||||
|
||||
if idx in _pending_tools:
|
||||
_pending_tools[idx]["id"] = call_id
|
||||
_pending_tools[idx]["name"] = fn_name
|
||||
if not _pending_tools[idx]["arguments"]:
|
||||
_pending_tools[idx]["arguments"] = fn_args
|
||||
else:
|
||||
_pending_tools[idx] = {"id": call_id, "name": fn_name, "arguments": fn_args}
|
||||
|
||||
tool_calls: List[Dict[str, Any]] = []
|
||||
for idx in sorted(_pending_tools.keys()):
|
||||
tc = _pending_tools[idx]
|
||||
tool_calls.append({
|
||||
"id": tc["id"],
|
||||
"type": "function",
|
||||
"function": {"name": tc["name"], "arguments": tc["arguments"]},
|
||||
})
|
||||
|
||||
return response_text, tool_calls if tool_calls else None
|
||||
|
||||
def _prepare_response_logging(self, temperature: float, stream: bool, verbose: bool, markdown: bool, **kwargs) -> Optional[Dict[str, Any]]:
|
||||
"""Prepare debug logging information for response methods"""
|
||||
if logging.getLogger().getEffectiveLevel() == logging.DEBUG:
|
||||
|
||||
@@ -539,6 +539,229 @@ class OpenAIClient:
|
||||
|
||||
return result
|
||||
|
||||
# ─── Responses API helpers ──────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _supports_responses_api(model: str) -> bool:
|
||||
"""
|
||||
Returns True for OpenAI models that support the Responses API.
|
||||
Mirrors LLM._supports_responses_api but works as a static method
|
||||
on a plain model string.
|
||||
"""
|
||||
if not model:
|
||||
return False
|
||||
model_lower = model.lower()
|
||||
|
||||
# Exclude non-OpenAI providers
|
||||
_non_openai = (
|
||||
"ollama/", "ollama_chat/", "gemini/", "vertex_ai/",
|
||||
"anthropic/", "claude", "deepseek/",
|
||||
)
|
||||
for prefix in _non_openai:
|
||||
if model_lower.startswith(prefix):
|
||||
return False
|
||||
|
||||
_openai_prefixes = (
|
||||
"gpt-4o", "gpt-4-turbo", "gpt-4.1", "gpt-4.5",
|
||||
"gpt-5",
|
||||
"o1", "o3", "o4",
|
||||
"chatgpt-4o",
|
||||
"azure/gpt-4o", "azure/gpt-4-turbo", "azure/gpt-4.1",
|
||||
"azure/gpt-5",
|
||||
"azure/o1", "azure/o3", "azure/o4",
|
||||
)
|
||||
for prefix in _openai_prefixes:
|
||||
if model_lower.startswith(prefix):
|
||||
return True
|
||||
|
||||
# openai/ prefix
|
||||
if model_lower.startswith("openai/"):
|
||||
remainder = model_lower[len("openai/"):]
|
||||
for prefix in _openai_prefixes:
|
||||
if remainder.startswith(prefix):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _use_responses_api(self, model: str) -> bool:
|
||||
"""
|
||||
Returns True if Responses API should be used for this call.
|
||||
Only returns True when:
|
||||
1. The model supports Responses API (OpenAI model)
|
||||
2. No custom base_url is set (not a local server)
|
||||
3. The openai SDK has the 'responses' attribute
|
||||
"""
|
||||
if self.base_url:
|
||||
return False
|
||||
if not self._supports_responses_api(model):
|
||||
return False
|
||||
try:
|
||||
return hasattr(self.sync_client, 'responses')
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _build_responses_input(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
model: str,
|
||||
temperature: float = 1.0,
|
||||
tools: Optional[List[Dict]] = None,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Transform Chat Completions-style messages and tools into
|
||||
Responses API parameters.
|
||||
|
||||
Mapping:
|
||||
- system message → ``instructions``
|
||||
- remaining msgs → ``input`` (list of role/content items)
|
||||
- tools are flattened from Chat Completions nested format
|
||||
"""
|
||||
params: Dict[str, Any] = {"model": model}
|
||||
|
||||
# ── Extract system instructions ──────────────────────────────────
|
||||
instructions = None
|
||||
input_items: List[Dict[str, Any]] = []
|
||||
for msg in messages:
|
||||
role = msg.get("role", "")
|
||||
if role in ("system", "developer"):
|
||||
content = msg.get("content", "")
|
||||
if instructions is None:
|
||||
instructions = content
|
||||
else:
|
||||
instructions += "\n" + content
|
||||
else:
|
||||
# Handle Chat Completions → Responses API format transforms
|
||||
if role == "assistant" and msg.get("tool_calls"):
|
||||
content = msg.get("content")
|
||||
if content and content.strip():
|
||||
input_items.append({"role": "assistant", "content": content})
|
||||
for tc in msg["tool_calls"]:
|
||||
fn = tc.get("function", tc) if isinstance(tc, dict) else tc
|
||||
fn_name = fn.get("name", "") if isinstance(fn, dict) else getattr(fn, "name", "")
|
||||
fn_args = fn.get("arguments", "{}") if isinstance(fn, dict) else getattr(fn, "arguments", "{}")
|
||||
tc_id = tc.get("id", "") if isinstance(tc, dict) else getattr(tc, "id", "")
|
||||
# Skip items with empty name — API rejects them
|
||||
if not fn_name:
|
||||
continue
|
||||
input_items.append({
|
||||
"type": "function_call",
|
||||
"call_id": tc_id,
|
||||
"name": fn_name,
|
||||
"arguments": fn_args if isinstance(fn_args, str) else json.dumps(fn_args),
|
||||
})
|
||||
elif role == "tool":
|
||||
input_items.append({
|
||||
"type": "function_call_output",
|
||||
"call_id": msg.get("tool_call_id", ""),
|
||||
"output": msg.get("content", ""),
|
||||
})
|
||||
else:
|
||||
input_items.append(msg)
|
||||
|
||||
if instructions:
|
||||
params["instructions"] = instructions
|
||||
params["input"] = input_items
|
||||
|
||||
# ── Transform tools to Responses API format ──────────────────────
|
||||
if tools:
|
||||
responses_tools = []
|
||||
for tool in tools:
|
||||
if isinstance(tool, dict) and "function" in tool:
|
||||
fn = tool["function"]
|
||||
responses_tool = {
|
||||
"type": "function",
|
||||
"name": fn.get("name", ""),
|
||||
}
|
||||
if "description" in fn:
|
||||
responses_tool["description"] = fn["description"]
|
||||
if "parameters" in fn:
|
||||
responses_tool["parameters"] = fn["parameters"]
|
||||
if fn.get("strict") is not None:
|
||||
responses_tool["strict"] = fn["strict"]
|
||||
responses_tools.append(responses_tool)
|
||||
else:
|
||||
responses_tools.append(tool)
|
||||
params["tools"] = responses_tools
|
||||
params["tool_choice"] = kwargs.pop("tool_choice", "auto")
|
||||
|
||||
# ── Scalar params ────────────────────────────────────────────────
|
||||
if temperature is not None:
|
||||
params["temperature"] = temperature
|
||||
|
||||
return params
|
||||
|
||||
def _responses_to_chat_completion(self, response) -> ChatCompletion:
|
||||
"""
|
||||
Wrap a Responses API response into a ChatCompletion dataclass
|
||||
so that all downstream code (tool loop, display, callbacks)
|
||||
works without modification.
|
||||
"""
|
||||
response_text = ""
|
||||
tool_calls_list: List[ToolCall] = []
|
||||
|
||||
output_items = getattr(response, 'output', None) or []
|
||||
for item in output_items:
|
||||
item_type = getattr(item, "type", "") if not isinstance(item, dict) else item.get("type", "")
|
||||
|
||||
if item_type == "message":
|
||||
content_list = getattr(item, "content", []) if not isinstance(item, dict) else item.get("content", [])
|
||||
for content_block in content_list:
|
||||
if isinstance(content_block, dict):
|
||||
block_type = content_block.get("type", "")
|
||||
block_text = content_block.get("text", "") or ""
|
||||
else:
|
||||
block_type = getattr(content_block, "type", "")
|
||||
block_text = getattr(content_block, "text", "") or ""
|
||||
if block_type == "output_text":
|
||||
response_text += block_text
|
||||
|
||||
elif item_type == "function_call":
|
||||
if isinstance(item, dict):
|
||||
call_id = item.get("call_id", item.get("id", f"tool_{len(tool_calls_list)}"))
|
||||
fn_name = item.get("name", "")
|
||||
fn_args = item.get("arguments", "{}")
|
||||
else:
|
||||
call_id = getattr(item, "call_id", None) or getattr(item, "id", f"tool_{len(tool_calls_list)}")
|
||||
fn_name = getattr(item, "name", "")
|
||||
fn_args = getattr(item, "arguments", "{}")
|
||||
tool_calls_list.append(ToolCall(
|
||||
id=call_id,
|
||||
type="function",
|
||||
function={
|
||||
"name": fn_name,
|
||||
"arguments": fn_args if isinstance(fn_args, str) else json.dumps(fn_args),
|
||||
}
|
||||
))
|
||||
|
||||
# Build usage
|
||||
raw_usage = getattr(response, 'usage', None)
|
||||
usage = None
|
||||
if raw_usage:
|
||||
usage = CompletionUsage(
|
||||
prompt_tokens=getattr(raw_usage, 'input_tokens', 0),
|
||||
completion_tokens=getattr(raw_usage, 'output_tokens', 0),
|
||||
total_tokens=getattr(raw_usage, 'total_tokens', 0),
|
||||
)
|
||||
|
||||
message = ChatCompletionMessage(
|
||||
content=response_text if response_text else None,
|
||||
role="assistant",
|
||||
tool_calls=tool_calls_list if tool_calls_list else None,
|
||||
)
|
||||
choice = Choice(
|
||||
finish_reason="tool_calls" if tool_calls_list else "stop",
|
||||
index=0,
|
||||
message=message,
|
||||
)
|
||||
return ChatCompletion(
|
||||
id=getattr(response, 'id', 'resp_unknown'),
|
||||
choices=[choice],
|
||||
created=int(time.time()),
|
||||
model=getattr(response, 'model', ''),
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
def _generate_tool_definition(self, func: Callable) -> Optional[Dict]:
|
||||
"""Generate a tool definition from a callable function."""
|
||||
try:
|
||||
@@ -675,6 +898,44 @@ class OpenAIClient:
|
||||
metadata={"model": model, "message_count": len(messages)}
|
||||
))
|
||||
|
||||
# ── Responses API path ───────────────────────────────────────
|
||||
if self._use_responses_api(model):
|
||||
try:
|
||||
resp_params = self._build_responses_input(
|
||||
messages, model, temperature, tools, **kwargs
|
||||
)
|
||||
raw = self.sync_client.responses.create(**resp_params)
|
||||
final_response = self._responses_to_chat_completion(raw)
|
||||
|
||||
# Display the response text if display_fn provided
|
||||
response_text = final_response.choices[0].message.content or ""
|
||||
if display_fn and response_text:
|
||||
from rich.markdown import Markdown
|
||||
console.print(Markdown(response_text))
|
||||
|
||||
# Emit stream events for compatibility
|
||||
if _emit:
|
||||
now = time.perf_counter()
|
||||
if response_text:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.FIRST_TOKEN,
|
||||
timestamp=now, content=response_text[:50]
|
||||
))
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.LAST_TOKEN,
|
||||
timestamp=now
|
||||
))
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.STREAM_END,
|
||||
timestamp=now, metadata={"responses_api": True}
|
||||
))
|
||||
|
||||
return final_response
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Responses API streaming failed, falling back: {e}")
|
||||
# Fall through to Chat Completions streaming
|
||||
|
||||
# ── Chat Completions streaming path ─────────────────────────
|
||||
# Create the response stream
|
||||
response_stream = self.sync_client.chat.completions.create(
|
||||
model=model,
|
||||
@@ -897,6 +1158,49 @@ class OpenAIClient:
|
||||
else:
|
||||
stream_callback(event)
|
||||
|
||||
# ── Responses API path ───────────────────────────────────────
|
||||
if self._use_responses_api(model):
|
||||
try:
|
||||
resp_params = self._build_responses_input(
|
||||
messages, model, temperature, tools, **kwargs
|
||||
)
|
||||
raw = await self.async_client.responses.create(**resp_params)
|
||||
final_response = self._responses_to_chat_completion(raw)
|
||||
|
||||
# Display the response text if display_fn provided
|
||||
response_text = final_response.choices[0].message.content or ""
|
||||
if display_fn and response_text:
|
||||
from rich.markdown import Markdown
|
||||
console.print(Markdown(response_text))
|
||||
|
||||
# Emit stream events for compatibility
|
||||
if _emit:
|
||||
now = time.perf_counter()
|
||||
async def _emit_event(evt):
|
||||
if asyncio.iscoroutinefunction(stream_callback):
|
||||
await stream_callback(evt)
|
||||
else:
|
||||
stream_callback(evt)
|
||||
if response_text:
|
||||
await _emit_event(StreamEvent(
|
||||
type=StreamEventType.FIRST_TOKEN,
|
||||
timestamp=now, content=response_text[:50]
|
||||
))
|
||||
await _emit_event(StreamEvent(
|
||||
type=StreamEventType.LAST_TOKEN,
|
||||
timestamp=now
|
||||
))
|
||||
await _emit_event(StreamEvent(
|
||||
type=StreamEventType.STREAM_END,
|
||||
timestamp=now, metadata={"responses_api": True}
|
||||
))
|
||||
|
||||
return final_response
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Responses API async streaming failed, falling back: {e}")
|
||||
# Fall through to Chat Completions streaming
|
||||
|
||||
# ── Chat Completions streaming path ─────────────────────────
|
||||
# Create the response stream
|
||||
response_stream = await self.async_client.chat.completions.create(
|
||||
model=model,
|
||||
@@ -1078,6 +1382,20 @@ class OpenAIClient:
|
||||
Returns:
|
||||
ChatCompletion object or stream iterator
|
||||
"""
|
||||
# ── Responses API path (non-streaming only) ────────────────────
|
||||
if not stream and self._use_responses_api(model):
|
||||
try:
|
||||
resp_params = self._build_responses_input(
|
||||
messages, model, temperature, tools,
|
||||
tool_choice=tool_choice, **kwargs
|
||||
)
|
||||
raw = self.sync_client.responses.create(**resp_params)
|
||||
return self._responses_to_chat_completion(raw)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Responses API failed, falling back to Chat Completions: {e}")
|
||||
# Fall through to Chat Completions
|
||||
|
||||
# ── Chat Completions path ─────────────────────────────────────
|
||||
params = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
@@ -1123,6 +1441,20 @@ class OpenAIClient:
|
||||
Returns:
|
||||
ChatCompletion object or async stream iterator
|
||||
"""
|
||||
# ── Responses API path (non-streaming only) ────────────────────
|
||||
if not stream and self._use_responses_api(model):
|
||||
try:
|
||||
resp_params = self._build_responses_input(
|
||||
messages, model, temperature, tools,
|
||||
tool_choice=tool_choice, **kwargs
|
||||
)
|
||||
raw = await self.async_client.responses.create(**resp_params)
|
||||
return self._responses_to_chat_completion(raw)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Responses API failed, falling back to Chat Completions: {e}")
|
||||
# Fall through to Chat Completions
|
||||
|
||||
# ── Chat Completions path ─────────────────────────────────────
|
||||
params = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
@@ -1156,6 +1488,8 @@ class OpenAIClient:
|
||||
reasoning_steps: bool = False,
|
||||
verbose: bool = True,
|
||||
max_iterations: int = 10,
|
||||
stream_callback: Optional[Callable] = None,
|
||||
emit_events: bool = False,
|
||||
**kwargs
|
||||
) -> Optional[ChatCompletion]:
|
||||
"""
|
||||
@@ -1179,6 +1513,8 @@ class OpenAIClient:
|
||||
reasoning_steps: Whether to show reasoning
|
||||
verbose: Whether to show verbose output
|
||||
max_iterations: Maximum tool calling iterations
|
||||
stream_callback: Optional callback for StreamEvent emission
|
||||
emit_events: Whether to emit StreamEvents (requires stream_callback)
|
||||
**kwargs: Additional API parameters
|
||||
|
||||
Returns:
|
||||
@@ -1208,6 +1544,8 @@ class OpenAIClient:
|
||||
console=console,
|
||||
display_fn=display_fn,
|
||||
reasoning_steps=reasoning_steps,
|
||||
stream_callback=stream_callback,
|
||||
emit_events=emit_events,
|
||||
**kwargs
|
||||
)
|
||||
else:
|
||||
@@ -1302,6 +1640,19 @@ class OpenAIClient:
|
||||
# Check for tool calls
|
||||
tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None)
|
||||
|
||||
# Emit llm_content for intermediate narrative display
|
||||
# (gpt-4.1+ models produce text alongside tool calls)
|
||||
response_content = getattr(final_response.choices[0].message, 'content', None)
|
||||
if response_content and response_content.strip() and tool_calls:
|
||||
try:
|
||||
execute_sync_callback(
|
||||
'llm_content',
|
||||
content=response_content.strip(),
|
||||
agent_name=None,
|
||||
)
|
||||
except Exception:
|
||||
pass # Narrative display is optional
|
||||
|
||||
if tool_calls and execute_tool_fn:
|
||||
# Convert ToolCall dataclass objects to dict for JSON serialization
|
||||
serializable_tool_calls = []
|
||||
@@ -1335,8 +1686,9 @@ class OpenAIClient:
|
||||
# Always trigger callback for tool call tracking (even when verbose=False)
|
||||
display_tool_call_fn = _get_display_tool_call()
|
||||
|
||||
# Execute the tool
|
||||
tool_result = execute_tool_fn(function_name, arguments)
|
||||
# Execute the tool (pass tool_call_id for event correlation)
|
||||
_tool_call_id = tool_call.id if hasattr(tool_call, 'id') else tool_call.get('id')
|
||||
tool_result = execute_tool_fn(function_name, arguments, tool_call_id=_tool_call_id)
|
||||
results_str = json.dumps(tool_result) if tool_result else "Function returned an empty output"
|
||||
|
||||
# Trigger callback with structured parameters for status output
|
||||
@@ -1377,6 +1729,8 @@ class OpenAIClient:
|
||||
reasoning_steps: bool = False,
|
||||
verbose: bool = True,
|
||||
max_iterations: int = 10,
|
||||
stream_callback: Optional[Callable] = None,
|
||||
emit_events: bool = False,
|
||||
**kwargs
|
||||
) -> Optional[ChatCompletion]:
|
||||
"""
|
||||
@@ -1394,6 +1748,8 @@ class OpenAIClient:
|
||||
reasoning_steps: Whether to show reasoning
|
||||
verbose: Whether to show verbose output
|
||||
max_iterations: Maximum tool calling iterations
|
||||
stream_callback: Optional callback for StreamEvent emission (can be sync or async)
|
||||
emit_events: Whether to emit StreamEvents (requires stream_callback)
|
||||
**kwargs: Additional API parameters
|
||||
|
||||
Returns:
|
||||
@@ -1419,6 +1775,8 @@ class OpenAIClient:
|
||||
console=console,
|
||||
display_fn=display_fn,
|
||||
reasoning_steps=reasoning_steps,
|
||||
stream_callback=stream_callback,
|
||||
emit_events=emit_events,
|
||||
**kwargs
|
||||
)
|
||||
else:
|
||||
@@ -1480,6 +1838,20 @@ class OpenAIClient:
|
||||
# Check for tool calls
|
||||
tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None)
|
||||
|
||||
# Emit llm_content for intermediate narrative display
|
||||
# (gpt-4.1+ models produce text alongside tool calls)
|
||||
response_content = getattr(final_response.choices[0].message, 'content', None)
|
||||
if response_content and response_content.strip() and tool_calls:
|
||||
try:
|
||||
from ..main import execute_sync_callback as _esc
|
||||
_esc(
|
||||
'llm_content',
|
||||
content=response_content.strip(),
|
||||
agent_name=None,
|
||||
)
|
||||
except Exception:
|
||||
pass # Narrative display is optional
|
||||
|
||||
if tool_calls and execute_tool_fn:
|
||||
# Convert ToolCall dataclass objects to dict for JSON serialization
|
||||
serializable_tool_calls = []
|
||||
@@ -1517,15 +1889,16 @@ class OpenAIClient:
|
||||
if verbose and console:
|
||||
console.print(f"[dim]Arguments:[/dim] {arguments}")
|
||||
|
||||
# Execute the tool (async)
|
||||
# Execute the tool (async) - pass tool_call_id for event correlation
|
||||
_tool_call_id = tool_call.id if hasattr(tool_call, 'id') else tool_call.get('id')
|
||||
if asyncio.iscoroutinefunction(execute_tool_fn):
|
||||
tool_result = await execute_tool_fn(function_name, arguments)
|
||||
tool_result = await execute_tool_fn(function_name, arguments, tool_call_id=_tool_call_id)
|
||||
else:
|
||||
# Run sync function in executor
|
||||
loop = asyncio.get_event_loop()
|
||||
tool_result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: execute_tool_fn(function_name, arguments)
|
||||
lambda fn=function_name, args=arguments, tcid=_tool_call_id: execute_tool_fn(fn, args, tool_call_id=tcid)
|
||||
)
|
||||
|
||||
results_str = json.dumps(tool_result) if tool_result else "Function returned an empty output"
|
||||
@@ -1706,9 +2079,10 @@ class OpenAIClient:
|
||||
if verbose:
|
||||
yield f"\n[Calling function: {function_name}]"
|
||||
|
||||
# Execute the tool with error handling
|
||||
# Execute the tool with error handling (pass tool_call_id for event correlation)
|
||||
_tool_call_id = tool_call.id if hasattr(tool_call, 'id') else tool_call.get('id')
|
||||
try:
|
||||
tool_result = execute_tool_fn(function_name, arguments)
|
||||
tool_result = execute_tool_fn(function_name, arguments, tool_call_id=_tool_call_id)
|
||||
results_str = json.dumps(tool_result) if tool_result else "Function returned an empty output"
|
||||
except Exception as e:
|
||||
results_str = f"Error executing function: {str(e)}"
|
||||
|
||||
@@ -4,14 +4,17 @@ import json
|
||||
import logging
|
||||
from typing import List, Optional, Dict, Any, Union, Literal, Type
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from rich import print
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
from rich.markdown import Markdown
|
||||
from rich.live import Live
|
||||
import asyncio
|
||||
|
||||
|
||||
def _rich():
|
||||
"""Lazy-load Rich display classes (cached by sys.modules after first call)."""
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
from rich.markdown import Markdown
|
||||
return Console, Panel, Text, Markdown
|
||||
|
||||
# Import token metrics if available
|
||||
try:
|
||||
from .telemetry.token_collector import TokenMetrics
|
||||
@@ -103,6 +106,20 @@ __all__ = [
|
||||
'display_self_reflection',
|
||||
]
|
||||
|
||||
# P3/G2: Supported callback types documentation
|
||||
# Standard callbacks: tool_call, interaction, error, llm_start, llm_end
|
||||
# Autonomy callbacks (PraisonAI unique):
|
||||
# - autonomy_iteration: {iteration, max_iterations, stage}
|
||||
# - autonomy_stage_change: {from_stage, to_stage}
|
||||
# - autonomy_doom_loop: {iteration, recovery_action}
|
||||
# - autonomy_complete: {completion_reason, iterations, duration_seconds}
|
||||
# - retry: {attempt, max_attempts, error, retry_in_seconds}
|
||||
SUPPORTED_CALLBACK_TYPES = [
|
||||
'tool_call', 'interaction', 'error', 'llm_start', 'llm_end', 'llm_content',
|
||||
'autonomy_iteration', 'autonomy_stage_change', 'autonomy_doom_loop', 'autonomy_complete',
|
||||
'retry',
|
||||
]
|
||||
|
||||
def register_display_callback(display_type: str, callback_fn, is_async: bool = False):
|
||||
"""Register a synchronous or asynchronous callback function for a specific display type.
|
||||
|
||||
@@ -225,16 +242,17 @@ def display_interaction(message, response, markdown=True, generation_time=None,
|
||||
Args:
|
||||
metrics: Optional dict with token_in, token_out, cost, model for footer display
|
||||
"""
|
||||
Console, Panel, Text, Markdown = _rich()
|
||||
if console is None:
|
||||
console = Console()
|
||||
|
||||
|
||||
if isinstance(message, list):
|
||||
text_content = next((item["text"] for item in message if item["type"] == "text"), "")
|
||||
message = text_content
|
||||
|
||||
message = _clean_display_content(str(message))
|
||||
response = _clean_display_content(str(response))
|
||||
|
||||
|
||||
# Skip display if response is empty (common with Gemini tool calls)
|
||||
if not response or not response.strip():
|
||||
return
|
||||
@@ -254,12 +272,12 @@ def display_interaction(message, response, markdown=True, generation_time=None,
|
||||
task_id=task_id,
|
||||
metrics=metrics
|
||||
)
|
||||
|
||||
|
||||
# Build response title with time
|
||||
response_title = "Response"
|
||||
if generation_time:
|
||||
response_title = f"Response ({generation_time:.1f}s)"
|
||||
|
||||
|
||||
# Build response content with optional metrics footer
|
||||
response_content = response
|
||||
if metrics and isinstance(metrics, dict):
|
||||
@@ -268,7 +286,7 @@ def display_interaction(message, response, markdown=True, generation_time=None,
|
||||
tokens_out = metrics.get('tokens_out', 0)
|
||||
cost = metrics.get('cost', 0)
|
||||
model = metrics.get('model', '')
|
||||
|
||||
|
||||
metrics_line = f"\n\n─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─\n📊 {tokens_in} tokens in · {tokens_out} out"
|
||||
if cost > 0:
|
||||
metrics_line += f" · ${cost:.4f}"
|
||||
@@ -289,25 +307,27 @@ def display_interaction(message, response, markdown=True, generation_time=None,
|
||||
def display_self_reflection(message: str, console=None):
|
||||
if not message or not message.strip():
|
||||
return
|
||||
Console, Panel, Text, _Md = _rich()
|
||||
if console is None:
|
||||
console = Console()
|
||||
message = _clean_display_content(str(message))
|
||||
|
||||
|
||||
# Execute synchronous callbacks
|
||||
execute_sync_callback('self_reflection', message=message)
|
||||
|
||||
|
||||
console.print(Panel.fit(Text(message, style="bold yellow"), title="Self Reflection", border_style="magenta"))
|
||||
|
||||
def display_instruction(message: str, console=None, agent_name: str = None, agent_role: str = None, agent_tools: List[str] = None):
|
||||
if not message or not message.strip():
|
||||
return
|
||||
Console, Panel, Text, _Md = _rich()
|
||||
if console is None:
|
||||
console = Console()
|
||||
message = _clean_display_content(str(message))
|
||||
|
||||
|
||||
# Execute synchronous callbacks
|
||||
execute_sync_callback('instruction', message=message, agent_name=agent_name, agent_role=agent_role, agent_tools=agent_tools)
|
||||
|
||||
|
||||
# Display agent info if available
|
||||
if agent_name:
|
||||
agent_info = f"[bold #FF9B9B]👤 Agent:[/] [#FFE5E5]{agent_name}[/]"
|
||||
@@ -317,7 +337,7 @@ def display_instruction(message: str, console=None, agent_name: str = None, agen
|
||||
tools_str = ", ".join(f"[italic #B4D4FF]{tool}[/]" for tool in agent_tools)
|
||||
agent_info += f"\n[bold #86A789]Tools:[/] {tools_str}"
|
||||
console.print(Panel(agent_info, border_style="#D2E3C8", title="[bold]Agent Info[/]", title_align="left", padding=(1, 2)))
|
||||
|
||||
|
||||
# Only print if log level is DEBUG
|
||||
if logging.getLogger().getEffectiveLevel() == logging.DEBUG:
|
||||
console.print(Panel.fit(Text(message, style="bold blue"), title="Instruction", border_style="cyan"))
|
||||
@@ -386,17 +406,18 @@ def display_tool_call(message: str, console=None, tool_name: str = None, tool_in
|
||||
def display_error(message: str, console=None):
|
||||
if not message or not message.strip():
|
||||
return
|
||||
Console, Panel, Text, _Md = _rich()
|
||||
if console is None:
|
||||
console = Console()
|
||||
message = _clean_display_content(str(message))
|
||||
|
||||
|
||||
# Execute synchronous callbacks
|
||||
execute_sync_callback('error', message=message)
|
||||
|
||||
|
||||
# Use semantic error color
|
||||
console.print(Panel.fit(
|
||||
Text(message, style=f"bold {PRAISON_COLORS['error_text']}"),
|
||||
title="⚠ Error",
|
||||
Text(message, style=f"bold {PRAISON_COLORS['error_text']}"),
|
||||
title="⚠ Error",
|
||||
border_style=PRAISON_COLORS["error"]
|
||||
))
|
||||
error_logs.append(message)
|
||||
@@ -405,32 +426,33 @@ def display_generating(content: str = "", start_time: Optional[float] = None):
|
||||
if not content or not str(content).strip():
|
||||
logging.debug("Empty content in display_generating, returning early")
|
||||
return None
|
||||
|
||||
|
||||
elapsed_str = ""
|
||||
if start_time is not None:
|
||||
elapsed = time.time() - start_time
|
||||
elapsed_str = f" {elapsed:.1f}s"
|
||||
|
||||
|
||||
content = _clean_display_content(str(content))
|
||||
|
||||
|
||||
# Execute synchronous callbacks
|
||||
execute_sync_callback('generating', content=content, elapsed_time=elapsed_str.strip() if elapsed_str else None)
|
||||
|
||||
# Use semantic response color
|
||||
|
||||
_Console, Panel, _Text, Markdown = _rich()
|
||||
return Panel(Markdown(content), title=f"Generating...{elapsed_str}", border_style=PRAISON_COLORS["response"])
|
||||
|
||||
def display_reasoning_steps(steps: List[str], console=None):
|
||||
"""Display reasoning steps with unique numbered circles.
|
||||
|
||||
|
||||
Uses ①②③ numbered circles for a distinctive, scannable format
|
||||
that shows the agent's thought process.
|
||||
|
||||
|
||||
Args:
|
||||
steps: List of reasoning step descriptions
|
||||
console: Rich console for output
|
||||
"""
|
||||
if not steps:
|
||||
return
|
||||
Console, Panel, Text, _Md = _rich()
|
||||
if console is None:
|
||||
console = Console()
|
||||
|
||||
@@ -456,18 +478,19 @@ def display_reasoning_steps(steps: List[str], console=None):
|
||||
|
||||
def display_working_status(phase: int = 0, status_text: str = None, console=None):
|
||||
"""Display animated working status with pulsing dots.
|
||||
|
||||
|
||||
Shows a unique "Working ●○○" indicator with phase-specific status.
|
||||
This is PraisonAI's distinctive approach to showing processing status.
|
||||
|
||||
|
||||
Args:
|
||||
phase: Current animation phase (0-3)
|
||||
status_text: Optional status description
|
||||
console: Rich console for output
|
||||
|
||||
|
||||
Returns:
|
||||
Panel object for use with Rich.Live
|
||||
"""
|
||||
Console, Panel, Text, _Md = _rich()
|
||||
if console is None:
|
||||
console = Console()
|
||||
|
||||
@@ -487,6 +510,7 @@ def display_working_status(phase: int = 0, status_text: str = None, console=None
|
||||
# Async versions with 'a' prefix
|
||||
async def adisplay_interaction(message, response, markdown=True, generation_time=None, console=None, agent_name=None, agent_role=None, agent_tools=None, task_name=None, task_description=None, task_id=None):
|
||||
"""Async version of display_interaction."""
|
||||
Console, Panel, Text, Markdown = _rich()
|
||||
if console is None:
|
||||
console = Console()
|
||||
|
||||
@@ -527,6 +551,7 @@ async def adisplay_self_reflection(message: str, console=None):
|
||||
"""Async version of display_self_reflection."""
|
||||
if not message or not message.strip():
|
||||
return
|
||||
Console, Panel, Text, _Md = _rich()
|
||||
if console is None:
|
||||
console = Console()
|
||||
message = _clean_display_content(str(message))
|
||||
@@ -540,6 +565,7 @@ async def adisplay_instruction(message: str, console=None, agent_name: str = Non
|
||||
"""Async version of display_instruction."""
|
||||
if not message or not message.strip():
|
||||
return
|
||||
Console, Panel, Text, _Md = _rich()
|
||||
if console is None:
|
||||
console = Console()
|
||||
message = _clean_display_content(str(message))
|
||||
@@ -567,6 +593,7 @@ async def adisplay_tool_call(message: str, console=None):
|
||||
if not message or not message.strip():
|
||||
logging.debug("Empty message in adisplay_tool_call, returning early")
|
||||
return
|
||||
Console, Panel, Text, _Md = _rich()
|
||||
if console is None:
|
||||
console = Console()
|
||||
message = _clean_display_content(str(message))
|
||||
@@ -581,6 +608,7 @@ async def adisplay_error(message: str, console=None):
|
||||
"""Async version of display_error."""
|
||||
if not message or not message.strip():
|
||||
return
|
||||
Console, Panel, Text, _Md = _rich()
|
||||
if console is None:
|
||||
console = Console()
|
||||
message = _clean_display_content(str(message))
|
||||
@@ -596,17 +624,18 @@ async def adisplay_generating(content: str = "", start_time: Optional[float] = N
|
||||
if not content or not str(content).strip():
|
||||
logging.debug("Empty content in adisplay_generating, returning early")
|
||||
return None
|
||||
|
||||
|
||||
elapsed_str = ""
|
||||
if start_time is not None:
|
||||
elapsed = time.time() - start_time
|
||||
elapsed_str = f" {elapsed:.1f}s"
|
||||
|
||||
|
||||
content = _clean_display_content(str(content))
|
||||
|
||||
|
||||
# Execute callbacks
|
||||
await execute_callback('generating', content=content, elapsed_time=elapsed_str.strip() if elapsed_str else None)
|
||||
|
||||
|
||||
_Console, Panel, _Text, Markdown = _rich()
|
||||
return Panel(Markdown(content), title=f"Generating...{elapsed_str}", border_style="green")
|
||||
|
||||
def clean_triple_backticks(text: str) -> str:
|
||||
|
||||
@@ -245,7 +245,7 @@ class AutoMemory:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
memory: "FileMemory",
|
||||
memory: Any,
|
||||
enabled: bool = True,
|
||||
extractor: Optional[AutoMemoryExtractor] = None,
|
||||
min_importance: float = 0.6,
|
||||
|
||||
@@ -80,7 +80,8 @@ class DocsManager:
|
||||
- Priority-based ordering
|
||||
"""
|
||||
|
||||
DOCS_DIR_NAME = ".praisonai/docs"
|
||||
from ..paths import DEFAULT_DIR_NAME as _DIR_NAME
|
||||
DOCS_DIR_NAME = f"{_DIR_NAME}/docs"
|
||||
SUPPORTED_EXTENSIONS = [".md", ".txt", ".rst"]
|
||||
|
||||
# Maximum characters per doc (context window management)
|
||||
@@ -101,7 +102,11 @@ class DocsManager:
|
||||
verbose: Verbosity level
|
||||
"""
|
||||
self.workspace_path = Path(workspace_path) if workspace_path else Path.cwd()
|
||||
self.global_docs_path = Path(global_docs_path) if global_docs_path else Path.home() / ".praisonai" / "docs"
|
||||
if global_docs_path:
|
||||
self.global_docs_path = Path(global_docs_path)
|
||||
else:
|
||||
from ..paths import get_docs_dir
|
||||
self.global_docs_path = get_docs_dir()
|
||||
self.verbose = verbose
|
||||
|
||||
self._docs: Dict[str, Doc] = {}
|
||||
|
||||
@@ -21,6 +21,7 @@ import time
|
||||
import sys
|
||||
import hashlib
|
||||
import logging
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
@@ -140,7 +141,8 @@ class FileMemory:
|
||||
if base_path:
|
||||
self.base_path = Path(base_path)
|
||||
else:
|
||||
self.base_path = Path(".praisonai/memory")
|
||||
from ..paths import get_project_data_dir
|
||||
self.base_path = get_project_data_dir() / "memory"
|
||||
|
||||
self.user_path = self.base_path / user_id
|
||||
self.episodic_path = self.user_path / "episodic"
|
||||
@@ -159,7 +161,8 @@ class FileMemory:
|
||||
# Load or create config
|
||||
self.config = self._load_config(config)
|
||||
|
||||
# Initialize memory stores
|
||||
# Initialize thread-safe memory stores with lock for in-memory data protection
|
||||
self._lock = threading.RLock() # Reentrant lock for nested operations
|
||||
self._short_term: List[MemoryItem] = []
|
||||
self._long_term: List[MemoryItem] = []
|
||||
self._entities: Dict[str, EntityItem] = {}
|
||||
@@ -317,42 +320,45 @@ class FileMemory:
|
||||
Returns:
|
||||
The generated memory ID
|
||||
"""
|
||||
item = MemoryItem(
|
||||
id=self._generate_id(content),
|
||||
content=content,
|
||||
metadata=metadata or {},
|
||||
importance=importance
|
||||
)
|
||||
|
||||
self._short_term.append(item)
|
||||
|
||||
# Enforce limit (rolling buffer)
|
||||
limit = self.config["short_term_limit"]
|
||||
if len(self._short_term) > limit:
|
||||
# Before removing, check if should promote to long-term
|
||||
if self.config["auto_promote"]:
|
||||
self._auto_promote_to_long_term()
|
||||
with self._lock:
|
||||
item = MemoryItem(
|
||||
id=self._generate_id(content),
|
||||
content=content,
|
||||
metadata=metadata or {},
|
||||
importance=importance
|
||||
)
|
||||
|
||||
# Remove oldest items
|
||||
self._short_term = self._short_term[-limit:]
|
||||
|
||||
self._save_short_term()
|
||||
self._log(f"Added short-term memory: {content[:50]}...")
|
||||
|
||||
# Emit trace event
|
||||
self._emit_memory_event("store", "short_term", len(content), metadata=metadata)
|
||||
|
||||
return item.id
|
||||
self._short_term.append(item)
|
||||
|
||||
# Enforce limit (rolling buffer)
|
||||
limit = self.config["short_term_limit"]
|
||||
if len(self._short_term) > limit:
|
||||
# Before removing, check if should promote to long-term
|
||||
if self.config["auto_promote"]:
|
||||
self._auto_promote_to_long_term()
|
||||
|
||||
# Remove oldest items
|
||||
self._short_term = self._short_term[-limit:]
|
||||
|
||||
self._save_short_term()
|
||||
self._log(f"Added short-term memory: {content[:50]}...")
|
||||
|
||||
# Emit trace event
|
||||
self._emit_memory_event("store", "short_term", len(content), metadata=metadata)
|
||||
|
||||
return item.id
|
||||
|
||||
def get_short_term(self, limit: Optional[int] = None) -> List[MemoryItem]:
|
||||
"""Get short-term memories, most recent first."""
|
||||
items = list(reversed(self._short_term))
|
||||
if limit:
|
||||
items = items[:limit]
|
||||
return items
|
||||
with self._lock:
|
||||
items = list(reversed(self._short_term))
|
||||
if limit:
|
||||
items = items[:limit]
|
||||
return items
|
||||
|
||||
def _auto_promote_to_long_term(self):
|
||||
"""Promote high-importance short-term memories to long-term."""
|
||||
# Note: This method is called within _lock context from add_short_term
|
||||
threshold = self.config["importance_threshold"]
|
||||
promoted = []
|
||||
|
||||
@@ -389,36 +395,38 @@ class FileMemory:
|
||||
Returns:
|
||||
The generated memory ID
|
||||
"""
|
||||
item = MemoryItem(
|
||||
id=self._generate_id(content),
|
||||
content=content,
|
||||
metadata=metadata or {},
|
||||
importance=importance
|
||||
)
|
||||
|
||||
self._long_term.append(item)
|
||||
|
||||
# Enforce limit
|
||||
limit = self.config["long_term_limit"]
|
||||
if len(self._long_term) > limit:
|
||||
# Remove lowest importance items
|
||||
self._long_term.sort(key=lambda x: x.importance, reverse=True)
|
||||
self._long_term = self._long_term[:limit]
|
||||
|
||||
self._save_long_term()
|
||||
self._log(f"Added long-term memory: {content[:50]}...")
|
||||
|
||||
# Emit trace event
|
||||
self._emit_memory_event("store", "long_term", len(content), metadata=metadata)
|
||||
|
||||
return item.id
|
||||
with self._lock:
|
||||
item = MemoryItem(
|
||||
id=self._generate_id(content),
|
||||
content=content,
|
||||
metadata=metadata or {},
|
||||
importance=importance
|
||||
)
|
||||
|
||||
self._long_term.append(item)
|
||||
|
||||
# Enforce limit
|
||||
limit = self.config["long_term_limit"]
|
||||
if len(self._long_term) > limit:
|
||||
# Remove lowest importance items
|
||||
self._long_term.sort(key=lambda x: x.importance, reverse=True)
|
||||
self._long_term = self._long_term[:limit]
|
||||
|
||||
self._save_long_term()
|
||||
self._log(f"Added long-term memory: {content[:50]}...")
|
||||
|
||||
# Emit trace event
|
||||
self._emit_memory_event("store", "long_term", len(content), metadata=metadata)
|
||||
|
||||
return item.id
|
||||
|
||||
def get_long_term(self, limit: Optional[int] = None) -> List[MemoryItem]:
|
||||
"""Get long-term memories, sorted by importance."""
|
||||
items = sorted(self._long_term, key=lambda x: x.importance, reverse=True)
|
||||
if limit:
|
||||
items = items[:limit]
|
||||
return items
|
||||
with self._lock:
|
||||
items = sorted(self._long_term, key=lambda x: x.importance, reverse=True)
|
||||
if limit:
|
||||
items = items[:limit]
|
||||
return items
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Entity Memory
|
||||
@@ -443,43 +451,46 @@ class FileMemory:
|
||||
Returns:
|
||||
The entity ID
|
||||
"""
|
||||
entity_id = self._generate_id(f"{name}:{entity_type}")
|
||||
|
||||
# Check if entity exists
|
||||
existing = self._find_entity_by_name(name, entity_type)
|
||||
if existing:
|
||||
# Update existing entity
|
||||
existing.attributes.update(attributes or {})
|
||||
if relationships:
|
||||
existing.relationships.extend(relationships)
|
||||
existing.updated_at = time.time()
|
||||
entity_id = existing.id
|
||||
else:
|
||||
# Create new entity
|
||||
entity = EntityItem(
|
||||
id=entity_id,
|
||||
name=name,
|
||||
entity_type=entity_type,
|
||||
attributes=attributes or {},
|
||||
relationships=relationships or []
|
||||
)
|
||||
self._entities[entity_id] = entity
|
||||
|
||||
self._save_entities()
|
||||
self._log(f"Added/updated entity: {name} ({entity_type})")
|
||||
|
||||
return entity_id
|
||||
with self._lock:
|
||||
entity_id = self._generate_id(f"{name}:{entity_type}")
|
||||
|
||||
# Check if entity exists
|
||||
existing = self._find_entity_by_name(name, entity_type)
|
||||
if existing:
|
||||
# Update existing entity
|
||||
existing.attributes.update(attributes or {})
|
||||
if relationships:
|
||||
existing.relationships.extend(relationships)
|
||||
existing.updated_at = time.time()
|
||||
entity_id = existing.id
|
||||
else:
|
||||
# Create new entity
|
||||
entity = EntityItem(
|
||||
id=entity_id,
|
||||
name=name,
|
||||
entity_type=entity_type,
|
||||
attributes=attributes or {},
|
||||
relationships=relationships or []
|
||||
)
|
||||
self._entities[entity_id] = entity
|
||||
|
||||
self._save_entities()
|
||||
self._log(f"Added/updated entity: {name} ({entity_type})")
|
||||
|
||||
return entity_id
|
||||
|
||||
def get_entity(self, name: str, entity_type: Optional[str] = None) -> Optional[EntityItem]:
|
||||
"""Get entity by name and optionally type."""
|
||||
return self._find_entity_by_name(name, entity_type)
|
||||
with self._lock:
|
||||
return self._find_entity_by_name(name, entity_type)
|
||||
|
||||
def get_all_entities(self, entity_type: Optional[str] = None) -> List[EntityItem]:
|
||||
"""Get all entities, optionally filtered by type."""
|
||||
entities = list(self._entities.values())
|
||||
if entity_type:
|
||||
entities = [e for e in entities if e.entity_type == entity_type]
|
||||
return entities
|
||||
with self._lock:
|
||||
entities = list(self._entities.values())
|
||||
if entity_type:
|
||||
entities = [e for e in entities if e.entity_type == entity_type]
|
||||
return entities
|
||||
|
||||
def _find_entity_by_name(self, name: str, entity_type: Optional[str] = None) -> Optional[EntityItem]:
|
||||
"""Find entity by name."""
|
||||
@@ -630,7 +641,9 @@ class FileMemory:
|
||||
|
||||
# Search short-term
|
||||
if "short_term" in memory_types:
|
||||
for item in self._short_term:
|
||||
with self._lock:
|
||||
short_term_items = list(self._short_term) # Create snapshot
|
||||
for item in short_term_items:
|
||||
score = score_match(item.content)
|
||||
if score > 0:
|
||||
results.append({
|
||||
@@ -641,7 +654,9 @@ class FileMemory:
|
||||
|
||||
# Search long-term
|
||||
if "long_term" in memory_types:
|
||||
for item in self._long_term:
|
||||
with self._lock:
|
||||
long_term_items = list(self._long_term) # Create snapshot
|
||||
for item in long_term_items:
|
||||
score = score_match(item.content)
|
||||
if score > 0:
|
||||
results.append({
|
||||
@@ -652,7 +667,9 @@ class FileMemory:
|
||||
|
||||
# Search entities
|
||||
if "entity" in memory_types:
|
||||
for entity in self._entities.values():
|
||||
with self._lock:
|
||||
entity_items = list(self._entities.values()) # Create snapshot
|
||||
for entity in entity_items:
|
||||
score = max(
|
||||
score_match(entity.name),
|
||||
score_match(str(entity.attributes))
|
||||
@@ -793,21 +810,23 @@ class FileMemory:
|
||||
source_count: Number of items summarized
|
||||
metadata: Optional metadata
|
||||
"""
|
||||
self._summaries.append({
|
||||
"id": self._generate_id(summary),
|
||||
"summary": summary,
|
||||
"source_type": source_type,
|
||||
"source_count": source_count,
|
||||
"metadata": metadata or {},
|
||||
"created_at": time.time()
|
||||
})
|
||||
|
||||
self._save_summaries()
|
||||
self._log(f"Added summary for {source_count} {source_type} items")
|
||||
with self._lock:
|
||||
self._summaries.append({
|
||||
"id": self._generate_id(summary),
|
||||
"summary": summary,
|
||||
"source_type": source_type,
|
||||
"source_count": source_count,
|
||||
"metadata": metadata or {},
|
||||
"created_at": time.time()
|
||||
})
|
||||
|
||||
self._save_summaries()
|
||||
self._log(f"Added summary for {source_count} {source_type} items")
|
||||
|
||||
def get_summaries(self, limit: int = 5) -> List[Dict[str, Any]]:
|
||||
"""Get recent summaries."""
|
||||
return list(reversed(self._summaries))[:limit]
|
||||
with self._lock:
|
||||
return list(reversed(self._summaries))[:limit]
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Utility Methods
|
||||
@@ -815,27 +834,29 @@ class FileMemory:
|
||||
|
||||
def clear_short_term(self):
|
||||
"""Clear all short-term memory."""
|
||||
self._short_term = []
|
||||
self._save_short_term()
|
||||
self._log("Cleared short-term memory")
|
||||
with self._lock:
|
||||
self._short_term = []
|
||||
self._save_short_term()
|
||||
self._log("Cleared short-term memory")
|
||||
|
||||
def clear_all(self):
|
||||
"""Clear all memory for this user."""
|
||||
self._short_term = []
|
||||
self._long_term = []
|
||||
self._entities = {}
|
||||
self._summaries = []
|
||||
|
||||
self._save_short_term()
|
||||
self._save_long_term()
|
||||
self._save_entities()
|
||||
self._save_summaries()
|
||||
|
||||
# Clear episodic
|
||||
for f in self.episodic_path.glob("*.json"):
|
||||
f.unlink()
|
||||
|
||||
self._log("Cleared all memory")
|
||||
with self._lock:
|
||||
self._short_term = []
|
||||
self._long_term = []
|
||||
self._entities = {}
|
||||
self._summaries = []
|
||||
|
||||
self._save_short_term()
|
||||
self._save_long_term()
|
||||
self._save_entities()
|
||||
self._save_summaries()
|
||||
|
||||
# Clear episodic
|
||||
for f in self.episodic_path.glob("*.json"):
|
||||
f.unlink()
|
||||
|
||||
self._log("Cleared all memory")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Selective Deletion
|
||||
@@ -851,13 +872,14 @@ class FileMemory:
|
||||
Returns:
|
||||
True if memory was found and deleted, False otherwise
|
||||
"""
|
||||
for i, item in enumerate(self._short_term):
|
||||
if item.id == memory_id:
|
||||
del self._short_term[i]
|
||||
self._save_short_term()
|
||||
self._log(f"Deleted short-term memory: {memory_id}")
|
||||
return True
|
||||
return False
|
||||
with self._lock:
|
||||
for i, item in enumerate(self._short_term):
|
||||
if item.id == memory_id:
|
||||
del self._short_term[i]
|
||||
self._save_short_term()
|
||||
self._log(f"Deleted short-term memory: {memory_id}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def delete_long_term(self, memory_id: str) -> bool:
|
||||
"""
|
||||
@@ -869,13 +891,14 @@ class FileMemory:
|
||||
Returns:
|
||||
True if memory was found and deleted, False otherwise
|
||||
"""
|
||||
for i, item in enumerate(self._long_term):
|
||||
if item.id == memory_id:
|
||||
del self._long_term[i]
|
||||
self._save_long_term()
|
||||
self._log(f"Deleted long-term memory: {memory_id}")
|
||||
return True
|
||||
return False
|
||||
with self._lock:
|
||||
for i, item in enumerate(self._long_term):
|
||||
if item.id == memory_id:
|
||||
del self._long_term[i]
|
||||
self._save_long_term()
|
||||
self._log(f"Deleted long-term memory: {memory_id}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def delete_entity(self, name: str) -> bool:
|
||||
"""
|
||||
@@ -887,24 +910,25 @@ class FileMemory:
|
||||
Returns:
|
||||
True if entity was found and deleted, False otherwise
|
||||
"""
|
||||
# Find entity by name using existing helper
|
||||
entity = self._find_entity_by_name(name)
|
||||
if entity:
|
||||
# Delete by entity's ID (which is the dict key)
|
||||
if entity.id in self._entities:
|
||||
del self._entities[entity.id]
|
||||
with self._lock:
|
||||
# Find entity by name using existing helper
|
||||
entity = self._find_entity_by_name(name)
|
||||
if entity:
|
||||
# Delete by entity's ID (which is the dict key)
|
||||
if entity.id in self._entities:
|
||||
del self._entities[entity.id]
|
||||
self._save_entities()
|
||||
self._log(f"Deleted entity: {name}")
|
||||
return True
|
||||
|
||||
# Try direct ID match as fallback (in case name IS the ID)
|
||||
if name in self._entities:
|
||||
del self._entities[name]
|
||||
self._save_entities()
|
||||
self._log(f"Deleted entity: {name}")
|
||||
self._log(f"Deleted entity by ID: {name}")
|
||||
return True
|
||||
|
||||
# Try direct ID match as fallback (in case name IS the ID)
|
||||
if name in self._entities:
|
||||
del self._entities[name]
|
||||
self._save_entities()
|
||||
self._log(f"Deleted entity by ID: {name}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
def delete_episodic(self, memory_id: str, date: Optional[str] = None) -> bool:
|
||||
"""
|
||||
@@ -1055,47 +1079,100 @@ class FileMemory:
|
||||
"""Get memory statistics."""
|
||||
episodic_count = sum(1 for _ in self.episodic_path.glob("*.json"))
|
||||
|
||||
return {
|
||||
"user_id": self.user_id,
|
||||
"short_term_count": len(self._short_term),
|
||||
"long_term_count": len(self._long_term),
|
||||
"entity_count": len(self._entities),
|
||||
"episodic_days": episodic_count,
|
||||
"summary_count": len(self._summaries),
|
||||
"storage_path": str(self.user_path)
|
||||
}
|
||||
with self._lock:
|
||||
return {
|
||||
"user_id": self.user_id,
|
||||
"short_term_count": len(self._short_term),
|
||||
"long_term_count": len(self._long_term),
|
||||
"entity_count": len(self._entities),
|
||||
"episodic_days": episodic_count,
|
||||
"summary_count": len(self._summaries),
|
||||
"storage_path": str(self.user_path)
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# MemoryProtocol Compliance Aliases
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def store_short_term(
|
||||
self,
|
||||
text: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs
|
||||
) -> str:
|
||||
"""Protocol-compliant alias for add_short_term."""
|
||||
return self.add_short_term(text, metadata)
|
||||
|
||||
def store_long_term(
|
||||
self,
|
||||
text: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs
|
||||
) -> str:
|
||||
"""Protocol-compliant alias for add_long_term."""
|
||||
return self.add_long_term(text, metadata)
|
||||
|
||||
def search_short_term(
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 5,
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Protocol-compliant alias for search (short_term only)."""
|
||||
return self.search(query, memory_types=["short_term"], limit=limit)
|
||||
|
||||
def search_long_term(
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 5,
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Protocol-compliant alias for search (long_term only)."""
|
||||
return self.search(query, memory_types=["long_term"], limit=limit)
|
||||
|
||||
def get_all_memories(self, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""Get all memories from short-term and long-term stores."""
|
||||
with self._lock:
|
||||
all_memories = []
|
||||
for item in self._short_term:
|
||||
all_memories.append({"type": "short_term", **item.to_dict()})
|
||||
for item in self._long_term:
|
||||
all_memories.append({"type": "long_term", **item.to_dict()})
|
||||
return all_memories
|
||||
|
||||
def export(self) -> Dict[str, Any]:
|
||||
"""Export all memory data."""
|
||||
return {
|
||||
"user_id": self.user_id,
|
||||
"config": self.config,
|
||||
"short_term": [item.to_dict() for item in self._short_term],
|
||||
"long_term": [item.to_dict() for item in self._long_term],
|
||||
"entities": {k: v.to_dict() for k, v in self._entities.items()},
|
||||
"summaries": self._summaries,
|
||||
"exported_at": time.time()
|
||||
}
|
||||
with self._lock:
|
||||
return {
|
||||
"user_id": self.user_id,
|
||||
"config": self.config,
|
||||
"short_term": [item.to_dict() for item in self._short_term],
|
||||
"long_term": [item.to_dict() for item in self._long_term],
|
||||
"entities": {k: v.to_dict() for k, v in self._entities.items()},
|
||||
"summaries": list(self._summaries),
|
||||
"exported_at": time.time()
|
||||
}
|
||||
|
||||
def import_data(self, data: Dict[str, Any]):
|
||||
"""Import memory data from export."""
|
||||
if "short_term" in data:
|
||||
self._short_term = [MemoryItem.from_dict(item) for item in data["short_term"]]
|
||||
self._save_short_term()
|
||||
|
||||
if "long_term" in data:
|
||||
self._long_term = [MemoryItem.from_dict(item) for item in data["long_term"]]
|
||||
self._save_long_term()
|
||||
|
||||
if "entities" in data:
|
||||
self._entities = {k: EntityItem.from_dict(v) for k, v in data["entities"].items()}
|
||||
self._save_entities()
|
||||
|
||||
if "summaries" in data:
|
||||
self._summaries = data["summaries"]
|
||||
self._save_summaries()
|
||||
|
||||
self._log("Imported memory data")
|
||||
with self._lock:
|
||||
if "short_term" in data:
|
||||
self._short_term = [MemoryItem.from_dict(item) for item in data["short_term"]]
|
||||
self._save_short_term()
|
||||
|
||||
if "long_term" in data:
|
||||
self._long_term = [MemoryItem.from_dict(item) for item in data["long_term"]]
|
||||
self._save_long_term()
|
||||
|
||||
if "entities" in data:
|
||||
self._entities = {k: EntityItem.from_dict(v) for k, v in data["entities"].items()}
|
||||
self._save_entities()
|
||||
|
||||
if "summaries" in data:
|
||||
self._summaries = data["summaries"]
|
||||
self._save_summaries()
|
||||
|
||||
self._log("Imported memory data")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Session Management
|
||||
@@ -1125,14 +1202,20 @@ class FileMemory:
|
||||
|
||||
session_file = sessions_path / f"{name}.json"
|
||||
|
||||
# Take snapshots under lock for consistent session data
|
||||
with self._lock:
|
||||
short_term_data = [item.to_dict() for item in self._short_term]
|
||||
long_term_snapshot = [item.to_dict() for item in self._long_term[-50:]] # Last 50
|
||||
entity_ids = list(self._entities.keys())
|
||||
|
||||
session_data = {
|
||||
"name": name,
|
||||
"user_id": self.user_id,
|
||||
"saved_at": time.time(),
|
||||
"saved_at_iso": datetime.now().isoformat(),
|
||||
"short_term": [item.to_dict() for item in self._short_term],
|
||||
"long_term_snapshot": [item.to_dict() for item in self._long_term[-50:]], # Last 50
|
||||
"entity_ids": list(self._entities.keys()),
|
||||
"short_term": short_term_data,
|
||||
"long_term_snapshot": long_term_snapshot,
|
||||
"entity_ids": entity_ids,
|
||||
"conversation_history": conversation_history or [],
|
||||
"metadata": metadata or {},
|
||||
"config": self.config
|
||||
@@ -1165,8 +1248,9 @@ class FileMemory:
|
||||
|
||||
# Restore short-term memory from session
|
||||
if "short_term" in session_data:
|
||||
self._short_term = [MemoryItem.from_dict(item) for item in session_data["short_term"]]
|
||||
self._save_short_term()
|
||||
with self._lock:
|
||||
self._short_term = [MemoryItem.from_dict(item) for item in session_data["short_term"]]
|
||||
self._save_short_term()
|
||||
|
||||
self._log(f"Resumed session '{name}'")
|
||||
|
||||
@@ -1237,14 +1321,16 @@ class FileMemory:
|
||||
Returns:
|
||||
The generated summary
|
||||
"""
|
||||
if len(self._short_term) <= max_items:
|
||||
return "" # No compression needed
|
||||
# Check length and gather content under lock
|
||||
with self._lock:
|
||||
if len(self._short_term) <= max_items:
|
||||
return "" # No compression needed
|
||||
|
||||
# Gather content to compress while holding lock
|
||||
items_to_compress = self._short_term[:-max_items]
|
||||
content_list = [item.content for item in items_to_compress]
|
||||
|
||||
# Gather content to compress
|
||||
items_to_compress = self._short_term[:-max_items]
|
||||
content_list = [item.content for item in items_to_compress]
|
||||
|
||||
# Generate summary
|
||||
# Generate summary OUTSIDE lock (LLM call may be slow)
|
||||
if llm_func:
|
||||
prompt = f"""Summarize the following conversation context into key points.
|
||||
Preserve important facts, decisions, and context.
|
||||
@@ -1259,16 +1345,17 @@ Summary:"""
|
||||
# Simple concatenation if no LLM
|
||||
summary = "Compressed context: " + " | ".join(content_list[:5]) + "..."
|
||||
|
||||
# Add summary as a high-importance long-term memory
|
||||
# Add summary as a high-importance long-term memory (add_long_term has its own lock)
|
||||
self.add_long_term(
|
||||
content=f"[Session Summary] {summary}",
|
||||
metadata={"type": "compression_summary", "items_compressed": len(items_to_compress)},
|
||||
importance=0.9
|
||||
)
|
||||
|
||||
# Keep only recent items
|
||||
self._short_term = self._short_term[-max_items:]
|
||||
self._save_short_term()
|
||||
# Keep only recent items under lock
|
||||
with self._lock:
|
||||
self._short_term = self._short_term[-max_items:]
|
||||
self._save_short_term()
|
||||
|
||||
self._log(f"Compressed {len(items_to_compress)} items into summary")
|
||||
|
||||
@@ -1517,7 +1604,9 @@ Summary:"""
|
||||
})
|
||||
|
||||
if memory_type in ("all", "entity", "entities"):
|
||||
for name, entity in self._entities.items():
|
||||
with self._lock:
|
||||
entities_snapshot = dict(self._entities)
|
||||
for name, entity in entities_snapshot.items():
|
||||
items.append({
|
||||
"type": "entity",
|
||||
"id": name,
|
||||
|
||||
@@ -103,7 +103,8 @@ class HooksManager:
|
||||
```
|
||||
"""
|
||||
|
||||
CONFIG_FILE = ".praisonai/hooks.json"
|
||||
from ..paths import DEFAULT_DIR_NAME as _DIR_NAME
|
||||
CONFIG_FILE = f"{_DIR_NAME}/hooks.json"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -384,7 +385,8 @@ class HooksManager:
|
||||
timeout: Global timeout in seconds
|
||||
enabled: Whether hooks are enabled
|
||||
"""
|
||||
config_dir = self.workspace_path / ".praisonai"
|
||||
from ..paths import DEFAULT_DIR_NAME
|
||||
config_dir = self.workspace_path / DEFAULT_DIR_NAME
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
config = {
|
||||
|
||||
@@ -5,23 +5,30 @@ Provides learning capabilities for agents to capture patterns, preferences,
|
||||
and insights from interactions to improve future responses.
|
||||
|
||||
Usage:
|
||||
from praisonaiagents import Agent, MemoryConfig, LearnConfig
|
||||
from praisonaiagents import Agent, LearnConfig
|
||||
|
||||
# Simple enable
|
||||
agent = Agent(memory=MemoryConfig(learn=True))
|
||||
# Simple enable (top-level param)
|
||||
agent = Agent(learn=True)
|
||||
|
||||
# With specific capabilities
|
||||
agent = Agent(memory=MemoryConfig(
|
||||
learn=LearnConfig(
|
||||
persona=True,
|
||||
insights=True,
|
||||
patterns=True,
|
||||
)
|
||||
# With specific capabilities and auto-learning
|
||||
agent = Agent(learn=LearnConfig(
|
||||
persona=True,
|
||||
insights=True,
|
||||
patterns=True,
|
||||
mode="agentic", # Auto-extract learnings
|
||||
))
|
||||
|
||||
# With database backend
|
||||
agent = Agent(learn=LearnConfig(
|
||||
backend="sqlite",
|
||||
db_url="sqlite:///learn.db",
|
||||
))
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
# Manager
|
||||
"LearnManager",
|
||||
# Stores
|
||||
"PersonaStore",
|
||||
"InsightStore",
|
||||
"ThreadStore",
|
||||
@@ -29,14 +36,22 @@ __all__ = [
|
||||
"DecisionStore",
|
||||
"FeedbackStore",
|
||||
"ImprovementStore",
|
||||
# Protocols
|
||||
"LearnMode",
|
||||
"LearnProtocol",
|
||||
"AsyncLearnProtocol",
|
||||
"LearnManagerProtocol",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy loading for learn module components."""
|
||||
# Manager
|
||||
if name == "LearnManager":
|
||||
from .manager import LearnManager
|
||||
return LearnManager
|
||||
|
||||
# Stores
|
||||
if name == "PersonaStore":
|
||||
from .stores import PersonaStore
|
||||
return PersonaStore
|
||||
@@ -58,4 +73,19 @@ def __getattr__(name: str):
|
||||
if name == "ImprovementStore":
|
||||
from .stores import ImprovementStore
|
||||
return ImprovementStore
|
||||
|
||||
# Protocols
|
||||
if name == "LearnMode":
|
||||
from .protocols import LearnMode
|
||||
return LearnMode
|
||||
if name == "LearnProtocol":
|
||||
from .protocols import LearnProtocol
|
||||
return LearnProtocol
|
||||
if name == "AsyncLearnProtocol":
|
||||
from .protocols import AsyncLearnProtocol
|
||||
return AsyncLearnProtocol
|
||||
if name == "LearnManagerProtocol":
|
||||
from .protocols import LearnManagerProtocol
|
||||
return LearnManagerProtocol
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -6,8 +6,9 @@ capturing and retrieving learned information.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
import logging
|
||||
|
||||
from ...config.feature_configs import LearnConfig, LearnScope
|
||||
from ...config.feature_configs import LearnConfig, LearnScope, LearnBackend
|
||||
from .stores import (
|
||||
PersonaStore,
|
||||
InsightStore,
|
||||
@@ -43,65 +44,163 @@ class LearnManager:
|
||||
config: Optional[LearnConfig] = None,
|
||||
user_id: Optional[str] = None,
|
||||
store_path: Optional[str] = None,
|
||||
custom_stores: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
"""
|
||||
Initialize LearnManager.
|
||||
|
||||
Args:
|
||||
config: LearnConfig for enabling/disabling learning capabilities
|
||||
user_id: User identifier for scoping learnings
|
||||
store_path: Custom path for storing learning data
|
||||
custom_stores: Optional dict of custom store implementations.
|
||||
Keys are store names (e.g., "domain", "skills").
|
||||
Values must implement LearnProtocol (add, search, list_all, etc.)
|
||||
|
||||
Example:
|
||||
```python
|
||||
class MyDomainStore:
|
||||
def add(self, content, metadata=None): ...
|
||||
def search(self, query, limit=10): ...
|
||||
def list_all(self, limit=100): ...
|
||||
def get(self, entry_id): ...
|
||||
def update(self, entry_id, content, metadata=None): ...
|
||||
def delete(self, entry_id): ...
|
||||
def clear(self): ...
|
||||
|
||||
manager = LearnManager(
|
||||
config=LearnConfig(persona=True),
|
||||
custom_stores={"domain": MyDomainStore()}
|
||||
)
|
||||
```
|
||||
"""
|
||||
self.config = config or LearnConfig()
|
||||
self.user_id = user_id or "default"
|
||||
self.store_path = store_path or self.config.store_path
|
||||
|
||||
# Resolve backend from config
|
||||
self._backend = self._create_backend()
|
||||
if self._backend is not None:
|
||||
logging.info(
|
||||
f"LearnManager using {self.config.backend} backend"
|
||||
)
|
||||
|
||||
scope = self.config.scope
|
||||
if isinstance(scope, LearnScope):
|
||||
scope = scope.value
|
||||
|
||||
self._stores: Dict[str, Any] = {}
|
||||
self._custom_stores = custom_stores or {}
|
||||
|
||||
if self.config.persona:
|
||||
# Built-in stores are added first, custom stores can override them
|
||||
if self.config.persona and "persona" not in self._custom_stores:
|
||||
self._stores["persona"] = PersonaStore(
|
||||
store_path=self._get_store_path("persona"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
if self.config.insights:
|
||||
if self.config.insights and "insights" not in self._custom_stores:
|
||||
self._stores["insights"] = InsightStore(
|
||||
store_path=self._get_store_path("insights"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
if self.config.thread:
|
||||
if self.config.thread and "threads" not in self._custom_stores:
|
||||
self._stores["threads"] = ThreadStore(
|
||||
store_path=self._get_store_path("threads"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
if self.config.patterns:
|
||||
if self.config.patterns and "patterns" not in self._custom_stores:
|
||||
self._stores["patterns"] = PatternStore(
|
||||
store_path=self._get_store_path("patterns"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
if self.config.decisions:
|
||||
if self.config.decisions and "decisions" not in self._custom_stores:
|
||||
self._stores["decisions"] = DecisionStore(
|
||||
store_path=self._get_store_path("decisions"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
if self.config.feedback:
|
||||
if self.config.feedback and "feedback" not in self._custom_stores:
|
||||
self._stores["feedback"] = FeedbackStore(
|
||||
store_path=self._get_store_path("feedback"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
if self.config.improvements:
|
||||
if self.config.improvements and "improvements" not in self._custom_stores:
|
||||
self._stores["improvements"] = ImprovementStore(
|
||||
store_path=self._get_store_path("improvements"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
# Add custom stores last (they override built-in stores)
|
||||
if self._custom_stores:
|
||||
for name, store in self._custom_stores.items():
|
||||
self._stores[name] = store
|
||||
|
||||
# PROPOSE mode: pending learnings queue
|
||||
self._pending: List[LearnEntry] = []
|
||||
|
||||
def _create_backend(self) -> Any:
|
||||
"""Create a storage backend from config.
|
||||
|
||||
Returns backend instance or None for FILE (default) backend.
|
||||
Falls back to None (FILE) on error.
|
||||
"""
|
||||
backend = self.config.backend
|
||||
backend_str = backend.value if isinstance(backend, LearnBackend) else str(backend)
|
||||
|
||||
if backend_str == "file":
|
||||
return None # FILE uses BaseJSONStore default
|
||||
|
||||
if backend_str == "sqlite":
|
||||
try:
|
||||
from ...storage.backends import SQLiteBackend
|
||||
db_path = self.config.db_url or f"{self.store_path or 'learn'}/learn.db"
|
||||
# Strip sqlite:/// prefix if present
|
||||
if db_path.startswith("sqlite:///"):
|
||||
db_path = db_path[len("sqlite:///"):]
|
||||
return SQLiteBackend(db_path=db_path, table_name="learn_store")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to create SQLite backend: {e}. Falling back to FILE.")
|
||||
return None
|
||||
|
||||
if backend_str == "redis":
|
||||
try:
|
||||
from ...storage.backends import RedisBackend
|
||||
url = self.config.db_url or "redis://localhost:6379"
|
||||
return RedisBackend(url=url, prefix="praison:learn:")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to create Redis backend: {e}. Falling back to FILE.")
|
||||
return None
|
||||
|
||||
if backend_str == "mongodb":
|
||||
try:
|
||||
from ...storage.backends import MongoDBBackend
|
||||
url = self.config.db_url or "mongodb://localhost:27017/"
|
||||
return MongoDBBackend(url=url, collection="praison_learn")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to create MongoDB backend: {e}. Falling back to FILE.")
|
||||
return None
|
||||
|
||||
logging.warning(f"Unknown learn backend '{backend_str}'. Falling back to FILE.")
|
||||
return None
|
||||
|
||||
def _get_store_path(self, store_name: str) -> Optional[str]:
|
||||
"""Get path for a specific store."""
|
||||
@@ -216,6 +315,88 @@ class LearnManager:
|
||||
for store_name, store in self._stores.items()
|
||||
}
|
||||
|
||||
@property
|
||||
def pending_learnings(self) -> List[LearnEntry]:
|
||||
"""Get list of pending learnings awaiting approval (PROPOSE mode)."""
|
||||
return list(self._pending)
|
||||
|
||||
def add_pending(
|
||||
self,
|
||||
content: str,
|
||||
category: str = "persona",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> LearnEntry:
|
||||
"""Add a learning to the pending queue for user approval.
|
||||
|
||||
Args:
|
||||
content: The learning content
|
||||
category: Store category (persona, insights, patterns, etc.)
|
||||
metadata: Optional metadata
|
||||
|
||||
Returns:
|
||||
The pending LearnEntry
|
||||
"""
|
||||
from datetime import datetime
|
||||
entry_id = f"pending_{category}_{len(self._pending)}_{datetime.utcnow().timestamp()}"
|
||||
entry = LearnEntry(
|
||||
id=entry_id,
|
||||
content=content,
|
||||
metadata={**(metadata or {}), "category": category, "status": "pending"},
|
||||
)
|
||||
self._pending.append(entry)
|
||||
return entry
|
||||
|
||||
def approve_learning(self, entry_id: str) -> bool:
|
||||
"""Approve a pending learning — move it to the appropriate store.
|
||||
|
||||
Args:
|
||||
entry_id: ID of the pending learning to approve
|
||||
|
||||
Returns:
|
||||
True if approved and stored, False if not found
|
||||
"""
|
||||
for i, entry in enumerate(self._pending):
|
||||
if entry.id == entry_id:
|
||||
category = entry.metadata.get("category", "persona")
|
||||
# Route to appropriate store
|
||||
if category in self._stores:
|
||||
self._stores[category].add(entry.content, entry.metadata)
|
||||
elif "persona" in self._stores:
|
||||
self._stores["persona"].add(entry.content, entry.metadata)
|
||||
self._pending.pop(i)
|
||||
return True
|
||||
return False
|
||||
|
||||
def reject_learning(self, entry_id: str) -> bool:
|
||||
"""Reject a pending learning — discard it.
|
||||
|
||||
Args:
|
||||
entry_id: ID of the pending learning to reject
|
||||
|
||||
Returns:
|
||||
True if found and rejected, False if not found
|
||||
"""
|
||||
for i, entry in enumerate(self._pending):
|
||||
if entry.id == entry_id:
|
||||
self._pending.pop(i)
|
||||
return True
|
||||
return False
|
||||
|
||||
def approve_all_learnings(self) -> int:
|
||||
"""Approve all pending learnings at once.
|
||||
|
||||
Returns:
|
||||
Number of learnings approved
|
||||
"""
|
||||
count = 0
|
||||
while self._pending:
|
||||
entry = self._pending[0]
|
||||
if self.approve_learning(entry.id):
|
||||
count += 1
|
||||
else:
|
||||
break # Safety
|
||||
return count
|
||||
|
||||
def to_system_prompt_context(self) -> str:
|
||||
"""
|
||||
Generate context suitable for injection into system prompt.
|
||||
@@ -275,3 +456,211 @@ class LearnManager:
|
||||
# Note: threads are session-specific and not included in system prompt
|
||||
|
||||
return "\n".join(parts) if parts else ""
|
||||
|
||||
def process_conversation(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
llm: Optional[str] = None,
|
||||
extract_only: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Process a conversation to extract learnings automatically.
|
||||
|
||||
Uses LLM to analyze the conversation and extract:
|
||||
- User preferences (persona)
|
||||
- Insights and observations
|
||||
- Patterns in user behavior
|
||||
|
||||
Args:
|
||||
messages: Conversation messages (list of {"role": ..., "content": ...})
|
||||
llm: Optional LLM model to use for extraction (defaults to gpt-4o-mini)
|
||||
|
||||
Returns:
|
||||
Dictionary with extracted learnings:
|
||||
{
|
||||
"persona": [...],
|
||||
"insights": [...],
|
||||
"patterns": [...],
|
||||
"stored": {"persona": N, "insights": N, "patterns": N}
|
||||
}
|
||||
"""
|
||||
if not messages:
|
||||
return {"persona": [], "insights": [], "patterns": [], "stored": {}}
|
||||
|
||||
# Build conversation text
|
||||
conversation_text = "\n".join([
|
||||
f"{msg.get('role', 'unknown')}: {msg.get('content', '')}"
|
||||
for msg in messages
|
||||
])
|
||||
|
||||
# Use LLM to extract learnings
|
||||
extraction_prompt = f"""Analyze this conversation and extract learnings.
|
||||
|
||||
CONVERSATION:
|
||||
{conversation_text}
|
||||
|
||||
Extract the following (if present):
|
||||
1. USER PREFERENCES: Things the user likes, dislikes, prefers, or their style
|
||||
2. INSIGHTS: Observations about the user's domain, work, or context
|
||||
3. PATTERNS: Recurring behaviors or request patterns
|
||||
|
||||
Return JSON:
|
||||
{{
|
||||
"persona": ["preference 1", "preference 2"],
|
||||
"insights": ["insight 1", "insight 2"],
|
||||
"patterns": ["pattern 1", "pattern 2"]
|
||||
}}
|
||||
|
||||
Only include items that are clearly evident from the conversation.
|
||||
Return empty arrays if nothing is found for a category."""
|
||||
|
||||
try:
|
||||
# Lazy import to avoid circular dependency
|
||||
from ...llm import LLM
|
||||
import json
|
||||
|
||||
model = llm or "gpt-4o-mini"
|
||||
llm_instance = LLM(model=model)
|
||||
response = llm_instance.get_response(
|
||||
prompt=extraction_prompt,
|
||||
output_json=True,
|
||||
)
|
||||
|
||||
# Parse response - handle both string and dict responses
|
||||
if isinstance(response, str):
|
||||
extracted = json.loads(response)
|
||||
elif isinstance(response, dict):
|
||||
extracted = response
|
||||
else:
|
||||
extracted = {"persona": [], "insights": [], "patterns": []}
|
||||
|
||||
# Store extracted learnings (unless extract_only)
|
||||
stored = {"persona": 0, "insights": 0, "patterns": 0}
|
||||
|
||||
if not extract_only:
|
||||
for preference in extracted.get("persona", []):
|
||||
if preference and "persona" in self._stores:
|
||||
self.capture_persona(preference)
|
||||
stored["persona"] += 1
|
||||
|
||||
for insight in extracted.get("insights", []):
|
||||
if insight and "insights" in self._stores:
|
||||
self.capture_insight(insight, source="auto_extraction")
|
||||
stored["insights"] += 1
|
||||
|
||||
for pattern in extracted.get("patterns", []):
|
||||
if pattern and "patterns" in self._stores:
|
||||
self.capture_pattern(pattern, pattern_type="auto_extracted")
|
||||
stored["patterns"] += 1
|
||||
|
||||
return {
|
||||
"persona": extracted.get("persona", []),
|
||||
"insights": extracted.get("insights", []),
|
||||
"patterns": extracted.get("patterns", []),
|
||||
"stored": stored,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.warning(f"Failed to extract learnings: {e}")
|
||||
return {"persona": [], "insights": [], "patterns": [], "stored": {}, "error": str(e)}
|
||||
|
||||
async def aprocess_conversation(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
llm: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Async version of process_conversation.
|
||||
|
||||
Args:
|
||||
messages: Conversation messages
|
||||
llm: Optional LLM model
|
||||
|
||||
Returns:
|
||||
Dictionary with extracted learnings
|
||||
"""
|
||||
if not messages:
|
||||
return {"persona": [], "insights": [], "patterns": [], "stored": {}}
|
||||
|
||||
# Build conversation text
|
||||
conversation_text = "\n".join([
|
||||
f"{msg.get('role', 'unknown')}: {msg.get('content', '')}"
|
||||
for msg in messages
|
||||
])
|
||||
|
||||
extraction_prompt = f"""Analyze this conversation and extract learnings.
|
||||
|
||||
CONVERSATION:
|
||||
{conversation_text}
|
||||
|
||||
Extract the following (if present):
|
||||
1. USER PREFERENCES: Things the user likes, dislikes, prefers, or their style
|
||||
2. INSIGHTS: Observations about the user's domain, work, or context
|
||||
3. PATTERNS: Recurring behaviors or request patterns
|
||||
|
||||
Return JSON:
|
||||
{{
|
||||
"persona": ["preference 1", "preference 2"],
|
||||
"insights": ["insight 1", "insight 2"],
|
||||
"patterns": ["pattern 1", "pattern 2"]
|
||||
}}
|
||||
|
||||
Only include items that are clearly evident from the conversation.
|
||||
Return empty arrays if nothing is found for a category."""
|
||||
|
||||
try:
|
||||
# Lazy import to avoid circular dependency
|
||||
from ...llm import LLM
|
||||
import json
|
||||
import asyncio
|
||||
|
||||
model = llm or "gpt-4o-mini"
|
||||
llm_instance = LLM(model=model)
|
||||
|
||||
# Run sync LLM call in executor for async compatibility
|
||||
loop = asyncio.get_event_loop()
|
||||
response = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: llm_instance.get_response(
|
||||
prompt=extraction_prompt,
|
||||
output_json=True,
|
||||
)
|
||||
)
|
||||
|
||||
# Parse response - handle both string and dict responses
|
||||
if isinstance(response, str):
|
||||
extracted = json.loads(response)
|
||||
elif isinstance(response, dict):
|
||||
extracted = response
|
||||
else:
|
||||
extracted = {"persona": [], "insights": [], "patterns": []}
|
||||
|
||||
stored = {"persona": 0, "insights": 0, "patterns": 0}
|
||||
|
||||
for preference in extracted.get("persona", []):
|
||||
if preference and "persona" in self._stores:
|
||||
self.capture_persona(preference)
|
||||
stored["persona"] += 1
|
||||
|
||||
for insight in extracted.get("insights", []):
|
||||
if insight and "insights" in self._stores:
|
||||
self.capture_insight(insight, source="auto_extraction")
|
||||
stored["insights"] += 1
|
||||
|
||||
for pattern in extracted.get("patterns", []):
|
||||
if pattern and "patterns" in self._stores:
|
||||
self.capture_pattern(pattern, pattern_type="auto_extracted")
|
||||
stored["patterns"] += 1
|
||||
|
||||
return {
|
||||
"persona": extracted.get("persona", []),
|
||||
"insights": extracted.get("insights", []),
|
||||
"patterns": extracted.get("patterns", []),
|
||||
"stored": stored,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.warning(f"Failed to extract learnings: {e}")
|
||||
return {"persona": [], "insights": [], "patterns": [], "stored": {}, "error": str(e)}
|
||||
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
Learn Protocol Definitions.
|
||||
|
||||
Provides Protocol interfaces for learning implementations.
|
||||
Enables custom learning backends and extensibility.
|
||||
"""
|
||||
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
|
||||
|
||||
# Re-export LearnMode from feature_configs (canonical location)
|
||||
# This avoids duplication while maintaining backward compatibility
|
||||
from ...config.feature_configs import LearnMode
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class LearnProtocol(Protocol):
|
||||
"""
|
||||
Protocol for learning store implementations.
|
||||
|
||||
Enables custom backends (file, SQLite, Redis, MongoDB, etc.)
|
||||
while maintaining a consistent interface.
|
||||
|
||||
Example:
|
||||
```python
|
||||
class RedisLearnStore:
|
||||
def add(self, content: str, metadata=None) -> Dict[str, Any]:
|
||||
# Store in Redis
|
||||
return {"id": "...", "content": content}
|
||||
|
||||
def search(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
# Search Redis
|
||||
return []
|
||||
|
||||
def list_all(self, limit: int = 100) -> List[Dict[str, Any]]:
|
||||
return []
|
||||
|
||||
def delete(self, entry_id: str) -> bool:
|
||||
return True
|
||||
|
||||
def clear(self) -> int:
|
||||
return 0
|
||||
```
|
||||
"""
|
||||
|
||||
def add(
|
||||
self,
|
||||
content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Add a new entry to the store.
|
||||
|
||||
Args:
|
||||
content: The content to store
|
||||
metadata: Optional metadata dictionary
|
||||
|
||||
Returns:
|
||||
Dictionary with entry details including 'id'
|
||||
"""
|
||||
...
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search entries by query.
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
limit: Maximum results to return
|
||||
|
||||
Returns:
|
||||
List of matching entries
|
||||
"""
|
||||
...
|
||||
|
||||
def list_all(
|
||||
self,
|
||||
limit: int = 100,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List all entries.
|
||||
|
||||
Args:
|
||||
limit: Maximum entries to return
|
||||
|
||||
Returns:
|
||||
List of all entries (most recent first)
|
||||
"""
|
||||
...
|
||||
|
||||
def get(
|
||||
self,
|
||||
entry_id: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get entry by ID.
|
||||
|
||||
Args:
|
||||
entry_id: Entry identifier
|
||||
|
||||
Returns:
|
||||
Entry dict or None if not found
|
||||
"""
|
||||
...
|
||||
|
||||
def update(
|
||||
self,
|
||||
entry_id: str,
|
||||
content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Update an existing entry.
|
||||
|
||||
Args:
|
||||
entry_id: Entry identifier
|
||||
content: New content
|
||||
metadata: Optional metadata to merge
|
||||
|
||||
Returns:
|
||||
Updated entry or None if not found
|
||||
"""
|
||||
...
|
||||
|
||||
def delete(
|
||||
self,
|
||||
entry_id: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Delete an entry.
|
||||
|
||||
Args:
|
||||
entry_id: Entry identifier
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
...
|
||||
|
||||
def clear(self) -> int:
|
||||
"""
|
||||
Clear all entries.
|
||||
|
||||
Returns:
|
||||
Number of entries cleared
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AsyncLearnProtocol(Protocol):
|
||||
"""
|
||||
Async Protocol for learning store implementations.
|
||||
|
||||
For backends that support async operations.
|
||||
"""
|
||||
|
||||
async def aadd(
|
||||
self,
|
||||
content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Async add entry."""
|
||||
...
|
||||
|
||||
async def asearch(
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Async search entries."""
|
||||
...
|
||||
|
||||
async def alist_all(
|
||||
self,
|
||||
limit: int = 100,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Async list all entries."""
|
||||
...
|
||||
|
||||
async def aget(
|
||||
self,
|
||||
entry_id: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Async get entry by ID."""
|
||||
...
|
||||
|
||||
async def aupdate(
|
||||
self,
|
||||
entry_id: str,
|
||||
content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Async update entry."""
|
||||
...
|
||||
|
||||
async def adelete(
|
||||
self,
|
||||
entry_id: str,
|
||||
) -> bool:
|
||||
"""Async delete entry."""
|
||||
...
|
||||
|
||||
async def aclear(self) -> int:
|
||||
"""Async clear all entries."""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class LearnManagerProtocol(Protocol):
|
||||
"""
|
||||
Protocol for LearnManager implementations.
|
||||
|
||||
Defines the interface for managing learning across multiple stores.
|
||||
"""
|
||||
|
||||
def capture_persona(self, content: str, category: str = "general") -> Optional[Dict[str, Any]]:
|
||||
"""Capture user preference or profile information."""
|
||||
...
|
||||
|
||||
def capture_insight(self, content: str, source: str = "interaction") -> Optional[Dict[str, Any]]:
|
||||
"""Capture an observation or learning."""
|
||||
...
|
||||
|
||||
def capture_pattern(self, pattern: str, pattern_type: str = "general") -> Optional[Dict[str, Any]]:
|
||||
"""Capture a reusable knowledge pattern."""
|
||||
...
|
||||
|
||||
def get_learning_context(self, limit_per_store: int = 5) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Get learning context from all enabled stores."""
|
||||
...
|
||||
|
||||
def search(self, query: str, limit: int = 10) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Search across all enabled stores."""
|
||||
...
|
||||
|
||||
def to_system_prompt_context(self) -> str:
|
||||
"""Generate context for system prompt injection."""
|
||||
...
|
||||
|
||||
def process_conversation(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
llm: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Process a conversation to extract learnings automatically.
|
||||
|
||||
Args:
|
||||
messages: Conversation messages
|
||||
llm: Optional LLM model to use for extraction
|
||||
|
||||
Returns:
|
||||
Dictionary with extracted learnings
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
__all__ = [
|
||||
'LearnMode',
|
||||
'LearnProtocol',
|
||||
'AsyncLearnProtocol',
|
||||
'LearnManagerProtocol',
|
||||
]
|
||||
@@ -73,12 +73,23 @@ class BaseStore(ABC):
|
||||
self._backend = backend
|
||||
self.store_path = store_path or self._default_path()
|
||||
self._entries: Dict[str, LearnEntry] = {}
|
||||
self._was_updated: bool = False
|
||||
|
||||
# DRY: Use BaseJSONStore for thread-safe storage
|
||||
self._store = BaseJSONStore(
|
||||
storage_path=self.store_path,
|
||||
backend=backend,
|
||||
)
|
||||
# Fall back to FILE backend if connection fails (e.g., Redis unavailable)
|
||||
try:
|
||||
self._store = BaseJSONStore(
|
||||
storage_path=self.store_path,
|
||||
backend=backend,
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.warning(f"Failed to connect to backend: {e}. Falling back to FILE.")
|
||||
self._backend = None
|
||||
self._store = BaseJSONStore(
|
||||
storage_path=self.store_path,
|
||||
backend=None,
|
||||
)
|
||||
self._load()
|
||||
|
||||
@property
|
||||
@@ -87,6 +98,15 @@ class BaseStore(ABC):
|
||||
"""Name of the store (used for file naming)."""
|
||||
pass
|
||||
|
||||
@property
|
||||
def was_updated(self) -> bool:
|
||||
"""Whether this store has been modified since last reset."""
|
||||
return self._was_updated
|
||||
|
||||
def reset_updated(self) -> None:
|
||||
"""Reset the was_updated flag."""
|
||||
self._was_updated = False
|
||||
|
||||
def _default_path(self) -> str:
|
||||
"""Default storage path (uses centralized paths - DRY)."""
|
||||
from ...paths import get_learn_dir, ensure_dir
|
||||
@@ -106,7 +126,24 @@ class BaseStore(ABC):
|
||||
self._store.save({k: v.to_dict() for k, v in self._entries.items()})
|
||||
|
||||
def add(self, content: str, metadata: Optional[Dict[str, Any]] = None) -> LearnEntry:
|
||||
"""Add a new entry."""
|
||||
"""Add a new entry with deduplication.
|
||||
|
||||
Checks for exact content matches to prevent duplicate learnings.
|
||||
If a duplicate is found, returns the existing entry instead of creating a new one.
|
||||
"""
|
||||
# Deduplication: Check for exact content match
|
||||
content_normalized = content.strip().lower()
|
||||
for existing_entry in self._entries.values():
|
||||
if existing_entry.content.strip().lower() == content_normalized:
|
||||
# Update metadata if provided, but don't create duplicate
|
||||
if metadata:
|
||||
existing_entry.metadata.update(metadata)
|
||||
existing_entry.updated_at = datetime.utcnow().isoformat()
|
||||
self._save()
|
||||
self._was_updated = True
|
||||
return existing_entry
|
||||
|
||||
# No duplicate found, create new entry
|
||||
entry_id = f"{self.store_name}_{len(self._entries)}_{datetime.utcnow().timestamp()}"
|
||||
entry = LearnEntry(
|
||||
id=entry_id,
|
||||
@@ -115,6 +152,7 @@ class BaseStore(ABC):
|
||||
)
|
||||
self._entries[entry_id] = entry
|
||||
self._save()
|
||||
self._was_updated = True
|
||||
return entry
|
||||
|
||||
def get(self, entry_id: str) -> Optional[LearnEntry]:
|
||||
@@ -146,6 +184,7 @@ class BaseStore(ABC):
|
||||
entry.metadata.update(metadata)
|
||||
entry.updated_at = datetime.utcnow().isoformat()
|
||||
self._save()
|
||||
self._was_updated = True
|
||||
return entry
|
||||
|
||||
def delete(self, entry_id: str) -> bool:
|
||||
@@ -153,6 +192,7 @@ class BaseStore(ABC):
|
||||
if entry_id in self._entries:
|
||||
del self._entries[entry_id]
|
||||
self._save()
|
||||
self._was_updated = True
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -161,6 +201,7 @@ class BaseStore(ABC):
|
||||
count = len(self._entries)
|
||||
self._entries = {}
|
||||
self._save()
|
||||
self._was_updated = True
|
||||
return count
|
||||
|
||||
|
||||
|
||||
@@ -113,7 +113,8 @@ class MCPConfigManager:
|
||||
- Easy server management
|
||||
"""
|
||||
|
||||
MCP_DIR_NAME = ".praisonai/mcp"
|
||||
from ..paths import DEFAULT_DIR_NAME as _DIR_NAME
|
||||
MCP_DIR_NAME = f"{_DIR_NAME}/mcp"
|
||||
SUPPORTED_EXTENSIONS = [".json", ".mcpjson"]
|
||||
|
||||
def __init__(
|
||||
@@ -131,7 +132,11 @@ class MCPConfigManager:
|
||||
verbose: Verbosity level
|
||||
"""
|
||||
self.workspace_path = Path(workspace_path) if workspace_path else Path.cwd()
|
||||
self.global_mcp_path = Path(global_mcp_path) if global_mcp_path else Path.home() / ".praisonai" / "mcp"
|
||||
if global_mcp_path:
|
||||
self.global_mcp_path = Path(global_mcp_path)
|
||||
else:
|
||||
from ..paths import get_mcp_dir
|
||||
self.global_mcp_path = get_mcp_dir()
|
||||
self.verbose = verbose
|
||||
|
||||
self._configs: Dict[str, MCPConfig] = {}
|
||||
|
||||
@@ -3,6 +3,7 @@ import sqlite3
|
||||
import json
|
||||
import time
|
||||
import shutil
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional, Union, Literal
|
||||
import logging
|
||||
from datetime import datetime
|
||||
@@ -23,102 +24,147 @@ def trace(self, message, *args, **kwargs):
|
||||
|
||||
logging.Logger.trace = trace
|
||||
|
||||
# Lazy availability flags and cached imports
|
||||
_chromadb_cache = {"available": None, "module": None, "settings": None}
|
||||
_mem0_cache = {"available": None, "module": None}
|
||||
_openai_cache = {"available": None, "module": None}
|
||||
_litellm_cache = {"available": None, "module": None}
|
||||
_pymongo_cache = {"available": None, "module": None, "client": None}
|
||||
# Thread-safe lazy imports using proper thread synchronization
|
||||
import threading
|
||||
|
||||
# Thread-safe import cache
|
||||
_import_lock = threading.Lock()
|
||||
_module_cache = {}
|
||||
|
||||
def _check_chromadb():
|
||||
"""Lazily check chromadb availability and cache."""
|
||||
if _chromadb_cache["available"] is None:
|
||||
"""Thread-safe lazy check for chromadb availability."""
|
||||
if "chromadb" in _module_cache:
|
||||
return _module_cache["chromadb"]["available"]
|
||||
|
||||
with _import_lock:
|
||||
if "chromadb" in _module_cache:
|
||||
return _module_cache["chromadb"]["available"]
|
||||
|
||||
try:
|
||||
import chromadb
|
||||
from chromadb.config import Settings as ChromaSettings
|
||||
_chromadb_cache["available"] = True
|
||||
_chromadb_cache["module"] = chromadb
|
||||
_chromadb_cache["settings"] = ChromaSettings
|
||||
_module_cache["chromadb"] = {
|
||||
"available": True,
|
||||
"module": chromadb,
|
||||
"settings": ChromaSettings
|
||||
}
|
||||
except ImportError:
|
||||
_chromadb_cache["available"] = False
|
||||
return _chromadb_cache["available"]
|
||||
_module_cache["chromadb"] = {"available": False}
|
||||
|
||||
return _module_cache["chromadb"]["available"]
|
||||
|
||||
def _get_chromadb():
|
||||
"""Get chromadb module (lazy load)."""
|
||||
"""Get chromadb module and settings (thread-safe lazy load)."""
|
||||
if not _check_chromadb():
|
||||
raise ImportError("chromadb is required. Install with: pip install chromadb")
|
||||
return _chromadb_cache["module"], _chromadb_cache["settings"]
|
||||
return _module_cache["chromadb"]["module"], _module_cache["chromadb"]["settings"]
|
||||
|
||||
def _check_mem0():
|
||||
"""Lazily check mem0 availability."""
|
||||
if _mem0_cache["available"] is None:
|
||||
"""Thread-safe lazy check for mem0 availability."""
|
||||
if "mem0" in _module_cache:
|
||||
return _module_cache["mem0"]["available"]
|
||||
|
||||
with _import_lock:
|
||||
if "mem0" in _module_cache:
|
||||
return _module_cache["mem0"]["available"]
|
||||
|
||||
try:
|
||||
import mem0
|
||||
_mem0_cache["available"] = True
|
||||
_mem0_cache["module"] = mem0
|
||||
_module_cache["mem0"] = {
|
||||
"available": True,
|
||||
"module": mem0
|
||||
}
|
||||
except ImportError:
|
||||
_mem0_cache["available"] = False
|
||||
return _mem0_cache["available"]
|
||||
_module_cache["mem0"] = {"available": False}
|
||||
|
||||
return _module_cache["mem0"]["available"]
|
||||
|
||||
def _get_mem0():
|
||||
"""Get mem0 module (lazy load)."""
|
||||
"""Get mem0 module (thread-safe lazy load)."""
|
||||
if not _check_mem0():
|
||||
raise ImportError("mem0 is required. Install with: pip install mem0ai")
|
||||
return _mem0_cache["module"]
|
||||
return _module_cache["mem0"]["module"]
|
||||
|
||||
def _check_openai():
|
||||
"""Lazily check openai availability."""
|
||||
if _openai_cache["available"] is None:
|
||||
"""Thread-safe lazy check for openai availability."""
|
||||
if "openai" in _module_cache:
|
||||
return _module_cache["openai"]["available"]
|
||||
|
||||
with _import_lock:
|
||||
if "openai" in _module_cache:
|
||||
return _module_cache["openai"]["available"]
|
||||
|
||||
try:
|
||||
import openai
|
||||
_openai_cache["available"] = True
|
||||
_openai_cache["module"] = openai
|
||||
_module_cache["openai"] = {
|
||||
"available": True,
|
||||
"module": openai
|
||||
}
|
||||
except ImportError:
|
||||
_openai_cache["available"] = False
|
||||
return _openai_cache["available"]
|
||||
_module_cache["openai"] = {"available": False}
|
||||
|
||||
return _module_cache["openai"]["available"]
|
||||
|
||||
def _get_openai():
|
||||
"""Get openai module (lazy load)."""
|
||||
"""Get openai module (thread-safe lazy load)."""
|
||||
if not _check_openai():
|
||||
raise ImportError("openai is required. Install with: pip install openai")
|
||||
return _openai_cache["module"]
|
||||
return _module_cache["openai"]["module"]
|
||||
|
||||
def _check_litellm():
|
||||
"""Lazily check litellm availability."""
|
||||
if _litellm_cache["available"] is None:
|
||||
"""Thread-safe lazy check for litellm availability."""
|
||||
if "litellm" in _module_cache:
|
||||
return _module_cache["litellm"]["available"]
|
||||
|
||||
with _import_lock:
|
||||
if "litellm" in _module_cache:
|
||||
return _module_cache["litellm"]["available"]
|
||||
|
||||
try:
|
||||
import litellm
|
||||
litellm.telemetry = False
|
||||
_litellm_cache["available"] = True
|
||||
_litellm_cache["module"] = litellm
|
||||
_module_cache["litellm"] = {
|
||||
"available": True,
|
||||
"module": litellm
|
||||
}
|
||||
except ImportError:
|
||||
_litellm_cache["available"] = False
|
||||
return _litellm_cache["available"]
|
||||
_module_cache["litellm"] = {"available": False}
|
||||
|
||||
return _module_cache["litellm"]["available"]
|
||||
|
||||
def _get_litellm():
|
||||
"""Get litellm module (lazy load)."""
|
||||
"""Get litellm module (thread-safe lazy load)."""
|
||||
if not _check_litellm():
|
||||
raise ImportError("litellm is required. Install with: pip install litellm")
|
||||
return _litellm_cache["module"]
|
||||
return _module_cache["litellm"]["module"]
|
||||
|
||||
def _check_pymongo():
|
||||
"""Lazily check pymongo availability."""
|
||||
if _pymongo_cache["available"] is None:
|
||||
"""Thread-safe lazy check for pymongo availability."""
|
||||
if "pymongo" in _module_cache:
|
||||
return _module_cache["pymongo"]["available"]
|
||||
|
||||
with _import_lock:
|
||||
if "pymongo" in _module_cache:
|
||||
return _module_cache["pymongo"]["available"]
|
||||
|
||||
try:
|
||||
import pymongo
|
||||
from pymongo import MongoClient
|
||||
_pymongo_cache["available"] = True
|
||||
_pymongo_cache["module"] = pymongo
|
||||
_pymongo_cache["client"] = MongoClient
|
||||
_module_cache["pymongo"] = {
|
||||
"available": True,
|
||||
"module": pymongo,
|
||||
"client": MongoClient
|
||||
}
|
||||
except ImportError:
|
||||
_pymongo_cache["available"] = False
|
||||
return _pymongo_cache["available"]
|
||||
_module_cache["pymongo"] = {"available": False}
|
||||
|
||||
return _module_cache["pymongo"]["available"]
|
||||
|
||||
def _get_pymongo():
|
||||
"""Get pymongo module and MongoClient (lazy load)."""
|
||||
"""Get pymongo module and MongoClient (thread-safe lazy load)."""
|
||||
if not _check_pymongo():
|
||||
raise ImportError("pymongo is required. Install with: pip install pymongo")
|
||||
return _pymongo_cache["module"], _pymongo_cache["client"]
|
||||
return _module_cache["pymongo"]["module"], _module_cache["pymongo"]["client"]
|
||||
|
||||
|
||||
|
||||
@@ -189,6 +235,16 @@ class Memory:
|
||||
self.cfg = config or {}
|
||||
self.verbose = verbose
|
||||
|
||||
# Thread-local storage for SQLite connections (thread-safe)
|
||||
self._local = threading.local()
|
||||
|
||||
# Write lock for serializing database modifications (thread-safe)
|
||||
self._write_lock = threading.Lock()
|
||||
|
||||
# Connection registry for cleanup across threads (use regular set with careful cleanup)
|
||||
self._all_connections = set()
|
||||
self._connection_lock = threading.Lock() # Protect the connection registry
|
||||
|
||||
# Set logger level based on verbose
|
||||
if verbose >= 5:
|
||||
logger.setLevel(logging.INFO)
|
||||
@@ -226,15 +282,17 @@ class Memory:
|
||||
self.embedding_dimensions = self._get_embedding_dimensions(self.embedding_model)
|
||||
self._log_verbose(f"Using embedding dimensions: {self.embedding_dimensions}")
|
||||
|
||||
# Create .praisonai directory if it doesn't exist
|
||||
os.makedirs(".praisonai", exist_ok=True)
|
||||
# Create project data directory if it doesn't exist
|
||||
from ..paths import get_project_data_dir
|
||||
_project_data = str(get_project_data_dir())
|
||||
os.makedirs(_project_data, exist_ok=True)
|
||||
|
||||
# Short-term DB
|
||||
self.short_db = self.cfg.get("short_db", ".praisonai/short_term.db")
|
||||
self.short_db = self.cfg.get("short_db", os.path.join(_project_data, "short_term.db"))
|
||||
self._init_stm()
|
||||
|
||||
# Long-term DB
|
||||
self.long_db = self.cfg.get("long_db", ".praisonai/long_term.db")
|
||||
self.long_db = self.cfg.get("long_db", os.path.join(_project_data, "long_term.db"))
|
||||
self._init_ltm()
|
||||
|
||||
# Conditionally init Mem0, MongoDB, or local RAG
|
||||
@@ -245,6 +303,50 @@ class Memory:
|
||||
elif self.use_rag:
|
||||
self._init_chroma()
|
||||
|
||||
def _get_stm_conn(self):
|
||||
"""Get thread-local short-term memory SQLite connection."""
|
||||
if not hasattr(self._local, 'stm_conn') or self._local.stm_conn is None:
|
||||
self._local.stm_conn = sqlite3.connect(
|
||||
self.short_db,
|
||||
check_same_thread=False, # Allow cross-thread cleanup
|
||||
timeout=30.0 # 30 second timeout for lock contention
|
||||
)
|
||||
# Configure busy timeout for better contention handling
|
||||
self._local.stm_conn.execute("PRAGMA busy_timeout=30000") # 30 seconds
|
||||
|
||||
# Enable WAL mode for concurrent read/write without blocking
|
||||
result = self._local.stm_conn.execute("PRAGMA journal_mode=WAL").fetchone()
|
||||
if result and result[0].upper() != 'WAL':
|
||||
logger.warning(f"WAL mode not enabled for STM, got: {result[0]}")
|
||||
self._local.stm_conn.commit()
|
||||
|
||||
# Register connection for cleanup
|
||||
with self._connection_lock:
|
||||
self._all_connections.add(self._local.stm_conn)
|
||||
return self._local.stm_conn
|
||||
|
||||
def _get_ltm_conn(self):
|
||||
"""Get thread-local long-term memory SQLite connection."""
|
||||
if not hasattr(self._local, 'ltm_conn') or self._local.ltm_conn is None:
|
||||
self._local.ltm_conn = sqlite3.connect(
|
||||
self.long_db,
|
||||
check_same_thread=False, # Allow cross-thread cleanup
|
||||
timeout=30.0 # 30 second timeout for lock contention
|
||||
)
|
||||
# Configure busy timeout for better contention handling
|
||||
self._local.ltm_conn.execute("PRAGMA busy_timeout=30000") # 30 seconds
|
||||
|
||||
# Enable WAL mode for concurrent read/write without blocking
|
||||
result = self._local.ltm_conn.execute("PRAGMA journal_mode=WAL").fetchone()
|
||||
if result and result[0].upper() != 'WAL':
|
||||
logger.warning(f"WAL mode not enabled for LTM, got: {result[0]}")
|
||||
self._local.ltm_conn.commit()
|
||||
|
||||
# Register connection for cleanup
|
||||
with self._connection_lock:
|
||||
self._all_connections.add(self._local.ltm_conn)
|
||||
return self._local.ltm_conn
|
||||
|
||||
def _log_verbose(self, msg: str, level: int = logging.INFO):
|
||||
"""Only log if verbose >= 5"""
|
||||
if self.verbose >= 5:
|
||||
@@ -274,7 +376,7 @@ class Memory:
|
||||
def _init_stm(self):
|
||||
"""Creates or verifies short-term memory table."""
|
||||
os.makedirs(os.path.dirname(self.short_db) or ".", exist_ok=True)
|
||||
conn = sqlite3.connect(self.short_db)
|
||||
conn = self._get_stm_conn()
|
||||
c = conn.cursor()
|
||||
c.execute("""
|
||||
CREATE TABLE IF NOT EXISTS short_mem (
|
||||
@@ -285,12 +387,11 @@ class Memory:
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def _init_ltm(self):
|
||||
"""Creates or verifies long-term memory table."""
|
||||
os.makedirs(os.path.dirname(self.long_db) or ".", exist_ok=True)
|
||||
conn = sqlite3.connect(self.long_db)
|
||||
conn = self._get_ltm_conn()
|
||||
c = conn.cursor()
|
||||
c.execute("""
|
||||
CREATE TABLE IF NOT EXISTS long_mem (
|
||||
@@ -301,7 +402,6 @@ class Memory:
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def _init_mem0(self):
|
||||
"""Initialize Mem0 client for agent or user memory with optional graph support."""
|
||||
@@ -577,15 +677,15 @@ class Memory:
|
||||
logger.error(f"Failed to store in MongoDB short-term memory: {e}")
|
||||
raise
|
||||
|
||||
# Existing SQLite store logic
|
||||
# Existing SQLite store logic (with write lock for concurrency safety)
|
||||
try:
|
||||
conn = sqlite3.connect(self.short_db)
|
||||
conn.execute(
|
||||
"INSERT INTO short_mem (id, content, meta, created_at) VALUES (?,?,?,?)",
|
||||
(ident, text, json.dumps(metadata), created_at)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
conn = self._get_stm_conn()
|
||||
with self._write_lock: # Serialize write operations
|
||||
conn.execute(
|
||||
"INSERT INTO short_mem (id, content, meta, created_at) VALUES (?,?,?,?)",
|
||||
(ident, text, json.dumps(metadata), created_at)
|
||||
)
|
||||
conn.commit()
|
||||
logger.info(f"Successfully stored in SQLite short-term memory with ID: {ident}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store in SQLite short-term memory: {e}")
|
||||
@@ -711,13 +811,12 @@ class Memory:
|
||||
|
||||
else:
|
||||
# Local fallback
|
||||
conn = sqlite3.connect(self.short_db)
|
||||
conn = self._get_stm_conn()
|
||||
c = conn.cursor()
|
||||
rows = c.execute(
|
||||
"SELECT id, content, meta FROM short_mem WHERE content LIKE ? LIMIT ?",
|
||||
(f"%{query}%", limit)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
results = []
|
||||
for row in rows:
|
||||
@@ -737,10 +836,10 @@ class Memory:
|
||||
|
||||
def reset_short_term(self):
|
||||
"""Completely clears short-term memory."""
|
||||
conn = sqlite3.connect(self.short_db)
|
||||
conn.execute("DELETE FROM short_mem")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
conn = self._get_stm_conn()
|
||||
with self._write_lock: # Serialize write operations
|
||||
conn.execute("DELETE FROM short_mem")
|
||||
conn.commit()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Long-Term Methods
|
||||
@@ -811,15 +910,15 @@ class Memory:
|
||||
logger.error(f"Failed to store in MongoDB long-term memory: {e}")
|
||||
# Continue to SQLite fallback
|
||||
|
||||
# Store in SQLite
|
||||
# Store in SQLite (with write lock for concurrency safety)
|
||||
try:
|
||||
conn = sqlite3.connect(self.long_db)
|
||||
conn.execute(
|
||||
"INSERT INTO long_mem (id, content, meta, created_at) VALUES (?,?,?,?)",
|
||||
(ident, text, json.dumps(metadata), created)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
conn = self._get_ltm_conn()
|
||||
with self._write_lock: # Serialize write operations
|
||||
conn.execute(
|
||||
"INSERT INTO long_mem (id, content, meta, created_at) VALUES (?,?,?,?)",
|
||||
(ident, text, json.dumps(metadata), created)
|
||||
)
|
||||
conn.commit()
|
||||
logger.info(f"Successfully stored in SQLite with ID: {ident}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error storing in SQLite: {e}")
|
||||
@@ -1000,13 +1099,12 @@ class Memory:
|
||||
self._log_verbose(f"Error searching ChromaDB: {e}", logging.ERROR)
|
||||
|
||||
# Always try SQLite as fallback or additional source
|
||||
conn = sqlite3.connect(self.long_db)
|
||||
conn = self._get_ltm_conn()
|
||||
c = conn.cursor()
|
||||
rows = c.execute(
|
||||
"SELECT id, content, meta, created_at FROM long_mem WHERE content LIKE ? LIMIT ?",
|
||||
(f"%{query}%", limit)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
for row in rows:
|
||||
meta = json.loads(row[2] or "{}")
|
||||
@@ -1049,10 +1147,10 @@ class Memory:
|
||||
|
||||
def reset_long_term(self):
|
||||
"""Clear local LTM DB, plus Chroma, MongoDB, or mem0 if in use."""
|
||||
conn = sqlite3.connect(self.long_db)
|
||||
conn.execute("DELETE FROM long_mem")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
conn = self._get_ltm_conn()
|
||||
with self._write_lock: # Serialize write operations
|
||||
conn.execute("DELETE FROM long_mem")
|
||||
conn.commit()
|
||||
|
||||
if self.use_mem0 and hasattr(self, "mem0_client"):
|
||||
# Mem0 has no universal reset API. Could implement partial or no-op.
|
||||
@@ -1083,16 +1181,16 @@ class Memory:
|
||||
"""
|
||||
deleted = False
|
||||
|
||||
# Delete from SQLite
|
||||
# Delete from SQLite (with write lock for concurrency safety)
|
||||
try:
|
||||
conn = sqlite3.connect(self.short_db)
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM short_mem WHERE id = ?", (memory_id,)
|
||||
)
|
||||
if cursor.rowcount > 0:
|
||||
deleted = True
|
||||
conn.commit()
|
||||
conn.close()
|
||||
conn = self._get_stm_conn()
|
||||
with self._write_lock: # Serialize write operations
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM short_mem WHERE id = ?", (memory_id,)
|
||||
)
|
||||
if cursor.rowcount > 0:
|
||||
deleted = True
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
self._log_verbose(f"Error deleting from SQLite short-term: {e}", logging.ERROR)
|
||||
|
||||
@@ -1124,16 +1222,16 @@ class Memory:
|
||||
"""
|
||||
deleted = False
|
||||
|
||||
# Delete from SQLite
|
||||
# Delete from SQLite (with write lock for concurrency safety)
|
||||
try:
|
||||
conn = sqlite3.connect(self.long_db)
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM long_mem WHERE id = ?", (memory_id,)
|
||||
)
|
||||
if cursor.rowcount > 0:
|
||||
deleted = True
|
||||
conn.commit()
|
||||
conn.close()
|
||||
conn = self._get_ltm_conn()
|
||||
with self._write_lock: # Serialize write operations
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM long_mem WHERE id = ?", (memory_id,)
|
||||
)
|
||||
if cursor.rowcount > 0:
|
||||
deleted = True
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
self._log_verbose(f"Error deleting from SQLite long-term: {e}", logging.ERROR)
|
||||
|
||||
@@ -1788,10 +1886,9 @@ class Memory:
|
||||
|
||||
try:
|
||||
# Get short-term memories
|
||||
conn = sqlite3.connect(self.short_db)
|
||||
conn = self._get_stm_conn()
|
||||
c = conn.cursor()
|
||||
rows = c.execute("SELECT id, content, meta, created_at FROM short_mem").fetchall()
|
||||
conn.close()
|
||||
|
||||
for row in rows:
|
||||
meta = json.loads(row[2] or "{}")
|
||||
@@ -1804,10 +1901,9 @@ class Memory:
|
||||
})
|
||||
|
||||
# Get long-term memories
|
||||
conn = sqlite3.connect(self.long_db)
|
||||
conn = self._get_ltm_conn()
|
||||
c = conn.cursor()
|
||||
rows = c.execute("SELECT id, content, meta, created_at FROM long_mem").fetchall()
|
||||
conn.close()
|
||||
|
||||
for row in rows:
|
||||
meta = json.loads(row[2] or "{}")
|
||||
@@ -1871,3 +1967,57 @@ class Memory:
|
||||
if self.learn is None:
|
||||
return ""
|
||||
return self.learn.to_system_prompt_context()
|
||||
|
||||
def close_connections(self):
|
||||
"""
|
||||
Close database connections.
|
||||
|
||||
Closes the current thread's connections and attempts to close all known
|
||||
connections from other threads. Each thread should call this method before
|
||||
terminating to ensure proper cleanup.
|
||||
"""
|
||||
# Close current thread's connections
|
||||
if hasattr(self._local, 'stm_conn') and self._local.stm_conn:
|
||||
try:
|
||||
self._local.stm_conn.close()
|
||||
self._local.stm_conn = None
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing current thread's STM connection: {e}")
|
||||
|
||||
if hasattr(self._local, 'ltm_conn') and self._local.ltm_conn:
|
||||
try:
|
||||
self._local.ltm_conn.close()
|
||||
self._local.ltm_conn = None
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing current thread's LTM connection: {e}")
|
||||
|
||||
# Close all known connections from the registry
|
||||
with self._connection_lock: # Ensure thread safety during cleanup
|
||||
connections_to_close = list(self._all_connections)
|
||||
for conn in connections_to_close:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.debug(f"Error closing registered connection: {e}")
|
||||
# Clear the registry
|
||||
self._all_connections.clear()
|
||||
|
||||
def __enter__(self):
|
||||
"""Allow Memory to be used as a context manager."""
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Ensure connections are closed when leaving a context manager block."""
|
||||
self.close_connections()
|
||||
|
||||
def __del__(self):
|
||||
"""
|
||||
Attempt to clean up any open SQLite connections when this instance
|
||||
is garbage-collected. Errors are suppressed to avoid issues during
|
||||
interpreter shutdown.
|
||||
"""
|
||||
try:
|
||||
self.close_connections()
|
||||
except Exception:
|
||||
# Best-effort cleanup during garbage collection
|
||||
pass
|
||||
@@ -40,6 +40,9 @@ class MemoryProtocol(Protocol):
|
||||
|
||||
def search_long_term(self, query: str, limit: int = 5, **kwargs):
|
||||
return self._data[:limit]
|
||||
|
||||
def get_all_memories(self, **kwargs):
|
||||
return self._data
|
||||
|
||||
# Use in agent
|
||||
memory: MemoryProtocol = InMemoryStore()
|
||||
@@ -122,6 +125,15 @@ class MemoryProtocol(Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
def get_all_memories(self, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all memories from all backends.
|
||||
|
||||
Returns:
|
||||
List of all memory entries (short-term and long-term combined)
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ResettableMemoryProtocol(MemoryProtocol, Protocol):
|
||||
|
||||
@@ -122,7 +122,8 @@ class RulesManager:
|
||||
```
|
||||
"""
|
||||
|
||||
RULES_DIR_NAME = ".praisonai/rules"
|
||||
from ..paths import DEFAULT_DIR_NAME as _DIR_NAME
|
||||
RULES_DIR_NAME = f"{_DIR_NAME}/rules"
|
||||
SUPPORTED_EXTENSIONS = [".md", ".mdc", ".txt"]
|
||||
|
||||
# Root-level instruction files (like Claude, Codex, Gemini, Cursor, Windsurf)
|
||||
@@ -165,7 +166,11 @@ class RulesManager:
|
||||
verbose: Verbosity level
|
||||
"""
|
||||
self.workspace_path = Path(workspace_path) if workspace_path else Path.cwd()
|
||||
self.global_rules_path = Path(global_rules_path) if global_rules_path else Path.home() / ".praisonai" / "rules"
|
||||
if global_rules_path:
|
||||
self.global_rules_path = Path(global_rules_path)
|
||||
else:
|
||||
from ..paths import get_rules_dir
|
||||
self.global_rules_path = get_rules_dir()
|
||||
self.verbose = verbose
|
||||
|
||||
self._rules: Dict[str, Rule] = {}
|
||||
|
||||
@@ -782,7 +782,8 @@ class WorkflowManager:
|
||||
```
|
||||
"""
|
||||
|
||||
WORKFLOWS_DIR = ".praisonai/workflows"
|
||||
from ..paths import DEFAULT_DIR_NAME as _DIR_NAME
|
||||
WORKFLOWS_DIR = f"{_DIR_NAME}/workflows"
|
||||
SUPPORTED_EXTENSIONS = [".md", ".yaml", ".yml"]
|
||||
|
||||
def __init__(
|
||||
@@ -1857,7 +1858,8 @@ class WorkflowManager:
|
||||
|
||||
def _get_checkpoints_dir(self) -> Path:
|
||||
"""Get the checkpoints directory path."""
|
||||
checkpoints_dir = self.workspace_path / ".praisonai" / "checkpoints"
|
||||
from ..paths import DEFAULT_DIR_NAME
|
||||
checkpoints_dir = self.workspace_path / DEFAULT_DIR_NAME / "checkpoints"
|
||||
checkpoints_dir.mkdir(parents=True, exist_ok=True)
|
||||
return checkpoints_dir
|
||||
|
||||
|
||||
@@ -49,6 +49,15 @@ __all__ = [
|
||||
"disable_trace_output",
|
||||
"is_trace_output_enabled",
|
||||
"get_trace_output",
|
||||
# Editor output (for editor preset - beginner-friendly numbered steps)
|
||||
"EditorOutput",
|
||||
"enable_editor_output",
|
||||
"disable_editor_output",
|
||||
"is_editor_output_enabled",
|
||||
"get_editor_output",
|
||||
"TOOL_LABELS",
|
||||
"BlockType",
|
||||
"DisplayBlock",
|
||||
]
|
||||
|
||||
|
||||
@@ -112,4 +121,37 @@ def __getattr__(name: str):
|
||||
from .trace import get_trace_output
|
||||
return get_trace_output
|
||||
|
||||
# Editor output (for editor preset - beginner-friendly numbered steps)
|
||||
if name == "EditorOutput":
|
||||
from .editor import EditorOutput
|
||||
return EditorOutput
|
||||
|
||||
if name == "enable_editor_output":
|
||||
from .editor import enable_editor_output
|
||||
return enable_editor_output
|
||||
|
||||
if name == "disable_editor_output":
|
||||
from .editor import disable_editor_output
|
||||
return disable_editor_output
|
||||
|
||||
if name == "is_editor_output_enabled":
|
||||
from .editor import is_editor_output_enabled
|
||||
return is_editor_output_enabled
|
||||
|
||||
if name == "get_editor_output":
|
||||
from .editor import get_editor_output
|
||||
return get_editor_output
|
||||
|
||||
if name == "TOOL_LABELS":
|
||||
from .editor import TOOL_LABELS
|
||||
return TOOL_LABELS
|
||||
|
||||
if name == "BlockType":
|
||||
from .editor import BlockType
|
||||
return BlockType
|
||||
|
||||
if name == "DisplayBlock":
|
||||
from .editor import DisplayBlock
|
||||
return DisplayBlock
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,543 @@
|
||||
"""
|
||||
Editor-Style Output Mode for PraisonAI Agents.
|
||||
|
||||
Provides a user-friendly display format with:
|
||||
- Numbered steps: Step 1: 📄 Creating file: /path
|
||||
- Human-readable tool names (not technical names)
|
||||
- Smart result formatting (JSON → summary, exit → ✓ Done)
|
||||
- Completion summary with duration and block count
|
||||
|
||||
Usage:
|
||||
# Via preset:
|
||||
agent = Agent(output="editor")
|
||||
|
||||
# Programmatic:
|
||||
from praisonaiagents.output.editor import enable_editor_output
|
||||
enable_editor_output()
|
||||
"""
|
||||
|
||||
import time
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
from enum import Enum
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Module-level state (same pattern as status.py)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
_editor_output_enabled = False
|
||||
_editor_output: Optional['EditorOutput'] = None
|
||||
|
||||
|
||||
class BlockType(Enum):
|
||||
"""Types of display blocks."""
|
||||
NARRATIVE = "narrative"
|
||||
COMMAND = "command"
|
||||
SUMMARY = "summary"
|
||||
ACTION = "action"
|
||||
CODE = "code"
|
||||
LIST = "list"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DisplayBlock:
|
||||
"""A single display block."""
|
||||
type: BlockType
|
||||
content: str
|
||||
title: Optional[str] = None
|
||||
items: List[str] = field(default_factory=list)
|
||||
output: Optional[str] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
# Human-readable tool name mappings
|
||||
TOOL_LABELS: Dict[str, tuple] = {
|
||||
# Web / search
|
||||
'internet_search': ('🔍', 'Searching the web'),
|
||||
'search_web': ('🔍', 'Searching the web'),
|
||||
'web_search': ('🔍', 'Searching the web'),
|
||||
# File operations
|
||||
'read_file': ('📖', 'Reading file'),
|
||||
'write_file': ('📝', 'Writing file'),
|
||||
'create_file': ('📄', 'Creating file'),
|
||||
'acp_create_file': ('📄', 'Creating file'),
|
||||
'acp_edit_file': ('✏️', 'Editing file'),
|
||||
'acp_delete_file': ('🗑️', 'Deleting file'),
|
||||
# Execution
|
||||
'execute_command': ('⚡', 'Running command'),
|
||||
'acp_execute_command': ('⚡', 'Running command'),
|
||||
# Directory
|
||||
'list_files': ('📂', 'Listing files'),
|
||||
'get_system_info': ('💻', 'Getting system info'),
|
||||
# LSP
|
||||
'lsp_get_diagnostics': ('🔬', 'Analyzing code'),
|
||||
'lsp_list_symbols': ('🔎', 'Finding code symbols'),
|
||||
'lsp_find_definition': ('📍', 'Finding definition'),
|
||||
'lsp_find_references': ('🔗', 'Finding references'),
|
||||
}
|
||||
|
||||
|
||||
class EditorOutput:
|
||||
"""
|
||||
User-friendly display for agent output.
|
||||
|
||||
Renders tool calls as numbered steps with emoji icons and
|
||||
human-readable labels. Formats results smartly (JSON → summary).
|
||||
|
||||
Thread-safe for multi-agent execution.
|
||||
"""
|
||||
|
||||
def __init__(self, console=None, use_rich: bool = True, agent_name: Optional[str] = None):
|
||||
self._use_rich = use_rich
|
||||
self._console = console
|
||||
self._blocks: List[DisplayBlock] = []
|
||||
self._start_time = time.time()
|
||||
self._step_count = 0
|
||||
self._llm_call_count = 0
|
||||
self._lock = threading.Lock()
|
||||
self._agent_name = agent_name # For multi-agent prefix support
|
||||
self._multi_agent_mode = False # Set True when multiple agents detected
|
||||
|
||||
if use_rich and console is None:
|
||||
try:
|
||||
from rich.console import Console
|
||||
self._console = Console()
|
||||
except ImportError:
|
||||
self._use_rich = False
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Public API
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def tool_call(
|
||||
self,
|
||||
tool_name: str,
|
||||
args: Optional[Dict[str, Any]] = None,
|
||||
result: Optional[str] = None,
|
||||
duration: Optional[float] = None,
|
||||
) -> None:
|
||||
"""Display a tool call as a numbered step with emoji."""
|
||||
with self._lock:
|
||||
self._step_count += 1
|
||||
step_num = self._step_count
|
||||
|
||||
# Get human-readable label
|
||||
icon, label = TOOL_LABELS.get(tool_name, ('🔧', f'Using {tool_name}'))
|
||||
|
||||
# Format action description with step number and context
|
||||
step_prefix = f"Step {step_num}: "
|
||||
action = self._format_action(step_prefix, icon, label, args)
|
||||
|
||||
# Format result for display
|
||||
display_result = self._format_result(result)
|
||||
|
||||
self._add_block(BlockType.COMMAND, action, output=display_result)
|
||||
self._render_command(action, display_result)
|
||||
|
||||
def llm_indicator(self, phase: str = "thinking") -> None:
|
||||
"""Display a subtle LLM activity indicator between steps."""
|
||||
if self._use_rich:
|
||||
self._console.print(f"[dim]▸ {phase.capitalize()}...[/dim]")
|
||||
else:
|
||||
print(f"▸ {phase.capitalize()}...")
|
||||
|
||||
def output(self, content: str, agent_name: Optional[str] = None) -> None:
|
||||
"""Display final agent output."""
|
||||
if self._use_rich:
|
||||
self._console.print()
|
||||
self._console.print(content)
|
||||
self._console.print()
|
||||
else:
|
||||
print()
|
||||
print(content)
|
||||
print()
|
||||
|
||||
def summary(self, title: str, items: Optional[List[str]] = None) -> None:
|
||||
"""Display completion summary."""
|
||||
self._add_block(BlockType.SUMMARY, "", title=title, items=items or [])
|
||||
self._render_summary(title, items)
|
||||
|
||||
def elapsed_time(self) -> float:
|
||||
"""Get elapsed time since display started."""
|
||||
return time.time() - self._start_time
|
||||
|
||||
def get_blocks(self) -> List[DisplayBlock]:
|
||||
"""Get all display blocks."""
|
||||
with self._lock:
|
||||
return self._blocks.copy()
|
||||
|
||||
def set_agent_name(self, name: str) -> None:
|
||||
"""Set the agent name for multi-agent prefix."""
|
||||
with self._lock:
|
||||
self._agent_name = name
|
||||
|
||||
def enable_multi_agent_mode(self) -> None:
|
||||
"""Enable multi-agent mode to show agent prefixes."""
|
||||
with self._lock:
|
||||
self._multi_agent_mode = True
|
||||
|
||||
def _get_prefix(self, agent_name: Optional[str] = None) -> str:
|
||||
"""Get the agent prefix for multi-agent mode."""
|
||||
if not self._multi_agent_mode:
|
||||
return ""
|
||||
name = agent_name or self._agent_name
|
||||
if name:
|
||||
return f"[{name}] "
|
||||
return ""
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Additional display methods (merged from CLI EditorDisplay)
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def narrative(self, text: str, agent_name: Optional[str] = None) -> None:
|
||||
"""
|
||||
Display a narrative block (agent thinking/explanation).
|
||||
|
||||
Args:
|
||||
text: The narrative text
|
||||
agent_name: Optional agent name for multi-agent prefix
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return
|
||||
self._add_block(BlockType.NARRATIVE, text)
|
||||
prefix = self._get_prefix(agent_name)
|
||||
if self._use_rich:
|
||||
self._console.print(f"{prefix}{text}")
|
||||
else:
|
||||
print(f"{prefix}{text}")
|
||||
|
||||
def code(self, code: str, language: str = "") -> None:
|
||||
"""
|
||||
Display a code block.
|
||||
|
||||
Args:
|
||||
code: The code content
|
||||
language: Programming language for syntax highlighting
|
||||
"""
|
||||
self._add_block(BlockType.CODE, code, metadata={"language": language})
|
||||
if self._use_rich:
|
||||
try:
|
||||
from rich.syntax import Syntax
|
||||
syntax = Syntax(code, language or "text", theme="monokai")
|
||||
self._console.print(syntax)
|
||||
except ImportError:
|
||||
self._console.print(f"```{language}\n{code}\n```")
|
||||
else:
|
||||
print(f"```{language}")
|
||||
print(code)
|
||||
print("```")
|
||||
|
||||
def action(self, title: str, details: Optional[List[str]] = None) -> None:
|
||||
"""
|
||||
Display an action summary block.
|
||||
|
||||
Args:
|
||||
title: Action title (e.g., "Changes applied")
|
||||
details: List of detail items
|
||||
"""
|
||||
self._add_block(BlockType.ACTION, "", title=title, items=details or [])
|
||||
if self._use_rich:
|
||||
self._console.print(f"[bold green]✓ {title}[/bold green]")
|
||||
if details:
|
||||
for item in details:
|
||||
self._console.print(f" - {item}")
|
||||
else:
|
||||
print(f"✓ {title}")
|
||||
if details:
|
||||
for item in details:
|
||||
print(f" - {item}")
|
||||
|
||||
def list_items(self, items: List[str], title: Optional[str] = None) -> None:
|
||||
"""
|
||||
Display a bulleted list.
|
||||
|
||||
Args:
|
||||
items: List items
|
||||
title: Optional title
|
||||
"""
|
||||
self._add_block(BlockType.LIST, "", title=title, items=items)
|
||||
if self._use_rich:
|
||||
if title:
|
||||
self._console.print(f"[bold]{title}[/bold]")
|
||||
for item in items:
|
||||
self._console.print(f" • {item}")
|
||||
else:
|
||||
if title:
|
||||
print(title)
|
||||
for item in items:
|
||||
print(f" • {item}")
|
||||
|
||||
def error(self, message: str, agent_name: Optional[str] = None) -> None:
|
||||
"""
|
||||
Display an error message.
|
||||
|
||||
Args:
|
||||
message: Error message
|
||||
agent_name: Optional agent name for multi-agent prefix
|
||||
"""
|
||||
prefix = self._get_prefix(agent_name)
|
||||
if self._use_rich:
|
||||
self._console.print(f"[red]{prefix}✗ Error: {message}[/red]")
|
||||
else:
|
||||
print(f"{prefix}✗ Error: {message}")
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
"""
|
||||
Export all blocks as Markdown.
|
||||
|
||||
Returns:
|
||||
Markdown string representation of all blocks
|
||||
"""
|
||||
lines = []
|
||||
with self._lock:
|
||||
blocks = self._blocks.copy()
|
||||
|
||||
for block in blocks:
|
||||
if block.type == BlockType.NARRATIVE:
|
||||
lines.append(block.content)
|
||||
lines.append("")
|
||||
elif block.type == BlockType.COMMAND:
|
||||
lines.append(f"**{block.content}**")
|
||||
if block.output:
|
||||
lines.append(f"> {block.output}")
|
||||
lines.append("")
|
||||
elif block.type == BlockType.CODE:
|
||||
lang = block.metadata.get("language", "")
|
||||
lines.append(f"```{lang}")
|
||||
lines.append(block.content)
|
||||
lines.append("```")
|
||||
lines.append("")
|
||||
elif block.type == BlockType.SUMMARY:
|
||||
if block.title:
|
||||
lines.append(f"## {block.title}")
|
||||
if block.items:
|
||||
for item in block.items:
|
||||
lines.append(f"- {item}")
|
||||
lines.append("")
|
||||
elif block.type == BlockType.ACTION:
|
||||
if block.title:
|
||||
lines.append(f"✓ **{block.title}**")
|
||||
if block.items:
|
||||
for item in block.items:
|
||||
lines.append(f" - {item}")
|
||||
lines.append("")
|
||||
elif block.type == BlockType.LIST:
|
||||
if block.title:
|
||||
lines.append(f"**{block.title}**")
|
||||
for item in block.items:
|
||||
lines.append(f"- {item}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Formatting helpers
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _format_action(
|
||||
step_prefix: str,
|
||||
icon: str,
|
||||
label: str,
|
||||
args: Optional[Dict[str, Any]],
|
||||
) -> str:
|
||||
"""Build human-readable action string from tool args."""
|
||||
if not args:
|
||||
return f"{step_prefix}{icon} {label}"
|
||||
|
||||
# Extract the most meaningful argument for context
|
||||
for key in ('query', 'filepath', 'file_path', 'directory', 'command'):
|
||||
if key in args:
|
||||
val = args[key]
|
||||
if key == 'query':
|
||||
return f'{step_prefix}{icon} {label} for "{val}"'
|
||||
return f"{step_prefix}{icon} {label}: {val}"
|
||||
|
||||
return f"{step_prefix}{icon} {label}"
|
||||
|
||||
@staticmethod
|
||||
def _format_result(result: Optional[str]) -> Optional[str]:
|
||||
"""Convert raw tool result to a user-friendly summary."""
|
||||
if not result:
|
||||
return None
|
||||
|
||||
result_str = str(result)
|
||||
|
||||
# JSON → summary
|
||||
if result_str.startswith(('[', '{')):
|
||||
try:
|
||||
import json
|
||||
data = json.loads(result_str)
|
||||
if isinstance(data, list):
|
||||
return f"✓ Found {len(data)} items"
|
||||
if isinstance(data, dict):
|
||||
if data.get('success') is True:
|
||||
return f"✓ Created: {data['file_created']}" if data.get('file_created') else "✓ Success"
|
||||
if data.get('success') is False:
|
||||
return f"⚠ Failed: {data.get('error', 'Unknown')[:80]}"
|
||||
if data.get('error'):
|
||||
return f"⚠ {data['error'][:80]}"
|
||||
if data.get('stdout'):
|
||||
first_line = data['stdout'].strip().split('\n')[0][:80]
|
||||
return f"✓ {first_line}"
|
||||
if data.get('exit_code') == 0:
|
||||
return "✓ Command completed"
|
||||
return "✓ Done"
|
||||
except (ValueError, TypeError):
|
||||
return "✓ Done"
|
||||
|
||||
# Boolean strings
|
||||
if result_str.lower() == 'true':
|
||||
return "✓ Success"
|
||||
if result_str.lower() == 'false':
|
||||
return "⚠ Failed"
|
||||
|
||||
# Short plain text → show it; long text → "Done"
|
||||
if len(result_str) <= 80 and '\n' not in result_str:
|
||||
return result_str
|
||||
return "✓ Done"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Internal rendering
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _add_block(self, block_type, content, **kwargs):
|
||||
block = DisplayBlock(type=block_type, content=content, **kwargs)
|
||||
with self._lock:
|
||||
self._blocks.append(block)
|
||||
|
||||
def _render_command(self, cmd: str, output: Optional[str] = None):
|
||||
if self._use_rich:
|
||||
self._console.print(f"[dim]{cmd}[/dim]")
|
||||
if output:
|
||||
self._console.print(output)
|
||||
else:
|
||||
print(cmd)
|
||||
if output:
|
||||
print(output)
|
||||
|
||||
def _render_summary(self, title: str, items: Optional[List[str]] = None):
|
||||
if self._use_rich:
|
||||
self._console.print()
|
||||
self._console.print(f"[bold]{title}[/bold]")
|
||||
if items:
|
||||
for item in items:
|
||||
self._console.print(f"- {item}")
|
||||
else:
|
||||
print()
|
||||
print(title)
|
||||
if items:
|
||||
for item in items:
|
||||
print(f"- {item}")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Module-level enable / disable (same pattern as status.py)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def is_editor_output_enabled() -> bool:
|
||||
"""Check whether editor output mode is currently active."""
|
||||
return _editor_output_enabled
|
||||
|
||||
|
||||
def enable_editor_output(
|
||||
use_color: bool = True,
|
||||
) -> EditorOutput:
|
||||
"""
|
||||
Enable editor output mode globally.
|
||||
|
||||
Registers display callbacks that render tool calls as numbered steps.
|
||||
|
||||
Returns:
|
||||
EditorOutput instance for programmatic access.
|
||||
"""
|
||||
global _editor_output_enabled, _editor_output
|
||||
|
||||
_editor_output = EditorOutput(use_rich=use_color)
|
||||
_editor_output_enabled = True
|
||||
|
||||
# Register callbacks with the display system
|
||||
from ..main import register_display_callback
|
||||
|
||||
def on_tool_call(
|
||||
message: str = None,
|
||||
tool_name: str = None,
|
||||
tool_input: dict = None,
|
||||
tool_output: str = None,
|
||||
**kwargs,
|
||||
):
|
||||
if not _editor_output_enabled or _editor_output is None:
|
||||
return
|
||||
if tool_name:
|
||||
_editor_output.tool_call(
|
||||
tool_name=tool_name,
|
||||
args=tool_input,
|
||||
result=str(tool_output)[:500] if tool_output else None,
|
||||
)
|
||||
|
||||
def on_interaction(
|
||||
message: str = None,
|
||||
response: str = None,
|
||||
agent_name: str = None,
|
||||
generation_time: float = None,
|
||||
**kwargs,
|
||||
):
|
||||
if not _editor_output_enabled or _editor_output is None:
|
||||
return
|
||||
# Dedup: skip if this exact response was already displayed
|
||||
# Covers both llm_content→interaction and interaction→interaction duplicates
|
||||
# (LLM layer and Agent layer both fire execute_sync_callback('interaction'))
|
||||
if response and hasattr(_editor_output, '_last_displayed_response') and _editor_output._last_displayed_response == response.strip():
|
||||
_editor_output._last_displayed_response = None # Reset for next turn
|
||||
return
|
||||
if response:
|
||||
_editor_output._last_displayed_response = response.strip()
|
||||
_editor_output.output(response, agent_name)
|
||||
|
||||
def on_error(message: str = None, **kwargs):
|
||||
if not _editor_output_enabled or _editor_output is None:
|
||||
return
|
||||
if message and _editor_output._use_rich:
|
||||
_editor_output._console.print(f"[red]✗ Error: {message}[/red]")
|
||||
elif message:
|
||||
print(f"✗ Error: {message}")
|
||||
|
||||
def on_llm_start(model: str = None, agent_name: str = None, **kwargs):
|
||||
if not _editor_output_enabled or _editor_output is None:
|
||||
return
|
||||
with _editor_output._lock:
|
||||
_editor_output._llm_call_count += 1
|
||||
count = _editor_output._llm_call_count
|
||||
phase = "Thinking" if count == 1 else "Responding"
|
||||
_editor_output.llm_indicator(phase)
|
||||
|
||||
def on_llm_content(content: str = None, agent_name: str = None, **kwargs):
|
||||
if not _editor_output_enabled or _editor_output is None:
|
||||
return
|
||||
if content and content.strip():
|
||||
# Dedup: skip if on_interaction already displayed this exact text
|
||||
if hasattr(_editor_output, '_last_displayed_response') and _editor_output._last_displayed_response == content.strip():
|
||||
return
|
||||
_editor_output._last_displayed_response = content.strip()
|
||||
_editor_output.narrative(content.strip(), agent_name=agent_name)
|
||||
|
||||
register_display_callback('tool_call', on_tool_call)
|
||||
register_display_callback('interaction', on_interaction)
|
||||
register_display_callback('error', on_error)
|
||||
register_display_callback('llm_start', on_llm_start)
|
||||
register_display_callback('llm_content', on_llm_content)
|
||||
|
||||
return _editor_output
|
||||
|
||||
|
||||
def disable_editor_output() -> None:
|
||||
"""Disable editor output mode."""
|
||||
global _editor_output_enabled, _editor_output
|
||||
_editor_output_enabled = False
|
||||
_editor_output = None
|
||||
|
||||
|
||||
def get_editor_output() -> Optional[EditorOutput]:
|
||||
"""Get the current editor output instance."""
|
||||
return _editor_output
|
||||
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
Lightweight output models for PraisonAI Agents.
|
||||
|
||||
This module contains Pydantic models that are used across the codebase.
|
||||
It is kept separate from main.py to avoid importing rich at module level.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Optional, Dict, Any, Literal, TYPE_CHECKING
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..telemetry.token_collector import TokenMetrics
|
||||
|
||||
|
||||
class TaskOutput(BaseModel):
|
||||
"""Output model for task results."""
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
description: str
|
||||
summary: Optional[str] = None
|
||||
raw: str
|
||||
pydantic: Optional[BaseModel] = None
|
||||
json_dict: Optional[Dict[str, Any]] = None
|
||||
agent: str
|
||||
output_format: Literal["RAW", "JSON", "Pydantic"] = "RAW"
|
||||
token_metrics: Optional['TokenMetrics'] = None
|
||||
|
||||
def json(self) -> Optional[str]:
|
||||
if self.output_format == "JSON" and self.json_dict:
|
||||
return json.dumps(self.json_dict)
|
||||
return None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
output_dict = {}
|
||||
if self.json_dict:
|
||||
output_dict.update(self.json_dict)
|
||||
if self.pydantic:
|
||||
output_dict.update(self.pydantic.model_dump())
|
||||
return output_dict
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.pydantic:
|
||||
return str(self.pydantic)
|
||||
if self.json_dict:
|
||||
return json.dumps(self.json_dict, indent=2)
|
||||
return self.raw
|
||||
|
||||
|
||||
class ReflectionOutput(BaseModel):
|
||||
"""Output model for self-reflection results."""
|
||||
reflection: str
|
||||
satisfactory: Literal["yes", "no"]
|
||||
@@ -80,6 +80,7 @@ class StatusOutput:
|
||||
use_color: bool = True,
|
||||
show_timestamps: bool = True, # NEW: control timestamp display
|
||||
show_metrics: bool = False, # Enable metrics display for debug mode
|
||||
show_tool_output: bool = True, # Show tool result output inline
|
||||
):
|
||||
self._file = file or sys.stderr # Use stderr to not interfere with agent output
|
||||
self._format = format
|
||||
@@ -87,6 +88,7 @@ class StatusOutput:
|
||||
self._use_color = use_color
|
||||
self._show_timestamps = show_timestamps
|
||||
self._show_metrics = show_metrics
|
||||
self._show_tool_output = show_tool_output
|
||||
self._console = None
|
||||
self._tool_start_times: Dict[str, float] = {}
|
||||
self._lock = threading.Lock() # Per-sink lock for thread safety
|
||||
@@ -120,12 +122,12 @@ class StatusOutput:
|
||||
parts = []
|
||||
for k, v in (redacted or {}).items():
|
||||
if isinstance(v, str):
|
||||
v_str = f'"{_truncate(v, 30)}"'
|
||||
v_str = f'"{_truncate(v, 80)}"'
|
||||
else:
|
||||
v_str = _truncate(str(v), 30)
|
||||
v_str = _truncate(str(v), 80)
|
||||
parts.append(f"{k}={v_str}")
|
||||
|
||||
return _truncate(", ".join(parts), 80)
|
||||
return _truncate(", ".join(parts), 200)
|
||||
|
||||
def agent_start(self, agent_name: str) -> None:
|
||||
"""Record agent start."""
|
||||
@@ -185,8 +187,8 @@ class StatusOutput:
|
||||
"""Record LLM call end with optional metrics for debug mode."""
|
||||
ts = time.time()
|
||||
|
||||
# Use latency_ms if provided, otherwise calculate from start time
|
||||
if latency_ms is not None:
|
||||
# Use latency_ms if provided and positive, otherwise calculate from start time
|
||||
if latency_ms is not None and latency_ms > 0:
|
||||
duration_ms = latency_ms
|
||||
elif duration_ms is None and hasattr(self, '_llm_start_time'):
|
||||
start_ts = self._llm_start_time
|
||||
@@ -267,9 +269,13 @@ class StatusOutput:
|
||||
args_str = self._format_args(self._pending_tool_args)
|
||||
|
||||
result_str = ""
|
||||
if result_summary:
|
||||
result_str = f" → {_truncate(result_summary, 50)}"
|
||||
if self._show_tool_output:
|
||||
if result_summary:
|
||||
result_str = f" → {_truncate(result_summary, 50)}"
|
||||
elif error_message:
|
||||
result_str = f" → ✗ {_truncate(error_message, 50)}"
|
||||
elif error_message:
|
||||
# Always show errors even when output is hidden
|
||||
result_str = f" → ✗ {_truncate(error_message, 50)}"
|
||||
|
||||
color = "cyan" if status == "ok" else "red"
|
||||
@@ -285,9 +291,9 @@ class StatusOutput:
|
||||
separator = "─" * 50
|
||||
self._emit_text(separator, ts, "dim", show_timestamp=False)
|
||||
self._emit_text("Final Output:", ts, "bold", show_timestamp=False)
|
||||
# Print actual content without truncation for final output
|
||||
# Print actual content to stdout (not stderr) so users see it
|
||||
with self._lock: # Thread-safe output
|
||||
print(content, file=self._file)
|
||||
print(content)
|
||||
|
||||
def _emit_text(self, message: str, ts: float, style: str = None, show_timestamp: bool = True) -> None:
|
||||
"""Emit a text line. Thread-safe for multi-agent execution."""
|
||||
@@ -320,6 +326,7 @@ def enable_status_output(
|
||||
use_color: bool = True,
|
||||
show_timestamps: bool = True,
|
||||
show_metrics: bool = False, # Enable metrics for debug mode
|
||||
show_tool_output: bool = True, # Show tool result output inline
|
||||
) -> StatusOutput:
|
||||
"""
|
||||
Enable actions output mode globally.
|
||||
@@ -334,6 +341,7 @@ def enable_status_output(
|
||||
use_color: Whether to use colored output (default: True)
|
||||
show_timestamps: Whether to show timestamps (default: True)
|
||||
show_metrics: Whether to show token/cost metrics (default: False)
|
||||
show_tool_output: Whether to show tool result output inline (default: True)
|
||||
|
||||
Returns:
|
||||
StatusOutput instance for programmatic access
|
||||
@@ -350,6 +358,7 @@ def enable_status_output(
|
||||
use_color=use_color,
|
||||
show_timestamps=show_timestamps,
|
||||
show_metrics=show_metrics,
|
||||
show_tool_output=show_tool_output,
|
||||
)
|
||||
_status_output_enabled = True
|
||||
|
||||
|
||||
Loaded 100 of 286 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user