Compare commits

..
Author SHA1 Message Date
praisonai-triage-agent[bot]andMervin Praison 3e9826bda7 fix: resolve missing AgentTeam import and RouterAgent kwargs forwarding
- Fix missing AgentTeam import in self-reflection-optimization-patterns.py
- Forward kwargs properly in RouterAgent._build_system_prompt to super()
- Addresses critical issues identified by Gemini code reviewer

Co-authored-by: Mervin Praison <[email protected]>
2026-06-12 23:25:32 +00:00
praisonai-triage-agent[bot]andMervin Praison fc298049e2 fix: resolve critical RouterAgent **kwargs bug and add missing if __name__ guards
- Fixed TypeError in RouterAgent._build_system_prompt() by removing **kwargs from super() call
- Added if __name__ == '__main__' guards to 6 example files to prevent auto-execution on import
- Addresses Gemini Code Assist review feedback

Co-authored-by: Mervin Praison <[email protected]>
2026-06-12 23:25:32 +00:00
Dhivya-Bharathy 0b7bf10820 fix(examples): phase-4 API alignment for 40 validation failures on v4.6.52 2026-06-12 23:25:32 +00:00
Dhivya-Bharathy cbb7c726aa fix(examples): phase-3 API alignment for consolidated params on v4.6.52 2026-06-12 23:25:12 +00:00
Dhivya-Bharathy 21d1986d0c fix(examples): align AgentTeam memory and knowledge configs with v4.6.52 API 2026-06-12 23:25:12 +00:00
praisonai-triage-agent[bot]andMervin Praison 26b5792192 fix: correct TypeError in 3 example files by using proper parameter names
- Fix MemoryConfig(memory=...) → user_id=... in agent_centric_api.py
- Fix Session(memory=...) → user_id=... in session-example.py
- Fix build_context_for_task(memory=...) → user_id=... in memory-quality-example.py

Co-authored-by: Mervin Praison <[email protected]>
2026-06-12 23:25:12 +00:00
Dhivya-Bharathy afcea5800e fix(examples): resolve syntax errors and align with consolidated-params API 2026-06-12 23:25:12 +00:00
414 changed files with 9743 additions and 39224 deletions

No files matched your search

+36 -47
View File
@@ -151,40 +151,24 @@ jobs:
id: check_fork
uses: actions/github-script@v7
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const isPR = context.eventName === 'pull_request'
|| context.eventName === 'pull_request_review'
|| context.eventName === 'pull_request_review_comment'
|| (context.payload.issue && context.payload.issue.pull_request);
if (!isPR) {
core.setOutput('is_pr', 'false');
core.setOutput('is_fork', 'false');
return;
}
const prNumber = context.payload.pull_request
? context.payload.pull_request.number
: context.payload.issue.number;
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
core.setOutput('is_pr', 'true');
core.setOutput('pr_number', String(prNumber));
core.setOutput('pr_branch', pr.data.head.ref);
core.setOutput('head_repo', pr.data.head.repo.full_name);
core.setOutput('merge_state', (pr.data.mergeable_state || pr.data.merge_state_status || '').toUpperCase());
const baseRepo = `${context.repo.owner}/${context.repo.repo}`;
if (pr.data.head.repo && pr.data.head.repo.full_name !== baseRepo) {
core.setOutput('is_fork', 'true');
core.setOutput('clone_url', pr.data.head.repo.clone_url);
core.setOutput('branch', pr.data.head.ref);
return;
const isPR = context.eventName === 'pull_request' || (context.payload.issue && context.payload.issue.pull_request);
if (isPR) {
const prNumber = context.payload.pull_request ? context.payload.pull_request.number : context.payload.issue.number;
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
core.setOutput('pr_branch', pr.data.head.ref);
if (pr.data.head.repo && pr.data.head.repo.full_name !== context.repo.full_name) {
core.setOutput('is_fork', 'true');
core.setOutput('clone_url', pr.data.head.repo.clone_url);
core.setOutput('branch', pr.data.head.ref);
return;
}
}
core.setOutput('is_fork', 'false');
@@ -196,19 +180,17 @@ jobs:
token: ${{ steps.app-token.outputs.token }}
- name: Fetch PR branch and Setup Remote
if: steps.check_fork.outputs.is_pr == 'true'
if: github.event.issue.pull_request
env:
PR_BRANCH: ${{ steps.check_fork.outputs.pr_branch }}
PR_NUMBER: ${{ steps.check_fork.outputs.pr_number }}
IS_FORK: ${{ steps.check_fork.outputs.is_fork }}
run: |
git fetch origin "pull/${PR_NUMBER}/head:${PR_BRANCH}"
git checkout "${PR_BRANCH}"
git fetch origin main
# Fetch PR head from base repo and put it into local branch
git fetch origin pull/${{ github.event.issue.number }}/head:"${PR_BRANCH}"
# If it's a fork, make origin point to local to trick claude-code-action's `git fetch origin <branch>`
if [ "${IS_FORK}" = "true" ]; then
git remote set-url origin "file://$(pwd)"
git remote set-url origin file://$(pwd)
fi
- uses: anthropics/claude-code-action@beta
@@ -218,13 +200,11 @@ jobs:
allowed_bots: 'praisonai-triage-agent[bot]'
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
github_token: ${{ steps.app-token.outputs.token }}
model: opus
trigger_phrase: "@claude"
label_trigger: "claude"
direct_prompt: |
${{ steps.check_fork.outputs.is_fork == 'true' && 'CRITICAL: THIS IS A PULL REQUEST FROM A FORK. YOU DO NOT HAVE PUSH PERMISSIONS TO THIS REPOSITORY. ONLY READ FILES, VALIDATE CODE, AND PROVIDE YOUR COMMENTS/FEEDBACK DIRECTLY IN THIS PR VIA COMMENTS. DO NOT ATTEMPT TO PUSH OR CREATE PULL REQUESTS.\n\n' || (steps.check_fork.outputs.is_pr == 'true' && 'NOTE: The branch is under MervinPraison/PraisonAI (not a fork). You are able to make modifications and push directly to this branch.\n\n' || 'NOTE: This is an issue (not a PR). Create a new fix branch per STEP 3 below.\n\n') }}
${{ steps.check_fork.outputs.is_pr == 'true' && format('CONTEXT: PR #{0} on branch {1}. head.repo={2}, is_fork={3}, merge_state={4}. NEVER treat cursor/*, claude/* branch names or bot authors as forks — only is_fork=true means fork.\n\n', steps.check_fork.outputs.pr_number, steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.head_repo, steps.check_fork.outputs.is_fork, steps.check_fork.outputs.merge_state) || '' }}
${{ steps.check_fork.outputs.is_fork == 'true' && 'CRITICAL: THIS IS A PULL REQUEST FROM A FORK. YOU DO NOT HAVE PUSH PERMISSIONS TO THIS REPOSITORY. ONLY READ FILES, VALIDATE CODE, AND PROVIDE YOUR COMMENTS/FEEDBACK DIRECTLY IN THIS PR VIA COMMENTS. DO NOT ATTEMPT TO PUSH OR CREATE PULL REQUESTS.\n\n' || 'NOTE: The branch is under MervinPraison/PraisonAI (not a fork). You are able to make modifications and push directly to this branch.\n\n' }}
You are working on the PraisonAI SDK. Follow AGENTS.md strictly.
STEP 0 — SETUP GIT IDENTITY & AUTH (GLOBAL — required for all repos):
@@ -253,9 +233,15 @@ jobs:
- Is there a name collision with existing exports in __init__.py?
If ANY of these conceptual checks fail (excluding routing), add a comment to the issue explaining why and close it. Do NOT create a PR.
${{ steps.check_fork.outputs.is_pr == 'true' && steps.check_fork.outputs.is_fork != 'true' && steps.check_fork.outputs.merge_state == 'DIRTY' && format('STEP 3 — RESOLVE MERGE CONFLICTS ON PR (branch: {0}, PR #{1}):\n- DO NOT create a new branch or PR\n- git fetch origin main && git rebase origin/main\n- Resolve conflicts: keep this PR intent, merge in main newer logic\n- git add -A && git rebase --continue (repeat until done)\n- git push --force-with-lease origin {0}\n- Run targeted pytest on changed paths\n- Comment files resolved and what you skipped\n', steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.pr_number) || '' }}
${{ steps.check_fork.outputs.is_pr == 'true' && steps.check_fork.outputs.is_fork != 'true' && steps.check_fork.outputs.merge_state != 'DIRTY' && format('STEP 3 — IMPLEMENT ON EXISTING PR (branch: {0}, PR #{1}):\n- DO NOT create a new branch or PR\n- git checkout {0}\n- Implement minimal, focused fixes from reviewer feedback\n- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper\n- Keep changes small and backward-compatible\n- Commit: git add -A && git commit -m "fix: <description>"\n- Push: git push origin {0}\n- Comment a summary of exact files modified and what you skipped\n', steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.pr_number) || '' }}
${{ steps.check_fork.outputs.is_pr != 'true' && 'STEP 3 — IMPLEMENT (for changes in THIS repo — PraisonAI):\n- Create a fix branch: git checkout -b claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)\n- Implement a minimal, focused fix\n- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper\n- Keep changes small and backward-compatible\n- Commit: git add -A && git commit -m "fix: <description> (fixes #$ISSUE_NUMBER)"\n- Push: git push origin claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)\nCRITICAL: After pushing, you MUST immediately run: gh pr create --title "fix: <title>" --body "Fixes #$ISSUE_NUMBER" --head claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)\nDo NOT stop after pushing. Do NOT say "manually create a PR". Always create the PR automatically.\n' || '' }}
STEP 3 — IMPLEMENT (for changes in THIS repo — PraisonAI):
- Create a fix branch: git checkout -b claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)
- Implement a minimal, focused fix
- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper
- Keep changes small and backward-compatible
- Commit: git add -A && git commit -m "fix: <description> (fixes #$ISSUE_NUMBER)"
- Push: git push origin claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)
CRITICAL: After pushing, you MUST immediately run: gh pr create --title "fix: <title>" --body "Fixes #$ISSUE_NUMBER" --head claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)
Do NOT stop after pushing. Do NOT say "manually create a PR". Always create the PR automatically.
STEP 3-ALT — IMPLEMENT IN EXTERNAL REPO (for PraisonAI-Tools, PraisonAIDocs, PraisonAI-Plugins):
When work must happen in a different repository, follow these steps EXACTLY. Do NOT attempt to use `cd`, as directory state is not preserved. Use `git -C` for all commands.
@@ -281,7 +267,10 @@ jobs:
- For docs changes: verify files exist and are valid markdown/MDX
- Ensure no regressions
${{ steps.check_fork.outputs.is_pr == 'true' && 'STEP 5 — SKIP: Already on an open PR. Do NOT run gh pr create.\n' || 'STEP 5 — CREATE PR:\n- Commit with descriptive message, push, and create PR using `gh pr create`\n- For external repos, use `gh pr create -R MervinPraison/<REPO_NAME>`\nCRITICAL: You MUST create the PR automatically using `gh pr create`. Do NOT just provide a link or say "manual push required".\n' }}
STEP 5 — CREATE PR:
- Commit with descriptive message, push, and create PR using `gh pr create`
- For external repos, use `gh pr create -R MervinPraison/<REPO_NAME>`
CRITICAL: You MUST create the PR automatically using `gh pr create`. Do NOT just provide a link or say "manual push required".
NOTE: If you worked on an external repo, the wrapper action will report "No commits" for the main repo at the end. This is expected, do not worry about it.
allowed_tools: |
Bash(git:*)
+2
View File
@@ -22,6 +22,8 @@ jobs:
uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -36,6 +36,9 @@ jobs:
uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
with:
token: ${{ steps.generate_token.outputs.token }}
- name: Run Gemini Issue Triage
@@ -37,6 +37,9 @@ jobs:
uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
with:
token: ${{ steps.generate_token.outputs.token }}
- name: Set up Git
+3
View File
@@ -48,6 +48,9 @@ jobs:
uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
with:
token: ${{ steps.generate_token.outputs.token }}
ref: ${{ steps.checkout_ref.outputs.ref }}
fetch-depth: 0
+4 -4
View File
@@ -60,16 +60,16 @@ jobs:
- name: Run PraisonAI integration tests
run: |
cd PraisonAI/src/praisonai
python -m pytest tests/integration/test_aiui_* -v
python -m pytest tests/integration/test_aiui_* -v --timeout=300
- name: Run PraisonAIUI integration tests
run: |
cd PraisonAIUI
if [ -f tests/integration/test_agentic_roundtrip.py ]; then
python -m pytest tests/integration/test_agentic_roundtrip.py -v
python -m pytest tests/integration/test_agentic_roundtrip.py -v --timeout=300
fi
if [ -f tests/test_feature_sdk_backends.py ]; then
python -m pytest tests/test_feature_sdk_backends.py -v
python -m pytest tests/test_feature_sdk_backends.py -v --timeout=300
fi
- name: Test Pattern C CLI
@@ -152,7 +152,7 @@ jobs:
run: |
cd PraisonAI/src/praisonai
if [ -n "$OPENAI_API_KEY" ]; then
python -m pytest tests/integration/test_aiui_host_agentic.py -v
python -m pytest tests/integration/test_aiui_host_agentic.py -v --timeout=600
else
echo "OPENAI_API_KEY not available, skipping agentic tests"
fi
+2 -2
View File
@@ -15,8 +15,8 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
+12 -3
View File
@@ -26,8 +26,11 @@ jobs:
# First checkout the upstream repo at the specified tag
- name: Checkout upstream repo
uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
with:
persist-credentials: false
repository: MervinPraison/PraisonAI
ref: ${{ github.event.inputs.tag }}
fetch-depth: 0
@@ -45,8 +48,11 @@ jobs:
# Now checkout the fork at main branch
- name: Checkout fork
uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
with:
persist-credentials: false
repository: ${{ github.event.inputs.fork_owner }}/PraisonAI
ref: main
fetch-depth: 0
@@ -443,8 +449,11 @@ jobs:
# First checkout the fork's main branch
- name: Checkout fork repo
uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
with:
persist-credentials: false
fetch-depth: 0
token: ${{ secrets.PAT_TOKEN }}
repository: ${{ github.event.inputs.fork_owner }}/PraisonAI
@@ -1,31 +0,0 @@
name: Sync Claude Secrets to PraisonAIUI
on:
workflow_dispatch:
permissions:
contents: read
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Sync secrets to PraisonAIUI
env:
GH_TOKEN: ${{ secrets.SECRETS_ADMIN_PAT }}
TARGET: MervinPraison/PraisonAIUI
CLAUDE_APP_ID: ${{ secrets.CLAUDE_APP_ID }}
CLAUDE_APP_PRIVATE_KEY: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
GH_TOKEN_VALUE: ${{ secrets.GH_TOKEN }}
run: |
set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "SECRETS_ADMIN_PAT is not configured in this repository"
exit 1
fi
echo -n "$CLAUDE_APP_ID" | gh secret set CLAUDE_APP_ID --repo "$TARGET"
echo -n "$CLAUDE_APP_PRIVATE_KEY" | gh secret set CLAUDE_APP_PRIVATE_KEY --repo "$TARGET"
echo -n "$CLAUDE_CODE_OAUTH_TOKEN" | gh secret set CLAUDE_CODE_OAUTH_TOKEN --repo "$TARGET"
echo -n "$GH_TOKEN_VALUE" | gh secret set GH_TOKEN --repo "$TARGET"
echo "Synced CLAUDE_APP_ID, CLAUDE_APP_PRIVATE_KEY, CLAUDE_CODE_OAUTH_TOKEN, GH_TOKEN → $TARGET"
+4 -2
View File
@@ -37,8 +37,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
with:
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
+2 -2
View File
@@ -22,8 +22,8 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
+8 -4
View File
@@ -13,8 +13,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v5
@@ -79,8 +81,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v5
+4 -2
View File
@@ -33,8 +33,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
with:
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
+16 -16
View File
@@ -45,8 +45,8 @@ jobs:
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
@@ -105,8 +105,8 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
@@ -187,8 +187,8 @@ jobs:
fi
- uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
if: steps.check-key.outputs.has_key == 'true'
- name: Set up Python 3.11
@@ -259,8 +259,8 @@ jobs:
fi
- uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
if: steps.check-key.outputs.has_key == 'true'
- name: Set up Python 3.11
@@ -318,8 +318,8 @@ jobs:
fi
- uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
if: steps.check-key.outputs.has_key == 'true'
- name: Set up Python 3.11
@@ -377,8 +377,8 @@ jobs:
fi
- uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
if: steps.check-key.outputs.has_key == 'true'
- name: Set up Python 3.11
@@ -436,8 +436,8 @@ jobs:
fi
- uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
if: steps.check-key.outputs.has_key == 'true'
- name: Set up Python 3.11
@@ -495,8 +495,8 @@ jobs:
fi
- uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
if: steps.check-key.outputs.has_key == 'true'
- name: Set up Python 3.11
+4 -2
View File
@@ -45,8 +45,10 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
with:
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
-3
View File
@@ -88,9 +88,6 @@ issue_details.json
.internal
.praisonai
# Backup files
*.bak
# Rust / Cargo
target/
Cargo.lock
-10
View File
@@ -1,10 +0,0 @@
# Agent Instructions
You are working on the PraisonAI project.
## Project Guidelines
- Follow the existing code style and conventions
- Be concise and helpful in responses
- Test implementation thoroughly
- Ensure backward compatibility with existing APIs
- Follow protocol-driven design: core protocols in praisonaiagents/, heavy implementations in praisonai/
+2 -2
View File
@@ -770,11 +770,11 @@ agent.start('Write a movie script about a robot in Mars');
## ⚡ Performance
PraisonAI is built for speed, with agent instantiation in around 14μs. This reduces overhead, improves responsiveness, and helps multi-agent systems scale efficiently in real-world production workloads.
PraisonAI is built for speed, with agent instantiation in under 4μs. This reduces overhead, improves responsiveness, and helps multi-agent systems scale efficiently in real-world production workloads.
| Performance Metric | PraisonAI |
|--------------------|-----------|
| Avg Instantiation Time | **14 μs** |
| Avg Instantiation Time | **3.77 μs** |
---
+3 -37
View File
@@ -59,8 +59,6 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">handoff_to</a>(target_agent: 'Agent', prompt: str, context: Optional[Dict[str, Any]] = None, config: Optional['HandoffConfig'] = None) -> 'HandoffResult'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">handoff_to_async</a>(target_agent: 'Agent', prompt: str, context: Optional[Dict[str, Any]] = None, config: Optional['HandoffConfig'] = None) -> 'HandoffResult'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">is_closed</a>() -> bool</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">llm_instance</a>()</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">llm_instance</a>(value)</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">llm_model</a>() -> Optional[str]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">output_style</a>() -> Optional[str]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">output_style</a>(value: Optional[str]) -> None</code>
@@ -242,7 +240,6 @@ from praisonaiagents import Memory, MemoryBackend, MemoryConfig
Methods:
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">build_cache_optimized_context</a>(task_descr: str, user_id: Optional[str] = None, additional: str = '', max_items: int = 3, include_cache_boundary: bool = True, include_in_output: Optional[bool] = None) -> Dict[str, str]</code>
* <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>
@@ -518,12 +515,10 @@ Methods:
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">add_memory</a>(text: str, memory_type: str = 'long', **metadata) -> None</code>
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">chat</a>(message: str, **kwargs) -> str</code>
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">clear_memory</a>(memory_type: str = 'all') -> None</code>
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">close</a>() -> None</code>
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">create_agent</a>(*args, **kwargs) -> 'Agent'</code>
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">get_context</a>(query: str, max_items: int = 3) -> str</code>
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">get_state</a>(key: str, default: Any = None) -> Any</code>
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">increment_state</a>(key: str, increment: int = 1, default: int = 0) -> None</code>
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">is_expired</a>() -> bool</code>
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">knowledge</a>() -> 'Knowledge'</code>
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">memory</a>() -> 'Memory'</code>
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">restore_state</a>() -> Dict[str, Any]</code>
@@ -532,7 +527,6 @@ Methods:
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">search_memory</a>(query: str, memory_type: str = 'long', limit: int = 5) -> List[Dict[str, Any]]</code>
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">send_message</a>(message: str, **kwargs) -> str</code>
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">set_state</a>(key: str, value: Any) -> None</code>
* <code title="class Session">Session.<a href="./src/praisonai-agents/praisonaiagents/session/api.py">time_to_expiry</a>() -> Optional[float]</code>
# MCP
@@ -615,7 +609,7 @@ Methods:
* <code title="class ContextManager">ContextManager.<a href="./src/praisonai-agents/praisonaiagents/context/manager.py">register_snapshot_callback</a>(callback: Callable[[SnapshotHookData], None]) -> None</code>
* <code title="class ContextManager">ContextManager.<a href="./src/praisonai-agents/praisonaiagents/context/manager.py">reset</a>() -> None</code>
* <code title="class ContextManager">ContextManager.<a href="./src/praisonai-agents/praisonaiagents/context/manager.py">set_tool_budget</a>(tool_name: str, max_tokens: int, protected: bool = False) -> None</code>
* <code title="class ContextManager">ContextManager.<a href="./src/praisonai-agents/praisonaiagents/context/manager.py">truncate_tool_output</a>(tool_name: str, output: str, tool_call_id: str = None, run_id: str = None) -> str</code>
* <code title="class ContextManager">ContextManager.<a href="./src/praisonai-agents/praisonaiagents/context/manager.py">truncate_tool_output</a>(tool_name: str, output: str) -> str</code>
# UI
@@ -704,7 +698,7 @@ Methods:
Types:
```python
from praisonaiagents import AgentAppConfig, AgentAppProtocol, AgentFlow, AgentManager, AgentOSConfig, AgentOSProtocol, AgentTeam, AutoApproveBackend, EmbeddingResult, RetryBackoffConfig, __version__, aembedding, aembeddings, embedding, embeddings, get_dimensions
from praisonaiagents import AgentAppConfig, AgentAppProtocol, AgentFlow, AgentManager, AgentOSConfig, AgentOSProtocol, AgentTeam, AutoApproveBackend, EmbeddingResult, aembedding, aembeddings, embedding, embeddings, get_dimensions
```
Methods:
@@ -795,17 +789,11 @@ Methods:
* <code title="cli">praisonai <a href="./src/praisonai/praisonai/cli/main.py">--help</a></code>
* <code title="cli">praisonai acp acp-main <a href="./src/praisonai/praisonai/cli/commands/acp.py">--help</a></code>
* <code title="cli">praisonai agent list <a href="./src/praisonai/praisonai/cli/commands/agent.py">--help</a></code>
* <code title="cli">praisonai agent show <a href="./src/praisonai/praisonai/cli/commands/agent.py">--help</a></code>
* <code title="cli">praisonai agents create <a href="./src/praisonai/praisonai/cli/commands/agents.py">--help</a></code>
* <code title="cli">praisonai agents info <a href="./src/praisonai/praisonai/cli/commands/agents.py">--help</a></code>
* <code title="cli">praisonai agents list <a href="./src/praisonai/praisonai/cli/commands/agents.py">--help</a></code>
* <code title="cli">praisonai app app <a href="./src/praisonai/praisonai/cli/commands/app.py">--help</a></code>
* <code title="cli">praisonai audit agent-centric <a href="./src/praisonai/praisonai/cli/commands/audit.py">--help</a></code>
* <code title="cli">praisonai auth list <a href="./src/praisonai/praisonai/cli/commands/auth.py">--help</a></code>
* <code title="cli">praisonai auth login <a href="./src/praisonai/praisonai/cli/commands/auth.py">--help</a></code>
* <code title="cli">praisonai auth logout <a href="./src/praisonai/praisonai/cli/commands/auth.py">--help</a></code>
* <code title="cli">praisonai auth status <a href="./src/praisonai/praisonai/cli/commands/auth.py">--help</a></code>
* <code title="cli">praisonai batch batch-run <a href="./src/praisonai/praisonai/cli/commands/batch.py">--help</a></code>
* <code title="cli">praisonai batch list <a href="./src/praisonai/praisonai/cli/commands/batch.py">--help</a></code>
* <code title="cli">praisonai batch report <a href="./src/praisonai/praisonai/cli/commands/batch.py">--help</a></code>
@@ -846,8 +834,6 @@ Methods:
* <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 command list <a href="./src/praisonai/praisonai/cli/commands/command.py">--help</a></code>
* <code title="cli">praisonai command show <a href="./src/praisonai/praisonai/cli/commands/command.py">--help</a></code>
* <code title="cli">praisonai commit commit-main <a href="./src/praisonai/praisonai/cli/commands/commit.py">--help</a></code>
* <code title="cli">praisonai completion bash <a href="./src/praisonai/praisonai/cli/commands/completion.py">--help</a></code>
* <code title="cli">praisonai completion completion-callback <a href="./src/praisonai/praisonai/cli/commands/completion.py">--help</a></code>
@@ -860,9 +846,6 @@ Methods:
* <code title="cli">praisonai config path <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
* <code title="cli">praisonai config reset <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
* <code title="cli">praisonai config set <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
* <code title="cli">praisonai config show <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
* <code title="cli">praisonai config sources <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
* <code title="cli">praisonai config validate <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
* <code title="cli">praisonai context add <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
* <code title="cli">praisonai context clear <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
* <code title="cli">praisonai context compact <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
@@ -897,12 +880,10 @@ Methods:
* <code title="cli">praisonai doctor db <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor doctor-callback <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor env <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor fix <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor mcp <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor network <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor packaging <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor performance <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor runtime <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor selftest <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor skills <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor tools <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
@@ -933,9 +914,6 @@ Methods:
* <code title="cli">praisonai gateway install <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai gateway logs <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai gateway mint-link <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai gateway pause <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai gateway reconnect <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai gateway resume <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>
@@ -1009,9 +987,6 @@ Methods:
* <code title="cli">praisonai memory search <a href="./src/praisonai/praisonai/cli/commands/memory.py">--help</a></code>
* <code title="cli">praisonai memory show <a href="./src/praisonai/praisonai/cli/commands/memory.py">--help</a></code>
* <code title="cli">praisonai memory status <a href="./src/praisonai/praisonai/cli/commands/memory.py">--help</a></code>
* <code title="cli">praisonai models describe <a href="./src/praisonai/praisonai/cli/commands/models.py">--help</a></code>
* <code title="cli">praisonai models list <a href="./src/praisonai/praisonai/cli/commands/models.py">--help</a></code>
* <code title="cli">praisonai models validate <a href="./src/praisonai/praisonai/cli/commands/models.py">--help</a></code>
* <code title="cli">praisonai n8n export <a href="./src/praisonai/praisonai/cli/commands/n8n.py">--help</a></code>
* <code title="cli">praisonai n8n import <a href="./src/praisonai/praisonai/cli/commands/n8n.py">--help</a></code>
* <code title="cli">praisonai n8n list <a href="./src/praisonai/praisonai/cli/commands/n8n.py">--help</a></code>
@@ -1029,14 +1004,6 @@ Methods:
* <code title="cli">praisonai pairing list <a href="./src/praisonai/praisonai/cli/commands/pairing.py">--help</a></code>
* <code title="cli">praisonai pairing revoke <a href="./src/praisonai/praisonai/cli/commands/pairing.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 permissions allow <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
* <code title="cli">praisonai permissions ask <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
* <code title="cli">praisonai permissions deny <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
* <code title="cli">praisonai permissions export <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
* <code title="cli">praisonai permissions import-rules <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
* <code title="cli">praisonai permissions list <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
* <code title="cli">praisonai permissions remove <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
* <code title="cli">praisonai permissions reset <a href="./src/praisonai/praisonai/cli/commands/permissions.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>
@@ -1116,7 +1083,6 @@ Methods:
* <code title="cli">praisonai serve gateway <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve lsp <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve mcp <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve openai <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve rag <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve recipe <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve registry <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
@@ -1336,7 +1302,7 @@ export { // Python parity additions
export { // Subagent Tool (agent-as-tool pattern)
SubagentTool, BaseTool, FunctionTool, ToolRegistry, ToolResult, ToolValidationError, createDelegator, createSubagentTool, createSubagentTools, createTool, getRegistry, getTool, registerTool, tool, validateTool } from "./tools";
export { airweaveSearch, bedrockBrowserClick, bedrockBrowserFill, bedrockBrowserNavigate, bedrockCodeInterpreter, codeExecution, codeMode, createCustomTool, exaSearch, firecrawlCrawl, firecrawlScrape, parallelSearch, perplexitySearch, registerCustomTool, registerLocalTool, registerNpmTool, superagentGuard, superagentRedact, superagentVerify, tavilyCrawl, tavilyExtract, tavilySearch, valyuBioSearch, valyuCompanyResearch, valyuEconomicsSearch, valyuFinanceSearch, valyuPaperSearch, valyuPatentSearch, valyuSecSearch, valyuWebSearch } from "./tools/builtins";
export { BudgetExceededError, MissingDependencyError, MissingEnvVarError, ToolsRegistry, composeMiddleware, createLoggingMiddleware, createRateLimitMiddleware, createRedactionMiddleware, createRetryMiddleware, createTimeoutMiddleware, createToolsRegistry, createTracingMiddleware, createValidationMiddleware, getToolsRegistry, get_registry, get_tool, register_tool, resetToolsRegistry, validate_tool } from "./tools/registry";
export { BudgetExceededError, MissingDependencyError, MissingEnvVarError, ToolsRegistry, composeMiddleware, createLoggingMiddleware, createRateLimitMiddleware, createRedactionMiddleware, createRetryMiddleware, createTimeoutMiddleware, createToolsRegistry, createTracingMiddleware, createValidationMiddleware, getRegistry, getTool, getToolsRegistry, get_registry, get_tool, registerTool, register_tool, resetToolsRegistry, validateTool, validate_tool } from "./tools/registry";
export type { InstallHints, PraisonTool, RedactionHooks, RegisteredTool, ToolCapabilities, ToolExecutionContext, ToolExecutionResult, ToolFactory, ToolHooks, ToolInstallStatus, ToolLimits, ToolLogger, ToolMetadata, ToolMiddleware, ToolParameterProperty, ToolParameterSchema } from "./tools/registry";
export { registerBuiltinTools, tools } from "./tools/tools";
export { // Classes
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.70" \
"praisonai>=4.6.55" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.70" \
"praisonai>=4.6.55" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.70" \
"praisonai>=4.6.55" \
"praisonai[ui]" \
"praisonai[crewai]"
+2 -2
View File
@@ -5,9 +5,9 @@ Demonstrates web search with consolidated params.
Presets: duckduckgo, tavily, google, bing, serper, search_only, fetch_only
"""
from praisonaiagents import Agent, WebConfig
from praisonaiagents import Agent
# Basic: Enable web search with preset (search only — fetch needs Claude)
# Basic: Enable web search with preset
agent = Agent(
instructions="You are a research assistant with web access.",
web="search_only", # Compatible across models without web_fetch
+12 -16
View File
@@ -6,18 +6,10 @@ It includes self-reflection for improving code quality and multiple specialized
"""
from praisonaiagents import Agent, Task, AgentTeam
from praisonaiagents.tools import CodeInterpreter
def run_python_code(code: str) -> str:
"""Execute Python code safely and return stdout or errors."""
import io
import contextlib
try:
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
exec(code, {"__builtins__": __builtins__})
return buf.getvalue() or "Executed successfully (no output)"
except Exception as e:
return f"Execution error: {e}"
# Initialize code execution tool
code_interpreter = CodeInterpreter()
# Custom code analysis tool
def analyze_code_complexity(code: str) -> str:
@@ -40,7 +32,9 @@ code_writer = Agent(
goal="Write clean, efficient, and well-documented Python code",
backstory="You are a senior Python developer with 10+ years of experience in writing production-quality code.",
instructions="Write Python code that follows PEP 8 standards, includes proper error handling, and has clear documentation.",
output="silent",
reflection=True,
)
code_reviewer = Agent(
@@ -58,7 +52,7 @@ code_executor = Agent(
goal="Safely execute and test Python code",
backstory="You are responsible for running code and reporting results or errors.",
instructions="Execute the provided code safely and report the output or any errors encountered.",
tools=[run_python_code]
tools=[code_interpreter]
)
# Example tasks
@@ -93,7 +87,7 @@ def demonstrate_code_agent():
workflow = AgentTeam(
agents=[code_writer, code_reviewer, code_executor],
tasks=[write_task, review_task, execute_task],
process="sequential", output="silent"
process="sequential", output="verbose"
)
# Run the workflow
@@ -112,8 +106,10 @@ general_code_agent = Agent(
3. Explain code concepts
4. Refactor code for better performance
5. Add tests and documentation""",
tools=[run_python_code, analyze_code_complexity],
output="silent",
tools=[code_interpreter, analyze_code_complexity],
reflection=True,
)
if __name__ == "__main__":
+16 -19
View File
@@ -41,25 +41,21 @@ def calculate_statistics(numbers: list) -> str:
"""Calculate basic statistics for a list of numbers."""
if not numbers:
return "No numbers provided"
try:
nums = [float(x) for x in numbers]
except (TypeError, ValueError):
return "Error: all values must be numbers"
n = len(nums)
mean = sum(nums) / n
sorted_nums = sorted(nums)
n = len(numbers)
mean = sum(numbers) / n
sorted_nums = sorted(numbers)
median = sorted_nums[n//2] if n % 2 else (sorted_nums[n//2-1] + sorted_nums[n//2]) / 2
variance = sum((x - mean)**2 for x in nums) / n
variance = sum((x - mean)**2 for x in numbers) / n
std_dev = math.sqrt(variance)
return f"""Statistics:
- Count: {n}
- Mean: {mean:.4f}
- Median: {median:.4f}
- Standard Deviation: {std_dev:.4f}
- Min: {min(nums)}
- Max: {max(nums)}"""
- Min: {min(numbers)}
- Max: {max(numbers)}"""
# Create specialized math agents
calculation_agent = Agent(
@@ -70,7 +66,8 @@ calculation_agent = Agent(
instructions="Perform the requested calculations step by step, showing your work clearly.",
tools=[basic_calculator, solve_quadratic, calculate_statistics],
reflection=True,
output="silent",
)
problem_solver_agent = Agent(
@@ -85,7 +82,8 @@ problem_solver_agent = Agent(
4. Show each step of the solution
5. Verify the answer makes sense""",
reflection=True,
output="silent",
)
math_tutor_agent = Agent(
@@ -93,8 +91,7 @@ math_tutor_agent = Agent(
role="Mathematics tutor and explainer",
goal="Explain mathematical concepts clearly and help users understand math",
backstory="You are a patient math tutor who can explain concepts at any level from elementary to advanced.",
instructions="Explain mathematical concepts using simple language, provide examples, and check for understanding.",
output="silent",
instructions="Explain mathematical concepts using simple language, provide examples, and check for understanding."
)
# Example workflow for solving complex problems
@@ -129,8 +126,7 @@ def solve_math_problem_workflow():
workflow = AgentTeam(
agents=[problem_solver_agent, calculation_agent, math_tutor_agent],
tasks=[understand_task, solve_task, verify_task],
process="sequential",
output="silent",
process="sequential", output="verbose"
)
return workflow.start()
@@ -152,7 +148,8 @@ math_assistant = Agent(
Always show your work step by step.""",
tools=[basic_calculator, solve_quadratic, calculate_statistics],
reflection=True,
output="silent",
)
if __name__ == "__main__":
-238
View File
@@ -1,238 +0,0 @@
#!/usr/bin/env python3
"""
Example: Bot In-Flight Run Control
Demonstrates how to use SessionRunControl to provide better UX for bots
during long-running agent operations. Shows busy feedback, pending message
handling, and /stop command support.
This example shows the "before" and "after" behavior described in issue #1914.
"""
import asyncio
import time
from typing import Optional
# Mock agent for demonstration
class MockAgent:
"""Simple mock agent that simulates long-running tasks."""
def __init__(self, name="assistant"):
self.name = name
self.chat_history = []
self._interrupt_controller = None
def chat(self, prompt: str) -> str:
"""Simulate a long-running chat response."""
print(f"[Agent] Starting to process: '{prompt[:50]}...'")
# Simulate long processing with interrupt checking
for i in range(30): # 3 second task (30 x 0.1s)
time.sleep(0.1)
# Check for interruption
if (self._interrupt_controller and
hasattr(self._interrupt_controller, 'is_set') and
self._interrupt_controller.is_set()):
reason = getattr(self._interrupt_controller, 'reason', 'unknown')
print(f"[Agent] ⚠️ Interrupted: {reason}")
raise InterruptedError(f"Task cancelled: {reason}")
response = f"Completed analysis of: {prompt}"
print(f"[Agent] ✅ Finished: {response}")
return response
async def demo_without_run_control():
"""Demonstrate the old behavior (silent blocking)."""
print("\n" + "="*60)
print("🔴 BEFORE: Without Run Control (Silent Blocking)")
print("="*60)
from praisonai.bots._session import BotSessionManager
# Standard session manager (no run control)
session_mgr = BotSessionManager()
agent = MockAgent("research-agent")
user_id = "user123"
print("\n1. User sends long task:")
print(" User: 'research quantum computing trends'")
# Start first task (this will run for 3 seconds)
task1 = asyncio.create_task(
session_mgr.chat(agent, user_id, "research quantum computing trends")
)
# Simulate user sending follow-up messages during execution
await asyncio.sleep(0.5)
print("\n2. User sends follow-up (this will block silently):")
print(" User: 'actually focus on business impact'")
task2 = asyncio.create_task(
session_mgr.chat(agent, user_id, "actually focus on business impact")
)
await asyncio.sleep(0.5)
print("\n3. User tries to send /stop (this will also block):")
print(" User: '/stop'")
task3 = asyncio.create_task(
session_mgr.chat(agent, user_id, "/stop")
)
print("\n⏳ Waiting for all messages to complete...")
results = await asyncio.gather(task1, task2, task3)
print("\n📊 Results (all processed sequentially with no feedback):")
for i, result in enumerate(results, 1):
print(f" {i}. {result}")
print("\n❌ Problems with this approach:")
print(" - No immediate feedback on follow-up messages")
print(" - No way to cancel long-running tasks")
print(" - User doesn't know if bot is working or broken")
async def demo_with_run_control():
"""Demonstrate the new behavior with run control."""
print("\n" + "="*60)
print("🟢 AFTER: With Run Control (Better UX)")
print("="*60)
from praisonai.bots._run_control import SessionRunControl
from praisonai.bots._session import BotSessionManager
from praisonai.bots._commands import handle_stop_command
# Session manager with run control
run_control = SessionRunControl(
busy_mode="queue",
busy_ack_template="⏳ {action} — will process after current task finishes"
)
session_mgr = BotSessionManager(run_control=run_control)
agent = MockAgent("research-agent")
user_id = "user123"
print("\n1. User sends long task:")
print(" User: 'research quantum computing trends'")
# Start first task
task1 = asyncio.create_task(
session_mgr.chat_with_run_control(agent, user_id, "research quantum computing trends")
)
# Immediate follow-up messages get acknowledgments
await asyncio.sleep(0.5)
print("\n2. User sends follow-up (gets immediate feedback):")
print(" User: 'actually focus on business impact'")
result2 = await session_mgr.chat_with_run_control(
agent, user_id, "actually focus on business impact"
)
print(f" Bot: {result2['response']}")
print(f" Metadata: {result2['metadata']}")
await asyncio.sleep(0.5)
print("\n3. User decides to stop current task:")
print(" User: '/stop'")
stop_response = await handle_stop_command(user_id, run_control)
print(f" Bot: {stop_response}")
# Wait for first task to complete (it should be cancelled)
try:
result1 = await task1
print(f"\n📊 Task 1 result: {result1['response']}")
print(f" Metadata: {result1['metadata']}")
except asyncio.CancelledError:
print("\n📊 Task 1 was cancelled as expected")
print("\n4. User sends fresh request after stopping:")
print(" User: 'what's the weather like?'")
result4 = await session_mgr.chat_with_run_control(
agent, user_id, "what's the weather like?"
)
print(f" Bot: {result4['response']}")
print("\n✅ Benefits of this approach:")
print(" - Immediate feedback on all messages")
print(" - Can cancel long-running tasks with /stop")
print(" - Pending messages are queued and processed in order")
print(" - User always knows what's happening")
async def demo_interrupt_mode():
"""Demonstrate interrupt mode."""
print("\n" + "="*60)
print("⚡ BONUS: Interrupt Mode (Cancel and Restart)")
print("="*60)
from praisonai.bots._run_control import SessionRunControl
from praisonai.bots._session import BotSessionManager
# Use interrupt mode instead of queue mode
run_control = SessionRunControl(busy_mode="interrupt")
session_mgr = BotSessionManager(run_control=run_control)
agent = MockAgent("research-agent")
user_id = "user123"
print("\n1. User starts long task:")
print(" User: 'research renewable energy for 30 pages'")
task1 = asyncio.create_task(
session_mgr.chat_with_run_control(agent, user_id, "research renewable energy for 30 pages")
)
await asyncio.sleep(0.5)
print("\n2. User changes mind (interrupts and restarts):")
print(" User: 'actually just summarize solar panel efficiency'")
result2 = await session_mgr.chat_with_run_control(
agent, user_id, "actually just summarize solar panel efficiency"
)
print(f" Bot: {result2['response']}")
# Wait for original task (should be interrupted)
try:
result1 = await task1
print(f"\n📊 Original task result: {result1['response']}")
if result1['metadata'].get('interrupted'):
print(" ✅ Original task was properly interrupted")
except:
print("\n📊 Original task was cancelled")
print("\n✅ Interrupt mode is great for:")
print(" - When users frequently change direction")
print(" - Interactive sessions where latest intent matters most")
print(" - Real-time collaboration scenarios")
async def main():
"""Run all demonstrations."""
print("🚀 Bot In-Flight Run Control Demo")
print("Solving the silent lock problem described in issue #1914")
# Show the problem
await demo_without_run_control()
# Show the solution
await demo_with_run_control()
# Show advanced feature
await demo_interrupt_mode()
print("\n" + "="*60)
print("🎯 Summary")
print("="*60)
print("SessionRunControl solves the silent blocking problem by providing:")
print("1. Immediate feedback for mid-run messages")
print("2. /stop command support via InterruptController")
print("3. Configurable policies (queue/interrupt/steer)")
print("4. Run generation tracking to prevent race conditions")
print("5. Pending message merging and ordering")
print("\nThis makes bot interactions feel responsive and predictable!")
if __name__ == "__main__":
asyncio.run(main())
-201
View File
@@ -1,201 +0,0 @@
"""
Prototype implementation of Gateway Server with backpressure and flow control.
This demonstrates how the bounded queues and slow consumer detection would be
implemented in the wrapper layer (praisonai package) using the configuration
from the core SDK (praisonaiagents).
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass, field
from typing import Any, Dict, Optional
# For the prototype, we'll define minimal config inline
# from praisonaiagents.gateway import GatewayConfig
@dataclass
class SessionConfig:
max_inbox: int = 256
@dataclass
class GatewayConfig:
session_config: SessionConfig = field(default_factory=SessionConfig)
max_buffered_bytes: int = 1024 * 1024 # 1MB default
logger = logging.getLogger(__name__)
@dataclass
class GatewaySession:
"""Gateway session with bounded inbox for backpressure."""
_session_id: str
_agent_id: str
_client_id: Optional[str] = None
_inbox: asyncio.Queue = field(default_factory=asyncio.Queue)
_max_inbox: int = 256
_is_executing: bool = False
def setup_inbox(self, max_inbox: int = 256) -> None:
"""Set up the inbox with proper bounds."""
self._max_inbox = max_inbox
if max_inbox > 0:
self._inbox = asyncio.Queue(maxsize=max_inbox)
else:
self._inbox = asyncio.Queue() # Unbounded for backward compat if 0
async def queue_message(self, message: str) -> bool:
"""Queue a user message with backpressure handling.
Returns:
True if message was queued, False if inbox is full.
"""
if self._max_inbox > 0:
# Bounded queue - check if full
try:
self._inbox.put_nowait(message)
return True
except asyncio.QueueFull:
return False
else:
# Unbounded queue for backward compatibility
await self._inbox.put(message)
return True
class GatewayServer:
"""Gateway server with flow control."""
def __init__(self, config: GatewayConfig):
self.config = config
self._sessions: Dict[str, GatewaySession] = {}
self._clients: Dict[str, Any] = {} # client_id -> WebSocket
def create_session(self, session_id: str, agent_id: str, client_id: str) -> GatewaySession:
"""Create a new session with bounded inbox."""
session = GatewaySession(
_session_id=session_id,
_agent_id=agent_id,
_client_id=client_id,
)
# Set up inbox with configured bounds
session.setup_inbox(self.config.session_config.max_inbox)
self._sessions[session_id] = session
return session
async def handle_message(self, session: GatewaySession, message: str, client_id: str) -> str:
"""Handle incoming message with inbox overflow protection."""
# Try to queue the message
queued = await session.queue_message(message)
if not queued:
# Inbox is full, send error response
await self._send_to_client(
client_id,
{
"type": "error",
"code": "inbox_full",
"message": "Message queue is full. Please wait for current messages to be processed."
}
)
return "Inbox full - message rejected."
# Start processing if not already running
if not session._is_executing:
session._is_executing = True
asyncio.create_task(self._drain_session_queue(session, client_id))
return "Processing started."
return "Message queued."
async def _drain_session_queue(self, session: GatewaySession, client_id: str) -> None:
"""Process all messages from the session's inbox queue."""
try:
while not session._inbox.empty():
msg = await session._inbox.get()
# Process message here (simplified for prototype)
logger.info(f"Processing message for session {session._session_id}: {msg}")
# Simulate processing delay
await asyncio.sleep(0.1)
# Send response to client (simplified)
await self._send_to_client(
client_id,
{
"type": "response",
"message": f"Processed: {msg}"
}
)
except Exception as e:
logger.error(f"Error processing queue for session {session._session_id}: {e}")
finally:
session._is_executing = False
async def _send_to_client(self, client_id: str, data: Dict[str, Any]) -> None:
"""Send data to client with slow consumer detection."""
ws = self._clients.get(client_id)
if ws:
try:
# Check for slow consumer (buffered bytes)
if self.config.max_buffered_bytes > 0:
# Try to get transport buffer size
transport = getattr(ws, 'transport', None) or getattr(ws, '_transport', None)
if transport and hasattr(transport, 'get_write_buffer_size'):
buffered_bytes = transport.get_write_buffer_size()
if buffered_bytes > self.config.max_buffered_bytes:
# Check if this is a droppable event type
event_type = data.get("type", "")
droppable_types = {"presence", "typing", "status"}
if event_type in droppable_types:
# Drop the message for slow consumers
logger.warning(f"Dropping {event_type} event for slow consumer {client_id} (buffered: {buffered_bytes} bytes)")
return
else:
# Critical event - close the connection
logger.warning(f"Closing slow consumer {client_id} (buffered: {buffered_bytes} bytes > {self.config.max_buffered_bytes})")
await ws.close(code=1013, reason="slow consumer")
return
# Send the message
await ws.send_json(data)
except Exception as e:
logger.error(f"Error sending to client {client_id}: {e}", exc_info=True)
# Example usage demonstrating the flow control
async def demo():
"""Demonstrate the flow control implementation."""
# Create config with bounded inbox and slow consumer detection
config = GatewayConfig()
config.session_config.max_inbox = 10 # Small inbox for demo
config.max_buffered_bytes = 1024 # 1KB for demo
# Create server and session
server = GatewayServer(config)
session = server.create_session("sess1", "agent1", "client1")
# Simulate filling up the inbox
print(f"Inbox max size: {session._inbox.maxsize}")
# Fill the inbox
for i in range(10):
success = await session.queue_message(f"Message {i}")
print(f"Queued message {i}: {success}")
# Try to add one more - should fail
success = await session.queue_message("Overflow message")
print(f"Overflow message queued: {success}")
# Process messages
while not session._inbox.empty():
msg = await session._inbox.get()
print(f"Processing: {msg}")
if __name__ == "__main__":
asyncio.run(demo())
@@ -179,7 +179,7 @@ def main():
context = memory.build_context_for_task(
task_descr="Explain AI alignment challenges",
memory={"user_id": "quality_demo_user"},
user_id="quality_demo_user",
max_items=3
)
-115
View File
@@ -1,115 +0,0 @@
#!/usr/bin/env python3
"""
JIRA Agent Watch Example
This example demonstrates how to use the JIRA watch tools with PraisonAI agents.
The agent can monitor JIRA issues and projects for changes, search for issues,
and get detailed issue information.
Setup:
1. Install JIRA library: pip install jira
2. Set environment variables:
- JIRA_EMAIL=your_email@example.com (for cloud JIRA)
- JIRA_API_TOKEN=your_api_token
- Or use JIRA_USERNAME + JIRA_API_TOKEN for server JIRA
Usage:
python jira_agent_example.py
"""
import os
from praisonaiagents import Agent
from praisonaiagents.tools import jira_tools
def main():
"""Demonstrate JIRA agent watch capabilities."""
# Check if JIRA credentials are available
jira_email = os.getenv('JIRA_EMAIL')
jira_token = os.getenv('JIRA_API_TOKEN')
jira_username = os.getenv('JIRA_USERNAME')
if not jira_token:
print("❌ JIRA_API_TOKEN environment variable not set")
print("Please set your JIRA API token:")
print("export JIRA_API_TOKEN='your_token_here'")
return
if not (jira_email or jira_username):
print("❌ JIRA credentials incomplete")
print("Please set either:")
print("- JIRA_EMAIL (for cloud JIRA)")
print("- JIRA_USERNAME (for server JIRA)")
return
# Create agent with JIRA tools
agent = Agent(
name="JIRA Monitor Agent",
instructions="""
You are a JIRA monitoring agent. You can:
1. Watch specific JIRA issues for changes
2. Monitor JIRA projects for new issues and updates
3. Search for issues using JQL queries
4. Get detailed information about specific issues
When using JIRA tools, always provide the full JIRA URL
(e.g., https://yourcompany.atlassian.net).
Be helpful and provide clear summaries of JIRA activity.
""",
tools=jira_tools(), # Add all JIRA tools
llm="gpt-4o-mini"
)
print("🎯 JIRA Agent Watch Example")
print("=" * 50)
print()
print("Available JIRA tools:")
print("- jira_watch_issue: Monitor a specific issue for changes")
print("- jira_watch_project: Monitor a project for new/updated issues")
print("- jira_get_issue_info: Get detailed info about an issue")
print("- jira_search_issues: Search issues using JQL")
print()
# Example interactions
example_prompts = [
"Get information about JIRA issue DEMO-1 from https://praisonai.atlassian.net",
"Search for open issues in project DEMO from https://praisonai.atlassian.net using JQL: 'project = DEMO AND status = Open'",
"Watch JIRA issue DEMO-1 from https://praisonai.atlassian.net for 2 minutes (check every 60 seconds, max 2 checks)",
]
print("📝 Example prompts you can try:")
for i, prompt in enumerate(example_prompts, 1):
print(f"{i}. {prompt}")
print()
# Interactive mode
print("💬 Interactive JIRA Agent (type 'quit' to exit)")
print("-" * 50)
while True:
try:
user_input = input("\n🧑 You: ").strip()
if user_input.lower() in ['quit', 'exit', 'q']:
print("👋 Goodbye!")
break
if not user_input:
continue
print("🤖 Agent: ", end="", flush=True)
response = agent.start(user_input)
print(response)
except KeyboardInterrupt:
print("\n👋 Goodbye!")
break
except Exception as e:
print(f"❌ Error: {e}")
if __name__ == "__main__":
main()
@@ -6,6 +6,7 @@ to different next steps based on output content.
"""
from praisonaiagents import AgentFlow, Task
from praisonaiagents import AgentFlowManager
# Create a workflow with branching
workflow = AgentFlow(
@@ -35,9 +36,16 @@ workflow = AgentFlow(
)
if __name__ == "__main__":
result = workflow.run("", llm="gpt-4o-mini", verbose=True)
# Create manager and register workflow
manager = WorkflowManager()
manager.workflows["Decision Workflow"] = workflow
# Execute - will branch to success_handler since 42 is positive
result = manager.execute(
"Decision Workflow",
default_llm="gpt-4o-mini"
)
print("Workflow completed!")
for step in result.get("steps", []):
name = step.get("step", step.get("name", "unknown"))
status = step.get("status", "completed")
print(f" {name}: {status}")
for step_result in result["results"]:
print(f" {step_result['step']}: {step_result['status']}")
@@ -6,8 +6,9 @@ functions that return StepResult with stop_workflow=True.
"""
from praisonaiagents import (
AgentFlow, Task, WorkflowContext, StepResult
Workflow, Task, WorkflowContext, StepResult
)
from praisonaiagents import AgentFlowManager
# Custom validator that can stop the workflow
def validate_data(context: WorkflowContext) -> StepResult:
@@ -49,13 +50,20 @@ workflow = AgentFlow(
)
if __name__ == "__main__":
manager = WorkflowManager()
manager.workflows["Data Processing"] = workflow
# Test with invalid data (will stop early)
print("=== Testing with invalid data ===")
result = workflow.run("", llm="gpt-4o-mini", verbose=True)
for step in result.get("steps", []):
print(f" {step.get('step', 'step')}: {step.get('output', step.get('status'))}")
result = manager.execute("Data Processing", default_llm="gpt-4o-mini")
for step_result in result["results"]:
print(f" {step_result['step']}: {step_result['output']}")
# Test with valid data (will complete)
print("\n=== Testing with valid data ===")
workflow.variables["data"] = {"value": 42}
result = workflow.run("", llm="gpt-4o-mini", verbose=True)
for step in result.get("steps", []):
print(f" {step.get('step', 'step')}: {step.get('status', 'completed')}")
result = manager.execute("Data Processing", default_llm="gpt-4o-mini")
for step_result in result["results"]:
print(f" {step_result['step']}: {step_result['status']}")
+15 -8
View File
@@ -6,6 +6,7 @@ Each item is processed individually with the loop variable.
"""
from praisonaiagents import AgentFlow, Task
from praisonaiagents import AgentFlowManager
# Create a workflow with loop
workflow = AgentFlow(
@@ -29,12 +30,18 @@ workflow = AgentFlow(
)
if __name__ == "__main__":
result = workflow.run("", llm="gpt-4o-mini", verbose=True)
# Create manager and register workflow
manager = WorkflowManager()
manager.workflows["Process Items"] = workflow
# Execute - will process each fruit in the list
result = manager.execute(
"Process Items",
default_llm="gpt-4o-mini"
)
print("Workflow completed!")
for step in result.get("steps", []):
name = step.get("step", step.get("name", "unknown"))
status = step.get("status", "completed")
print(f" {name}: {status}")
out = step.get("output")
if isinstance(out, list):
print(f" Loop processed {len(out)} items")
for step_result in result["results"]:
print(f" {step_result['step']}: {step_result['status']}")
if isinstance(step_result.get("output"), list):
print(f" Loop processed {len(step_result['output'])} items")
@@ -5,7 +5,7 @@ Demonstrates running multiple agents concurrently and
combining their results with an aggregator agent.
"""
from praisonaiagents import Agent, AgentFlow
from praisonaiagents import Agent, Workflow
from praisonaiagents.workflows import parallel
# Create parallel research agents
@@ -5,7 +5,7 @@ Demonstrates decision-based routing where an agent classifier
routes to specialized handler agents based on the request type.
"""
from praisonaiagents import Agent, AgentFlow
from praisonaiagents import Agent, Workflow
from praisonaiagents.workflows import route
# Create classifier agent
+59 -60
View File
@@ -1,6 +1,6 @@
# Documentation Parity Tracker (Python)
> **Categories:** 64 | **Documented:** 61 | **Parity:** 95.3%
> **Categories:** 60 | **Documented:** 57 | **Parity:** 95.0%
This report compares **Python SDK feature categories** against **Python documentation** (docs/concepts, docs/features, etc.).
@@ -8,76 +8,72 @@ This report compares **Python SDK feature categories** against **Python document
| Metric | Count |
|--------|-------|
| Feature Categories | 64 |
| **Documented Categories** | **61** |
| Feature Categories | 60 |
| **Documented Categories** | **57** |
| **Undocumented Categories** | **3** |
| **Parity** | **95.3%** |
| **Parity** | **95.0%** |
## Documented Categories
| Category | Features | Docs | Lines |
|----------|----------|------|-------|
| ✅ AGUI | 1 | 2 | 420 |
| ✅ Agent | 21 | 34 | 9360 |
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2880 |
| ✅ Agent-to-User (A2U) | 1 | 3 | 547 |
| ✅ Approval | 1 | 3 | 1283 |
| ✅ Agent | 19 | 30 | 8367 |
| ✅ Agent-to-Agent (A2A) | 1 | 3 | 625 |
| ✅ Approval | 1 | 2 | 889 |
| ✅ Audio | 2 | 10 | 649 |
| ✅ Auto Generation | 4 | 6 | 1724 |
| ✅ Autonomy | 3 | 5 | 1919 |
| ✅ Bots | 7 | 14 | 5228 |
| ✅ Budget | 1 | 1 | 287 |
| ✅ CLI | 5 | 98 | 24103 |
| ✅ Auto Generation | 3 | 6 | 1386 |
| ✅ Autonomy | 3 | 4 | 1146 |
| ✅ Bots | 5 | 2 | 573 |
| ✅ Chunking | 2 | 2 | 285 |
| ✅ Citations | 2 | 1 | 202 |
| ✅ Code Execution | 3 | 13 | 3551 |
| ✅ Code Execution | 3 | 13 | 3395 |
| ✅ Conditions | 1 | 3 | 1086 |
| ✅ Configuration | 3 | 7 | 1812 |
| ✅ Context Management | 16 | 31 | 9294 |
| ✅ Database | 1 | 34 | 4339 |
| ✅ Configuration | 2 | 7 | 1753 |
| ✅ Context Management | 13 | 29 | 7576 |
| ✅ Database | 1 | 31 | 3037 |
| ✅ Display | 6 | 2 | 1175 |
| ✅ Embeddings | 6 | 23 | 1966 |
| ✅ Evaluation | 1 | 6 | 2539 |
| ✅ Events | 1 | 2 | 704 |
| ✅ Execution | 3 | 3 | 1059 |
| ✅ Files | 2 | 6 | 1100 |
| ✅ Flow | 1 | 2 | 435 |
| ✅ Gateway | 7 | 10 | 3924 |
| ✅ Guardrails | 4 | 4 | 2008 |
| ✅ Handoffs | 11 | 5 | 2155 |
| ✅ Hooks | 2 | 6 | 1916 |
| ✅ Evaluation | 1 | 6 | 2538 |
| ✅ Events | 1 | 2 | 699 |
| ✅ Execution | 3 | 2 | 515 |
| ✅ Files | 2 | 5 | 863 |
| ✅ Flow | 1 | 1 | 284 |
| ✅ Gateway | 6 | 1 | 256 |
| ✅ Guardrails | 4 | 4 | 2279 |
| ✅ Handoffs | 10 | 4 | 1674 |
| ✅ Hooks | 2 | 6 | 1606 |
| ✅ Image | 1 | 11 | 1096 |
| ✅ Knowledge | 3 | 12 | 3466 |
| ✅ LLM | 3 | 5 | 2251 |
| ✅ Loops | 4 | 3 | 774 |
| ✅ MCP | 2 | 51 | 10960 |
| ✅ Memory | 6 | 16 | 5855 |
| ✅ Observability | 2 | 23 | 2590 |
| ✅ Output | 3 | 4 | 903 |
| ✅ Parallel Execution | 3 | 2 | 399 |
| ✅ Planning | 6 | 5 | 1519 |
| ✅ Plugins | 8 | 3 | 1502 |
| ✅ Prompts | 2 | 7 | 1570 |
| ✅ Providers | 2 | 51 | 5911 |
| ✅ Knowledge | 3 | 12 | 3262 |
| ✅ LLM | 2 | 2 | 1049 |
| ✅ Loops | 4 | 1 | 250 |
| ✅ MCP | 2 | 47 | 9754 |
| ✅ Memory | 6 | 12 | 4474 |
| ✅ Output | 3 | 4 | 868 |
| ✅ Parallel Execution | 2 | 2 | 347 |
| ✅ Planning | 6 | 5 | 1506 |
| ✅ Plugins | 8 | 3 | 1494 |
| ✅ Prompts | 2 | 4 | 796 |
| ✅ Providers | 2 | 50 | 5396 |
| ✅ Query | 1 | 2 | 616 |
| ✅ RAG | 5 | 15 | 3190 |
| ✅ RAG | 5 | 14 | 2910 |
| ✅ Realtime | 2 | 5 | 584 |
| ✅ Reflection | 3 | 2 | 469 |
| ✅ Retrieval | 2 | 5 | 944 |
| ✅ Retrieval | 2 | 5 | 941 |
| ✅ Routing | 1 | 2 | 597 |
| ✅ Sandbox | 5 | 6 | 2369 |
| ✅ Security | 1 | 3 | 2518 |
| ✅ Sessions | 4 | 9 | 3826 |
| ✅ Skills | 5 | 9 | 3415 |
| ✅ Tasks | 2 | 6 | 3064 |
| ✅ Telemetry | 1 | 2 | 560 |
| ✅ Sandbox | 5 | 3 | 1002 |
| ✅ Security | 1 | 1 | 875 |
| ✅ Sessions | 2 | 8 | 2128 |
| ✅ Skills | 5 | 5 | 2002 |
| ✅ Tasks | 2 | 5 | 2590 |
| ✅ Telemetry | 1 | 2 | 541 |
| ✅ Templates | 1 | 7 | 1411 |
| ✅ Tools | 12 | 113 | 23809 |
| ✅ Tools | 4 | 97 | 17827 |
| ✅ Tracing | 3 | 2 | 139 |
| ✅ Video | 2 | 6 | 510 |
| ✅ Video | 2 | 5 | 460 |
| ✅ Vision | 2 | 1 | 283 |
| ✅ Web | 4 | 6 | 1297 |
| ✅ Workflows | 4 | 11 | 4302 |
| ✅ Web | 4 | 6 | 1267 |
| ✅ Workflows | 4 | 9 | 3579 |
## Stub Documentation (Need Content)
@@ -98,18 +94,21 @@ These categories have documentation files but < 50 lines (stubs):
These docs exist but don't match any implemented feature category:
- ℹ️ AI SDK (2 docs, 527 lines)
- ℹ️ Callbacks (1 docs, 1122 lines)
- ℹ️ Chat (3 docs, 634 lines)
- ℹ️ AI SDK (1 docs, 112 lines)
- ℹ️ Agent-to-User (A2U) (3 docs, 770 lines)
- ℹ️ CLI (91 docs, 21386 lines)
- ℹ️ Callbacks (1 docs, 985 lines)
- ℹ️ Chat (3 docs, 546 lines)
- ℹ️ Deep Research (2 docs, 437 lines)
- ℹ️ Deployment (29 docs, 6013 lines)
- ℹ️ Jobs (2 docs, 961 lines)
- ℹ️ Deployment (29 docs, 5931 lines)
- ℹ️ Jobs (1 docs, 358 lines)
- ℹ️ Middleware (1 docs, 60 lines)
- ℹ️ Process (1 docs, 545 lines)
- ℹ️ Scheduler (2 docs, 1084 lines)
- ℹ️ Storage (5 docs, 957 lines)
- ℹ️ Streaming (4 docs, 1163 lines)
- ℹ️ Token Management (4 docs, 1383 lines)
- ℹ️ Observability (21 docs, 1580 lines)
- ℹ️ Process (1 docs, 506 lines)
- ℹ️ Scheduler (2 docs, 858 lines)
- ℹ️ Storage (4 docs, 722 lines)
- ℹ️ Streaming (3 docs, 485 lines)
- ℹ️ Token Management (3 docs, 1020 lines)
- ℹ️ Vector Store (11 docs, 733 lines)
---
@@ -1,70 +1,35 @@
# PraisonAI Agents - Benchmark Results
**Generated:** 2026-06-17
**Methodology:** 10 warmup runs + 1000 measured iterations (`performance_benchmark.py` config)
**Config:** `Agent(name=..., instructions=..., model="gpt-4o-mini", tools=[get_weather], output="silent")`
**Generated:** 2025-12-18 14:40:22
**Iterations:** 100
**Test:** Agent instantiation (without tools)
## Agent Instantiation Time
## Results
| Framework | Avg Time (μs) | Min (μs) | Median (μs) | Relative |
|-----------|---------------|----------|-------------|----------|
| OpenAI Agents SDK | 5.05 | — | — | 1.00x (fastest) |
| Agno | 5.27 | — | — | 1.04x |
| **PraisonAI** | **13.62** | **12.58** | **12.96** | **2.69x** |
| PraisonAI (LiteLLM) | 14.49 | 13.37 | 13.71 | 2.86x |
### PraisonAI detail (1000 iter)
| Variant | Avg (μs) | Min (μs) | Median (μs) | Std (μs) |
|---------|----------|----------|-------------|----------|
| With tools (`gpt-4o-mini`) | 13.62 | 12.58 | 12.96 | 2.73 |
| With tools (`openai/gpt-4o-mini`) | 14.49 | 13.37 | 13.71 | 4.14 |
| Minimal, no tools | 13.40 | — | — | — |
## Historical baseline
| Source | Config | Avg (μs) | Notes |
|--------|--------|----------|-------|
| `performance_benchmark.py` (Dec 2025) | tools + 1000 iter | **3.20** | Pre-init-bloat baseline |
| `simple_benchmark.py` (Dec 2025) | no tools, 100 iter | **3.77** | Saved in prior `BENCHMARK_RESULTS.md` |
| Current (Jun 2026) | tools + 1000 iter | **13.62** | After lazy-init optimisations (was ~31 μs pre-fix) |
| Framework | Avg Time (μs) | Relative |
|-----------|---------------|----------|
| **PraisonAI** | **3.77** | **1.00x (fastest)** |
| OpenAI Agents SDK | 5.26 | 1.39x |
| Agno | 5.64 | 1.49x |
| PraisonAI (LiteLLM) | 7.56 | 2.00x |
| PydanticAI | 226.94 | 60.16x |
| LangGraph | 4,558.71 | 1,209x |
| CrewAI | 15,607.92 | 4,138x |
## Package Versions
| Package | Version |
|---------|---------|
| PraisonAI | 1.6.60 |
| Agno | 2.4.2 |
| OpenAI Agents SDK | 0.7.0 |
|---------|--------|
| PraisonAI | 0.1.5 |
| Agno | 2.3.14 |
| PydanticAI | 1.35.0 |
| OpenAI Agents SDK | 0.6.3 |
| LangGraph | 1.0.5 |
| CrewAI | 1.6.1 |
## How to Reproduce
PraisonAI-only (fast; avoids CrewAI/LangGraph import hang):
```bash
cd praisonai-agents
PYTHONWARNINGS='ignore::DeprecationWarning' python -c "
import time, statistics
from typing import Literal
from praisonaiagents import Agent
def get_weather(city: Literal['nyc', 'sf']):
return 'cloudy' if city == 'nyc' else 'sunny'
def bench(fn, n=1000, w=10):
for _ in range(w): fn()
times = []
for _ in range(n):
s = time.perf_counter(); fn(); times.append(time.perf_counter()-s)
print(f'avg={statistics.mean(times)*1e6:.2f} μs')
bench(lambda: Agent(name='Test', instructions='Hi', model='gpt-4o-mini', tools=[get_weather], output='silent'))
"
```
Full multi-framework benchmark:
```bash
cd praisonai-agents
PYTHONWARNINGS='ignore::DeprecationWarning' python benchmarks/performance_benchmark.py --save
python benchmarks/simple_benchmark.py
```
@@ -1,62 +0,0 @@
# Agent Instantiation — Root Cause Analysis
**Date:** 2026-06-17
**Version:** praisonaiagents 1.6.58 (dev)
**Methodology:** 10 warmup + 1000 measured iterations, `time.perf_counter()`
## Summary
Instantiation regressed from **~3.2 μs** (Dec 2025) to **~31 μs** (~10×). After fixes, steady-state is **~16 μs** (~2× historical). PraisonAI is competitive with Agno (~5 μs) on warmed benchmarks using `model=`.
## Before vs After Fixes
| Config | Historical | Pre-fix | Post-fix |
|--------|------------|---------|----------|
| Minimal (`name`, `output="silent"`) | 3.20 μs | 31.36 μs | **16.35 μs** |
| `model="gpt-4o-mini"` | 3.77 μs | 32.19 μs | **16.86 μs** |
| `llm="gpt-4o-mini"` (deprecated) | 3.77 μs | 46.48 μs | **~18 μs** (warn once) |
| LiteLLM `openai/gpt-4o-mini` cold | 7.31 μs | **3.7 s** | **53.63 μs** |
| LiteLLM warmed instantiate | — | 60 μs | **19.83 μs** |
| Import `Agent` | — | 20.8 ms | **29.6 ms** |
## Root Causes (validated)
| ID | Cause | Status | Fix applied |
|----|-------|--------|-------------|
| H1 | Config loader cold start (+9 ms first init) | CONFIRMED | Fast path when no config defaults (`_defaults_has_any_values`) |
| H2 | LoopGuard on every init (~2 μs) | CONFIRMED | Lazy `_ensure_loop_guard()` on first tool call |
| H3 | Approval presets | REJECTED | No change needed |
| H4 | `llm=` deprecation every init (+16 μs) | CONFIRMED | Warn once per process in `warn_deprecated_param` |
| H5 | Init bloat / config resolution (~25 μs) | CONFIRMED | Partially addressed via loader fast path |
| H6 | LiteLLM eager `LLM()` import (+3.7 s) | CONFIRMED | Deferred `_llm_init_params` + lazy `llm_instance` property |
| H7 | `ExecutionConfig` deprecation every init | CONFIRMED | Fixed stack walk; internal SDK path cached |
## cProfile — Post-fix (500 warmed `model=` inits)
| Function | Time (ms) | Per call |
|----------|-----------|----------|
| `Agent.__init__` | 8 | ~16 μs |
| `apply_config_defaults` | 1 | ~2 μs |
| `ExecutionConfig.__post_init__` | 1 | ~2 μs (first call only walks stack) |
| `_warnings.warn` | **0** | removed from hot path |
## Fixes implemented
1. **`praisonaiagents/config/feature_configs.py`** — Fix `ExecutionConfig.__post_init__` deprecation guard; cache internal SDK detection.
2. **`praisonaiagents/utils/deprecation.py`** — Warn once per process for deprecated params.
3. **`praisonaiagents/config/loader.py`** — Skip `apply_config_defaults` lookups when config has no defaults.
4. **`praisonaiagents/agent/agent.py`** — Lazy LoopGuard, lazy LLM instance, deferred LiteLLM params.
5. **`benchmarks/simple_benchmark.py`** — Use `model=`, add 10-iteration warmup.
6. **`benchmarks/performance_benchmark.py`** — Use `model=` instead of deprecated `llm=`.
## Remaining gap (~16 μs vs ~3 μs)
The residual ~13 μs is primarily **`Agent.__init__` size** (~1200 lines of consolidated config resolution added since v0.1.5). Further optimisation requires a dedicated slim fast-path for `Agent(name=..., output="silent")` without full config object churn.
## How to reproduce
```bash
cd praisonai-agents
PYTHONWARNINGS='ignore::DeprecationWarning' python benchmarks/simple_benchmark.py
PYTHONWARNINGS='ignore::DeprecationWarning' python benchmarks/performance_benchmark.py
```
@@ -120,8 +120,8 @@ def run_benchmark(iterations: int = 100) -> List[ParamResult]:
('backstory', 'I am an AI assistant.', 'backstory="..."'),
# LLM configuration
('model', 'gpt-4o-mini', 'model="gpt-4o-mini"'),
('model', 'openai/gpt-4o-mini', 'model="openai/gpt-4o-mini" (LiteLLM)'),
('llm', 'gpt-4o-mini', 'llm="gpt-4o-mini"'),
('llm', 'openai/gpt-4o-mini', 'llm="openai/gpt-4o-mini" (LiteLLM)'),
# Tools
('tools', [sample_tool], 'tools=[sample_tool]'),
@@ -298,7 +298,7 @@ def save_results(results: List[ParamResult], filename: str = 'PARAM_IMPACT_RESUL
f.write('### Best Practices\n\n')
f.write('1. Use `output="silent"` for production workloads\n')
f.write('2. Only enable `context=True` when needed for long conversations\n')
f.write('3. Use `model="gpt-4o-mini"` for fastest instantiation\n')
f.write('3. Use `llm="gpt-4o-mini"` (OpenAI SDK) for fastest instantiation\n')
f.write('4. Avoid `memory=True` unless persistent memory is required\n')
f.write('\n## How to Reproduce\n\n')
@@ -185,7 +185,7 @@ def instantiate_praisonai_agent():
return PraisonAgent(
name="Test Agent",
instructions="Be concise, reply with one sentence.",
model="gpt-4o-mini",
llm="gpt-4o-mini",
tools=tools,
output="silent"
)
@@ -196,7 +196,7 @@ def instantiate_praisonai_litellm_agent():
return PraisonAgent(
name="Test Agent",
instructions="Be concise, reply with one sentence.",
model="openai/gpt-4o-mini",
llm="openai/gpt-4o-mini",
tools=tools,
output="silent"
)
@@ -14,7 +14,6 @@ import argparse
ITERATIONS = 100
WARMUP = 10
def sample_tool(city: Literal['nyc', 'sf']):
@@ -24,10 +23,8 @@ def sample_tool(city: Literal['nyc', 'sf']):
return 'sunny'
def measure_instantiation(create_fn, iterations=ITERATIONS, warmup=WARMUP):
def measure_instantiation(create_fn, iterations=ITERATIONS):
"""Measure average instantiation time in microseconds."""
for _ in range(warmup):
create_fn()
times = []
for _ in range(iterations):
start = time.perf_counter()
@@ -51,11 +48,11 @@ def run_benchmark():
from praisonaiagents import Agent as PraisonAgent
results['PraisonAI'] = measure_instantiation(
lambda: PraisonAgent(name='Test', model='gpt-4o-mini', output="silent")
lambda: PraisonAgent(name='Test', llm='gpt-4o-mini', output="silent")
)
results['PraisonAI (LiteLLM)'] = measure_instantiation(
lambda: PraisonAgent(name='Test', model='openai/gpt-4o-mini', output="silent")
lambda: PraisonAgent(name='Test', llm='openai/gpt-4o-mini', output="silent")
)
# Other frameworks for comparison
@@ -1,75 +0,0 @@
#!/usr/bin/env python3
"""Example of using LLMConfig with Agent for model fallback configuration."""
from praisonaiagents import Agent
from praisonaiagents.config import LLMConfig
# Example 1: Pass LLMConfig via llm parameter
def example_llm_param():
"""Using LLMConfig via the llm parameter."""
config = LLMConfig(
model="gpt-4o",
fallback_models=["claude-3-5-sonnet", "gpt-4o-mini"],
base_url="https://api.example.com",
api_key="test-key"
)
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant",
llm=config # Pass LLMConfig object directly
)
print(f"Primary model: {agent.llm}")
print(f"Fallback models: {agent.fallback_models}")
print(f"Base URL: {agent.base_url}")
print(f"API Key: {agent.api_key}")
# Example 2: Pass LLMConfig via model parameter
def example_model_param():
"""Using LLMConfig via the model parameter (preferred)."""
config = LLMConfig(
model="gpt-4o",
fallback_models=["claude-3-5-sonnet", "gpt-4o-mini"],
base_url="https://api.example.com",
api_key="test-key"
)
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant",
model=config # Pass LLMConfig via model parameter
)
print(f"Primary model: {agent.llm}")
print(f"Fallback models: {agent.fallback_models}")
print(f"Base URL: {agent.base_url}")
print(f"API Key: {agent.api_key}")
# Example 3: Traditional usage still works
def example_traditional():
"""Traditional usage without LLMConfig (no fallback support)."""
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant",
model="gpt-4o",
base_url="https://api.example.com",
api_key="test-key"
)
print(f"Primary model: {agent.llm}")
print(f"Fallback models: {agent.fallback_models}") # Will be empty
print(f"Base URL: {agent.base_url}")
print(f"API Key: {agent.api_key}")
if __name__ == "__main__":
print("=== Example 1: LLMConfig via llm parameter ===")
example_llm_param()
print()
print("=== Example 2: LLMConfig via model parameter ===")
example_model_param()
print()
print("=== Example 3: Traditional usage (no fallback) ===")
example_traditional()
@@ -114,9 +114,6 @@ def _get_lazy_cache():
# ============================================================================
_LAZY_IMPORTS = {
# Version information (lazy loaded to avoid file I/O on import)
'__version__': ('praisonaiagents._version', '__version__'),
# Tools (moved from eager imports for lazy loading)
'Tools': ('praisonaiagents.tools.tools', 'Tools'),
'BaseTool': ('praisonaiagents.tools.base', 'BaseTool'),
@@ -232,7 +229,6 @@ _LAZY_IMPORTS = {
# Agent classes
'Agent': ('praisonaiagents.agent.agent', 'Agent'),
'RetryBackoffConfig': ('praisonaiagents.agent.retry_utils', 'RetryBackoffConfig'),
'BudgetExceededError': ('praisonaiagents.errors', 'BudgetExceededError'),
# Error hierarchy - structured exception handling
@@ -477,7 +473,7 @@ _LAZY_IMPORTS = {
'GatewayClientProtocol': ('praisonaiagents.gateway.protocols', 'GatewayClientProtocol'),
'GatewayEvent': ('praisonaiagents.gateway.protocols', 'GatewayEvent'),
'GatewayMessage': ('praisonaiagents.gateway.protocols', 'GatewayMessage'),
'GatewayEventType': ('praisonaiagents.gateway.protocols', 'EventType'),
'EventType': ('praisonaiagents.gateway.protocols', 'EventType'),
'GatewayConfig': ('praisonaiagents.gateway.config', 'GatewayConfig'),
'SessionConfig': ('praisonaiagents.gateway.config', 'SessionConfig'),
@@ -779,12 +775,8 @@ def warmup(include_litellm: bool = False, include_openai: bool = True) -> dict:
# ============================================================================
__all__ = [
# Version information
'__version__',
# Core classes - the essentials
'Agent',
'RetryBackoffConfig',
'AgentTeam', # Primary class for multi-agent coordination (v1.0+)
'AgentManager', # Silent alias for AgentTeam
'Agents', # Deprecated alias for AgentTeam (emits warning)
Whitespace-only changes.
@@ -111,3 +111,32 @@ def _get_plugins_list() -> list:
PLUGINS_ENABLED = _get_plugins_enabled()
PLUGINS_LIST = _get_plugins_list()
def missing_dependency_error(
package_name: str,
extra_name: Optional[str] = None,
feature_name: Optional[str] = None
) -> ImportError:
"""
Create a standardized ImportError for missing optional dependencies.
Args:
package_name: The name of the missing package (e.g., 'litellm')
extra_name: The pip extra to install (e.g., 'llm' for [llm])
feature_name: Human-readable feature name (e.g., 'LLM support')
Returns:
ImportError with helpful install instructions
"""
if extra_name:
install_cmd = f"pip install 'praisonaiagents[{extra_name}]'"
else:
install_cmd = f"pip install {package_name}"
feature_desc = f" for {feature_name}" if feature_name else ""
return ImportError(
f"{package_name} is required{feature_desc} but not installed. "
f"Please install with: {install_cmd}"
)
@@ -0,0 +1,49 @@
"""
Resolver helper functions for consolidated parameters.
These helpers wrap the canonical resolver for specific parameter types,
handling special cases like callable validators for guardrails.
"""
from typing import Any, Type
from .config.param_resolver import resolve, ArrayMode
from .config.presets import GUARDRAIL_PRESETS
def resolve_guardrails(value: Any, config_class: Type) -> Any:
"""
Resolve guardrails parameter.
Handles special case of callable validators which have highest precedence.
Args:
value: The guardrails parameter value
config_class: GuardrailConfig class
Returns:
Resolved config, callable, or string
"""
# Handle None
if value is None:
return None
# Handle False (disabled)
if value is False:
return None
# Handle callable (highest precedence after None check)
# This includes functions, lambdas, and callable objects
if callable(value) and not isinstance(value, type):
return value
# Use canonical resolver for everything else
return resolve(
value=value,
param_name="guardrails",
config_class=config_class,
presets=GUARDRAIL_PRESETS,
array_mode=ArrayMode.PRESET_OVERRIDE,
string_mode="llm_prompt",
default=None,
)
@@ -1,51 +0,0 @@
"""
Version utilities for praisonaiagents package.
This module provides version information by reading from pyproject.toml
to avoid duplication and ensure the single source of truth.
"""
import re
from pathlib import Path
def get_version() -> str:
"""
Get the version string from pyproject.toml.
Returns:
str: Version string (e.g., "1.6.52")
Raises:
RuntimeError: If version cannot be found or parsed
"""
try:
# Get the package root (praisonaiagents directory)
package_root = Path(__file__).parent
# Go up to src/praisonai-agents directory
pyproject_path = package_root.parent / "pyproject.toml"
if not pyproject_path.exists():
raise RuntimeError(f"pyproject.toml not found at {pyproject_path}")
content = pyproject_path.read_text()
# Look for version = "X.Y.Z" pattern
match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content)
if not match:
raise RuntimeError("Version not found in pyproject.toml")
return match.group(1)
except Exception as e:
# Fallback to "unknown" to avoid breaking imports
import warnings
warnings.warn(
f"Failed to read version from pyproject.toml: {e}. Using 'unknown'.",
RuntimeWarning
)
return "unknown"
# Cache the version to avoid reading the file multiple times
__version__ = get_version()
@@ -7,7 +7,6 @@ import asyncio
import contextlib
import threading
import concurrent.futures
import random
from typing import List, Optional, Any, Dict, Union, Literal, TYPE_CHECKING, Callable, Generator
from collections import OrderedDict
import inspect
@@ -17,7 +16,7 @@ from .chat_mixin import ChatMixin
from .execution_mixin import ExecutionMixin
from .memory_mixin import MemoryMixin
from .async_memory_mixin import AsyncMemoryMixin
from .tool_execution import ToolExecutionMixin, BackoffPolicy
from .tool_execution import ToolExecutionMixin
from .chat_handler import ChatHandlerMixin
from .session_manager import SessionManagerMixin
from .async_safety import AsyncSafeState
@@ -259,9 +258,6 @@ if TYPE_CHECKING:
# Import structured error from central errors module
from ..errors import BudgetExceededError
# Import retry configuration
from .retry_utils import RetryBackoffConfig
class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin, ExecutionMixin, MemoryMixin, AsyncMemoryMixin):
# Class-level counter for generating unique display names for nameless agents
_agent_counter = 0
@@ -317,38 +313,6 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
cls._default_model_checked = True
return cls._default_model
def _ensure_llm_instance(self):
"""Lazy-create LLM instance from deferred init params (avoids import at Agent())."""
if self._llm_instance is not None:
return self._llm_instance
if self._llm_init_params:
try:
from ..llm.llm import LLM
self._llm_instance = LLM(**self._llm_init_params)
except ImportError as e:
raise ImportError(
"LLM features requested but dependencies not installed. "
"Please install with: pip install \"praisonaiagents[llm]\""
) from e
return self._llm_instance
@property
def llm_instance(self):
return self._ensure_llm_instance()
@llm_instance.setter
def llm_instance(self, value):
self._llm_instance = value
if value is not None:
self._llm_init_params = None
def _ensure_loop_guard(self):
"""Lazy-create loop guard on first tool execution."""
if self._loop_guard is None:
from ..escalation.loop_guard import LoopGuard, LoopGuardConfig
self._loop_guard = LoopGuard(LoopGuardConfig(enabled=True))
return self._loop_guard
@classmethod
def _configure_logging(cls):
"""Configure logging settings once for all agent instances."""
@@ -554,7 +518,7 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
backstory: Optional[str] = None,
instructions: Optional[str] = None,
# LLM configuration
llm: Optional[Union[str, Any]] = None, # Can be string, dict, or LLMConfig object
llm: Optional[Union[str, Any]] = None,
model: Optional[Union[str, Any]] = None, # Alias for llm=
base_url: Optional[str] = None, # Kept separate (connection/auth)
api_key: Optional[str] = None, # Kept separate (connection/auth)
@@ -592,13 +556,11 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
tool_config: Optional[Union[bool, 'ToolConfig']] = None, # Tool execution configuration (timeout, retry, parallel)
learn: Optional[Union[bool, str, Dict[str, Any], 'LearnConfig']] = None, # Continuous learning (peer to memory)
backend: Optional[Any] = None, # External managed agent backend (e.g., ManagedAgentIntegration)
cli_backend: Optional[Union[str, Any]] = None, # CLI backend for delegating turns (e.g., "claude-code") - DEPRECATED
runtime: Optional[Union[str, Dict[str, Any], 'AgentRuntimeConfig']] = None, # Model-scoped runtime configuration
cli_backend: Optional[Union[str, Any]] = None, # CLI backend for delegating turns (e.g., "claude-code")
interrupt_controller: Optional['InterruptController'] = None, # G2: Cooperative cancellation
tool_search: Optional[Union[bool, str, Dict[str, Any], 'ToolSearchConfig']] = False, # Progressive tool disclosure
message_steering: Optional[Union[bool, 'MessageSteeringProtocol']] = False, # Real-time message steering during execution
sandbox: Optional[Union[bool, 'SandboxConfig']] = None, # Sandbox for safe code execution
retry: Optional[Union[bool, Dict[str, Any], 'RetryBackoffConfig']] = None, # Retry configuration with exponential backoff
):
"""Initialize an Agent instance.
@@ -608,10 +570,9 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
goal: Primary objective the agent aims to achieve.
backstory: Background context shaping personality and decisions.
instructions: Direct instructions (overrides role/goal/backstory). Recommended for simple agents.
llm: Model name string ("gpt-4o", "anthropic/claude-3-sonnet"), LLMConfig object, or custom LLM.
Can accept LLMConfig(model="gpt-4o", fallback_models=["claude-3-5-sonnet", "gpt-4o-mini"]).
llm: Model name string ("gpt-4o", "anthropic/claude-3-sonnet") or LLM object.
Defaults to OPENAI_MODEL_NAME env var or "gpt-4o-mini".
model: Alias for llm parameter. Also accepts LLMConfig objects.
model: Alias for llm parameter.
base_url: Custom LLM endpoint URL (e.g., for Ollama). Kept separate for auth.
api_key: API key for LLM provider. Kept separate for auth.
tools: List of tools, functions, callables, or MCP instances.
@@ -696,15 +657,13 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
- None: Use local execution (default)
When provided, agent can delegate execution to managed infrastructure
for long-running tasks or when local resources are constrained.
cli_backend: **DEPRECATED** CLI backend for delegating full turns. Use 'runtime' parameter instead.
This parameter is deprecated in favor of model-scoped runtime configuration.
Will emit deprecation warning when used.
runtime: Model-scoped runtime configuration for turn delegation. Accepts:
- str: Runtime ID ("claude-code", "praisonai")
- Dict[str, Any]: Runtime config {"runtime": "claude-code", "config_overrides": {...}}
- AgentRuntimeConfig: Pre-configured runtime instance
- None: Use model-based resolution (default)
Enables turn delegation with per-model runtime selection and fail-closed behavior.
cli_backend: CLI backend for delegating full turns to external CLI tools. Accepts:
- str: Backend ID ("claude-code", "codex-cli", "gemini-cli")
- CliBackendProtocol: Custom CLI backend instance
- None: Use standard LLM execution (default)
When provided, agent delegates entire conversation turns to the CLI tool
instead of using the built-in LLM. Enables session continuity and
tool integration through external AI coding assistants.
tool_search: Progressive tool disclosure configuration. Accepts:
- bool: False=disabled (default), True=auto mode
- str: Mode ("auto", "on", "off")
@@ -759,16 +718,16 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
# Precedence: Explicit params > Config file > Built-in defaults
# Only applies when param is None (not explicitly set)
# ============================================================
from ..config.loader import apply_config_defaults, get_default, _defaults_has_any_values
from ..config.loader import apply_config_defaults, get_default
# Apply config defaults for LLM if not explicitly set
if llm is None and model is None and _defaults_has_any_values():
if llm is None and model is None:
config_model = get_default("model")
if config_model:
llm = config_model
# Apply config defaults for base_url if not explicitly set
if base_url is None and _defaults_has_any_values():
if base_url is None:
config_base_url = get_default("base_url")
if config_base_url:
base_url = config_base_url
@@ -796,8 +755,6 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
if autonomy is None:
# AutonomyConfig is in agent/autonomy.py - use dict for config defaults
autonomy = apply_config_defaults("autonomy", autonomy, None)
if retry is None:
retry = apply_config_defaults("retry", retry, RetryBackoffConfig)
# ============================================================
# DEPRECATION WARNINGS for params consolidated into configs
@@ -1032,7 +989,7 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
_on_budget_exceeded = 'stop'
# Default to False when no ExecutionConfig provided
parallel_tool_calls = False
_exec_config = ExecutionConfig() # Default config for non-execution case
# (already set from parameter, no need to override)
# ─────────────────────────────────────────────────────────────────────
# Resolve TEMPLATES param - FAST PATH
@@ -1435,7 +1392,7 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
# String could be LLM prompt - passthrough for later processing
_guardrails_config = guardrails
else:
from ..config.param_resolver import resolve_guardrails as _resolve_guardrails
from .._resolver_helpers import resolve_guardrails as _resolve_guardrails
_guardrails_config = _resolve_guardrails(
value=guardrails,
config_class=GuardrailConfig,
@@ -1521,8 +1478,10 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
# Initialize autonomy features (agent-centric escalation/doom-loop)
self._init_autonomy(autonomy, verification_hooks=verification_hooks)
# Initialize loop guard lazily on first tool execution (zero init overhead)
self._loop_guard = None
# Initialize loop guard for all execution modes (not just autonomous)
# Provides idempotency-aware guardrails for tool execution loops
from ..escalation.loop_guard import LoopGuard, LoopGuardConfig
self._loop_guard = LoopGuard(LoopGuardConfig(enabled=True))
# If instructions are provided, use them to set role, goal, and backstory
if instructions:
@@ -1568,8 +1527,6 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
self.interrupt_controller = interrupt_controller
# Check for model name in environment variable if not provided
self._using_custom_llm = False
self._llm_instance = None
self._llm_init_params = None
# Flag to track if final result has been displayed to prevent duplicates
self._final_display_shown = False
@@ -1584,22 +1541,7 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
# Handle llm= deprecation: model= is the preferred parameter name
# llm= still works but shows deprecation warning
fallback_models = None # Initialize for internal use
# Check if llm is an LLMConfig object
from ..config import LLMConfig
if isinstance(llm, LLMConfig):
# Extract values from LLMConfig
llm_config_obj = llm
llm = llm_config_obj.model
fallback_models = llm_config_obj.fallback_models
if base_url is None:
base_url = llm_config_obj.base_url
if api_key is None:
api_key = llm_config_obj.api_key
if auth is None:
auth = llm_config_obj.auth
elif llm is not None and model is None:
if llm is not None and model is None:
warn_deprecated_param(
"llm",
since="1.0.0",
@@ -1607,23 +1549,9 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
alternative="use 'model' instead. Example: Agent(model='gpt-4o-mini')",
stacklevel=3
)
# model= is the preferred parameter (no warning)
if model is not None:
# Check if model is an LLMConfig object
if isinstance(model, LLMConfig):
# Extract values from LLMConfig (same as llm handling)
llm = model.model
if fallback_models is None:
fallback_models = model.fallback_models
if base_url is None:
base_url = model.base_url
if api_key is None:
api_key = model.api_key
if auth is None:
auth = model.auth
else:
llm = model # model= takes precedence
llm = model # model= takes precedence
# Store rate limiter (optional, zero overhead when None)
self._rate_limiter = rate_limiter
@@ -1637,74 +1565,102 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
self.base_url = base_url
self.api_key = api_key
# If base_url is provided, defer custom LLM instance creation
# If base_url is provided, always create a custom LLM instance
if base_url:
if isinstance(llm, dict):
llm_config = llm.copy()
llm_config['base_url'] = base_url
if api_key:
llm_config['api_key'] = api_key
if auth:
llm_config['auth'] = auth
llm_config['metrics'] = metrics
llm_config['max_iter'] = max_iter
self._llm_init_params = llm_config
self.llm = llm.get('model', Agent._get_default_model())
else:
model_name = llm or Agent._get_default_model()
self._llm_init_params = {
'model': model_name,
'base_url': base_url,
'api_key': api_key,
'auth': auth,
'metrics': metrics,
'max_iter': max_iter,
'web_search': web_search,
'web_fetch': web_fetch,
'prompt_caching': prompt_caching,
'claude_memory': claude_memory,
}
self.llm = model_name
self._using_custom_llm = True
try:
from ..llm.llm import LLM
# Handle different llm parameter types with base_url
if isinstance(llm, dict):
# Merge base_url and api_key into the dict
llm_config = llm.copy()
llm_config['base_url'] = base_url
if api_key:
llm_config['api_key'] = api_key
if auth:
llm_config['auth'] = auth
llm_config['metrics'] = metrics
llm_config['max_iter'] = max_iter
self.llm_instance = LLM(**llm_config)
self.llm = llm.get('model', Agent._get_default_model())
else:
# Create LLM with model string and base_url (cached for performance)
model_name = llm or Agent._get_default_model()
self.llm_instance = LLM(
model=model_name,
base_url=base_url,
api_key=api_key,
auth=auth,
metrics=metrics,
max_iter=max_iter,
web_search=web_search,
web_fetch=web_fetch,
prompt_caching=prompt_caching,
claude_memory=claude_memory
)
self.llm = model_name
self._using_custom_llm = True
except ImportError as e:
raise ImportError(
"LLM features requested but dependencies not installed. "
"Please install with: pip install \"praisonaiagents[llm]\""
) from e
# If the user passes a dictionary (for advanced configuration)
elif isinstance(llm, dict) and "model" in llm:
llm_config = llm.copy()
if api_key and 'api_key' not in llm_config:
llm_config['api_key'] = api_key
if auth and 'auth' not in llm_config:
llm_config['auth'] = auth
llm_config['metrics'] = metrics
llm_config['max_iter'] = max_iter
self._llm_init_params = llm_config
self._using_custom_llm = True
self.llm = llm_config.get('model', Agent._get_default_model())
# If the user passes a string with a slash (provider/model) — defer LiteLLM import
try:
from ..llm.llm import LLM
# Add api_key if provided and not in dict
if api_key and 'api_key' not in llm:
llm = llm.copy()
llm['api_key'] = api_key
# Add auth if provided and not in dict
if auth and 'auth' not in llm:
llm = llm.copy()
llm['auth'] = auth
# Add metrics parameter
llm = llm.copy()
llm['metrics'] = metrics
llm['max_iter'] = max_iter
self.llm_instance = LLM(**llm) # Pass all dict items as kwargs
self._using_custom_llm = True
self.llm = llm.get('model', Agent._get_default_model())
except ImportError as e:
raise ImportError(
"LLM features requested but dependencies not installed. "
"Please install with: pip install \"praisonaiagents[llm]\""
) from e
# If the user passes a string with a slash (provider/model)
elif isinstance(llm, str) and "/" in llm:
llm_params = {'model': llm}
if api_key:
llm_params['api_key'] = api_key
if auth:
llm_params['auth'] = auth
llm_params['metrics'] = metrics
llm_params['web_search'] = web_search
llm_params['web_fetch'] = web_fetch
llm_params['prompt_caching'] = prompt_caching
llm_params['claude_memory'] = claude_memory
llm_params['max_iter'] = max_iter
self._llm_init_params = llm_params
self._using_custom_llm = True
self.llm = llm
if tools:
logging.debug(f"Tools passed to Agent with custom LLM: {tools}")
self.tools = tools
try:
from ..llm.llm import LLM
# Pass the entire string so LiteLLM can parse provider/model
llm_params = {'model': llm}
if api_key:
llm_params['api_key'] = api_key
if auth:
llm_params['auth'] = auth
llm_params['metrics'] = metrics
llm_params['web_search'] = web_search
llm_params['web_fetch'] = web_fetch
llm_params['prompt_caching'] = prompt_caching
llm_params['claude_memory'] = claude_memory
llm_params['max_iter'] = max_iter
self.llm_instance = LLM(**llm_params)
self._using_custom_llm = True
self.llm = llm
# Ensure tools are properly accessible when using custom LLM
if tools:
logging.debug(f"Tools passed to Agent with custom LLM: {tools}")
# Store the tools for later use
self.tools = tools
except ImportError as e:
raise ImportError(
"LLM features requested but dependencies not installed. "
"Please install with: pip install \"praisonaiagents[llm]\""
) from e
# Otherwise, fall back to OpenAI environment/name (cached for performance)
else:
self.llm = llm or Agent._get_default_model()
# Store fallback models for resilience (defensive copy to avoid external mutations)
self.fallback_models = list(fallback_models) if fallback_models else []
# Handle tools parameter - ensure it's always a list
if callable(tools):
# If a single function/callable is passed, wrap it in a list
@@ -1804,8 +1760,6 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
self.allow_code_execution = allow_code_execution
self.max_retry_limit = max_retry_limit
self.code_execution_mode = code_execution_mode
# Store execution config for guardrail retry backoff
self._execution_config = _exec_config
self.embedder_config = embedder_config
self.knowledge = knowledge
self.use_system_prompt = use_system_prompt
@@ -1901,24 +1855,20 @@ Your Goal: {self.goal}
self._approval_backend = None
self._approve_all_tools = False
self._approval_timeout = 0
self._approval_permissions = None
else:
# Unknown string — treat as no approval
self._approval_backend = None
self._approve_all_tools = False
self._approval_timeout = 0
self._approval_permissions = None
elif approval is True:
from ..approval.backends import AutoApproveBackend
self._approval_backend = AutoApproveBackend()
self._approve_all_tools = False
self._approval_timeout = 0 # 0 = use backend default
self._approval_permissions = None
elif approval is False or approval is None:
self._approval_backend = None
self._approve_all_tools = False
self._approval_timeout = 0
self._approval_permissions = None
# No explicit approval kwarg — honour PRAISONAI_TOOL_SAFETY.
# Default preset "default" blocks only destructive ops
# (delete_*, execute_command, execute_code, kill_process, move/copy)
@@ -1948,21 +1898,17 @@ Your Goal: {self.goal}
self._approval_backend = approval.backend
self._approve_all_tools = approval.all_tools
self._approval_timeout = approval.timeout # None = indefinite, 0 = backend default
# Store permissions if provided (for CI-safe declarative policies)
self._approval_permissions = getattr(approval, 'permissions', None)
elif isinstance(approval, dict):
# Dict config: convert to ApprovalConfig
approval_config = ApprovalConfig(**approval)
self._approval_backend = approval_config.backend
self._approve_all_tools = approval_config.all_tools
self._approval_timeout = approval_config.timeout
self._approval_permissions = getattr(approval_config, 'permissions', None)
else:
# Plain backend object — dangerous tools only, backend default timeout
self._approval_backend = approval
self._approve_all_tools = False
self._approval_timeout = 0
self._approval_permissions = None
# Per-agent autonomy→approval bridge (G-BRIDGE-1 fix)
# If autonomy level is full_auto and no explicit approval was set,
@@ -1984,16 +1930,6 @@ Your Goal: {self.goal}
# Store tool retry policy for tool execution with exponential backoff
self._tool_retry_policy = _tool_config.retry_policy if _tool_config else None
# Retry configuration with jittered exponential backoff
if isinstance(retry, RetryBackoffConfig):
self._retry_config = retry
elif isinstance(retry, dict):
self._retry_config = RetryBackoffConfig(**retry)
elif retry is True:
self._retry_config = RetryBackoffConfig() # Use defaults
else:
self._retry_config = None # No retry configuration
# Cache for system prompts and formatted tools with eager thread-safe lock
# Use OrderedDict for LRU behavior
self._system_prompt_cache = OrderedDict()
@@ -2096,26 +2032,10 @@ Your Goal: {self.goal}
# Backend - external managed agent backend for hybrid execution
self.backend = backend
# CLI Backend - external CLI backend for delegating full turns (DEPRECATED)
# CLI Backend - external CLI backend for delegating full turns
self._cli_backend = None
if cli_backend is not None:
from ..utils.deprecation import warn_deprecated_param
warn_deprecated_param(
"cli_backend",
since="1.0.0",
removal="2.0.0",
alternative="use 'runtime=' instead for model-scoped runtime configuration. For migration assistance, run: praisonai doctor fix --execute",
stacklevel=3
)
self._cli_backend = self._resolve_cli_backend(cli_backend)
# Runtime Configuration - model-scoped runtime selection
self._runtime_config = None
if runtime is not None:
self._runtime_config = self._resolve_runtime_config(runtime)
# Runtime resolver for per-turn resolution
self._runtime_resolver = None # Will be initialized on first use
# Telemetry - lazy initialized via property for performance
self.__telemetry = None
@@ -2183,7 +2103,6 @@ Your Goal: {self.goal}
# LLM configuration
'llm': self.llm,
'fallback_models': list(self.fallback_models) if hasattr(self, 'fallback_models') and self.fallback_models else None,
'base_url': getattr(self, 'base_url', None),
'api_key': getattr(self, 'api_key', None),
'auth': getattr(self, 'auth', None),
@@ -2217,9 +2136,6 @@ Your Goal: {self.goal}
# Tool configuration - use consolidated config when available
'tool_config': getattr(self, '_tool_config', None),
# Retry configuration
'retry': getattr(self, '_retry_config', None),
# CLI backend
'cli_backend': getattr(self, '_cli_backend', None),
@@ -5038,22 +4954,6 @@ Answer:"""
retry_count += 1
logging.warning(f"Agent {self.name}: Guardrail validation failed (retry {retry_count}/{self.max_guardrail_retries}): {error}")
# Add exponential backoff delay to avoid hammering the LLM
execution_config = getattr(self, '_execution_config', None)
if execution_config is not None:
total_delay = BackoffPolicy.delay(
retry_count,
execution_config.retry_initial_delay,
execution_config.retry_backoff_factor,
execution_config.retry_jitter
)
else:
# Fall back to simple backoff if no execution config
total_delay = 1.0 * (2.0 ** (retry_count - 1))
logging.info(f"Agent {self.name}: Waiting {total_delay:.2f}s before guardrail retry")
time.sleep(total_delay)
# Regenerate response for retry
try:
retry_prompt = f"{prompt}\n\nNote: Previous response failed validation due to: {error}. Please provide an improved response."
@@ -5091,22 +4991,6 @@ Answer:"""
retry_count += 1
logging.warning(f"Agent {self.name}: Guardrail validation failed (retry {retry_count}/{self.max_guardrail_retries}): {error}")
# Add exponential backoff delay to avoid hammering the LLM
execution_config = getattr(self, '_execution_config', None)
if execution_config is not None:
total_delay = BackoffPolicy.delay(
retry_count,
execution_config.retry_initial_delay,
execution_config.retry_backoff_factor,
execution_config.retry_jitter
)
else:
# Fall back to simple backoff if no execution config
total_delay = 1.0 * (2.0 ** (retry_count - 1))
logging.info(f"Agent {self.name}: Waiting {total_delay:.2f}s before guardrail retry")
await asyncio.sleep(total_delay)
# Regenerate response for retry (async version)
try:
retry_prompt = f"{prompt}\n\nNote: Previous response failed validation due to: {error}. Please provide an improved response."
@@ -5195,163 +5079,17 @@ Answer:"""
raise TypeError(f"Invalid cli_backend type: {type(cli_backend)}. Expected CliBackendProtocol instance or factory callable.")
def _resolve_runtime_config(self, runtime):
"""Resolve runtime parameter to AgentRuntimeConfig instance.
Args:
runtime: Runtime configuration parameter
Returns:
AgentRuntimeConfig instance
Raises:
TypeError: If runtime type is invalid
ValueError: If runtime configuration is invalid
"""
try:
from ..runtime import AgentRuntimeConfig
except ImportError:
raise ImportError(
"Runtime configuration features requested but not available. "
"This should not happen in a properly installed praisonaiagents package."
)
# If already an AgentRuntimeConfig instance, return as-is
if isinstance(runtime, AgentRuntimeConfig):
return runtime
# If string, create config with runtime ID
if isinstance(runtime, str):
return AgentRuntimeConfig.from_runtime_id(runtime)
# If dictionary, create config from dict
if isinstance(runtime, dict):
return AgentRuntimeConfig.from_dict(runtime)
raise TypeError(f"Invalid runtime type: {type(runtime)}. Expected str, dict, or AgentRuntimeConfig instance.")
def _get_runtime_resolver(self):
"""Get or create runtime resolver instance (lazy initialization)."""
if self._runtime_resolver is None:
try:
from ..runtime.resolver import RuntimeResolver
self._runtime_resolver = RuntimeResolver()
except ImportError:
raise ImportError(
"Runtime resolution features requested but not available. "
"This should not happen in a properly installed praisonaiagents package."
)
return self._runtime_resolver
async def _resolve_turn_runtime(self):
"""Resolve runtime for current turn based on model and configuration.
Returns:
Runtime instance if resolution succeeds, None otherwise
"""
try:
from ..runtime.resolver import RuntimeResolutionContext
except ImportError:
return None
# Create resolution context with current model and provider info
context = RuntimeResolutionContext(
model_name=getattr(self, 'llm', None),
provider_name=self._extract_provider_from_model(getattr(self, 'llm', None)),
agent_config={'agent_id': getattr(self, 'agent_id', None)}
)
try:
resolver = self._get_runtime_resolver()
result = resolver.resolve_runtime_instance(
context=context,
model_runtime_configs=getattr(self, '_model_runtime_configs', None),
provider_runtime_configs=getattr(self, '_provider_runtime_configs', None),
legacy_cli_backend=getattr(self, '_cli_backend', None)
)
if result and result.runtime:
return result.runtime
except (ValueError, RuntimeError) as e:
# Only fall back for registry not initialized, propagate configuration errors
if "not initialized" in str(e).lower():
# Registry not initialized - this is expected in SDK-only mode
pass
else:
# Configuration error (unknown runtime ID etc.) - fail closed
raise RuntimeError(
f"Runtime resolution failed for agent={getattr(self, 'display_name', 'unknown')!r}, "
f"model={getattr(self, 'llm', None)!r}: {e}. "
"Fix the runtime ID/configuration or remove the runtime override."
) from e
return None
def _extract_provider_from_model(self, model_name):
"""Extract provider name from model name.
Args:
model_name: Model name string (e.g., "anthropic/claude-3-sonnet")
Returns:
Provider name if detectable, None otherwise
"""
if not model_name or not isinstance(model_name, str):
return None
# Common provider patterns
if '/' in model_name:
return model_name.split('/')[0]
# Provider inference based on model name patterns
model_lower = model_name.lower()
if 'claude' in model_lower or 'anthropic' in model_lower:
return 'anthropic'
elif 'gpt' in model_lower or 'openai' in model_lower:
return 'openai'
elif 'gemini' in model_lower or 'google' in model_lower:
return 'google'
elif 'llama' in model_lower:
return 'meta'
return None
async def _chat_via_runtime(self, runtime_instance, prompt: str, **kwargs) -> Optional[str]:
"""Chat implementation using resolved runtime instance.
This is similar to _chat_via_cli_backend but uses the resolved runtime
from the model-scoped runtime resolution system.
Args:
runtime_instance: Resolved runtime instance (CliBackendProtocol)
prompt: User prompt
**kwargs: Additional chat parameters
Returns:
Runtime response content
"""
if not runtime_instance:
raise RuntimeError("Runtime instance is None")
# Delegate to CLI backend implementation with runtime instance
# Pass runtime instance directly to avoid mutating shared state
return await self._chat_via_cli_backend(prompt=prompt, cli_backend=runtime_instance, **kwargs)
async def _chat_via_cli_backend(self, prompt: str, cli_backend: Any = None, **kwargs) -> Optional[str]:
async def _chat_via_cli_backend(self, prompt: str, **kwargs) -> Optional[str]:
"""Chat implementation using CLI backend delegation.
Args:
prompt: User prompt
cli_backend: Optional specific backend instance to use (for runtime delegation)
**kwargs: Additional chat parameters (passed through as metadata)
Returns:
CLI backend response content
"""
# Use provided backend or fall back to instance backend
backend = cli_backend or self._cli_backend
if not backend:
if not self._cli_backend:
raise RuntimeError("CLI backend not configured")
try:
@@ -5390,7 +5128,7 @@ Answer:"""
images = None
# Execute CLI backend
result = await backend.execute(
result = await self._cli_backend.execute(
prompt=prompt,
session=session_binding,
images=images,
@@ -87,41 +87,12 @@ Your Goal: {self.goal}"""
# Add memory context if memory is enabled
if self._memory_instance:
# Use cache-optimized context if model supports prompt caching and cache boundary is requested
use_cache_boundaries = hasattr(self, '_model_supports_prompt_caching') and self._model_supports_prompt_caching()
if use_cache_boundaries and hasattr(self._memory_instance, 'build_cache_optimized_context'):
try:
# Get the goal/task description for context building
task_desc = getattr(self, 'goal', '') or 'general assistance'
cache_result = self._memory_instance.build_cache_optimized_context(
task_descr=task_desc,
include_cache_boundary=True
)
memory_context = cache_result.get('stable_prefix', '')
cache_boundary = cache_result.get('cache_boundary', '')
if memory_context:
system_prompt += f"\n\n## Memory (Information you remember about the user)\n{memory_context}"
if cache_boundary:
system_prompt += cache_boundary
# Display memory info to user if verbose
if self.verbose:
self._display_memory_info()
except (AttributeError, KeyError, TypeError) as e:
# Fall back to standard memory context if cache optimization fails
memory_context = self.get_memory_context()
if memory_context:
system_prompt += f"\n\n## Memory (Information you remember about the user)\n{memory_context}"
# Display memory info to user if verbose
if self.verbose:
self._display_memory_info()
else:
# Standard memory context without cache boundaries
memory_context = self.get_memory_context()
if memory_context:
system_prompt += f"\n\n## Memory (Information you remember about the user)\n{memory_context}"
# Display memory info to user if verbose
if self.verbose:
self._display_memory_info()
memory_context = self.get_memory_context()
if memory_context:
system_prompt += f"\n\n## Memory (Information you remember about the user)\n{memory_context}"
# Display memory info to user if verbose
if self.verbose:
self._display_memory_info()
# Add learn context if learn is enabled (auto-inject when memory="learn")
learn_context = self.get_learn_context()
@@ -135,60 +106,6 @@ Your Goal: {self.goal}"""
system_prompt += f"\n\n## Available Skills\n{skills_prompt}"
system_prompt += "\n\nWhen a skill is relevant to the task, read its SKILL.md file to get detailed instructions. If the skill has scripts in its scripts/ directory, you can execute them using the execute_code or run_script tool."
# Cache the base system prompt BEFORE adding session context
# Session context is per-turn and should not be cached
if cache_key:
self._cache_put(self._system_prompt_cache, cache_key, system_prompt)
# Add session context (platform awareness) if available - AFTER caching
try:
from ..session.context import get_session_context
session_ctx = get_session_context()
# Format session context into prompt if origin or targets are present
if session_ctx.origin or session_ctx.reachable_targets:
context_parts = []
# Add origin information
if session_ctx.origin:
origin = session_ctx.origin
origin_str = f"You are replying on {origin.platform}"
if origin.chat_type and origin.chat_type != "unknown":
origin_str += f" ({origin.chat_type}"
if origin.display_name:
origin_str += f' "{origin.display_name}"'
origin_str += ")"
if origin.thread_id:
origin_str += f" in thread {origin.thread_id}"
context_parts.append(origin_str + ".")
# Add reachable targets
if session_ctx.reachable_targets:
target_descriptions = []
for target in session_ctx.reachable_targets:
desc = f"{target.name}"
if target.kind == "home":
desc += f" ({target.platform}, home channel)"
elif target.kind == "alias":
desc += f" ({target.platform}:{target.channel_id})"
target_descriptions.append(desc)
if target_descriptions:
context_parts.append(
f"Reachable delivery targets: {', '.join(target_descriptions)}."
)
if context_parts:
system_prompt += "\n\n## Session Context\n" + "\n".join(context_parts)
except ImportError:
# Session context module not available, continue without it
pass
except Exception as e:
# Log unexpected errors but continue
import logging
logging.debug(f"Session context injection failed: {e}", exc_info=True)
pass
# Add tool usage instructions if tools are available
# Use provided tools or fall back to self.tools
tools_to_use = tools if tools is not None else self.tools
@@ -228,7 +145,10 @@ Your Goal: {self.goal}"""
except ImportError:
pass # Trust module not available, skip security instructions
# Note: Caching is done BEFORE session context injection to avoid cross-user leakage
# Cache the generated system prompt (only if cache_key is set, i.e., memory not enabled)
# Use LRU eviction to prevent unbounded growth
if cache_key:
self._cache_put(self._system_prompt_cache, cache_key, system_prompt)
return system_prompt
def _build_response_format(self, schema_model):
@@ -532,17 +452,6 @@ Your Goal: {self.goal}"""
else:
cleaned_tools.append(tool)
# Sort tools deterministically for cache stability
def sort_formatted_tools(tools_list):
"""Sort already-formatted tool schemas by function name."""
def sort_key(tool):
if isinstance(tool, dict) and tool.get('type') == 'function':
return str(tool.get('function', {}).get('name') or '')
return ''
return sorted(tools_list, key=sort_key)
cleaned_tools = sort_formatted_tools(cleaned_tools)
# Cache the formatted tools with LRU eviction, including tool search metadata
self._cache_put(
self._formatted_tools_cache,
@@ -960,22 +869,7 @@ Your Goal: {self.goal}"""
return CompactionRoute.TRUNCATE_TOOLS, truncated_msgs
def _get_next_fallback_model(self, fallback_index):
"""Get the next fallback model from the chain, if available.
Args:
fallback_index: Current index in the fallback chain (0-based)
Returns:
str or None: Next model name if available, None otherwise
"""
if not hasattr(self, 'fallback_models') or not self.fallback_models:
return None
if fallback_index >= len(self.fallback_models):
return None
return self.fallback_models[fallback_index]
def _chat_completion(self, messages, temperature=1.0, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, _retry_depth=0, _fallback_index=0):
def _chat_completion(self, messages, temperature=1.0, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, _retry_depth=0):
start_time = time.time()
# --- Proactive Context Budget Management (default-on) ---
@@ -1039,7 +933,7 @@ Your Goal: {self.goal}"""
try:
# First attempt: try with streaming enabled for better user experience
stream_callback = self.stream_emitter.emit if hasattr(self, 'stream_emitter') else None
final_response = self._chat_completion_with_retry(
final_response = self._execute_unified_chat_completion(
messages=messages,
temperature=temperature,
tools=formatted_tools,
@@ -1060,11 +954,7 @@ Your Goal: {self.goal}"""
stream = False # Set for the main execution below
else:
raise # Re-raise if it's a different ValueError
except Exception as e:
from ..errors import LLMError
# Don't retry if it's an LLMError that has exhausted retries
if isinstance(e, LLMError):
raise # Re-raise LLMErrors immediately to avoid double retry
except Exception:
# For any other exception, fall back to non-streaming
logging.debug(f"{self.name}: Streaming attempt failed, falling back to non-streaming")
stream = False # Set for the main execution below
@@ -1075,7 +965,7 @@ Your Goal: {self.goal}"""
# UNIFIED: Single protocol-driven dispatch path (fixes DRY violation)
# All LLM providers now go through unified dispatcher for consistency and maintainability
stream_callback = self.stream_emitter.emit if hasattr(self, 'stream_emitter') else None
final_response = self._chat_completion_with_retry(
final_response = self._execute_unified_chat_completion(
messages=messages,
temperature=temperature,
tools=formatted_tools,
@@ -1222,8 +1112,7 @@ Your Goal: {self.goal}"""
return self._chat_completion(
truncated_messages, temperature, tools, stream,
reasoning_steps, task_name, task_description, task_id, response_format,
_retry_depth=_retry_depth + 1,
_fallback_index=_fallback_index
_retry_depth=_retry_depth + 1
)
except Exception as compression_error:
logging.error(f"[{self.name}] Context compression failed: {compression_error}")
@@ -1234,34 +1123,9 @@ Your Goal: {self.goal}"""
# Don't retry without actual credential rotation
elif classification.should_fallback_model:
# Try next model in the fallback chain
next_model = self._get_next_fallback_model(_fallback_index)
if next_model:
current_model = self.llm if isinstance(self.llm, str) else str(self.llm)
logging.info(f"[{self.name}] {current_model} unavailable — falling back to {next_model}")
# Apply backoff if suggested
if classification.backoff_seconds and classification.backoff_seconds > 0:
time.sleep(classification.backoff_seconds)
# Temporarily override the model and clear dispatcher cache for this call
original_llm = self.llm
original_dispatcher = getattr(self, '_unified_dispatcher', None)
try:
self.llm = next_model
self._unified_dispatcher = None # Force recreation with new model
return self._chat_completion(
messages, temperature, tools, stream,
reasoning_steps, task_name, task_description, task_id, response_format,
_retry_depth=_retry_depth + 1,
_fallback_index=_fallback_index + 1
)
finally:
self.llm = original_llm
self._unified_dispatcher = original_dispatcher # Restore original dispatcher
else:
logging.warning(f"[{self.name}] {classification.user_message} (no more fallback models available)")
# Continue to error handling without retry
# TODO: Implement model fallback when available
logging.warning(f"[{self.name}] {classification.user_message} (model fallback not yet implemented)")
# Don't retry without actual model fallback
elif classification.is_retryable and classification.backoff_seconds > 0:
if _retry_depth < 2: # Limit retry attempts
@@ -1270,8 +1134,7 @@ Your Goal: {self.goal}"""
return self._chat_completion(
messages, temperature, tools, stream,
reasoning_steps, task_name, task_description, task_id, response_format,
_retry_depth=_retry_depth + 1,
_fallback_index=_fallback_index
_retry_depth=_retry_depth + 1
)
# Include remediation hints for unimplemented recovery actions
@@ -1279,10 +1142,7 @@ Your Goal: {self.goal}"""
if classification.should_rotate_credential:
user_message += " Credential rotation is not yet implemented."
if classification.should_fallback_model:
if not self.fallback_models:
user_message += " No fallback models configured."
elif _fallback_index >= len(self.fallback_models):
user_message += f" All {len(self.fallback_models)} fallback models exhausted."
user_message += " Model fallback is not yet implemented."
# Create LLMError with classification context
error = LLMError(
@@ -1320,8 +1180,7 @@ Your Goal: {self.goal}"""
response_format=None,
stream_callback=None,
emit_events=True,
_retry_depth=0,
_fallback_index=0
_retry_depth=0
):
"""
Handle LLM errors in async context using structured classification.
@@ -1386,7 +1245,7 @@ Your Goal: {self.goal}"""
return await self._handle_async_llm_error(
retry_error, truncated_messages, temperature, tools, stream,
reasoning_steps, task_name, task_description, task_id, response_format,
stream_callback, emit_events, _retry_depth + 1, _fallback_index
stream_callback, emit_events, _retry_depth + 1
)
except Exception as compression_error:
logging.error(f"[{self.name}] Context compression failed: {compression_error}")
@@ -1397,39 +1256,9 @@ Your Goal: {self.goal}"""
# Don't retry without actual credential rotation
elif classification.should_fallback_model:
# Try next model in the fallback chain
next_model = self._get_next_fallback_model(_fallback_index)
if next_model:
current_model = self.llm if isinstance(self.llm, str) else str(self.llm)
logging.info(f"[{self.name}] {current_model} unavailable — falling back to {next_model}")
# Apply backoff if suggested
if classification.backoff_seconds and classification.backoff_seconds > 0:
await asyncio.sleep(classification.backoff_seconds)
# Temporarily override the model and clear dispatcher cache for this call
original_llm = self.llm
original_dispatcher = getattr(self, '_unified_dispatcher', None)
try:
self.llm = next_model
self._unified_dispatcher = None # Force recreation with new model
return await self._execute_unified_achat_completion(
messages, temperature, tools, stream,
reasoning_steps, task_name, task_description, task_id, response_format,
stream_callback, emit_events
)
except Exception as retry_error:
return await self._handle_async_llm_error(
retry_error, messages, temperature, tools, stream,
reasoning_steps, task_name, task_description, task_id, response_format,
stream_callback, emit_events, _retry_depth + 1, _fallback_index + 1
)
finally:
self.llm = original_llm
self._unified_dispatcher = original_dispatcher # Restore original dispatcher
else:
logging.warning(f"[{self.name}] {classification.user_message} (no more fallback models available)")
# Continue to error handling without retry
# TODO: Implement model fallback when available
logging.warning(f"[{self.name}] {classification.user_message} (model fallback not yet implemented)")
# Don't retry without actual model fallback
elif classification.is_retryable and classification.backoff_seconds > 0:
if _retry_depth < 2: # Limit retry attempts
@@ -1446,7 +1275,7 @@ Your Goal: {self.goal}"""
return await self._handle_async_llm_error(
retry_error, messages, temperature, tools, stream,
reasoning_steps, task_name, task_description, task_id, response_format,
stream_callback, emit_events, _retry_depth + 1, _fallback_index
stream_callback, emit_events, _retry_depth + 1
)
# Include remediation hints for unimplemented recovery actions
@@ -1454,10 +1283,7 @@ Your Goal: {self.goal}"""
if classification.should_rotate_credential:
user_message += " Credential rotation is not yet implemented."
if classification.should_fallback_model:
if not self.fallback_models:
user_message += " No fallback models configured."
elif _fallback_index >= len(self.fallback_models):
user_message += f" All {len(self.fallback_models)} fallback models exhausted."
user_message += " Model fallback is not yet implemented."
# Create LLMError with classification context
error = LLMError(
@@ -1501,7 +1327,7 @@ Your Goal: {self.goal}"""
"""
Execute unified chat completion using composition instead of runtime class mutation.
This method provides a unified dispatch for all LLM providers using
This method provides the same functionality as UnifiedChatMixin but uses
composition for safety and maintainability.
"""
from ..llm import create_llm_dispatcher
@@ -1625,7 +1451,7 @@ Your Goal: {self.goal}"""
"""
Execute unified async chat completion using composition instead of runtime class mutation.
This method provides a unified async dispatch for all LLM providers using
This method provides the same functionality as UnifiedChatMixin but uses
composition for safety and maintainability.
"""
from ..llm import create_llm_dispatcher
@@ -2010,7 +1836,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
logging.warning(f"Context management error (continuing without): {e}")
return messages, None
def _truncate_tool_output(self, tool_name: str, output: str, tool_call_id: str | None = None) -> str:
def _truncate_tool_output(self, tool_name: str, output: str) -> str:
"""
Truncate tool output according to configured budget.
@@ -2019,7 +1845,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
Args:
tool_name: Name of the tool
output: Raw tool output
tool_call_id: Optional ID for the tool call
Returns:
Truncated output if over budget, otherwise original
@@ -2028,8 +1853,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
return output
try:
run_id = getattr(self, '_run_id', None)
return self.context_manager.truncate_tool_output(tool_name, output, tool_call_id, run_id)
return self.context_manager.truncate_tool_output(tool_name, output)
except Exception as e:
logging.warning(f"Tool output truncation error: {e}")
return output
@@ -2672,9 +2496,8 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# If not satisfactory and not at max reflections, continue with regeneration
logging.debug(f"{self.name} reflection count {reflection_count + 1}, continuing reflection process")
messages.append({"role": "user", "content": "Now regenerate your response using the reflection you made"})
# For reflection, always use non-streaming to ensure compatibility with sync adapters
# and to avoid streaming complexity during regeneration process
use_stream = False
# For custom LLMs during reflection, always use non-streaming to ensure complete responses
use_stream = self.stream if not self._using_custom_llm else False
response = self._chat_completion(messages, temperature=temperature, tools=None, stream=use_stream, task_name=task_name, task_description=task_description, task_id=task_id)
content = response.choices[0].message.content
response_text = content.strip() if content else ""
@@ -3007,7 +2830,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
response_format = self._build_response_format(schema_model)
# Use composition instead of runtime class mutation for safety
response = await self._achat_completion_with_retry(
response = await self._execute_unified_achat_completion(
messages=messages,
temperature=temperature,
tools=formatted_tools,
@@ -3179,7 +3002,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
{"role": "user", "content": "Now regenerate your response using the reflection you made"}
]
new_response = await self._achat_completion_with_retry(
new_response = await self._execute_unified_achat_completion(
messages=regenerate_messages,
temperature=temperature,
tools=formatted_tools,
@@ -4062,156 +3885,3 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
raise
logging.debug(f"[compaction] skipped (non-fatal): {_ce}")
return False
def _chat_completion_with_retry(self, messages, temperature=1.0, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, stream_callback=None, emit_events=True):
"""
Wrapper for _execute_unified_chat_completion that adds jittered exponential backoff retry logic.
This method wraps the unified chat completion call and adds retry capability for
transient failures like rate limits, network errors, and service outages.
"""
retry_config = getattr(self, '_retry_config', None)
if not retry_config:
return self._execute_unified_chat_completion(messages, temperature, tools, stream, reasoning_steps,
task_name, task_description, task_id, response_format,
stream_callback=stream_callback, emit_events=emit_events)
from .retry_utils import jittered_backoff
from ..hooks import HookEvent, OnRetryInput
import time
max_attempts = retry_config.max_retries + 1
for attempt in range(max_attempts):
try:
# Call the underlying unified chat completion directly to avoid infinite recursion
return self._execute_unified_chat_completion(messages, temperature, tools, stream, reasoning_steps,
task_name, task_description, task_id, response_format,
stream_callback=stream_callback, emit_events=emit_events)
except Exception as e:
from ..errors import LLMError
# Only retry LLMErrors that are marked as retryable
if not isinstance(e, LLMError) or not e.is_retryable:
raise # Re-raise non-retryable errors immediately
# If this is the last attempt, re-raise the error
if attempt >= max_attempts - 1:
raise
# Calculate delay for this retry attempt
delay = jittered_backoff(
attempt,
base_delay=retry_config.base_delay,
max_delay=retry_config.max_delay,
jitter_ratio=retry_config.jitter_ratio,
)
# Fire OnRetry hook with delay information
retry_input = OnRetryInput(
session_id=getattr(self, '_session_id', 'default'),
cwd=os.getcwd(),
event_name=HookEvent.ON_RETRY,
timestamp=str(time.time()),
agent_name=self.name,
retry_count=attempt + 1,
max_retries=retry_config.max_retries,
error_message=str(e),
operation="llm_request",
delay_seconds=delay,
attempt=attempt
)
self._hook_runner.execute_sync(HookEvent.ON_RETRY, retry_input)
# Log retry attempt (buffered to avoid spam during transient failures)
logger.debug(f"[{self.name}] Retry {attempt + 1}/{max_attempts} after {delay:.1f}s: {str(e)[:100]}")
# Sleep with interrupt awareness - make interruption terminal
interrupt_fn = getattr(self, '_is_interrupted', lambda: False)
sleep_start = time.time()
while time.time() - sleep_start < delay:
if interrupt_fn():
# Interruption is terminal - stop retrying
raise RuntimeError("Agent interrupted during retry backoff")
time.sleep(min(0.2, delay - (time.time() - sleep_start)))
# This should never be reached, but just in case
raise RuntimeError("Retry loop completed without returning or raising an exception")
async def _achat_completion_with_retry(self, messages, temperature=1.0, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, stream_callback=None, emit_events=True):
"""
Async wrapper for _execute_unified_achat_completion that adds jittered exponential backoff retry logic.
This method wraps the async chat completion call and adds retry capability for
transient failures like rate limits, network errors, and service outages.
"""
retry_config = getattr(self, '_retry_config', None)
if not retry_config:
return await self._execute_unified_achat_completion(
messages, temperature, tools, stream, reasoning_steps,
task_name, task_description, task_id, response_format,
stream_callback=stream_callback, emit_events=emit_events
)
from .retry_utils import jittered_backoff, interruptible_sleep
from ..hooks import HookEvent, OnRetryInput
import time
import asyncio
max_attempts = retry_config.max_retries + 1
for attempt in range(max_attempts):
try:
# Call the underlying unified chat completion directly to avoid infinite recursion
return await self._execute_unified_achat_completion(
messages, temperature, tools, stream, reasoning_steps,
task_name, task_description, task_id, response_format,
stream_callback=stream_callback, emit_events=emit_events
)
except Exception as e:
from ..errors import LLMError
# Only retry LLMErrors that are marked as retryable
if not isinstance(e, LLMError) or not e.is_retryable:
raise # Re-raise non-retryable errors immediately
# If this is the last attempt, re-raise the error
if attempt >= max_attempts - 1:
raise
# Calculate delay for this retry attempt
delay = jittered_backoff(
attempt,
base_delay=retry_config.base_delay,
max_delay=retry_config.max_delay,
jitter_ratio=retry_config.jitter_ratio,
)
# Fire OnRetry hook with delay information
retry_input = OnRetryInput(
session_id=getattr(self, '_session_id', 'default'),
cwd=os.getcwd(),
event_name=HookEvent.ON_RETRY,
timestamp=str(time.time()),
agent_name=self.name,
retry_count=attempt + 1,
max_retries=retry_config.max_retries,
error_message=str(e),
operation="async_llm_request",
delay_seconds=delay,
attempt=attempt
)
await self._hook_runner.execute_async(HookEvent.ON_RETRY, retry_input)
# Log retry attempt
logger.debug(f"[{self.name}] Async retry {attempt + 1}/{max_attempts} after {delay:.1f}s: {str(e)[:100]}")
# Async sleep with interrupt awareness using the helper
interrupt_fn = getattr(self, '_is_interrupted', lambda: False)
if not await interruptible_sleep(delay, interrupt_fn=interrupt_fn):
raise RuntimeError("Agent interrupted during retry backoff")
# This should never be reached, but just in case
raise RuntimeError("Async retry loop completed without returning or raising an exception")
@@ -317,9 +317,8 @@ class MemoryMixin:
return
try:
if self._session_store is None:
from ..session import get_default_session_store
self._session_store = get_default_session_store()
from ..session import get_default_session_store
self._session_store = get_default_session_store()
# Restore chat history from previous session
history = self._session_store.get_chat_history(self._session_id)
@@ -1,101 +0,0 @@
"""
Retry utilities with jittered exponential backoff for the PraisonAI SDK.
Provides interrupt-aware retry mechanisms for LLM API calls and tool execution
with configurable backoff strategies, jitter, and buffered status reporting.
"""
import asyncio
import random
import time
from dataclasses import dataclass
from typing import Callable, Optional
@dataclass
class RetryBackoffConfig:
"""Configuration for jittered exponential backoff retry behavior."""
base_delay: float = 5.0 # Base delay in seconds
max_delay: float = 120.0 # Maximum delay in seconds
jitter_ratio: float = 0.5 # Jitter as fraction of delay added on top of base (0.0-1.0)
max_retries: int = 3 # Maximum retry attempts
def __post_init__(self):
"""Validate configuration parameters."""
if self.base_delay <= 0:
raise ValueError("base_delay must be > 0")
if self.max_delay < self.base_delay:
raise ValueError("max_delay must be >= base_delay")
if not (0 <= self.jitter_ratio <= 1):
raise ValueError("jitter_ratio must be between 0 and 1")
if self.max_retries < 0:
raise ValueError("max_retries must be >= 0")
def jittered_backoff(
attempt: int,
*,
base_delay: float = 5.0,
max_delay: float = 120.0,
jitter_ratio: float = 0.5,
) -> float:
"""
Calculate delay for jittered exponential backoff.
Args:
attempt: Current attempt number (0-based)
base_delay: Base delay in seconds
max_delay: Maximum delay cap in seconds
jitter_ratio: Jitter ratio (0.0-1.0), fraction of delay added as positive jitter
Returns:
Delay in seconds with jitter applied
Example:
>>> # Attempt 0: ~5s, Attempt 1: ~10s, Attempt 2: ~20s
>>> delay = jittered_backoff(1, base_delay=5.0, max_delay=120.0, jitter_ratio=0.5)
"""
# Exponential backoff: base * 2^attempt
delay = min(base_delay * (2 ** max(0, attempt)), max_delay)
# Apply jitter: delay + uniform(0, jitter_ratio * delay) for additive positive jitter
if jitter_ratio > 0:
jitter_range = delay * jitter_ratio
jitter = random.uniform(0, jitter_range) # Positive additive jitter only
delay = max(0.1, min(delay + jitter, max_delay)) # Clamp again after jitter
return delay
async def interruptible_sleep(
seconds: float,
check_interval: float = 0.2,
interrupt_fn: Optional[Callable[[], bool]] = None,
) -> bool:
"""
Sleep with periodic interruption checks.
Args:
seconds: Total sleep duration in seconds
check_interval: How often to check for interruption (seconds)
interrupt_fn: Function that returns True if sleep should be interrupted
Returns:
True if completed full sleep, False if interrupted
Example:
>>> interrupted = await interruptible_sleep(30.0, interrupt_fn=lambda: agent.is_stopped())
"""
if interrupt_fn is None:
interrupt_fn = lambda: False
elapsed = 0.0
while elapsed < seconds:
if interrupt_fn():
return False # Interrupted
sleep_time = min(check_interval, seconds - elapsed)
await asyncio.sleep(sleep_time)
elapsed += sleep_time
return True # Completed full sleep
@@ -5,10 +5,15 @@ Provides safe code execution capabilities to agents via the sandbox framework.
"""
import logging
from typing import Optional, Dict, Any, Union, TYPE_CHECKING
from typing import Optional, Dict, Any, Union
if TYPE_CHECKING:
from ..sandbox import SandboxConfig, SandboxManager, SandboxResult
from ..sandbox import (
SandboxConfig,
SandboxManager,
SandboxResult,
check_code_safety,
format_warnings,
)
logger = logging.getLogger(__name__)
@@ -21,33 +26,27 @@ class SandboxMixin:
def __init__(self, *args, **kwargs):
# Extract sandbox config from kwargs
sandbox = kwargs.pop('sandbox', None)
self.sandbox_config: Optional[SandboxConfig] = kwargs.pop('sandbox', None)
# Convert boolean True to default config
if sandbox is True:
from ..sandbox import SandboxConfig
sandbox = SandboxConfig.subprocess()
elif sandbox is False:
sandbox = None
self.sandbox_config: Optional['SandboxConfig'] = sandbox
if self.sandbox_config is True:
self.sandbox_config = SandboxConfig.subprocess()
super().__init__(*args, **kwargs)
self._sandbox_manager: Optional['SandboxManager'] = None
self._sandbox_manager: Optional[SandboxManager] = None
@property
def has_sandbox(self) -> bool:
"""Check if sandbox is configured for this agent."""
return self.sandbox_config is not None
def get_sandbox_manager(self) -> Optional['SandboxManager']:
def get_sandbox_manager(self) -> Optional[SandboxManager]:
"""Get or create sandbox manager."""
if not self.has_sandbox:
return None
if self._sandbox_manager is None:
from ..sandbox import SandboxManager
self._sandbox_manager = SandboxManager(self.sandbox_config)
return self._sandbox_manager
@@ -58,7 +57,7 @@ class SandboxMixin:
language: str = "python",
check_security: bool = True,
**kwargs
) -> 'SandboxResult':
) -> SandboxResult:
"""Execute code safely in configured sandbox.
Args:
@@ -80,7 +79,6 @@ class SandboxMixin:
# Security pre-check if enabled
if check_security:
from ..sandbox import check_code_safety, format_warnings
warnings = check_code_safety(code, language)
if warnings:
warning_text = format_warnings(warnings)
@@ -99,7 +97,7 @@ class SandboxMixin:
language: str = "python",
check_security: bool = True,
**kwargs
) -> 'SandboxResult':
) -> SandboxResult:
"""Synchronous wrapper for execute_code.
Args:
@@ -119,7 +117,7 @@ class SandboxMixin:
command: Union[str, list],
check_security: bool = True,
**kwargs
) -> 'SandboxResult':
) -> SandboxResult:
"""Run a shell command in the sandbox.
Args:
@@ -141,7 +139,6 @@ class SandboxMixin:
# Security pre-check
if check_security:
from ..sandbox import check_code_safety, format_warnings
warnings = check_code_safety(command_str, "bash")
if warnings:
warning_text = format_warnings(warnings)
@@ -7,10 +7,21 @@ Reuses existing telemetry/token_collector for token tracking.
import logging
from praisonaiagents._logging import get_logger
from typing import Optional, List, Dict, Any
from typing import Optional, List, Dict, Any, Callable
from dataclasses import dataclass, field
logger = get_logger(__name__)
@dataclass
class SummarizationConfig:
"""Configuration for auto-summarization."""
enabled: bool = False
threshold: float = 0.8 # 80% of context window
context_window: int = 128000 # Default for GPT-4
summary_model: Optional[str] = None # Use smaller model for summaries
preserve_system: bool = True # Keep system message
preserve_recent: int = 2 # Keep last N message pairs
class SummarizationManager:
"""
Manages automatic conversation summarization.
@@ -168,3 +179,45 @@ SUMMARY:"""
"""Number of times summarization has been performed."""
return self._summarization_count
async def summarize_conversation(
messages: List[Dict[str, str]],
llm_call: Callable,
manager: SummarizationManager,
) -> List[Dict[str, str]]:
"""
Summarize conversation using LLM.
Args:
messages: Current message history
llm_call: Async function to call LLM
manager: SummarizationManager instance
Returns:
New message history with summary
"""
prompt = manager.generate_summary_prompt(messages)
# Call LLM to generate summary
summary = await llm_call(prompt)
# Prepare new history
new_history = manager.prepare_summarized_history(messages, summary)
# Reset token count (will be recalculated on next message)
manager.reset_tokens()
return new_history
def summarize_conversation_sync(
messages: List[Dict[str, str]],
llm_call: Callable,
manager: SummarizationManager,
) -> List[Dict[str, str]]:
"""
Synchronous version of summarize_conversation.
"""
prompt = manager.generate_summary_prompt(messages)
summary = llm_call(prompt)
new_history = manager.prepare_summarized_history(messages, summary)
manager.reset_tokens()
return new_history
@@ -14,7 +14,6 @@ import asyncio
import inspect
import contextvars
import concurrent.futures
import random
from typing import List, Optional, Any, Dict, Union, TYPE_CHECKING
from ..errors import ToolExecutionError
from ..tools.trust import wrap_if_external
@@ -25,30 +24,6 @@ if TYPE_CHECKING:
pass
class BackoffPolicy:
"""Exponential backoff policy for tool retries."""
@staticmethod
def delay(attempt: int, initial_delay: float, backoff_factor: float, jitter: float, max_delay: float = 60.0) -> float:
"""Calculate delay for a retry attempt.
Args:
attempt: Attempt number (1-based)
initial_delay: Initial delay in seconds
backoff_factor: Exponential backoff multiplier
jitter: Fraction of base delay to add as random jitter
max_delay: Maximum delay to cap exponential growth
Returns:
Delay in seconds
"""
base = initial_delay * (backoff_factor ** (attempt - 1))
# Cap the base delay to prevent excessively long waits
base = min(base, max_delay)
jitter_amount = random.uniform(0, jitter * base)
return base + jitter_amount
class ToolExecutionMixin:
"""Mixin providing toolexecution methods for the Agent class."""
@@ -256,10 +231,9 @@ class ToolExecutionMixin:
arguments.update(res.output.modified_data)
# Loop guard check - prevent tool execution loops with graduated response
if hasattr(self, '_ensure_loop_guard'):
loop_guard = self._ensure_loop_guard()
if hasattr(self, '_loop_guard') and self._loop_guard:
from ..escalation.loop_guard import GuardAction
decision = loop_guard.check(function_name, arguments, is_pre_execution=True)
decision = self._loop_guard.check(function_name, arguments, is_pre_execution=True)
if decision.action == GuardAction.WARN:
# Inject warning into tool result so LLM sees guidance
@@ -271,6 +245,7 @@ class ToolExecutionMixin:
blocked_result = {"error": f"[loop-guard] {decision.message}", "loop_blocked": True}
elif decision.action == GuardAction.HALT:
# Halt execution with exception
from ..errors import ToolExecutionError
raise ToolExecutionError(
f"[loop-guard] {decision.message}",
tool_name=function_name,
@@ -298,159 +273,32 @@ class ToolExecutionMixin:
if blocked_result is not None:
result = blocked_result
else:
# Apply tool retry logic with exponential backoff
execution_config = getattr(self, '_execution_config', None)
if execution_config is None:
# Fall back to reading individual config attributes for backward compatibility
max_retry_limit = getattr(self, 'max_retry_limit', 2)
retry_initial_delay = 1.0
retry_backoff_factor = 2.0
retry_jitter = 0.1
else:
max_retry_limit = execution_config.max_retry_limit
retry_initial_delay = execution_config.retry_initial_delay
retry_backoff_factor = execution_config.retry_backoff_factor
retry_jitter = execution_config.retry_jitter
result = None
last_exception = None
# max_retry_limit is the number of retries (not total attempts)
# So total attempts = 1 (initial) + max_retry_limit (retries)
for attempt in range(1, max_retry_limit + 2):
try:
# P8/G11: Apply tool timeout if configured
tool_timeout = getattr(self, '_tool_timeout', None)
if tool_timeout and tool_timeout > 0:
# Use copy_context to preserve injection context in executor thread
ctx = contextvars.copy_context()
def execute_with_context():
with with_injection_context(state):
return self._execute_tool_with_circuit_breaker(function_name, arguments)
# Use reusable executor to prevent resource leaks
if not hasattr(self, '_tool_executor'):
self._tool_executor = concurrent.futures.ThreadPoolExecutor(
max_workers=2, thread_name_prefix=f"tool-{self.name}"
)
future = self._tool_executor.submit(ctx.run, execute_with_context)
try:
result = future.result(timeout=tool_timeout)
except concurrent.futures.TimeoutError:
future.cancel()
logging.warning(f"Tool {function_name} timed out after {tool_timeout}s")
result = {"error": f"Tool timed out after {tool_timeout}s", "timeout": True}
else:
with with_injection_context(state):
result = self._execute_tool_with_circuit_breaker(function_name, arguments)
# Check if the result indicates a retryable error
if isinstance(result, dict) and result.get("error"):
# Check if this is a circuit breaker error (always retryable)
if result.get("circuit_open"):
raise ToolExecutionError(
result["error"],
tool_name=function_name,
agent_id=self.name,
is_retryable=True,
)
# Check if this is a timeout error (retryable)
elif result.get("timeout"):
raise ToolExecutionError(
result["error"],
tool_name=function_name,
agent_id=self.name,
is_retryable=True,
)
# For other error dicts, treat as non-retryable unless specified
else:
# Success path - return the result
break
else:
# Success path - return the result
break
except ToolExecutionError as e:
last_exception = e
# Only retry if the error is marked as retryable and we have retries left
# attempt starts at 1, so (attempt - 1) gives us the retry count
if not e.is_retryable or (attempt - 1) >= max_retry_limit:
raise e
# Calculate delay for exponential backoff
delay = BackoffPolicy.delay(attempt, retry_initial_delay, retry_backoff_factor, retry_jitter)
logging.warning(
f"Tool {function_name} failed (attempt {attempt}/{max_retry_limit + 1}): {e}. "
f"Retrying in {delay:.2f}s..."
)
time.sleep(delay)
except Exception as e:
# Wrap unexpected exceptions in ToolExecutionError
# Most tool errors are considered retryable unless they're programming errors
is_retryable = not isinstance(e, (ValueError, TypeError, AttributeError))
tool_error = ToolExecutionError(
f"Tool '{function_name}' failed: {e}",
tool_name=function_name,
agent_id=self.name,
is_retryable=is_retryable,
)
last_exception = tool_error
# attempt starts at 1, so (attempt - 1) gives us the retry count
if not is_retryable or (attempt - 1) >= max_retry_limit:
raise tool_error from e
# Calculate delay for exponential backoff
delay = BackoffPolicy.delay(attempt, retry_initial_delay, retry_backoff_factor, retry_jitter)
logging.warning(
f"Tool {function_name} failed (attempt {attempt}/{max_retry_limit + 1}): {e}. "
f"Retrying in {delay:.2f}s..."
)
time.sleep(delay)
# Apply runtime-scoped middleware normalization BEFORE hooks fire
# Plugin harnesses can register middleware to normalize vendor-specific results
runtime_id = getattr(self, '_runtime_id', 'praisonai') # Default to native runtime
normalized_result = None # Track normalized result for hooks
if runtime_id != 'praisonai': # Skip for native runtime to avoid allocation
try:
from ..runtime import get_middleware, MiddlewareContext
middleware = get_middleware(runtime_id)
# P8/G11: Apply tool timeout if configured
tool_timeout = getattr(self, '_tool_timeout', None)
if tool_timeout and tool_timeout > 0:
# Use copy_context to preserve injection context in executor thread
ctx = contextvars.copy_context()
# Only allocate context and normalize if not using default pass-through
if middleware.runtime_id != 'praisonai':
mw_ctx = MiddlewareContext(
tool_name=function_name,
runtime_id=runtime_id,
agent_id=self.name,
session_id=getattr(self, '_session_id', None),
execution_time_ms=(_time.time() - _tool_start_time) * 1000,
metadata={'original_result_type': type(result).__name__}
def execute_with_context():
with with_injection_context(state):
return self._execute_tool_with_circuit_breaker(function_name, arguments)
# Use reusable executor to prevent resource leaks
if not hasattr(self, '_tool_executor'):
self._tool_executor = concurrent.futures.ThreadPoolExecutor(
max_workers=2, thread_name_prefix=f"tool-{self.name}"
)
normalized_result = middleware.normalize(result, function_name, mw_ctx)
# Handle error cases by propagating error message as result
if not normalized_result.success and normalized_result.error_message:
# For failed tools, include error context in the result
result = f"Tool Error: {normalized_result.error_message}"
else:
# For successful tools, use the normalized content
result = normalized_result.content
# Store normalized result for hooks to access full context
self._last_normalized_result = normalized_result
logger.debug(f"Applied runtime middleware for {runtime_id}: {function_name}")
except ImportError:
# Runtime middleware not available - continue without normalization
logger.debug("Runtime middleware not available, skipping normalization")
except Exception as e:
# Don't let middleware failures break tool execution
logger.warning(f"Runtime middleware failed for {runtime_id}: {e}")
future = self._tool_executor.submit(ctx.run, execute_with_context)
try:
result = future.result(timeout=tool_timeout)
except concurrent.futures.TimeoutError:
future.cancel()
logging.warning(f"Tool {function_name} timed out after {tool_timeout}s")
result = {"error": f"Tool timed out after {tool_timeout}s", "timeout": True}
else:
with with_injection_context(state):
result = self._execute_tool_with_circuit_breaker(function_name, arguments)
# Apply prompt injection protection for external tools
# Zero-cost for trusted tools, wraps external content in security markers
@@ -464,7 +312,7 @@ class ToolExecutionMixin:
if self.context_manager:
# Use context-aware truncation with configured budget
truncated = self._truncate_tool_output(function_name, result_str, tool_call_id)
truncated = self._truncate_tool_output(function_name, result_str)
else:
# Apply default limit even without context management
# This prevents runaway tool outputs from causing overflow
@@ -475,21 +323,6 @@ class ToolExecutionMixin:
head = result_str[:limit - tail_size]
tail = result_str[-tail_size:] if tail_size > 0 else ""
truncated = f"{head}\n...[{len(result_str):,} chars, showing first/last portions]...\n{tail}"
# Store full output for later retrieval
try:
from ..runtime.tool_output_store import get_tool_output_store
store = get_tool_output_store(getattr(self, '_run_id', None))
metadata = store.store(function_name, result_str, call_id=tool_call_id)
if metadata:
# Add reference to stored output in the truncated preview
truncated = store.format_reference(metadata, truncated)
logging.debug(f"Stored full {function_name} output ({len(result_str)} bytes) at {metadata.get('path')}")
except ImportError:
# Fallback if store not available
pass
except Exception as e:
logging.debug(f"Failed to store tool output: {e}")
else:
truncated = result_str
@@ -498,7 +331,7 @@ class ToolExecutionMixin:
# For dicts, truncate large string fields (e.g., raw_content from search)
if isinstance(result, dict):
max_field_chars = getattr(self, 'tool_output_limit', 16000) if not self.context_manager else None
result = self._truncate_dict_fields(result, function_name, max_field_chars, tool_call_id)
result = self._truncate_dict_fields(result, function_name, max_field_chars)
else:
result = truncated
except Exception as e:
@@ -540,15 +373,6 @@ class ToolExecutionMixin:
# Trigger AFTER_TOOL hook
from ..hooks import HookEvent, AfterToolInput
# Extract error information from normalized result if available
tool_error = None
if hasattr(self, '_last_normalized_result') and self._last_normalized_result:
if not self._last_normalized_result.success and self._last_normalized_result.error_message:
tool_error = self._last_normalized_result.error_message
# Clean up temporary attribute
delattr(self, '_last_normalized_result')
after_tool_input = AfterToolInput(
session_id=getattr(self, '_session_id', 'default'),
cwd=os.getcwd(),
@@ -558,7 +382,6 @@ class ToolExecutionMixin:
tool_name=function_name,
tool_input=arguments,
tool_output=result,
tool_error=tool_error,
execution_time_ms=(_time.time() - _tool_start_time) * 1000
)
self._hook_runner.execute_sync(HookEvent.AFTER_TOOL, after_tool_input, target=function_name)
@@ -571,12 +394,11 @@ class ToolExecutionMixin:
self._doom_loop_tracker.mark_progress(f"tool:{function_name}")
# Record tool execution in loop guard
if hasattr(self, '_ensure_loop_guard'):
loop_guard = self._ensure_loop_guard()
if hasattr(self, '_loop_guard') and self._loop_guard:
is_success = result is not None and not (isinstance(result, dict) and result.get('error'))
loop_guard.record(function_name, arguments, is_success)
self._loop_guard.record(function_name, arguments, is_success)
# Handle warning injection for WARN decisions
decision = loop_guard.check(function_name, arguments, is_pre_execution=False)
decision = self._loop_guard.check(function_name, arguments, is_pre_execution=False)
if decision.action.value == "warn":
if isinstance(result, str):
result = f"{result}\n\n[loop-guard] {decision.message}"
@@ -595,20 +417,22 @@ class ToolExecutionMixin:
return result
except Exception as e:
# Emit tool call end with error for exceptions that escape the retry loop
# Emit tool call end with error
_duration_ms = (_time.time() - _tool_start_time) * 1000
_trace_emitter.tool_call_end(self.name, function_name, None, _duration_ms, str(e))
# Wrap the exception if it's not already a ToolExecutionError
if not isinstance(e, ToolExecutionError):
is_retryable = not isinstance(e, (ValueError, TypeError, AttributeError))
raise ToolExecutionError(
f"Tool '{function_name}' failed: {e}",
tool_name=function_name,
agent_id=self.name,
is_retryable=is_retryable,
) from e
raise # Re-raise if already wrapped
# Preserve existing ToolExecutionError unchanged to maintain loop guard HALT behavior
if isinstance(e, ToolExecutionError):
raise
# Gap 3a fix: Wrap exceptions in ToolExecutionError for better observability
is_retryable = not isinstance(e, (ValueError, TypeError, AttributeError))
raise ToolExecutionError(
f"Tool '{function_name}' failed: {e}",
tool_name=function_name,
agent_id=self.name,
is_retryable=is_retryable,
) from e
def _trigger_after_agent_hook(self, prompt, response, start_time, tools_used=None):
"""Trigger AFTER_AGENT hook and return response."""
@@ -707,7 +531,7 @@ class ToolExecutionMixin:
response=response,
)
def _truncate_dict_fields(self, data: dict, tool_name: str, max_field_chars: int = None, tool_call_id: str = None) -> dict:
def _truncate_dict_fields(self, data: dict, tool_name: str, max_field_chars: int = None) -> dict:
"""Truncate large string fields in a dict to prevent context overflow."""
if max_field_chars is None:
# Use tool budget from context manager (default 5000 tokens * 4 chars/token = 20000 chars)
@@ -722,30 +546,13 @@ class ToolExecutionMixin:
tail_limit = int(max_field_chars * 0.15)
head = value[:head_limit]
tail = value[-tail_limit:] if tail_limit > 0 else ""
truncated = f"{head}\n...[{len(value):,} chars, showing first/last portions]...\n{tail}"
# Store full field value for later retrieval
try:
from ..runtime.tool_output_store import get_tool_output_store
from uuid import uuid4
store = get_tool_output_store(getattr(self, '_run_id', None))
# Add unique suffix to prevent collisions with repeated keys
unique_suffix = uuid4().hex[:8]
field_call_id = f"{tool_call_id}_{key}_{unique_suffix}" if tool_call_id else f"{tool_name}_{key}_{unique_suffix}"
metadata = store.store(f"{tool_name}.{key}", value, call_id=field_call_id)
if metadata:
truncated = store.format_reference(metadata, truncated)
logging.debug(f"Stored full {tool_name}.{key} field ({len(value)} bytes) at {metadata.get('path')}")
except Exception as e:
logging.debug(f"Failed to store dict field: {e}")
result[key] = truncated
result[key] = f"{head}\n...[{len(value):,} chars, showing first/last portions]...\n{tail}"
logging.debug(f"Smart truncated field '{key}' from {len(value)} to ~{max_field_chars} chars")
elif isinstance(value, dict):
result[key] = self._truncate_dict_fields(value, tool_name, max_field_chars, tool_call_id)
result[key] = self._truncate_dict_fields(value, tool_name, max_field_chars)
elif isinstance(value, list):
result[key] = [
self._truncate_dict_fields(item, tool_name, max_field_chars, tool_call_id) if isinstance(item, dict)
self._truncate_dict_fields(item, tool_name, max_field_chars) if isinstance(item, dict)
else (self._smart_truncate_str(item, max_field_chars) if isinstance(item, str) and len(item) > max_field_chars else item)
for item in value
]
@@ -777,22 +584,12 @@ class ToolExecutionMixin:
from ..approval import get_approval_registry
from ..approval.protocols import ApprovalRequest, ApprovalDecision
from ..approval.registry import DEFAULT_DANGEROUS_TOOLS
from ..tools import get_registry as get_tool_registry
backend = getattr(self, '_approval_backend', None)
approve_all = getattr(self, '_approve_all_tools', False)
# Check tool trust level if available
tool_registry = get_tool_registry()
trust_level = tool_registry.get_trust_level(tool_name)
if backend is not None:
# Check if tool needs approval based on multiple criteria
needs_approval = (
approve_all
or tool_name in DEFAULT_DANGEROUS_TOOLS
or (trust_level == "external") # External tools need approval
)
needs_approval = approve_all or tool_name in DEFAULT_DANGEROUS_TOOLS
if needs_approval:
request = ApprovalRequest(
tool_name=tool_name,
@@ -0,0 +1,152 @@
"""
Unified Chat Mixin for Agent class.
This module consolidates the dual execution paths (custom LLM vs OpenAI)
into a single async-first protocol-driven implementation, eliminating
the DRY violation and maintenance burden described in Issue #1304.
"""
import asyncio
import logging
import time
from typing import Any, Dict, List, Optional, Union
from ..llm import create_llm_dispatcher, UnifiedLLMDispatcher
class UnifiedChatMixin:
"""
Unified chat implementation that replaces dual execution paths.
This mixin consolidates:
- Path A: LLM.get_response() / LLM.get_response_async()
- Path B: OpenAIClient chat completion
Into a single async-first protocol-driven dispatch using UnifiedLLMDispatcher.
"""
def _get_unified_dispatcher(self) -> UnifiedLLMDispatcher:
"""
Get or create the unified LLM dispatcher for this agent.
This method implements the provider selection logic that was
previously scattered in _chat_completion() method.
"""
if hasattr(self, '_unified_dispatcher') and self._unified_dispatcher is not None:
return self._unified_dispatcher
# Determine which adapter to use based on existing agent configuration
if self._using_custom_llm and hasattr(self, 'llm_instance'):
# Use LiteLLM adapter for custom LLM instances
dispatcher = create_llm_dispatcher(llm_instance=self.llm_instance)
else:
# Use OpenAI adapter for direct OpenAI access
if not hasattr(self, '_openai_client') or self._openai_client is None:
# Initialize OpenAI client if not present
from ..llm import get_openai_client
self._openai_client = get_openai_client(api_key=getattr(self, 'api_key', None))
dispatcher = create_llm_dispatcher(openai_client=self._openai_client, model=self.llm)
# Cache the dispatcher
self._unified_dispatcher = dispatcher
return dispatcher
def _unified_chat_completion(
self,
messages: List[Dict[str, Any]],
temperature: float = 1.0,
tools: Optional[List[Dict[str, Any]]] = None,
stream: bool = True,
reasoning_steps: bool = False,
task_name: Optional[str] = None,
task_description: Optional[str] = None,
task_id: Optional[str] = None,
response_format: Optional[Dict[str, Any]] = None
) -> Any:
"""
Unified sync chat completion method.
This replaces the existing _chat_completion() method's dual dispatch logic
with a single path through UnifiedLLMDispatcher.
"""
start_time = time.time()
# Get unified dispatcher (handles provider selection)
dispatcher = self._get_unified_dispatcher()
# Use the same message processing logic but route through unified dispatcher
formatted_tools = self._format_tools_for_completion(tools)
try:
# Single dispatch path for all providers
final_response = dispatcher.chat_completion(
messages=messages,
tools=formatted_tools,
temperature=temperature,
max_tokens=getattr(self, 'max_tokens', None),
stream=stream,
response_format=response_format,
**{
'reasoning_steps': reasoning_steps,
'task_name': task_name,
'task_description': task_description,
'task_id': task_id,
}
)
return final_response
except Exception as e:
logging.error(f"Unified chat completion failed: {e}")
# Apply the same error handling as the original implementation
raise
async def _unified_achat_completion(
self,
messages: List[Dict[str, Any]],
temperature: float = 1.0,
tools: Optional[List[Dict[str, Any]]] = None,
stream: bool = True,
reasoning_steps: bool = False,
task_name: Optional[str] = None,
task_description: Optional[str] = None,
task_id: Optional[str] = None,
response_format: Optional[Dict[str, Any]] = None
) -> Any:
"""
Unified async chat completion method.
This provides native async implementation, eliminating the need
for separate async/sync implementations in the LLM layer.
"""
start_time = time.time()
# Get unified dispatcher (handles provider selection)
dispatcher = self._get_unified_dispatcher()
# Use the same message processing logic but route through unified dispatcher
formatted_tools = self._format_tools_for_completion(tools)
try:
# Single async dispatch path for all providers
final_response = await dispatcher.achat_completion(
messages=messages,
tools=formatted_tools,
temperature=temperature,
max_tokens=getattr(self, 'max_tokens', None),
stream=stream,
response_format=response_format,
**{
'reasoning_steps': reasoning_steps,
'task_name': task_name,
'task_description': task_description,
'task_id': task_id,
}
)
return final_response
except Exception as e:
logging.error(f"Unified async chat completion failed: {e}")
# Apply the same error handling as the original implementation
raise
@@ -78,37 +78,12 @@ class UnifiedExecutionMixin:
_trace_emitter.agent_start(self.name, {"role": self.role, "goal": self.goal})
# Reset loop guard for new chat turn (always-on protection)
if hasattr(self, '_ensure_loop_guard'):
self._ensure_loop_guard().reset_turn()
if hasattr(self, '_loop_guard') and self._loop_guard:
self._loop_guard.reset_turn()
try:
# Runtime resolution - check for model-scoped runtime before legacy CLI backend
runtime_instance = None
if getattr(self, '_runtime_config', None) is not None:
runtime_instance = await self._resolve_turn_runtime()
# Model-scoped runtime routing - delegate entire turn to resolved runtime
if runtime_instance is not None:
return await self._chat_via_runtime(
runtime_instance=runtime_instance,
prompt=prompt,
temperature=temperature,
tools=tools,
output_json=output_json,
output_pydantic=output_pydantic,
reasoning_steps=reasoning_steps,
stream=stream,
task_name=task_name,
task_description=task_description,
task_id=task_id,
config=config,
force_retrieval=force_retrieval,
skip_retrieval=skip_retrieval,
attachments=attachments,
tool_choice=tool_choice
)
elif hasattr(self, '_cli_backend') and self._cli_backend is not None:
# Legacy CLI backend with deprecation warning (emitted in Agent.__init__)
# CLI Backend routing - delegate entire turn if configured
if hasattr(self, '_cli_backend') and self._cli_backend is not None:
return await self._chat_via_cli_backend(
prompt=prompt,
temperature=temperature,
@@ -40,21 +40,6 @@ class TaskStatus(Enum):
# Set up logger
logger = get_logger(__name__)
def _launch_auth_token() -> Optional[str]:
return os.environ.get("PRAISONAI_LAUNCH_AUTH_TOKEN")
def _authorise_launch_request(request) -> bool:
token = _launch_auth_token()
if not token:
return True
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer ") and auth[7:] == token:
return True
return request.headers.get("X-Auth-Token") == token
# Agent server registry for thread-safe server management
@@ -260,20 +245,7 @@ Expected Output: {task.expected_output}."""
# Add memory context if available
if task.memory:
try:
# Use cache-optimized context if available for better prompt caching
if hasattr(task.memory, 'build_cache_optimized_context'):
try:
cache_result = task.memory.build_cache_optimized_context(
task_descr=task.description,
include_cache_boundary=False # Don't include boundary for agent task context
)
memory_context = cache_result.get('stable_prefix', '')
except Exception as e:
# Fall back to standard context building
logger.debug(f"Cache-optimized context failed for task '{task.description or task.id}', falling back to standard: {e}")
memory_context = task.memory.build_context_for_task(task.description)
else:
memory_context = task.memory.build_context_for_task(task.description)
memory_context = task.memory.build_context_for_task(task.description)
if memory_context:
# Log detailed memory context for debugging
logger.debug(f"Memory context for task '{task.description}': {memory_context}")
@@ -680,7 +652,7 @@ class AgentTeam(SpawnAnnounceProtocol):
_stream = _output_config.stream
else:
_verbose = 0
_stream = False
_stream = True
# ─────────────────────────────────────────────────────────────────────
# Resolve EXECUTION param using canonical resolver
@@ -1056,65 +1028,6 @@ class AgentTeam(SpawnAnnounceProtocol):
task_result = _process_task_result(self, context, agent_output)
return task_result.task_output
def _apply_task_guardrail(self, task, task_id, task_output):
"""Apply guardrail validation to task output.
Returns:
tuple: (task_output, should_retry) where should_retry is True if task should be retried
Raises:
Exception: If guardrail validation fails after max retries
"""
if not task._guardrail_fn:
return task_output, False
try:
guardrail_result = task._process_guardrail(task_output)
if not guardrail_result.success:
if task.retry_count >= task.max_retries:
raise Exception(
f"Task failed guardrail validation after {task.max_retries} retries. "
f"Last error: {guardrail_result.error}"
)
task.retry_count += 1
task.status = "in progress" # Keep task in progress for retry
logger.warning(f"Task {task_id}: Guardrail validation failed (retry {task.retry_count}/{task.max_retries}): {guardrail_result.error}")
return task_output, True # Signal retry needed
# If guardrail passed and returned a modified result
if guardrail_result.result is not None:
if isinstance(guardrail_result.result, str):
# Update the task output with the modified result
task_output.raw = guardrail_result.result
# Clear structured fields to avoid stale cache
if hasattr(task_output, 'json_dict'):
task_output.json_dict = None
if hasattr(task_output, 'pydantic'):
task_output.pydantic = None
task.result = task_output
elif hasattr(guardrail_result.result, 'raw'):
# Replace with the new task output
task_output = guardrail_result.result
task.result = task_output
logger.info(f"Task {task_id}: Guardrail validation passed")
return task_output, False # No retry needed
except Exception as e:
logger.error(f"Task {task_id}: Error in guardrail processing: {e}")
# Handle guardrail failure with retry logic
if task.retry_count >= task.max_retries:
raise Exception(
f"Task failed due to guardrail processing error after {task.max_retries} retries. "
f"Last error: {e}"
) from e
task.retry_count += 1
task.status = "in progress"
logger.warning(f"Task {task_id}: Guardrail processing error (retry {task.retry_count}/{task.max_retries}): {e}")
return task_output, True # Signal retry needed
async def arun_task(self, task_id):
"""Async version of run_task method"""
if task_id not in self.tasks:
@@ -1133,11 +1046,53 @@ class AgentTeam(SpawnAnnounceProtocol):
if task.status in ["not started", "in progress"]:
task_output = await self.aexecute_task(task_id)
if task_output and self.completion_checker(task, task_output.raw):
# Apply guardrail validation using shared helper
task_output, should_retry = self._apply_task_guardrail(task, task_id, task_output)
if should_retry:
retries += 1
continue
# Run guardrail validation BEFORE marking task complete
if task._guardrail_fn:
try:
guardrail_result = task._process_guardrail(task_output)
if not guardrail_result.success:
if task.retry_count >= task.max_retries:
raise Exception(
f"Task failed guardrail validation after {task.max_retries} retries. "
f"Last error: {guardrail_result.error}"
)
task.retry_count += 1
task.status = "in progress" # Keep task in progress for retry
logger.warning(f"Task {task_id}: Guardrail validation failed (retry {task.retry_count}/{task.max_retries}): {guardrail_result.error}")
retries += 1
continue # Actually retry the task
# If guardrail passed and returned a modified result
if guardrail_result.result is not None:
if isinstance(guardrail_result.result, str):
# Update the task output with the modified result
task_output.raw = guardrail_result.result
# Clear structured fields to avoid stale cache
if hasattr(task_output, 'json_dict'):
task_output.json_dict = None
if hasattr(task_output, 'pydantic'):
task_output.pydantic = None
task.result = task_output
elif hasattr(guardrail_result.result, 'raw'):
# Replace with the new task output
task_output = guardrail_result.result
task.result = task_output
logger.info(f"Task {task_id}: Guardrail validation passed")
except Exception as e:
logger.error(f"Task {task_id}: Error in guardrail processing: {e}")
# Handle guardrail failure with retry logic
if task.retry_count >= task.max_retries:
raise Exception(
f"Task failed due to guardrail processing error after {task.max_retries} retries. "
f"Last error: {e}"
) from e
task.retry_count += 1
task.status = "in progress"
logger.warning(f"Task {task_id}: Guardrail processing error (retry {task.retry_count}/{task.max_retries}): {e}")
retries += 1
continue # Retry the task
task.status = "completed"
# Run execute_callback for memory operations
@@ -1151,6 +1106,20 @@ class AgentTeam(SpawnAnnounceProtocol):
raise
if hasattr(task, 'fail_on_memory_error') and task.fail_on_memory_error:
raise
# Run task callback if exists
if task.callback:
try:
if asyncio.iscoroutinefunction(task.callback):
if run_coroutine_safely:
run_coroutine_safely(task.callback(task_output))
else:
logger.warning("run_coroutine_safely not available, skipping async callback")
else:
task.callback(task_output)
except Exception as e:
logger.error(f"Error executing task callback for task {task_id}: {e}")
logger.exception(e)
self.save_output_to_file(task, task_output)
if self.verbose >= 1:
@@ -1365,11 +1334,52 @@ class AgentTeam(SpawnAnnounceProtocol):
if task.status in ["not started", "in progress"]:
task_output = self.execute_task(task_id)
if task_output and self.completion_checker(task, task_output.raw):
# Apply guardrail validation using shared helper
task_output, should_retry = self._apply_task_guardrail(task, task_id, task_output)
if should_retry:
retries += 1
continue
# Add guardrail validation (matches arun_task logic)
if task._guardrail_fn:
try:
guardrail_result = task._process_guardrail(task_output)
if not guardrail_result.success:
if task.retry_count >= task.max_retries:
raise Exception(
f"Task failed guardrail validation after {task.max_retries} retries. "
f"Last error: {guardrail_result.error}"
)
task.retry_count += 1
task.status = "in progress" # Keep task in progress for retry
logger.warning(f"Task {task_id}: Guardrail validation failed (retry {task.retry_count}/{task.max_retries}): {guardrail_result.error}")
retries += 1
continue # Retry the task
# If guardrail passed and returned a modified result
if guardrail_result.result is not None:
if isinstance(guardrail_result.result, str):
# Update the task output with the modified result
task_output.raw = guardrail_result.result
# Clear structured fields to avoid stale cache
if hasattr(task_output, 'json_dict'):
task_output.json_dict = None
if hasattr(task_output, 'pydantic'):
task_output.pydantic = None
task.result = task_output
elif hasattr(guardrail_result.result, 'raw'):
# Replace with the new task output
task_output = guardrail_result.result
task.result = task_output
logger.info(f"Task {task_id}: Guardrail validation passed")
except Exception as e:
logger.error(f"Task {task_id}: Error in guardrail processing: {e}")
# Handle guardrail failure with retry logic
if task.retry_count >= task.max_retries:
raise Exception(
f"Task failed due to guardrail processing error after {task.max_retries} retries. "
f"Last error: {e}"
) from e
task.retry_count += 1
task.status = "in progress"
logger.warning(f"Task {task_id}: Guardrail processing error (retry {task.retry_count}/{task.max_retries}): {e}")
retries += 1
continue # Retry the task
task.status = "completed"
# Run execute_callback for memory operations
@@ -1379,6 +1389,20 @@ class AgentTeam(SpawnAnnounceProtocol):
except Exception as e:
logger.error(f"Error executing memory callback for task {task_id}: {e}")
logger.exception(e)
# Run task callback if exists
if task.callback:
try:
if asyncio.iscoroutinefunction(task.callback):
if run_coroutine_safely:
run_coroutine_safely(task.callback(task_output))
else:
logger.warning("run_coroutine_safely not available, skipping async callback")
else:
task.callback(task_output)
except Exception as e:
logger.error(f"Error executing task callback for task {task_id}: {e}")
logger.exception(e)
self.save_output_to_file(task, task_output)
@@ -1969,8 +1993,6 @@ class AgentTeam(SpawnAnnounceProtocol):
# Define the endpoint handler
@app.post(path)
async def handle_query(request: Request, query_data: Optional[AgentQuery] = None):
if not _authorise_launch_request(request):
raise HTTPException(status_code=401, detail="Unauthorized")
# Handle both direct JSON with query field and form data
if query_data is None:
try:
@@ -2049,9 +2071,7 @@ class AgentTeam(SpawnAnnounceProtocol):
# Add GET endpoint to list available agents
@app.get(f"{path}/list")
async def list_agents(request: Request):
if not _authorise_launch_request(request):
raise HTTPException(status_code=401, detail="Unauthorized")
async def list_agents():
return {
"agents": [
{"name": agent.display_name, "id": agent.display_name.lower().replace(' ', '_')}
@@ -2066,8 +2086,6 @@ class AgentTeam(SpawnAnnounceProtocol):
# Create a closure to capture the agent instance
def create_agent_handler(agent):
async def handle_single_agent(request: Request):
if not _authorise_launch_request(request):
raise HTTPException(status_code=401, detail="Unauthorized")
try:
request_data = await request.json()
query = request_data.get("query", "")
@@ -53,4 +53,3 @@ class AgentAppConfig:
workers: int = 1
timeout: int = 60
metadata: Dict[str, Any] = field(default_factory=dict)
api_key: Optional[str] = None
@@ -74,15 +74,11 @@ class ApprovalConfig:
- *positive float* — wait up to that many seconds.
- ``None`` — wait **indefinitely** (no timeout).
- *unset / 0* — use the backend's own default timeout.
permissions: Declarative permission rules mapping patterns to actions
(e.g., {"read:*": "allow", "bash:rm *": "deny"}).
Used for CI-safe, non-interactive permission policies.
"""
backend: Any = None
all_tools: bool = False
timeout: Optional[float] = 0
permissions: Optional[Dict[str, Any]] = None
@runtime_checkable
@@ -194,13 +194,9 @@ class ApprovalRegistry:
def is_yaml_approved(self, tool_name: str) -> bool:
try:
if tool_name not in self._yaml_approved_tools.get():
return False
return tool_name in self._yaml_approved_tools.get()
except LookupError:
return False
if self.get_risk_level(tool_name) == "critical":
return False
return True
# ── Env-var check ────────────────────────────────────────────────────
@@ -8,7 +8,23 @@ All implementations live in the praisonai wrapper package.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
pass
from .protocols import (
BotOSProtocol, # noqa: F401
BotProtocol,
BotMessageProtocol,
BotUserProtocol,
BotChannelProtocol,
BotMessage,
BotUser,
BotChannel,
MessageType,
ChatCommandInfo,
ChatCommandProtocol,
ProbeResult,
HealthResult,
EmailProtocol,
EmailInbox,
)
from .protocols import (
BotProtocol,
BotMessageProtocol,
@@ -25,30 +41,6 @@ from .protocols import (
HealthResult,
EmailProtocol,
EmailInbox,
SupportsPresentation,
PlatformCapabilities,
)
from .presentation import (
MessagePresentation,
PresentationBlock,
PresentationButton,
PresentationAction,
SelectOption,
PresentationLimits,
ActionType,
ButtonStyle,
BlockType,
)
from .interactive import (
InteractiveContext,
InteractiveRegistry,
InteractiveHandler,
encode_action,
decode_callback,
create_registry,
get_registry,
register_handler,
unregister_handler,
)
from .config import BotConfig, BotOSConfig
@@ -70,24 +62,4 @@ __all__ = [
"HealthResult",
"EmailProtocol",
"EmailInbox",
"MessagePresentation",
"PresentationBlock",
"PresentationButton",
"PresentationAction",
"SelectOption",
"PresentationLimits",
"SupportsPresentation",
"ActionType",
"ButtonStyle",
"BlockType",
"PlatformCapabilities",
"InteractiveContext",
"InteractiveRegistry",
"InteractiveHandler",
"encode_action",
"decode_callback",
"create_registry",
"get_registry",
"register_handler",
"unregister_handler",
]
@@ -91,16 +91,6 @@ class BotConfig:
# When set, stale sessions older than this are auto-reaped.
session_ttl: int = 0
# In-flight run control settings for mid-run message handling
# busy_mode: Policy for handling messages during active runs
# - "queue": Queue message for next turn (default)
# - "interrupt": Cancel current run and start new one
# - "steer": Inject message into current run via steering
busy_mode: str = "queue"
# Template for busy acknowledgment messages (use {action} placeholder)
busy_ack: str = "⏳ {action} — will be considered next"
# Workspace settings for file operation containment and security
workspace_dir: Optional[str] = None # default: ~/.praisonai/workspaces/<scope>/<session_key>
workspace_access: str = "rw" # "rw" (read-write) | "ro" (read-only) | "none" (copy-on-write sandbox)
@@ -111,16 +101,6 @@ class BotConfig:
# Owner user ID for pairing approvals (platform-specific format)
owner_user_id: Optional[str] = None
# Progressive streaming for channel bots (default: False)
streaming: bool = False
# Edit interval for streaming responses in milliseconds (default: 700ms)
stream_edit_interval_ms: int = 700
# Intentional silence support
allow_silence: bool = False
silence_token: Optional[str] = None
def __post_init__(self) -> None:
if self.unknown_user_policy not in {"deny", "pair", "allow"}:
@@ -156,17 +136,11 @@ class BotConfig:
"ack_emoji": self.ack_emoji,
"done_emoji": self.done_emoji,
"session_ttl": self.session_ttl,
"busy_mode": self.busy_mode,
"busy_ack": self.busy_ack,
"workspace_dir": self.workspace_dir,
"workspace_access": self.workspace_access,
"workspace_scope": self.workspace_scope,
"unknown_user_policy": self.unknown_user_policy,
"owner_user_id": "***" if self.owner_user_id else None,
"streaming": self.streaming,
"stream_edit_interval_ms": self.stream_edit_interval_ms,
"allow_silence": self.allow_silence,
"silence_token": self.silence_token,
"metadata": self.metadata,
}
@@ -1,271 +0,0 @@
"""
Interactive action dispatch system for messaging bots.
Provides a registry and dispatch protocol for handling callbacks from
interactive UI elements (buttons, select menus) across all messaging platforms.
This complements the presentation.py render side with symmetric inbound handling.
This is a core protocol with no heavy implementations - channel-specific
callback decoding belongs in the wrapper (praisonai).
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable, Dict, Optional, Tuple, TYPE_CHECKING
if TYPE_CHECKING:
from .presentation import PresentationAction
from .protocols import BotAdapter, BotMessage
logger = logging.getLogger(__name__)
@dataclass
class InteractiveContext:
"""Context for interactive callback handling.
Attributes:
callback_data: Raw callback data from the platform
user_id: ID of the user who triggered the action
message_id: ID of the message containing the interactive element
chat_id: ID of the chat where the action was triggered
bot_adapter: The bot adapter handling this interaction
platform_data: Platform-specific additional data
"""
callback_data: str
user_id: str
message_id: Optional[str] = None
chat_id: Optional[str] = None
bot_adapter: Optional["BotAdapter"] = None
platform_data: Dict[str, Any] = field(default_factory=dict)
InteractiveHandler = Callable[[InteractiveContext], Awaitable[Optional[str]]]
def encode_action(namespace: str, action: "PresentationAction") -> str:
"""Encode an action with namespace for callback data.
Args:
namespace: The namespace for this action handler
action: The presentation action to encode
Returns:
Encoded callback data string
"""
from .presentation import ActionType
if action.type == ActionType.CALLBACK:
# For callback type, encode namespace with the value
if action.value:
return f"{namespace}:{action.value}"
return namespace
elif action.type == ActionType.COMMAND:
# For command type, use cmd: prefix
if action.command:
return f"cmd:{action.command}"
return namespace
else:
# URL and web_app types don't need encoding
return namespace
def decode_callback(data: str) -> Tuple[str, Dict[str, Any]]:
"""Decode callback data into namespace and payload.
Args:
data: Raw callback data string
Returns:
Tuple of (namespace, payload_dict)
"""
if not data:
return ("unknown", {})
# Handle command callbacks (cmd:)
if data.startswith("cmd:"):
command = data[4:]
return ("command", {"command": command})
# Handle namespaced callbacks (namespace:payload)
if ":" in data:
parts = data.split(":", 1)
namespace = parts[0]
payload = parts[1] if len(parts) > 1 else ""
return (namespace, {"value": payload})
# Plain data without namespace
return (data, {})
class InteractiveRegistry:
"""Registry for interactive callback handlers.
Manages registration and dispatch of handlers for different
callback namespaces. Each namespace can have one handler.
"""
def __init__(self):
"""Initialize the registry."""
self._handlers: Dict[str, InteractiveHandler] = {}
self._fallback_handler: Optional[InteractiveHandler] = None
def register(
self,
namespace: str,
handler: InteractiveHandler
) -> None:
"""Register a handler for a namespace.
Args:
namespace: The namespace to handle (e.g., "approval", "menu")
handler: Async function to handle callbacks in this namespace
"""
if namespace in self._handlers:
logger.warning(f"Overwriting existing handler for namespace: {namespace}")
self._handlers[namespace] = handler
logger.debug(f"Registered handler for namespace: {namespace}")
def unregister(self, namespace: str) -> None:
"""Unregister a handler.
Args:
namespace: The namespace to unregister
"""
if namespace in self._handlers:
del self._handlers[namespace]
logger.debug(f"Unregistered handler for namespace: {namespace}")
def set_fallback(self, handler: InteractiveHandler) -> None:
"""Set a fallback handler for unmatched callbacks.
Args:
handler: Async function to handle unmatched callbacks
"""
self._fallback_handler = handler
async def dispatch(self, context: InteractiveContext) -> bool:
"""Dispatch a callback to the appropriate handler.
Args:
context: The interactive context
Returns:
True if handled, False otherwise
"""
namespace, payload = decode_callback(context.callback_data)
# Try to find a handler for this namespace
handler = self._handlers.get(namespace)
if handler:
try:
# Add decoded payload to context
context.platform_data["decoded_namespace"] = namespace
context.platform_data["decoded_payload"] = payload
result = await handler(context)
if result:
logger.debug(f"Handler for namespace '{namespace}' returned: {result}")
return True
else:
logger.debug(f"Handler for namespace '{namespace}' returned None, trying fallback")
except Exception as e:
logger.error(f"Error in handler for namespace '{namespace}': {e}")
# Continue to fallback handler
# Try fallback handler
if self._fallback_handler:
try:
context.platform_data["decoded_namespace"] = namespace
context.platform_data["decoded_payload"] = payload
result = await self._fallback_handler(context)
if result:
logger.debug(f"Fallback handler returned: {result}")
return True
else:
logger.debug(f"Fallback handler returned None")
return False
except Exception as e:
logger.error(f"Error in fallback handler: {e}")
return False
logger.debug(f"No handler found for namespace: {namespace}")
return False
def has_handler(self, namespace: str) -> bool:
"""Check if a namespace has a registered handler.
Args:
namespace: The namespace to check
Returns:
True if handler exists
"""
return namespace in self._handlers
def list_namespaces(self) -> list[str]:
"""List all registered namespaces.
Returns:
List of namespace names
"""
return list(self._handlers.keys())
# Global registry instance - deprecated, use create_registry() for new code
_global_registry = InteractiveRegistry()
def create_registry() -> InteractiveRegistry:
"""Create a new interactive registry instance.
Each adapter should create its own registry to avoid conflicts
when multiple adapters are used in the same process.
Returns:
A new InteractiveRegistry instance
"""
return InteractiveRegistry()
def get_registry() -> InteractiveRegistry:
"""Get the global interactive registry.
.. deprecated::
Use create_registry() to create adapter-specific registries instead.
The global registry can cause conflicts when multiple adapters are used.
Returns:
The global InteractiveRegistry instance
"""
return _global_registry
def register_handler(namespace: str, handler: InteractiveHandler) -> None:
"""Register a handler in the global registry.
.. deprecated::
Use registry.register() on an adapter-specific registry instead.
Args:
namespace: The namespace to handle
handler: Async function to handle callbacks
"""
_global_registry.register(namespace, handler)
def unregister_handler(namespace: str) -> None:
"""Unregister a handler from the global registry.
.. deprecated::
Use registry.unregister() on an adapter-specific registry instead.
Args:
namespace: The namespace to unregister
"""
_global_registry.unregister(namespace)
@@ -1,456 +0,0 @@
"""
Presentation primitives for interactive UI in messaging bots.
Defines typed, portable presentation blocks (buttons, selects, text)
that channel adapters render as native widgets. Enables structured
interactive UI across Telegram, Slack, Discord, and other platforms.
This is a core protocol with no heavy implementations - channel-specific
rendering belongs in the wrapper (praisonai).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, List, Literal, Optional, Union
class ActionType(str, Enum):
"""Types of actions that can be triggered by interactive elements."""
COMMAND = "command" # Execute a slash command
CALLBACK = "callback" # Opaque callback data for the plugin
URL = "url" # Open a URL
WEB_APP = "web_app" # Open a web app (Telegram mini apps, etc)
class ButtonStyle(str, Enum):
"""Visual styles for buttons."""
PRIMARY = "primary" # Primary action (blue/green)
DANGER = "danger" # Destructive action (red)
SECONDARY = "secondary" # Secondary action (gray)
SUCCESS = "success" # Success action (green)
WARNING = "warning" # Warning action (yellow)
class BlockType(str, Enum):
"""Types of presentation blocks."""
TEXT = "text" # Text content
BUTTONS = "buttons" # Button row/grid
SELECT = "select" # Dropdown/select menu
DIVIDER = "divider" # Visual separator
CONTEXT = "context" # Contextual info (smaller text)
@dataclass
class PresentationAction:
"""An action triggered by an interactive element.
Attributes:
type: The action type (command, callback, url, web_app)
command: Slash command to execute (for type="command")
value: Callback data (for type="callback")
url: URL to open (for type="url")
web_app_url: Web app URL (for type="web_app")
"""
type: Union[ActionType, str]
command: Optional[str] = None
value: Optional[str] = None
url: Optional[str] = None
web_app_url: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
data = {"type": self.type.value if isinstance(self.type, ActionType) else self.type}
if self.command is not None:
data["command"] = self.command
if self.value is not None:
data["value"] = self.value
if self.url is not None:
data["url"] = self.url
if self.web_app_url is not None:
data["web_app_url"] = self.web_app_url
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "PresentationAction":
"""Create from dictionary."""
return cls(
type=data.get("type", "callback"),
command=data.get("command"),
value=data.get("value"),
url=data.get("url"),
web_app_url=data.get("web_app_url"),
)
@dataclass
class PresentationButton:
"""A button in an interactive presentation.
Attributes:
label: Button text label
action: Action to trigger when clicked
url: Direct URL (alternative to action)
priority: Truncation priority (higher survives)
style: Visual style
disabled: Whether button is disabled
"""
label: str
action: Optional[PresentationAction] = None
url: Optional[str] = None
priority: int = 0
style: Optional[Union[ButtonStyle, str]] = None
disabled: bool = False
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
data = {"label": self.label, "priority": self.priority, "disabled": self.disabled}
if self.action is not None:
data["action"] = self.action.to_dict()
if self.url is not None:
data["url"] = self.url
if self.style is not None:
data["style"] = self.style.value if isinstance(self.style, ButtonStyle) else self.style
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "PresentationButton":
"""Create from dictionary."""
return cls(
label=data.get("label", ""),
action=PresentationAction.from_dict(data["action"]) if "action" in data else None,
url=data.get("url"),
priority=data.get("priority", 0),
style=data.get("style"),
disabled=data.get("disabled", False),
)
@dataclass
class SelectOption:
"""An option in a select menu.
Attributes:
label: Option text label
value: Option value
description: Optional description
emoji: Optional emoji icon
default: Whether this is the default option
"""
label: str
value: str
description: Optional[str] = None
emoji: Optional[str] = None
default: bool = False
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
data = {"label": self.label, "value": self.value, "default": self.default}
if self.description is not None:
data["description"] = self.description
if self.emoji is not None:
data["emoji"] = self.emoji
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "SelectOption":
"""Create from dictionary."""
return cls(
label=data.get("label", ""),
value=data.get("value", ""),
description=data.get("description"),
emoji=data.get("emoji"),
default=data.get("default", False),
)
@dataclass
class PresentationBlock:
"""A block in a message presentation.
Blocks are the building blocks of interactive messages.
Each block type has specific properties and behavior.
"""
type: Union[BlockType, str]
text: Optional[str] = None
buttons: Optional[List[PresentationButton]] = None
options: Optional[List[SelectOption]] = None
placeholder: Optional[str] = None
action_id: Optional[str] = None
@staticmethod
def make_text(content: str, markdown: bool = True) -> "PresentationBlock":
"""Create a text block."""
return PresentationBlock(type=BlockType.TEXT, text=content)
@staticmethod
def make_buttons(items: List[PresentationButton]) -> "PresentationBlock":
"""Create a buttons block."""
return PresentationBlock(type=BlockType.BUTTONS, buttons=items)
@staticmethod
def make_select(
options: List[SelectOption],
placeholder: Optional[str] = None,
action_id: Optional[str] = None,
) -> "PresentationBlock":
"""Create a select menu block."""
return PresentationBlock(
type=BlockType.SELECT,
options=options,
placeholder=placeholder,
action_id=action_id,
)
@staticmethod
def make_divider() -> "PresentationBlock":
"""Create a divider block."""
return PresentationBlock(type=BlockType.DIVIDER)
@staticmethod
def make_context(content: str) -> "PresentationBlock":
"""Create a context block (smaller text)."""
return PresentationBlock(type=BlockType.CONTEXT, text=content)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
data = {"type": self.type.value if isinstance(self.type, BlockType) else self.type}
if self.text is not None:
data["text"] = self.text
if self.buttons is not None:
data["buttons"] = [b.to_dict() for b in self.buttons]
if self.options is not None:
data["options"] = [o.to_dict() for o in self.options]
if self.placeholder is not None:
data["placeholder"] = self.placeholder
if self.action_id is not None:
data["action_id"] = self.action_id
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "PresentationBlock":
"""Create from dictionary."""
return cls(
type=data.get("type", "text"),
text=data.get("text"),
buttons=(
[PresentationButton.from_dict(b) for b in data["buttons"]]
if "buttons" in data else None
),
options=(
[SelectOption.from_dict(o) for o in data["options"]]
if "options" in data else None
),
placeholder=data.get("placeholder"),
action_id=data.get("action_id"),
)
@dataclass
class MessagePresentation:
"""A complete interactive message presentation.
Attributes:
blocks: List of presentation blocks
tone: Optional tone/style hint for the whole message
ephemeral: Whether message should be ephemeral/temporary
replace_message_id: ID of message to replace (for updates)
"""
blocks: List[PresentationBlock] = field(default_factory=list)
tone: Optional[str] = None
ephemeral: bool = False
replace_message_id: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
data = {"blocks": [b.to_dict() for b in self.blocks], "ephemeral": self.ephemeral}
if self.tone is not None:
data["tone"] = self.tone
if self.replace_message_id is not None:
data["replace_message_id"] = self.replace_message_id
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "MessagePresentation":
"""Create from dictionary."""
return cls(
blocks=[PresentationBlock.from_dict(b) for b in data.get("blocks", [])],
tone=data.get("tone"),
ephemeral=data.get("ephemeral", False),
replace_message_id=data.get("replace_message_id"),
)
@staticmethod
def approval(
prompt: str,
approval_id: str,
allow_always: bool = False,
context: Optional[str] = None,
) -> "MessagePresentation":
"""Create a standard approval presentation.
Args:
prompt: The approval prompt text
approval_id: Unique ID for this approval
allow_always: Whether to include "Allow Always" option
context: Optional context information
Returns:
A presentation with standard approval buttons
"""
blocks = []
# Add prompt
blocks.append(PresentationBlock.make_text(prompt))
# Add context if provided
if context:
blocks.append(PresentationBlock.make_context(context))
# Create buttons
buttons = [
PresentationButton(
label="Allow Once",
action=PresentationAction(
type=ActionType.COMMAND,
command=f"/approve {approval_id} allow-once"
),
style=ButtonStyle.PRIMARY,
priority=10,
),
PresentationButton(
label="Deny",
action=PresentationAction(
type=ActionType.COMMAND,
command=f"/approve {approval_id} deny"
),
style=ButtonStyle.DANGER,
priority=9,
),
]
if allow_always:
buttons.insert(1, PresentationButton(
label="Allow Always",
action=PresentationAction(
type=ActionType.COMMAND,
command=f"/approve {approval_id} allow-always"
),
style=ButtonStyle.SUCCESS,
priority=8,
))
blocks.append(PresentationBlock.make_buttons(buttons))
return MessagePresentation(blocks=blocks)
@dataclass
class PresentationLimits:
"""Channel-specific limits for presentations.
Attributes:
max_buttons: Maximum buttons per row/message
max_button_rows: Maximum button rows
max_button_label: Maximum characters in button label
max_options: Maximum options in select menu
max_option_label: Maximum characters in option label
max_text_length: Maximum text block length
supports_markdown: Whether channel supports markdown
supports_select: Whether channel supports select menus
supports_web_apps: Whether channel supports web apps
"""
max_buttons: int = 10
max_button_rows: int = 5
max_button_label: int = 50
max_options: int = 25
max_option_label: int = 50
max_text_length: int = 4096
supports_markdown: bool = True
supports_select: bool = True
supports_web_apps: bool = False
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
return {
"max_buttons": self.max_buttons,
"max_button_rows": self.max_button_rows,
"max_button_label": self.max_button_label,
"max_options": self.max_options,
"max_option_label": self.max_option_label,
"max_text_length": self.max_text_length,
"supports_markdown": self.supports_markdown,
"supports_select": self.supports_select,
"supports_web_apps": self.supports_web_apps,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "PresentationLimits":
"""Create from dictionary."""
return cls(
max_buttons=data.get("max_buttons", 10),
max_button_rows=data.get("max_button_rows", 5),
max_button_label=data.get("max_button_label", 50),
max_options=data.get("max_options", 25),
max_option_label=data.get("max_option_label", 50),
max_text_length=data.get("max_text_length", 4096),
supports_markdown=data.get("supports_markdown", True),
supports_select=data.get("supports_select", True),
supports_web_apps=data.get("supports_web_apps", False),
)
@staticmethod
def telegram() -> "PresentationLimits":
"""Get Telegram-specific limits."""
return PresentationLimits(
max_buttons=8,
max_button_rows=100,
max_button_label=64,
max_options=0, # No native select
max_text_length=4096,
supports_markdown=True,
supports_select=False,
supports_web_apps=True,
)
@staticmethod
def slack() -> "PresentationLimits":
"""Get Slack-specific limits."""
return PresentationLimits(
max_buttons=5,
max_button_rows=1,
max_button_label=75,
max_options=100,
max_option_label=75,
max_text_length=3000,
supports_markdown=True,
supports_select=True,
supports_web_apps=False,
)
@staticmethod
def discord() -> "PresentationLimits":
"""Get Discord-specific limits."""
return PresentationLimits(
max_buttons=25,
max_button_rows=5,
max_button_label=80,
max_options=25,
max_option_label=100,
max_text_length=2000,
supports_markdown=True,
supports_select=True,
supports_web_apps=False,
)
@@ -22,7 +22,6 @@ from typing import (
List,
Optional,
Protocol,
TypedDict,
Union,
runtime_checkable,
)
@@ -31,105 +30,6 @@ if TYPE_CHECKING:
from ..agent import Agent
class ChannelCapabilities(TypedDict, total=False):
"""Declares what features a bot channel supports.
This allows shared engines to adapt behavior based on platform capabilities,
enabling graceful degradation when features aren't available.
Attributes:
live_edit: Whether the channel supports editing messages in place
reactions: Whether the channel supports adding reactions to messages
typing: Whether the channel supports typing indicators
text_limit: Maximum message length (0 = unlimited)
edit_rate_limit: Minimum seconds between edits (for throttling)
reaction_rate_limit: Minimum seconds between reactions
"""
live_edit: bool
reactions: bool
typing: bool
text_limit: int
edit_rate_limit: float
reaction_rate_limit: float
class RunStatus(Enum):
"""Run status states for progress feedback.
Used by status engines to show agent execution state through
reactions or status lines.
"""
QUEUED = "queued"
THINKING = "thinking"
TOOL = "tool"
DONE = "done"
ERROR = "error"
@dataclass(frozen=True)
class PlatformCapabilities:
"""Platform-specific capabilities descriptor for bot adapters.
Declares what a messaging platform can do, enabling the shared delivery
machinery to apply features like streaming, rate limiting, and chunking
uniformly across all platforms.
Attributes:
max_message_length: Maximum message length in the platform's unit
length_unit: Unit for message length ("codepoints" or "utf16")
supports_edit: Whether the platform supports in-place message edits (for streaming)
supports_typing: Whether the platform supports typing indicators
markdown_dialect: Markdown flavor the platform uses (e.g., "markdown", "telegram_markdown_v2")
needs_rate_limit: Whether the platform needs rate limiting
edit_interval_ms: Minimum milliseconds between message edits (for streaming)
max_files_per_message: Maximum number of file attachments per message
max_file_size_mb: Maximum file size in megabytes
supported_file_types: List of supported file extensions/mime types
"""
max_message_length: int = 4096
length_unit: str = "codepoints" # "codepoints" or "utf16"
supports_edit: bool = False
supports_typing: bool = True
markdown_dialect: str = "markdown"
needs_rate_limit: bool = True
edit_interval_ms: int = 1000 # Minimum ms between edits
max_files_per_message: int = 1
max_file_size_mb: int = 10
supported_file_types: List[str] = field(default_factory=lambda: ["*"])
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"max_message_length": self.max_message_length,
"length_unit": self.length_unit,
"supports_edit": self.supports_edit,
"supports_typing": self.supports_typing,
"markdown_dialect": self.markdown_dialect,
"needs_rate_limit": self.needs_rate_limit,
"edit_interval_ms": self.edit_interval_ms,
"max_files_per_message": self.max_files_per_message,
"max_file_size_mb": self.max_file_size_mb,
"supported_file_types": self.supported_file_types,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "PlatformCapabilities":
"""Create from dictionary."""
return cls(
max_message_length=data.get("max_message_length", 4096),
length_unit=data.get("length_unit", "codepoints"),
supports_edit=data.get("supports_edit", False),
supports_typing=data.get("supports_typing", True),
markdown_dialect=data.get("markdown_dialect", "markdown"),
needs_rate_limit=data.get("needs_rate_limit", True),
edit_interval_ms=data.get("edit_interval_ms", 1000),
max_files_per_message=data.get("max_files_per_message", 1),
max_file_size_mb=data.get("max_file_size_mb", 10),
supported_file_types=data.get("supported_file_types", ["*"]),
)
class MessageType(str, Enum):
"""Types of bot messages."""
@@ -418,80 +318,6 @@ class ProbeResult:
}
class HealthReason(Enum):
"""Reasons for channel health status."""
HEALTHY = "healthy"
NOT_RUNNING = "not-running"
DISCONNECTED = "disconnected"
STALE_SOCKET = "stale-socket"
STUCK = "stuck"
BUSY = "busy"
STARTUP_GRACE = "startup-grace"
ERROR = "error"
@property
def is_recoverable(self) -> bool:
"""Whether this reason indicates a recoverable state."""
return self in {
HealthReason.DISCONNECTED,
HealthReason.STALE_SOCKET,
HealthReason.STUCK,
HealthReason.ERROR,
}
def evaluate_channel_health(
health: HealthResult,
startup_grace_seconds: float = 60.0,
stale_after_seconds: float = 120.0,
current_time: Optional[float] = None,
) -> HealthReason:
"""Evaluate channel health and return a reason.
Pure function that evaluates a HealthResult and determines
the health reason based on various criteria.
Args:
health: The health result to evaluate
startup_grace_seconds: Grace period for startup
stale_after_seconds: Time after which no activity is considered stale
current_time: Current timestamp (for testing)
Returns:
HealthReason indicating the channel's health status
"""
if current_time is None:
current_time = time.time()
# Not running
if not health.is_running:
return HealthReason.NOT_RUNNING
# Startup grace period
if health.uptime_seconds is not None and health.uptime_seconds < startup_grace_seconds:
return HealthReason.STARTUP_GRACE
# Check for errors
if health.error:
return HealthReason.ERROR
# Check probe result
if health.probe and not health.probe.ok:
return HealthReason.DISCONNECTED
# Check for stale socket (no transport activity)
if health.last_activity is not None:
time_since_activity = current_time - health.last_activity
if time_since_activity > stale_after_seconds:
return HealthReason.STALE_SOCKET
# Overall health status
if not health.ok:
return HealthReason.ERROR
return HealthReason.HEALTHY
@dataclass
class HealthResult:
"""Detailed health status of a bot.
@@ -505,8 +331,6 @@ class HealthResult:
sessions: Number of active sessions
error: Error message (if unhealthy)
details: Additional platform-specific health details
reason: Health status reason (optional)
last_activity: Last transport activity timestamp (optional)
"""
ok: bool
@@ -517,8 +341,6 @@ class HealthResult:
sessions: int = 0
error: Optional[str] = None
details: Dict[str, Any] = field(default_factory=dict)
reason: Optional[HealthReason] = None
last_activity: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
@@ -531,8 +353,6 @@ class HealthResult:
"sessions": self.sessions,
"error": self.error,
"details": self.details,
"reason": self.reason.value if self.reason else None,
"last_activity": self.last_activity,
}
@@ -568,24 +388,6 @@ class BotProtocol(Protocol):
"""The bot's user information."""
...
@property
def capabilities(self) -> ChannelCapabilities:
"""Channel capabilities for feature discovery.
Returns capabilities that shared engines use to adapt behavior.
Channels that don't support a feature should return False for it.
"""
...
@property
def platform_capabilities(self) -> PlatformCapabilities:
"""Platform capabilities descriptor.
Returns the platform's capabilities for use by shared delivery code.
Adapters should override this to declare their specific capabilities.
"""
...
# Lifecycle methods
async def start(self) -> None:
"""Start the bot (begin receiving messages)."""
@@ -697,33 +499,6 @@ class BotProtocol(Protocol):
"""
...
# Reactions
async def add_reaction(self, channel_id: str, message_id: str, emoji: str) -> bool:
"""Add a reaction emoji to a message.
Args:
channel_id: Channel containing the message
message_id: Message to react to
emoji: Emoji to add (Unicode or custom emoji ID)
Returns:
True if reaction was added successfully
"""
...
async def remove_reaction(self, channel_id: str, message_id: str, emoji: str) -> bool:
"""Remove a reaction emoji from a message.
Args:
channel_id: Channel containing the message
message_id: Message to remove reaction from
emoji: Emoji to remove
Returns:
True if reaction was removed successfully
"""
...
# User/channel info
async def get_user(self, user_id: str) -> Optional[BotUser]:
"""Get user information.
@@ -838,55 +613,6 @@ class ChatCommandProtocol(Protocol):
...
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# SupportsPresentation — interactive UI presentation protocol
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@runtime_checkable
class SupportsPresentation(Protocol):
"""Protocol for channel adapters that support presentations.
Channel adapters implement this protocol to render
portable presentations as native widgets.
"""
@property
def presentation_limits(self) -> "PresentationLimits":
"""Get channel-specific presentation limits."""
...
async def render_presentation(
self,
target: str,
presentation: "MessagePresentation",
) -> Optional[str]:
"""Render a presentation to a target (chat/channel).
Args:
target: Target identifier (chat_id, channel_id, etc.)
presentation: The presentation to render
Returns:
Message ID if sent successfully, None otherwise
"""
...
def truncate_presentation(
self,
presentation: "MessagePresentation",
) -> "MessagePresentation":
"""Truncate a presentation to fit channel limits.
Args:
presentation: The presentation to truncate
Returns:
Truncated presentation that fits channel limits
"""
...
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# EmailProtocol — email-specific bot capabilities
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -1,52 +0,0 @@
"""
Silence protocol for bot gateways.
Provides a canonical contract for agents to signal intentional silence
(no reply) in group/ambient channels. Zero dependencies.
"""
SILENT_REPLY_TOKEN = "NO_REPLY"
_MARKERS = frozenset({"NO_REPLY", "[SILENT]", "SILENT"})
def is_intentional_silence_response(text: str | None) -> bool:
"""Check if a response is an intentional silence signal.
True only when the reply is *exactly* a silence marker (not prose mentioning it).
Blank/empty responses are NOT treated as deliberate silence.
Args:
text: The response text to check
Returns:
True if the response is exactly a silence token, False otherwise
Examples:
>>> is_intentional_silence_response("NO_REPLY")
True
>>> is_intentional_silence_response("[SILENT]")
True
>>> is_intentional_silence_response("I think NO_REPLY is good")
False
>>> is_intentional_silence_response("")
False
>>> is_intentional_silence_response(None)
False
"""
if not text:
return False # blank != deliberate silence
normalized = text.strip().upper()
# Check for exact match
if normalized in _MARKERS:
return True
# Check for bracket-wrapped version (only for tokens that aren't already bracketed)
if normalized.startswith("[") and normalized.endswith("]"):
inner = normalized[1:-1]
if inner == "SILENT": # Only [SILENT] is a valid bracketed marker
return True
return False
@@ -170,17 +170,6 @@ class EventBus:
Returns:
The published Event object
"""
# Fast path: if no subscribers, return a minimal event without expensive operations
if not self._subscribers:
# Convert EventType enum to string
type_str = event_type.value if isinstance(event_type, EventType) else event_type
return Event(
type=type_str,
data=data or {},
source=source,
metadata=metadata or {},
)
# Convert EventType enum to string
type_str = event_type.value if isinstance(event_type, EventType) else event_type
@@ -205,10 +194,6 @@ class EventBus:
"""
logger.debug(f"Publishing event: {event.type}")
# Fast path: if no subscribers, skip expensive work
if not self._subscribers:
return event
# Store in history
with self._lock:
self._event_history.append(event)
@@ -260,15 +245,6 @@ class EventBus:
"""
type_str = event_type.value if isinstance(event_type, EventType) else event_type
# Fast path: if no subscribers, return a minimal event without expensive operations
if not self._subscribers:
return Event(
type=type_str,
data=data or {},
source=source,
metadata=metadata or {},
)
event = Event(
type=type_str,
data=data or {},
@@ -345,11 +321,6 @@ class EventBus:
with self._lock:
return len(self._subscribers)
@property
def has_subscribers(self) -> bool:
"""Check if there are any subscribers (lock-free for performance)."""
return bool(self._subscribers)
def __repr__(self) -> str:
return f"EventBus(subscribers={self.subscriber_count})"
@@ -33,10 +33,6 @@ __all__ = [
"CompactionStrategy",
# Results
"CompactionResult",
# Protocols
"ToolResultPrunerProtocol",
"MessageFormatterProtocol",
"SummaryBuilderProtocol",
]
@@ -58,16 +54,4 @@ def __getattr__(name: str):
from .result import CompactionResult
return CompactionResult
if name == "ToolResultPrunerProtocol":
from .protocols import ToolResultPrunerProtocol
return ToolResultPrunerProtocol
if name == "MessageFormatterProtocol":
from .protocols import MessageFormatterProtocol
return MessageFormatterProtocol
if name == "SummaryBuilderProtocol":
from .protocols import SummaryBuilderProtocol
return SummaryBuilderProtocol
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -5,13 +5,12 @@ Manages context window by compacting messages when needed.
"""
import re
from typing import List, Dict, Any, Optional, Callable, Awaitable, Tuple
from typing import List, Dict, Any, Optional, Callable, Awaitable
import asyncio
from .config import CompactionConfig, COMPACTION_PREFIX, SUMMARY_TEMPLATE
from .strategy import CompactionStrategy
from .result import CompactionResult
from .protocols import ToolResultPrunerProtocol, MessageFormatterProtocol, SummaryBuilderProtocol
class ContextCompactor:
@@ -35,10 +34,7 @@ class ContextCompactor:
preserve_system: bool = True,
preserve_recent: int = 5,
config: Optional[CompactionConfig] = None,
llm_summarize_fn: Optional[Callable[[str], Awaitable[str]]] = None,
tool_pruner: Optional[ToolResultPrunerProtocol] = None,
message_formatter: Optional[MessageFormatterProtocol] = None,
summary_builder: Optional[SummaryBuilderProtocol] = None
llm_summarize_fn: Optional[Callable[[str], Awaitable[str]]] = None
):
"""
Initialize the compactor.
@@ -49,43 +45,30 @@ class ContextCompactor:
strategy: Compaction strategy to use
preserve_system: Keep system messages
preserve_recent: Number of recent messages to preserve
config: Optional CompactionConfig for advanced settings
<<<<<<< HEAD
config: Optional CompactionConfig to override defaults
llm_summarize_fn: Async function to call LLM for summarization
tool_pruner: Protocol implementation for tool result pruning
message_formatter: Protocol implementation for message formatting
summary_builder: Protocol implementation for summary building
"""
# Use provided config or create default
self.config = config or CompactionConfig(
max_tokens=max_tokens,
target_tokens=target_tokens or int(max_tokens * 0.75),
preserve_system=preserve_system,
preserve_recent=preserve_recent
)
# Initialize config first
self.config = config or CompactionConfig()
# Set instance attributes from config to ensure consistency
self.max_tokens = self.config.max_tokens
self.target_tokens = self.config.target_tokens
# Use config values if config provided, otherwise use constructor args
if config is not None:
self.max_tokens = config.max_tokens
self.target_tokens = config.target_tokens
self.preserve_system = config.preserve_system
self.preserve_recent = config.preserve_recent
else:
self.max_tokens = max_tokens
self.target_tokens = target_tokens or int(max_tokens * 0.75)
self.preserve_system = preserve_system
self.preserve_recent = preserve_recent
self.strategy = strategy
self.preserve_system = self.config.preserve_system
self.preserve_recent = self.config.preserve_recent
self.llm_summarize_fn = llm_summarize_fn
# Protocol implementations (defaults to None - no heavy implementations in core)
self.tool_pruner = tool_pruner
self.message_formatter = message_formatter
self.summary_builder = summary_builder
# Anti-thrashing state tracking
self._last_savings_pct: float = 100.0 # Start high to allow first compaction
self._low_savings_streak: int = 0
# Iterative summary state
# Track previous summaries for iterative update
self._previous_summary: Optional[str] = None
self._previous_summary_global_idx: int = 0
# Reset state tracking for each compact() call
self._used_previous_summary: bool = False
def estimate_tokens(self, text: str) -> int:
"""Estimate token count for text."""
@@ -113,26 +96,12 @@ class ContextCompactor:
return sum(self.count_message_tokens(m) for m in messages)
def needs_compaction(self, messages: List[Dict[str, Any]]) -> bool:
"""
Check if messages need compaction with anti-thrashing protection.
Returns False if:
- Token count is below threshold
- We've had too many consecutive low-savings attempts
"""
if self.count_total_tokens(messages) <= self.max_tokens:
return False
# Anti-thrashing: skip if we've had too many low-savings attempts
if self._low_savings_streak >= self.config.max_consecutive_low_savings:
return False
return True
"""Check if messages need compaction."""
return self.count_total_tokens(messages) > self.max_tokens
def compact(
self,
messages: List[Dict[str, Any]],
focus_topic: str = ""
messages: List[Dict[str, Any]]
) -> tuple[List[Dict[str, Any]], CompactionResult]:
"""
Compact messages to fit within token limit (synchronous version).
@@ -142,49 +111,31 @@ class ContextCompactor:
Args:
messages: List of messages to compact
focus_topic: Optional topic to focus on during summarization
Returns:
Tuple of (compacted messages, result)
"""
# Reset state for this compaction call
self._used_previous_summary = False
original_tokens = self.count_total_tokens(messages)
if original_tokens <= self.max_tokens:
result = CompactionResult(
return messages, CompactionResult(
original_tokens=original_tokens,
compacted_tokens=original_tokens,
messages_removed=0,
messages_kept=len(messages),
strategy_used=self.strategy
)
result.calculate_savings_pct()
return messages, result
# Apply tool result deduplication pre-pass if enabled
processed_messages = messages
tool_results_pruned = 0
if self.config.tool_prune_before_summarise and self.tool_pruner:
processed_messages, tool_results_pruned = self.tool_pruner.prune(
messages,
self.config.max_tool_result_size
)
# Skip early exit - proceed with full compaction strategy
# Anti-thrashing check will be applied after strategy runs
if self.strategy == CompactionStrategy.TRUNCATE:
compacted = self._truncate(processed_messages)
compacted = self._truncate(messages)
elif self.strategy == CompactionStrategy.SLIDING:
compacted = self._sliding_window(processed_messages)
compacted = self._sliding_window(messages)
elif self.strategy == CompactionStrategy.SUMMARIZE:
compacted = self._summarize(processed_messages)
compacted = self._summarize(messages)
elif self.strategy == CompactionStrategy.SMART:
compacted = self._smart_compact(processed_messages)
compacted = self._smart_compact(messages)
elif self.strategy == CompactionStrategy.PRUNE:
compacted = self._prune(processed_messages)
compacted = self._prune(messages)
elif self.strategy == CompactionStrategy.LLM_SUMMARIZE:
if self.llm_summarize_fn:
# For sync calls with LLM function, we need to run async
@@ -193,17 +144,17 @@ class ContextCompactor:
try:
loop = asyncio.get_running_loop()
# If in async context, fallback to naive summarization
compacted = self._summarize(processed_messages)
compacted = self._summarize(messages)
except RuntimeError:
# No running loop, safe to create one
compacted = asyncio.run(self._llm_summarize_async(processed_messages, focus_topic))
compacted = asyncio.run(self._llm_summarize_async(messages))
except Exception:
# Fallback to naive summarization if async fails
compacted = self._summarize(processed_messages)
compacted = self._summarize(messages)
else:
compacted = self._llm_summarize(processed_messages, focus_topic)
compacted = self._llm_summarize(messages)
else:
compacted = self._truncate(processed_messages)
compacted = self._truncate(messages)
compacted_tokens = self.count_total_tokens(compacted)
@@ -212,75 +163,49 @@ class ContextCompactor:
compacted_tokens=compacted_tokens,
messages_removed=len(messages) - len(compacted),
messages_kept=len(compacted),
strategy_used=self.strategy,
tool_results_pruned=tool_results_pruned,
previous_summary_reused=getattr(self, '_used_previous_summary', False)
strategy_used=self.strategy
)
result.calculate_savings_pct()
# Update anti-thrashing tracking based on actual results
self._last_savings_pct = result.savings_pct
if result.savings_pct >= self.config.min_savings_pct:
self._low_savings_streak = 0
else:
self._low_savings_streak += 1
return compacted, result
async def compact_async(
self,
messages: List[Dict[str, Any]],
focus_topic: str = ""
messages: List[Dict[str, Any]]
) -> tuple[List[Dict[str, Any]], CompactionResult]:
"""
Compact messages to fit within token limit (asynchronous version).
Args:
messages: List of messages to compact
focus_topic: Optional topic to focus on during summarization
Returns:
Tuple of (compacted messages, result)
"""
# Reset state for this compaction call
self._used_previous_summary = False
original_tokens = self.count_total_tokens(messages)
if original_tokens <= self.max_tokens:
result = CompactionResult(
return messages, CompactionResult(
original_tokens=original_tokens,
compacted_tokens=original_tokens,
messages_removed=0,
messages_kept=len(messages),
strategy_used=self.strategy
)
result.calculate_savings_pct()
return messages, result
# Apply tool result deduplication pre-pass if enabled
processed_messages = messages
tool_results_pruned = 0
if self.config.tool_prune_before_summarise and self.tool_pruner:
processed_messages, tool_results_pruned = self.tool_pruner.prune(
messages,
self.config.max_tool_result_size
)
if self.strategy == CompactionStrategy.TRUNCATE:
compacted = self._truncate(processed_messages)
compacted = self._truncate(messages)
elif self.strategy == CompactionStrategy.SLIDING:
compacted = self._sliding_window(processed_messages)
compacted = self._sliding_window(messages)
elif self.strategy == CompactionStrategy.SUMMARIZE:
compacted = self._summarize(processed_messages)
compacted = self._summarize(messages)
elif self.strategy == CompactionStrategy.SMART:
compacted = self._smart_compact(processed_messages)
compacted = self._smart_compact(messages)
elif self.strategy == CompactionStrategy.PRUNE:
compacted = self._prune(processed_messages)
compacted = self._prune(messages)
elif self.strategy == CompactionStrategy.LLM_SUMMARIZE:
compacted = await self._llm_summarize_async(processed_messages, focus_topic)
compacted = await self._llm_summarize_async(messages)
else:
compacted = self._truncate(processed_messages)
compacted = self._truncate(messages)
compacted_tokens = self.count_total_tokens(compacted)
@@ -289,35 +214,11 @@ class ContextCompactor:
compacted_tokens=compacted_tokens,
messages_removed=len(messages) - len(compacted),
messages_kept=len(compacted),
strategy_used=self.strategy,
tool_results_pruned=tool_results_pruned,
previous_summary_reused=getattr(self, '_used_previous_summary', False)
strategy_used=self.strategy
)
result.calculate_savings_pct()
# Update anti-thrashing tracking based on actual results
self._last_savings_pct = result.savings_pct
if result.savings_pct >= self.config.min_savings_pct:
self._low_savings_streak = 0
else:
self._low_savings_streak += 1
return compacted, result
def _prune_tool_results(self, messages: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], int]:
"""
Delegate tool result pruning to injected protocol implementation.
If no tool pruner is provided, returns messages unchanged.
This maintains backward compatibility while enforcing protocol-driven design.
Returns:
Tuple of (processed messages, number of tool results pruned)
"""
if self.tool_pruner:
return self.tool_pruner.prune(messages, self.config.max_tool_result_size)
return messages, 0
def _truncate(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Truncate oldest messages."""
result = []
@@ -343,17 +244,11 @@ class ContextCompactor:
# If still over limit, truncate more
while self.count_total_tokens(result) > self.target_tokens and len(result) > 1:
# Remove oldest message (respecting preserve_system setting)
removed = False
# Remove oldest non-system message
for i, msg in enumerate(result):
# Skip system messages only if preserve_system is True
if not (self.preserve_system and msg.get("role") == "system"):
if msg.get("role") != "system":
result.pop(i)
removed = True
break
if not removed:
# Only system messages remain but still over budget — stop to avoid infinite loop
break
return result
@@ -465,117 +360,72 @@ class ContextCompactor:
result.extend(recent)
return result
def _llm_summarize(self, messages: List[Dict[str, Any]], focus_topic: str = "") -> List[Dict[str, Any]]:
def _llm_summarize(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Use LLM to summarize older messages with iterative support.
Use LLM to summarize older messages with anti-injection framing.
Supports iterative summarization - if we have a previous summary,
we only summarize the new messages since that summary.
Also includes anti-injection framing when configured.
Args:
messages: List of messages to compact
focus_topic: Optional topic to focus on during summarization
Note: This implementation uses structured templates and anti-injection prefixes
to prevent the model from treating summarized content as active instructions.
"""
self._used_previous_summary = False
result = []
# Keep system messages
system_msgs = [m for m in messages if m.get("role") == "system"]
# Keep system messages (excluding previous compacted summaries)
system_msgs = [
m for m in messages
if m.get("role") == "system" and not m.get("_compacted")
]
other_msgs = [m for m in messages if m.get("role") != "system"]
result.extend(system_msgs)
# Keep recent messages
recent = other_msgs[-self.preserve_recent:]
older = other_msgs[:-self.preserve_recent] if len(other_msgs) > self.preserve_recent else []
# Determine what to summarize
total_original_messages = len(messages)
if (self.config.enable_iterative_summary and
self._previous_summary and
total_original_messages > self._previous_summary_global_idx):
# Iterative: summarize only new messages since previous summary
# Calculate how many old messages to summarize based on global position
messages_since_summary = total_original_messages - self._previous_summary_global_idx
new_older_messages = max(0, messages_since_summary - self.preserve_recent)
if new_older_messages > 0:
to_summarize = other_msgs[-messages_since_summary:-self.preserve_recent]
if older:
if self.config.structured_template:
structured = self._build_structured_summary(older)
else:
to_summarize = []
self._used_previous_summary = True
else:
# Fresh summary: summarize all older messages
to_summarize = other_msgs[:-self.preserve_recent] if len(other_msgs) > self.preserve_recent else []
self._used_previous_summary = False
if to_summarize or self._previous_summary:
# Build summary content
summary_parts = []
# Include previous summary if doing iterative summarization
if self._used_previous_summary and self._previous_summary:
summary_parts.append(f"[Previous Summary]: {self._previous_summary}")
summary_parts.append("[New Activity]:")
# Add new messages to summarize
for msg in to_summarize:
role = msg.get("role", "unknown")
content = msg.get("content", "")
if isinstance(content, str) and content:
# Extract key information with focus consideration
if focus_topic and focus_topic.lower() in content.lower():
# Prioritize content related to focus topic
summary_parts.append(f"[{role}] *FOCUS*: {content[:200]}...")
else:
# Fallback to simple summary
summary_parts = []
for msg in older:
role = msg.get("role", "unknown")
content = msg.get("content", "")
if isinstance(content, str) and content:
summary_parts.append(f"[{role}]: {content[:150]}...")
structured = "\n".join(summary_parts[:10])
if summary_parts:
focus_hint = f"\nFocus on: {focus_topic}" if focus_topic else ""
if structured:
# Apply anti-injection prefix
prefixed = f"{self.config.compaction_prefix}\n\n{structured}"
if self._used_previous_summary:
summary = (
f"[Iterative conversation summary - update with new activity{focus_hint}]\n"
+ "\n".join(summary_parts[:15])
)
else:
summary = (
f"[Compacted conversation history - summarize key points{focus_hint}]\n"
+ "\n".join(summary_parts[:10])
)
# Handle iterative update if enabled
if self.config.iterative_update and self._previous_summary:
structured = self._merge_summaries(self._previous_summary, structured)
prefixed = f"{self.config.compaction_prefix}\n\n{structured}"
# Apply anti-injection prefix if configured
if hasattr(self.config, 'compaction_prefix') and self.config.compaction_prefix:
summary = f"{self.config.compaction_prefix}\n\n{summary}"
# Store for next iteration
if self.config.iterative_update:
self._previous_summary = structured
summary_msg = {
result.append({
"role": "system",
"content": summary,
"content": prefixed,
"_compacted": True,
"_original_count": len(to_summarize),
"_iterative": self._used_previous_summary,
"_focus_topic": focus_topic
}
result.append(summary_msg)
# Update tracking for future iterative summarization
self._previous_summary = summary
self._previous_summary_global_idx = len(messages)
"_original_count": len(older),
"_anti_injection": True,
})
result.extend(recent)
return result
async def _llm_summarize_async(self, messages: List[Dict[str, Any]], focus_topic: str = "") -> List[Dict[str, Any]]:
async def _llm_summarize_async(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Use LLM to intelligently summarize older messages.
This method invokes the agent's LLM to create a meaningful summary
that preserves key facts, identifiers, and the user's intent.
Args:
messages: List of messages to compact
focus_topic: Optional topic to focus on during summarization
"""
self._used_previous_summary = False
result = []
# Keep system messages
@@ -586,71 +436,33 @@ class ContextCompactor:
# Keep recent messages
recent = other_msgs[-self.preserve_recent:]
# Determine what to summarize based on iterative settings
total_original_messages = len(messages)
if (self.config.enable_iterative_summary and
self._previous_summary and
total_original_messages > self._previous_summary_global_idx):
# Iterative: summarize only new messages since previous summary
messages_since_summary = total_original_messages - self._previous_summary_global_idx
new_older_messages = max(0, messages_since_summary - self.preserve_recent)
if new_older_messages > 0:
older = other_msgs[-messages_since_summary:-self.preserve_recent]
else:
older = []
self._used_previous_summary = True
else:
# Fresh summary: summarize all older messages
older = other_msgs[:-self.preserve_recent] if len(other_msgs) > self.preserve_recent else []
self._used_previous_summary = False
older = other_msgs[:-self.preserve_recent] if len(other_msgs) > self.preserve_recent else []
if older and self.llm_summarize_fn:
try:
# Format messages for summarization using protocol
if self.message_formatter:
history_text = self.message_formatter.format_for_summary(older)
else:
# Fallback to basic formatting if no protocol implementation
history_text = str(older)
# Format messages for summarization
history_text = self._format_messages_for_summary(older)
# Create summarization prompt that preserves important information
focus_hint = f" Focus especially on: {focus_topic}." if focus_topic else ""
iterative_hint = ""
if self._used_previous_summary and self._previous_summary:
iterative_hint = f"\n\n[Previous Summary]: {self._previous_summary}\n[New Activity to Add]:"
prompt = (
"Summarise the following conversation history. Preserve verbatim: "
"all file paths, IDs, hashes, URLs, task references, error messages, "
"tool outputs, and the user's requests. Be concise but complete. "
f"Focus on facts and actions taken, not general conversation.{focus_hint}"
f"{iterative_hint}\n\n{history_text}"
"Focus on facts and actions taken, not general conversation.\n\n"
f"{history_text}"
)
# Call the LLM for summarization
summary = await self.llm_summarize_fn(prompt)
# Add the LLM-generated summary as a system message
summary_content = summary
if self._used_previous_summary:
summary_content = f"[Iterative conversation summary]\n{summary}"
else:
summary_content = f"[Previous conversation summary]\n{summary}"
result.append({
"role": "system",
"content": summary_content,
"content": f"[Previous conversation summary]\n{summary}",
"_compacted": True,
"_original_count": len(older),
"_llm_generated": True,
"_iterative": self._used_previous_summary,
"_focus_topic": focus_topic
})
# Update tracking for future iterative summarization
self._previous_summary = summary
self._previous_summary_global_idx = len(messages)
except Exception as e:
# Fallback to naive summarization if LLM call fails
import logging
@@ -694,41 +506,133 @@ class ContextCompactor:
"content": summary,
"_compacted": True,
"_original_count": len(older),
"_fallback": True,
})
result.extend(recent)
return result
def _format_messages_for_summary(self, messages: List[Dict[str, Any]]) -> str:
"""
Delegate message formatting to protocol implementation.
"""Format messages for LLM summarization."""
formatted = []
for i, msg in enumerate(messages):
role = msg.get("role", "unknown")
content = msg.get("content", "")
# Handle tool calls
if msg.get("tool_calls"):
tool_calls = msg.get("tool_calls", [])
tool_names = [tc.get("function", {}).get("name", "unknown") for tc in tool_calls]
formatted.append(f"{i+1}. {role}: Called tools: {', '.join(tool_names)}")
# Handle content
if isinstance(content, str) and content.strip():
# Truncate very long content but preserve structure
if len(content) > 1000:
content = content[:800] + "...[truncated]..." + content[-200:]
formatted.append(f"{i+1}. {role}: {content}")
elif isinstance(content, list):
# Handle multi-part content
parts = []
for part in content[:3]: # Limit to first 3 parts
if isinstance(part, dict):
text = part.get("text", "")
if text:
parts.append(text[:200] + "..." if len(text) > 200 else text)
if parts:
formatted.append(f"{i+1}. {role}: {' | '.join(parts)}")
# Handle tool results with IDs
if msg.get("tool_call_id"):
tool_id = msg.get("tool_call_id", "")
formatted.append(f"{i+1}. {role} (tool {tool_id}): {str(content)[:500]}...")
If no formatter is provided, returns basic string representation.
"""
if self.message_formatter:
return self.message_formatter.format_for_summary(messages)
return str(messages)
return "\n".join(formatted)
def _build_structured_summary(self, messages: List[Dict[str, Any]]) -> str:
"""
Delegate structured summary building to protocol implementation.
Build a structured summary using the configured template.
If no summary builder is provided, returns basic summary.
Args:
messages: Messages to summarize
Returns:
Structured summary string
"""
if self.summary_builder:
return self.summary_builder.build_structured_summary(messages)
return f"Summary of {len(messages)} messages"
# Extract information from messages
active_task = "No specific task identified"
completed = []
in_progress = []
pending = []
files = set()
remaining = []
for msg in messages:
role = msg.get("role", "")
content = msg.get("content", "")
if not isinstance(content, str):
continue
content_lower = content.lower()
# Extract file paths
file_matches = re.findall(r'[\w/\.\-]+\.[a-zA-Z]{1,4}', content)
files.update(file_matches[:5]) # Limit to 5 files
# Categorize content based on keywords and role
content_snippet = content[:200] + ("..." if len(content) > 200 else "")
if role == "user":
if any(word in content_lower for word in ["please", "can you", "help"]) or re.search(r'\bdo\b', content_lower):
active_task = content_snippet
elif "?" in content:
pending.append(content_snippet)
elif role == "assistant":
if any(word in content_lower for word in ["completed", "done", "finished"]):
completed.append(content_snippet)
elif any(word in content_lower for word in ["working", "processing", "analyzing"]):
in_progress.append(content_snippet)
elif any(word in content_lower for word in ["will", "plan to", "next"]):
remaining.append(content_snippet)
elif role == "tool":
# Tool results are generally completed actions
tool_name = msg.get("name", "unknown")
completed.append(f"Tool {tool_name}: {content_snippet}")
# Format using template
return SUMMARY_TEMPLATE.format(
active_task=active_task,
completed="\n".join(f"- {item}" for item in completed[:3]) or "None identified",
in_progress="\n".join(f"- {item}" for item in in_progress[:3]) or "None identified",
pending="\n".join(f"- {item}" for item in pending[:3]) or "None identified",
files=", ".join(list(files)[:5]) or "None mentioned",
remaining="\n".join(f"- {item}" for item in remaining[:3]) or "None identified"
)
def _merge_summaries(self, previous: str, current: str) -> str:
"""
Delegate summary merging to protocol implementation.
Merge previous summary with current one for iterative updates.
If no summary builder is provided, returns current summary only.
Args:
previous: Previous summary content
current: Current summary content
Returns:
Merged summary
"""
if self.summary_builder:
return self.summary_builder.merge_summaries(previous, current)
return current
# Simple merge strategy: prioritize current but preserve unique info from previous
if not previous:
return current
# Preserve context but avoid excessive growth
MAX_PREVIOUS_LENGTH = 500
if len(previous) > MAX_PREVIOUS_LENGTH:
truncated_previous = previous[:MAX_PREVIOUS_LENGTH]
return f"{current}\n\n[Previous context]: {truncated_previous}..."
else:
return f"{current}\n\n[Previous context]: {previous}"
def get_stats(self, messages: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Get statistics about messages."""
@@ -50,28 +50,3 @@ class CompactionConfig:
compaction_prefix: str = COMPACTION_PREFIX # Override for custom framing
structured_template: bool = True # Emit Active Task / Remaining Work sections
iterative_update: bool = True # Merge previous summary on re-compaction
# Anti-thrashing protection
min_savings_pct: float = 10.0 # Skip if projected saving < 10% (0-100 scale)
max_consecutive_low_savings: int = 2 # Abort after N low-savings attempts
# Tool result optimization
tool_prune_before_summarise: bool = True # Deduplicate tool results before summarization
max_tool_result_size: int = 500 # Maximum size for tool results before pruning
# Iterative summarization
enable_iterative_summary: bool = True # Build on previous summaries instead of starting from scratch
def __post_init__(self):
"""Validate and normalize configuration values."""
# Normalize min_savings_pct to 0-100 scale if provided as ratio
if self.min_savings_pct < 1.0:
self.min_savings_pct *= 100.0
# Validate ranges
if not 0.0 <= self.min_savings_pct <= 100.0:
raise ValueError("min_savings_pct must be between 0 and 100")
if self.max_consecutive_low_savings < 0:
raise ValueError("max_consecutive_low_savings must be >= 0")
if self.max_tool_result_size <= 0:
raise ValueError("max_tool_result_size must be > 0")
@@ -1,73 +0,0 @@
"""
Compaction Protocols for PraisonAI Agents.
Protocol-driven interfaces for context compaction functionality.
"""
from typing import Protocol, List, Dict, Any, Tuple, Optional
class ToolResultPrunerProtocol(Protocol):
"""Protocol for pruning and deduplicating tool results."""
def prune(
self,
messages: List[Dict[str, Any]],
max_tool_result_size: int = 500
) -> Tuple[List[Dict[str, Any]], int]:
"""
Prune and deduplicate tool results to reduce token usage.
Args:
messages: List of messages to process
max_tool_result_size: Maximum size for tool results before truncation
Returns:
Tuple of (processed messages, number of tool results pruned)
"""
...
class MessageFormatterProtocol(Protocol):
"""Protocol for formatting messages for summarization."""
def format_for_summary(self, messages: List[Dict[str, Any]]) -> str:
"""
Format messages for LLM summarization.
Args:
messages: Messages to format
Returns:
Formatted string suitable for summarization
"""
...
class SummaryBuilderProtocol(Protocol):
"""Protocol for building structured summaries."""
def build_structured_summary(self, messages: List[Dict[str, Any]]) -> str:
"""
Build a structured summary from messages.
Args:
messages: Messages to summarize
Returns:
Structured summary string
"""
...
def merge_summaries(self, previous: str, current: str) -> str:
"""
Merge previous summary with current one for iterative updates.
Args:
previous: Previous summary content
current: Current summary content
Returns:
Merged summary
"""
...
@@ -18,16 +18,6 @@ class CompactionResult:
strategy_used: CompactionStrategy
summary: str = ""
# Anti-thrashing and optimization info
savings_pct: float = field(default=0.0, init=False) # Percentage of tokens saved (computed)
tool_results_pruned: int = 0 # Number of tool results deduplicated/pruned
previous_summary_reused: bool = False # Whether iterative summary was used
was_skipped_due_to_low_savings: bool = False # Whether compaction was skipped due to anti-thrashing
def __post_init__(self):
"""Initialize computed fields after construction."""
self.calculate_savings_pct()
@property
def tokens_saved(self) -> int:
"""Get number of tokens saved."""
@@ -40,13 +30,6 @@ class CompactionResult:
return 0.0
return self.compacted_tokens / self.original_tokens
def calculate_savings_pct(self) -> None:
"""Calculate and set the savings percentage."""
if self.original_tokens > 0:
self.savings_pct = (self.tokens_saved / self.original_tokens) * 100.0
else:
self.savings_pct = 0.0
@property
def was_compacted(self) -> bool:
"""Check if compaction occurred."""
@@ -62,9 +45,5 @@ class CompactionResult:
"messages_kept": self.messages_kept,
"strategy_used": self.strategy_used.value,
"compression_ratio": self.compression_ratio,
"savings_pct": self.savings_pct,
"tool_results_pruned": self.tool_results_pruned,
"previous_summary_reused": self.previous_summary_reused,
"was_skipped_due_to_low_savings": self.was_skipped_due_to_low_savings,
"summary": self.summary
}
@@ -26,7 +26,6 @@ __all__ = [
"OutputPreset",
"ExecutionPreset",
# Config classes
"LLMConfig",
"MemoryConfig",
"KnowledgeConfig",
"PlanningConfig",
@@ -111,7 +110,6 @@ _MODULE_MAP = {
"WebSearchProvider": "feature_configs",
"OutputPreset": "feature_configs",
"ExecutionPreset": "feature_configs",
"LLMConfig": "feature_configs",
"MemoryConfig": "feature_configs",
"KnowledgeConfig": "feature_configs",
"PlanningConfig": "feature_configs",
@@ -724,9 +724,6 @@ class ExecutionConfig:
# Retry settings
max_retry_limit: int = 2
retry_initial_delay: float = 1.0 # seconds
retry_backoff_factor: float = 2.0
retry_jitter: float = 0.1 # fraction of computed delay
# Tool call limits (loop protection)
max_tool_calls_per_turn: int = 10
@@ -769,7 +766,7 @@ class ExecutionConfig:
parallel_tool_calls: bool = False
def __post_init__(self) -> None:
"""Post-initialization processing with deprecation warnings and validation."""
"""Post-initialization processing with deprecation warnings."""
# Handle context_compaction serialization round-trip
if isinstance(self.context_compaction, dict):
from ..context.policy import ContextCompactionPolicy
@@ -777,47 +774,29 @@ class ExecutionConfig:
self.context_compaction
)
# Emit deprecation warning once per process for default behavior change.
# Walk the stack once — dataclass __init__ uses caller filename "<string>".
if self.context_compaction is False:
if getattr(ExecutionConfig, '_context_compaction_internal', False):
return
if getattr(ExecutionConfig, '_context_compaction_warned', False):
return
import warnings
import inspect
_INTERNAL = ('praisonaiagents', 'test_', '__pycache__')
frame = inspect.currentframe()
try:
caller = frame.f_back if frame else None
is_internal = False
while caller is not None:
if any(p in caller.f_code.co_filename for p in _INTERNAL):
is_internal = True
break
caller = caller.f_back
if is_internal:
ExecutionConfig._context_compaction_internal = True
return
warnings.warn(
"ExecutionConfig.context_compaction will default to True in the next "
"release for proactive context overflow protection. To disable, explicitly "
"set context_compaction=False. To use the new default early, set "
"context_compaction=True.",
DeprecationWarning,
stacklevel=3,
)
ExecutionConfig._context_compaction_warned = True
finally:
del frame
# Validate retry configuration parameters
if self.retry_initial_delay <= 0:
raise ValueError("ExecutionConfig.retry_initial_delay must be positive.")
if self.retry_backoff_factor < 1.0:
raise ValueError("ExecutionConfig.retry_backoff_factor must be >= 1.0.")
if self.retry_jitter < 0:
raise ValueError("ExecutionConfig.retry_jitter must be non-negative.")
# Emit deprecation warning for default behavior change
# Only warn if context_compaction was not explicitly set (i.e., using the default False)
import warnings
import inspect
frame = inspect.currentframe()
try:
# Check if this is being called from user code (not internal)
caller_frame = frame.f_back
if (caller_frame and
not any(path_part in caller_frame.f_code.co_filename
for path_part in ['praisonaiagents', 'test_', '__pycache__'])):
# This is being called from user code
if self.context_compaction is False:
warnings.warn(
"ExecutionConfig.context_compaction will default to True in the next "
"release for proactive context overflow protection. To disable, explicitly "
"set context_compaction=False. To use the new default early, set "
"context_compaction=True.",
DeprecationWarning,
stacklevel=3
)
finally:
del frame
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
@@ -826,9 +805,6 @@ class ExecutionConfig:
"max_rpm": self.max_rpm,
"max_execution_time": self.max_execution_time,
"max_retry_limit": self.max_retry_limit,
"retry_initial_delay": self.retry_initial_delay,
"retry_backoff_factor": self.retry_backoff_factor,
"retry_jitter": self.retry_jitter,
"code_execution": self.code_execution,
"code_mode": self.code_mode,
"code_sandbox_mode": self.code_sandbox_mode,
@@ -867,69 +843,6 @@ class ExecutionConfig:
)
@dataclass
class LLMConfig:
"""
Configuration for LLM model settings.
Groups all LLM-related parameters including model selection,
API settings, and fallback configuration.
Usage:
# With primary model only
Agent(llm_config=LLMConfig(model="gpt-4o"))
# With fallback chain
Agent(llm_config=LLMConfig(
model="gpt-4o",
fallback_models=["claude-3-5-sonnet", "gpt-4o-mini"],
base_url="https://api.example.com",
api_key="sk-...",
))
# With LiteLLM-style provider prefix
Agent(llm_config=LLMConfig(
model="anthropic/claude-3-5-sonnet",
fallback_models=["openai/gpt-4o", "openai/gpt-4o-mini"],
))
"""
# Primary model to use (required)
model: str
# Ordered fallback models for resilience (optional)
fallback_models: Optional[List[str]] = None
# API endpoint override (optional)
base_url: Optional[str] = None
# API key (optional, defaults to env vars)
api_key: Optional[str] = None
# Additional auth headers (optional)
auth: Optional[Dict[str, str]] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"model": self.model,
"fallback_models": list(self.fallback_models) if self.fallback_models else None,
"base_url": self.base_url,
"api_key": self.api_key,
"auth": dict(self.auth) if self.auth else None,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "LLMConfig":
"""Create LLMConfig from dictionary."""
return cls(
model=data["model"],
fallback_models=list(data["fallback_models"]) if data.get("fallback_models") else None,
base_url=data.get("base_url"),
api_key=data.get("api_key"),
auth=dict(data["auth"]) if data.get("auth") else None,
)
@dataclass
class ToolConfig:
"""
@@ -1176,8 +1089,8 @@ class MultiAgentOutputConfig:
# Verbosity level (0=silent, 1=minimal, 2+=verbose)
verbose: int = 0
# Streaming output - False by default for sync multi-agent compatibility
stream: bool = False
# Streaming output
stream: bool = True
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
@@ -1352,49 +1265,79 @@ def resolve_memory(value: MemoryParam) -> Optional[MemoryConfig]:
"""
Resolve memory= parameter following precedence ladder.
Delegates to the canonical resolver in param_resolver.py with
special handling for backward compatibility.
Precedence: Instance > Config > Dict > String > Bool > Default
Args:
value: Memory parameter in any supported form
Returns:
MemoryConfig if enabled, None if disabled
Examples:
>>> resolve_memory(None) # Default: disabled
None
>>> resolve_memory(False) # Explicit disable
None
>>> resolve_memory(True) # Enable with defaults
MemoryConfig(backend='file')
>>> resolve_memory("redis") # String shorthand
MemoryConfig(backend='redis')
>>> resolve_memory({"backend": "sqlite", "user_id": "alice"}) # Dict
MemoryConfig(backend='sqlite', user_id='alice')
>>> resolve_memory(MemoryConfig(backend="postgres")) # Config passthrough
MemoryConfig(backend='postgres')
"""
from .param_resolver import resolve_memory as _resolve
# Default: disabled
if value is None:
return None
# Special case: handle string backends not in MEMORY_PRESETS
# Bool: False = disabled, True = defaults
if value is False:
return None
if value is True:
return MemoryConfig()
# String: backend shorthand
if isinstance(value, str):
try:
# Try to resolve with canonical resolver first
return _resolve(value, MemoryConfig)
backend = MemoryBackend(value.lower())
except ValueError:
# Fall back to old behavior for custom/unknown backends
backend = value # Allow custom backend strings
return MemoryConfig(backend=backend)
# Dict: expand to config
if isinstance(value, dict):
# Handle backend enum conversion
backend = value.get("backend", MemoryBackend.FILE)
if isinstance(backend, str):
try:
backend = MemoryBackend(value.lower())
backend = MemoryBackend(backend.lower())
except ValueError:
backend = value # Allow custom backend strings
return MemoryConfig(backend=backend)
pass # Keep as string for custom backends
return MemoryConfig(
backend=backend,
user_id=value.get("user_id"),
session_id=value.get("session_id"),
auto_memory=value.get("auto_memory", False),
claude_memory=value.get("claude_memory", False),
db=value.get("db"),
config=value.get("config"),
)
# Special case: handle arbitrary instances with passthrough
if not isinstance(value, (type(None), bool, dict, list, tuple, MemoryConfig)):
# Try canonical resolver first
result = _resolve(value, MemoryConfig)
# If canonical resolver returns None for an instance, pass it through
if result is None and hasattr(value, '__class__'):
return value
return result
# Config: passthrough
if isinstance(value, MemoryConfig):
return value
# Default: use canonical resolver
return _resolve(value, MemoryConfig)
# Instance (MemoryManager): return as-is (caller handles)
# This is the highest precedence - user provided a pre-configured instance
return value
def resolve_knowledge(value: KnowledgeParam) -> Optional[KnowledgeConfig]:
"""
Resolve knowledge= parameter following precedence ladder.
Delegates to the canonical resolver in param_resolver.py.
Kept for backward compatibility with tests.
Precedence: Instance > Config > Array > Dict > String > Bool > Default
Args:
value: Knowledge parameter in any supported form
@@ -1402,91 +1345,164 @@ def resolve_knowledge(value: KnowledgeParam) -> Optional[KnowledgeConfig]:
Returns:
KnowledgeConfig if enabled, None if disabled
"""
from .param_resolver import resolve_knowledge as _resolve
return _resolve(value, KnowledgeConfig)
# Default: disabled
if value is None:
return None
# Bool: False = disabled, True = defaults
if value is False:
return None
if value is True:
return KnowledgeConfig()
# String: single source
if isinstance(value, str):
return KnowledgeConfig(sources=[value])
# Array: list of sources
if isinstance(value, list):
return KnowledgeConfig(sources=value)
# Dict: expand to config
if isinstance(value, dict):
return KnowledgeConfig(
sources=value.get("sources", []),
embedder=value.get("embedder", "openai"),
embedder_config=value.get("embedder_config"),
chunking_strategy=value.get("chunking_strategy", ChunkingStrategy.SEMANTIC),
chunk_size=value.get("chunk_size", 1000),
chunk_overlap=value.get("chunk_overlap", 200),
retrieval_k=value.get("retrieval_k", 5),
retrieval_threshold=value.get("retrieval_threshold", 0.0),
rerank=value.get("rerank", False),
rerank_model=value.get("rerank_model"),
auto_retrieve=value.get("auto_retrieve", True),
)
# Config: passthrough
if isinstance(value, KnowledgeConfig):
return value
# Instance: return as-is
return value
def resolve_planning(value: PlanningParam) -> Optional[PlanningConfig]:
"""
Resolve planning= parameter following precedence ladder.
Delegates to the canonical resolver in param_resolver.py.
Kept for backward compatibility with tests.
"""
from .param_resolver import resolve_planning as _resolve
return _resolve(value, PlanningConfig)
"""Resolve planning= parameter following precedence ladder."""
if value is None or value is False:
return None
if value is True:
return PlanningConfig()
if isinstance(value, dict):
return PlanningConfig(**value)
if isinstance(value, PlanningConfig):
return value
return value
def resolve_reflection(value: ReflectionParam) -> Optional[ReflectionConfig]:
"""
Resolve reflection= parameter following precedence ladder.
Delegates to the canonical resolver in param_resolver.py.
Kept for backward compatibility with tests.
"""
from .param_resolver import resolve_reflection as _resolve
return _resolve(value, ReflectionConfig)
"""Resolve reflection= parameter following precedence ladder."""
if value is None or value is False:
return None
if value is True:
return ReflectionConfig()
if isinstance(value, dict):
return ReflectionConfig(**value)
if isinstance(value, ReflectionConfig):
return value
return value
def resolve_guardrails(value: GuardrailParam) -> Optional[GuardrailConfig]:
"""
Resolve guardrails= parameter following precedence ladder.
Delegates to the canonical resolver in param_resolver.py with
special handling for callable validators.
"""
from .param_resolver import resolve_guardrails as _resolve
# Special case: wrap callable in GuardrailConfig for backward compatibility
if callable(value) and not isinstance(value, type):
"""Resolve guardrails= parameter following precedence ladder."""
if value is None or value is False:
return None
if value is True:
return GuardrailConfig()
if callable(value):
return GuardrailConfig(validator=value)
# Default: use canonical resolver
return _resolve(value, GuardrailConfig)
if isinstance(value, dict):
return GuardrailConfig(**value)
if isinstance(value, GuardrailConfig):
return value
return value
def resolve_web(value: WebParam) -> Optional[WebConfig]:
"""
Resolve web= parameter following precedence ladder.
Delegates to the canonical resolver in param_resolver.py.
Kept for backward compatibility with tests.
"""
from .param_resolver import resolve_web as _resolve
return _resolve(value, WebConfig)
"""Resolve web= parameter following precedence ladder."""
if value is None or value is False:
return None
if value is True:
return WebConfig()
if isinstance(value, dict):
return WebConfig(**value)
if isinstance(value, WebConfig):
return value
return value
def resolve_output(value: OutputParam) -> Optional[OutputConfig]:
"""Resolve output= parameter following precedence ladder."""
if value is None:
return None
if isinstance(value, str):
try:
preset = OutputPreset(value.lower())
return OutputConfig.from_preset(preset)
except ValueError:
return None
if isinstance(value, dict):
return OutputConfig(**value)
if isinstance(value, OutputConfig):
return value
return value
def resolve_execution(value: ExecutionParam) -> Optional[ExecutionConfig]:
"""Resolve execution= parameter following precedence ladder."""
if value is None:
return None
if isinstance(value, str):
try:
preset = ExecutionPreset(value.lower())
return ExecutionConfig.from_preset(preset)
except ValueError:
return None
if isinstance(value, dict):
return ExecutionConfig(**value)
if isinstance(value, ExecutionConfig):
return value
return value
def resolve_caching(value: CachingParam) -> Optional[CachingConfig]:
"""
Resolve caching= parameter following precedence ladder.
Delegates to the canonical resolver in param_resolver.py.
Kept for backward compatibility with tests.
"""
from .param_resolver import resolve_caching as _resolve
return _resolve(value, CachingConfig)
"""Resolve caching= parameter following precedence ladder."""
if value is None or value is False:
return None
if value is True:
return CachingConfig()
if isinstance(value, dict):
return CachingConfig(**value)
if isinstance(value, CachingConfig):
return value
return value
def resolve_autonomy(value: AutonomyParam) -> Optional[AutonomyConfig]:
"""
Resolve autonomy= parameter following precedence ladder.
Delegates to the canonical resolver in param_resolver.py.
Kept for backward compatibility with tests.
"""
from .param_resolver import resolve_autonomy as _resolve
return _resolve(value, AutonomyConfig)
"""Resolve autonomy= parameter following precedence ladder."""
if value is None or value is False:
return None
if value is True:
return AutonomyConfig()
if isinstance(value, dict):
return AutonomyConfig(**value)
if isinstance(value, AutonomyConfig):
return value
return value
def resolve_tool_search(value: ToolSearchParam) -> Optional[ToolSearchConfig]:
"""
Resolve tool_search= parameter following precedence ladder.
NOTE: This resolver has zero references in the codebase but is kept
for backward compatibility. Consider removing in a future version.
"""
# Simple implementation since it's unused
"""Resolve tool_search= parameter following precedence ladder."""
if value is None or value is False:
return None
if value is True:
@@ -1569,6 +1585,8 @@ __all__ = [
"resolve_reflection",
"resolve_guardrails",
"resolve_web",
"resolve_output",
"resolve_execution",
"resolve_caching",
"resolve_autonomy",
"resolve_tool_search",
@@ -83,7 +83,6 @@ class ConfigValidationError(Exception):
# Global config cache (lazy loaded)
_config_cache: Optional[Dict[str, Any]] = None
_config_loaded: bool = False
_defaults_has_values: Optional[bool] = None
def _default_project_plugins_dir():
@@ -419,27 +418,11 @@ def get_enabled_plugins() -> Optional[List[str]]:
return None # All plugins enabled
def _defaults_has_any_values() -> bool:
"""Return True if config file defines any defaults (cached after first check)."""
global _defaults_has_values
if _defaults_has_values is None:
defaults = get_defaults_config()
_defaults_has_values = any(
getattr(defaults, key, None) is not None
for key in (
"model", "base_url", "memory", "knowledge", "planning",
"reflection", "web", "output", "execution", "caching", "autonomy",
)
)
return _defaults_has_values
def clear_config_cache() -> None:
"""Clear the config cache (for testing)."""
global _config_cache, _config_loaded, _defaults_has_values
global _config_cache, _config_loaded
_config_cache = None
_config_loaded = False
_defaults_has_values = None
def validate_config(config: Dict[str, Any], raise_on_error: bool = False) -> List[str]:
@@ -620,8 +603,6 @@ def apply_config_defaults(
if explicit_value is not None:
# If True and we have config defaults, merge them
if explicit_value is True:
if not _defaults_has_any_values():
return explicit_value
config_defaults = get_default(param_name)
if config_defaults and isinstance(config_defaults, dict):
if config_class:
@@ -631,10 +612,6 @@ def apply_config_defaults(
return config_defaults
return explicit_value
# Fast path: no config defaults file values — skip lookups
if not _defaults_has_any_values():
return None
# Check if config has defaults for this param
config_defaults = get_default(param_name)
if config_defaults is None:
@@ -239,12 +239,12 @@ EXECUTION_PRESETS: Dict[str, Dict[str, Any]] = {
# =============================================================================
WEB_PRESETS: Dict[str, Dict[str, Any]] = {
# Providers - fetch disabled by default for OpenAI model compatibility
"duckduckgo": {"search": True, "fetch": False, "search_provider": "duckduckgo"},
"tavily": {"search": True, "fetch": False, "search_provider": "tavily"},
"google": {"search": True, "fetch": False, "search_provider": "google"},
"bing": {"search": True, "fetch": False, "search_provider": "bing"},
"serper": {"search": True, "fetch": False, "search_provider": "serper"},
# Providers
"duckduckgo": {"search": True, "fetch": True, "search_provider": "duckduckgo"},
"tavily": {"search": True, "fetch": True, "search_provider": "tavily"},
"google": {"search": True, "fetch": True, "search_provider": "google"},
"bing": {"search": True, "fetch": True, "search_provider": "bing"},
"serper": {"search": True, "fetch": True, "search_provider": "serper"},
# Modes
"search_only": {"search": True, "fetch": False},
"fetch_only": {"search": False, "fetch": True},
@@ -322,8 +322,8 @@ CACHING_PRESETS: Dict[str, Dict[str, Any]] = {
# =============================================================================
MULTI_AGENT_OUTPUT_PRESETS: Dict[str, Dict[str, Any]] = {
"verbose": {"verbose": 2, "stream": False},
"minimal": {"verbose": 1, "stream": False},
"verbose": {"verbose": 2, "stream": True},
"minimal": {"verbose": 1, "stream": True},
"silent": {"verbose": 0, "stream": False},
}
@@ -94,6 +94,65 @@ class SessionDeduplicationCache:
self._cache.clear()
self._stats = {"duplicates_prevented": 0, "tokens_saved": 0}
def deduplicate_topics(topics: list, key: str = "title", similarity_threshold: float = 0.8) -> list:
"""
Programmatic deduplication of topics/items before agent processing.
This helps prevent duplicate content from being passed to downstream agents,
reducing token waste and improving quality.
Args:
topics: List of topic dicts or strings
key: Key to use for comparison if topics are dicts (default: "title")
similarity_threshold: Similarity threshold for fuzzy matching (0.0-1.0)
Returns:
Deduplicated list of topics
"""
if not topics:
return topics
seen_hashes = set()
seen_normalized = set()
unique_topics = []
for topic in topics:
# Get the content to compare
if isinstance(topic, dict):
content = str(topic.get(key, topic.get("content", str(topic))))
else:
content = str(topic)
# Normalize for comparison
normalized = content.lower().strip()
# Remove common words for better matching
normalized = " ".join(w for w in normalized.split() if len(w) > 3)
# Check exact hash match
content_hash = hashlib.sha256(normalized.encode()).hexdigest()
if content_hash in seen_hashes:
continue
# Check fuzzy match using simple word overlap
is_duplicate = False
for seen in seen_normalized:
# Calculate Jaccard similarity
words1 = set(normalized.split())
words2 = set(seen.split())
if words1 and words2:
intersection = len(words1 & words2)
union = len(words1 | words2)
similarity = intersection / union if union > 0 else 0
if similarity >= similarity_threshold:
is_duplicate = True
break
if not is_duplicate:
seen_hashes.add(content_hash)
seen_normalized.add(normalized)
unique_topics.append(topic)
return unique_topics
class EstimationMode(str, Enum):
"""Token estimation modes."""
@@ -726,7 +785,7 @@ class ContextManager:
if protected and tool_name not in self.config.protected_tools:
self.config.protected_tools.append(tool_name)
def truncate_tool_output(self, tool_name: str, output: str, tool_call_id: str = None, run_id: str = None) -> str:
def truncate_tool_output(self, tool_name: str, output: str) -> str:
"""Truncate tool output according to its budget."""
max_tokens = self.get_tool_budget(tool_name)
@@ -746,17 +805,6 @@ class ContextManager:
tail = output[-tail_size:] if tail_size > 0 else ""
truncated = f"{head}\n...[{len(output):,} chars, showing first/last portions]...\n{tail}"
# Store full output for later retrieval
try:
from ..runtime.tool_output_store import get_tool_output_store
store = get_tool_output_store(run_id)
metadata = store.store(tool_name, output, call_id=tool_call_id)
if metadata:
truncated = store.format_reference(metadata, truncated)
logger.debug(f"Stored full {tool_name} output ({len(output)} bytes) at {metadata.get('path')}")
except Exception as e:
logger.debug(f"Failed to store tool output: {e}")
self._add_history_event(
OptimizationEventType.CAP_OUTPUTS,
tokens_before=current_tokens,
@@ -22,20 +22,8 @@ from .protocols import (
PushChannelProtocol,
PresenceProtocol,
DeliveryGuaranteeProtocol,
OutboundDeliveryProtocol,
ChannelInfo,
PresenceInfo,
# Home channel and delivery protocols
HomeChannelRegistryProtocol,
DeliveryResolverProtocol,
# Protocol version negotiation
PROTOCOL_VERSION,
MIN_PROTOCOL_VERSION,
MAX_PROTOCOL_VERSION,
ProtocolHello,
ProtocolHelloOk,
GapInfo,
ResumeSnapshot,
)
from .config import (
GatewayConfig,
@@ -101,20 +89,8 @@ __all__ = [
"PushChannelProtocol",
"PresenceProtocol",
"DeliveryGuaranteeProtocol",
"OutboundDeliveryProtocol",
"ChannelInfo",
"PresenceInfo",
# Home channel and delivery protocols
"HomeChannelRegistryProtocol",
"DeliveryResolverProtocol",
# Protocol version negotiation
"PROTOCOL_VERSION",
"MIN_PROTOCOL_VERSION",
"MAX_PROTOCOL_VERSION",
"ProtocolHello",
"ProtocolHelloOk",
"GapInfo",
"ResumeSnapshot",
# Config (always available)
"GatewayConfig",
"SessionConfig",
@@ -17,33 +17,14 @@ class SessionConfig:
max_messages: Maximum messages to keep in history (0 = unlimited)
persist: Whether to persist session state
persist_path: Path for session persistence
resume_window: How long (seconds) a session stays resumable after disconnect
max_inbox: Maximum queued messages per session (0 = unlimited, default 256)
metadata: Additional session metadata
mirror_runtime_state: Enable runtime state mirroring for native transcript replay (Issue #1943)
"""
timeout: int = 3600 # 1 hour default
max_messages: int = 1000
persist: bool = False
persist_path: Optional[str] = None
resume_window: int = 86400 # 24 hours default
max_inbox: int = 256 # Default bounded queue size
metadata: Dict[str, Any] = field(default_factory=dict)
mirror_runtime_state: bool = False # Opt-in to avoid storage bloat
def __post_init__(self) -> None:
"""Validate configuration values."""
if self.max_inbox < 0:
raise ValueError(
"max_inbox must be >= 0 (use 0 for unlimited queue size)"
)
if self.timeout < 0:
raise ValueError("timeout must be >= 0")
if self.max_messages < 0:
raise ValueError("max_messages must be >= 0")
if self.resume_window < 0:
raise ValueError("resume_window must be >= 0")
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
@@ -52,10 +33,7 @@ class SessionConfig:
"max_messages": self.max_messages,
"persist": self.persist,
"persist_path": self.persist_path,
"resume_window": self.resume_window,
"max_inbox": self.max_inbox,
"metadata": self.metadata,
"mirror_runtime_state": self.mirror_runtime_state,
}
@@ -228,7 +206,6 @@ class GatewayConfig:
reconnect_timeout: Time to wait for reconnection before closing session
ssl_cert: Path to SSL certificate (for HTTPS/WSS)
ssl_key: Path to SSL key
max_buffered_bytes: Maximum buffered bytes before slow consumer disconnect (default 1MB)
push: Push notification service configuration
"""
@@ -245,23 +222,12 @@ class GatewayConfig:
reconnect_timeout: int = 60
ssl_cert: Optional[str] = None
ssl_key: Optional[str] = None
max_buffered_bytes: int = 1024 * 1024 # 1MB default
push: PushConfig = field(default_factory=PushConfig)
def __post_init__(self) -> None:
"""Post-initialization to set bind_host from host if not specified and validate values."""
"""Post-initialization to set bind_host from host if not specified."""
if self.bind_host is None:
self.bind_host = self.host
if self.max_buffered_bytes < 0:
raise ValueError(
"max_buffered_bytes must be >= 0 (use 0 to disable slow-consumer checks)"
)
if self.max_connections < 0:
raise ValueError("max_connections must be >= 0")
if self.heartbeat_interval < 0:
raise ValueError("heartbeat_interval must be >= 0")
if self.reconnect_timeout < 0:
raise ValueError("reconnect_timeout must be >= 0")
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary (hides sensitive data)."""
@@ -277,7 +243,6 @@ class GatewayConfig:
"heartbeat_interval": self.heartbeat_interval,
"reconnect_timeout": self.reconnect_timeout,
"ssl_enabled": bool(self.ssl_cert and self.ssl_key),
"max_buffered_bytes": self.max_buffered_bytes,
"push": self.push.to_dict(),
}
@@ -400,22 +365,6 @@ class MultiChannelGatewayConfig:
"""
# Parse gateway section
gw_data = data.get("gateway", {})
# Parse session config if provided
session_config = SessionConfig()
if "session_config" in gw_data:
sc_data = gw_data["session_config"]
if isinstance(sc_data, dict):
session_config = SessionConfig(
timeout=sc_data.get("timeout", 3600),
max_messages=sc_data.get("max_messages", 1000),
persist=sc_data.get("persist", False),
persist_path=sc_data.get("persist_path"),
resume_window=sc_data.get("resume_window", 86400),
max_inbox=sc_data.get("max_inbox", 256),
metadata=sc_data.get("metadata", {}),
)
gateway_config = GatewayConfig(
host=gw_data.get("host", "127.0.0.1"),
port=gw_data.get("port", 8765),
@@ -423,13 +372,6 @@ class MultiChannelGatewayConfig:
allowed_origins=gw_data.get("allowed_origins", []),
auth_token=gw_data.get("auth_token"),
max_connections=gw_data.get("max_connections", 1000),
max_sessions_per_agent=gw_data.get("max_sessions_per_agent", 0),
session_config=session_config,
heartbeat_interval=gw_data.get("heartbeat_interval", 30),
reconnect_timeout=gw_data.get("reconnect_timeout", 60),
ssl_cert=gw_data.get("ssl_cert"),
ssl_key=gw_data.get("ssl_key"),
max_buffered_bytes=gw_data.get("max_buffered_bytes", 1024 * 1024),
)
# Parse agents section (pass through as dicts)
@@ -17,36 +17,19 @@ from enum import Enum
from typing import (
TYPE_CHECKING,
Any,
Awaitable,
Callable,
Dict,
List,
Literal,
Optional,
Protocol,
TypedDict,
Union,
runtime_checkable,
)
# Gateway protocol versioning constants
GATEWAY_PROTOCOL_VERSION = 1
MIN_CLIENT_PROTOCOL_VERSION = 1
if TYPE_CHECKING:
from praisonai.gateway.pairing import PairedChannel
from ..agent import Agent
from ..bots.presentation import MessagePresentation
from ..scheduler.models import DeliveryTarget
class ConnectErrorCode(str, Enum):
"""Structured error codes for connection failures."""
AUTH_REQUIRED = "auth_required"
AUTH_UNAUTHORIZED = "auth_unauthorized"
PROTOCOL_UNSUPPORTED = "protocol_unsupported"
PAIRING_REQUIRED = "pairing_required"
AGENT_NOT_FOUND = "agent_not_found"
class EventType(str, Enum):
@@ -70,7 +53,6 @@ class EventType(str, Enum):
# Message events
MESSAGE = "message"
MESSAGE_ACK = "message_ack"
MESSAGE_ABORT = "message_abort"
TYPING = "typing"
# Streaming events (relayed from agent's StreamEventEmitter)
@@ -102,65 +84,6 @@ class EventType(str, Enum):
# Polling events
POLL_REQUEST = "poll_request"
POLL_RESPONSE = "poll_response"
# Handshake events
HELLO = "hello"
HELLO_OK = "hello_ok"
HELLO_ERROR = "hello_error"
@dataclass
class HelloParams:
"""Parameters for initiating a versioned handshake.
Attributes:
agent_id: The agent to connect to
protocol_min: Minimum protocol version the client supports
protocol_max: Maximum protocol version the client supports
capabilities: Optional list of capability tokens the client supports
session_id: Optional session to resume
since: Optional cursor for event replay
"""
agent_id: str
protocol_min: int
protocol_max: int
capabilities: List[str] = field(default_factory=list)
session_id: Optional[str] = None
since: Optional[int] = None
@dataclass
class HelloResult:
"""Result of a successful handshake negotiation.
Attributes:
protocol: The negotiated protocol version
features: Supported methods and events
policy: Gateway policy limits (max_payload, heartbeat_ms, etc.)
session_id: The session ID for this connection
resumed: Whether an existing session was resumed
cursor: Current event cursor position
"""
protocol: int
features: Dict[str, List[str]] # {"methods": [...], "events": [...]}
policy: Dict[str, int] # {"max_payload": ..., "heartbeat_ms": ...}
session_id: str
resumed: bool
cursor: int
@dataclass
class HelloError:
"""Error response for failed handshake.
Attributes:
code: Structured error code
message: Human-readable error message
next_action: Suggested next action (e.g., "upgrade_client", "pair_device")
"""
code: ConnectErrorCode
message: str
next_action: Optional[str] = None
@dataclass
@@ -174,7 +97,6 @@ class GatewayEvent:
timestamp: Event creation time
source: Source identifier (agent_id, client_id, etc.)
target: Target identifier (optional, for directed events)
sequence: Monotonic sequence number for gap detection (optional)
"""
type: Union[EventType, str]
@@ -183,11 +105,10 @@ class GatewayEvent:
timestamp: float = field(default_factory=time.time)
source: Optional[str] = None
target: Optional[str] = None
sequence: Optional[int] = None # Monotonic sequence for gap detection
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
result = {
return {
"type": self.type.value if isinstance(self.type, EventType) else self.type,
"data": self.data,
"event_id": self.event_id,
@@ -195,9 +116,6 @@ class GatewayEvent:
"source": self.source,
"target": self.target,
}
if self.sequence is not None:
result["sequence"] = self.sequence
return result
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "GatewayEvent":
@@ -215,7 +133,6 @@ class GatewayEvent:
timestamp=data.get("timestamp", time.time()),
source=data.get("source"),
target=data.get("target"),
sequence=data.get("sequence"),
)
@@ -231,7 +148,6 @@ class GatewayMessage:
timestamp: Message creation time
metadata: Additional message metadata
reply_to: ID of message being replied to (optional)
presentation: Optional interactive presentation (buttons, menus, etc.)
"""
content: Union[str, Dict[str, Any]]
@@ -241,11 +157,10 @@ class GatewayMessage:
timestamp: float = field(default_factory=time.time)
metadata: Dict[str, Any] = field(default_factory=dict)
reply_to: Optional[str] = None
presentation: Optional["MessagePresentation"] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
data = {
return {
"content": self.content,
"sender_id": self.sender_id,
"session_id": self.session_id,
@@ -254,20 +169,10 @@ class GatewayMessage:
"metadata": self.metadata,
"reply_to": self.reply_to,
}
if self.presentation is not None:
data["presentation"] = self.presentation.to_dict()
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "GatewayMessage":
"""Create from dictionary."""
from ..bots.presentation import MessagePresentation
presentation = None
raw_presentation = data.get("presentation")
if isinstance(raw_presentation, dict):
presentation = MessagePresentation.from_dict(raw_presentation)
return cls(
content=data.get("content", ""),
sender_id=data.get("sender_id", "unknown"),
@@ -276,7 +181,6 @@ class GatewayMessage:
timestamp=data.get("timestamp", time.time()),
metadata=data.get("metadata", {}),
reply_to=data.get("reply_to"),
presentation=presentation,
)
@@ -784,126 +688,6 @@ class DeliveryGuaranteeProtocol(Protocol):
...
@runtime_checkable
class OutboundDeliveryProtocol(Protocol):
"""Protocol for durable outbound message delivery.
Ensures messages sent to external channels (Telegram, Slack, Discord, etc.)
are persisted before sending and can be retried on failure. This provides
crash-safe at-least-once delivery for channel replies.
Example usage (implementation in praisonai wrapper):
from praisonai.bots import OutboundQueue
outbox = OutboundQueue(path="~/.praisonai/state/outbox.sqlite")
# Enqueue before sending
key = await outbox.enqueue(
idempotency_key="msg-123",
target_channel="telegram:12345",
payload={"text": "Hello", "metadata": {...}}
)
# Attempt delivery
success = await deliver_with_retry(adapter, channel_id, payload)
# Mark as sent only if successful
if success:
await outbox.mark_sent(key)
# On restart, drain pending messages
await outbox.drain(delivery_handler)
"""
async def enqueue(
self,
idempotency_key: str,
target: str,
payload: Dict[str, Any],
metadata: Optional[Dict[str, Any]] = None,
) -> str:
"""Persist an outbound message for delivery.
Args:
idempotency_key: Unique key to prevent duplicate sends
target: Target channel identifier (e.g., "telegram:12345")
payload: Message payload to deliver
metadata: Optional metadata for tracking/routing
Returns:
Unique entry key for tracking this message
"""
...
async def mark_sent(self, key: str) -> bool:
"""Mark a message as successfully sent.
Args:
key: The entry key returned by enqueue()
Returns:
True if marked successfully, False if not found
"""
...
async def mark_failed(
self,
key: str,
error: str,
permanent: bool = False,
) -> bool:
"""Mark a message as failed.
Args:
key: The entry key returned by enqueue()
error: Error description
permanent: If True, won't retry this message
Returns:
True if marked successfully, False if not found
"""
...
async def drain(
self,
sender: Callable[[str, Dict[str, Any]], Awaitable[bool]],
limit: Optional[int] = None,
) -> tuple[int, int]:
"""Process pending messages.
Called on startup to retry unsent messages. Messages are processed
oldest-first to maintain order.
Args:
sender: Async function that attempts delivery. Should return
True on success, False to retry later.
limit: Optional max messages to process
Returns:
Tuple of (succeeded, failed) counts
"""
...
def pending_count(self) -> int:
"""Get count of pending messages awaiting delivery."""
...
def size(self) -> int:
"""Get total number of messages in queue."""
...
async def purge_old(self, max_age_seconds: int = 86400 * 7) -> int:
"""Remove old sent messages.
Args:
max_age_seconds: Age threshold for removal
Returns:
Number of messages purged
"""
...
# ---------------------------------------------------------------------------
# Auth Mode protocols and helpers (bind-aware authentication posture)
# ---------------------------------------------------------------------------
@@ -1120,133 +904,3 @@ class SessionBindingProtocol(Protocol):
Principal information if found, None otherwise
"""
...
# Home Channel and Delivery Routing Protocols
# ---------------------------------------------------------------------------
@runtime_checkable
class HomeChannelRegistryProtocol(Protocol):
"""Protocol for managing default delivery targets per platform.
Home channels provide a per-platform default delivery target that can be
set once from inside a chat and persisted, so scheduled jobs can deliver
results without requiring explicit channel IDs.
"""
def set_home(
self,
platform: str,
chat_id: str,
thread_id: Optional[str] = None
) -> None:
"""Set the home channel for a platform.
Args:
platform: Platform name (e.g., "telegram", "slack", "discord")
chat_id: Platform-specific chat/channel ID
thread_id: Optional thread ID for threaded platforms
"""
...
def get_home(self, platform: str) -> Optional[tuple[str, Optional[str]]]:
"""Get the home channel for a platform.
Args:
platform: Platform name to look up
Returns:
Tuple of (chat_id, thread_id) if set, None otherwise
"""
...
def platforms_with_home(self) -> List[str]:
"""List all platforms that have a home channel configured.
Returns:
List of platform names with home channels
"""
...
@runtime_checkable
class DeliveryResolverProtocol(Protocol):
"""Protocol for resolving delivery routing tokens.
Resolves tokens like "origin", "telegram", "all" to concrete delivery
targets at fire time, enabling ergonomic routing without hard-coded IDs.
"""
def resolve(
self,
token: str,
*,
origin: Optional["DeliveryTarget"] = None
) -> List["DeliveryTarget"]:
"""Resolve a routing token to concrete delivery targets.
Token formats:
- "origin": Reply to the chat where the job was created (requires origin)
- "<platform>": That platform's home channel
- "<platform>:<chat_id>[:<thread_id>]": Explicit target
- "all": Fan-out to every connected platform with a home channel
Args:
token: Routing token to resolve
origin: Original delivery target (for "origin" token)
Returns:
List of concrete delivery targets
"""
...
# ---------------------------------------------------------------------------
# Protocol Version Negotiation (Issue #2130)
# ---------------------------------------------------------------------------
# Protocol version constants
PROTOCOL_VERSION = 1
MIN_PROTOCOL_VERSION = 1
MAX_PROTOCOL_VERSION = 1
class ProtocolHello(TypedDict, total=False):
"""Protocol version negotiation handshake request.
Sent by client during join to negotiate protocol version.
"""
min_version: int # Minimum protocol version client supports
max_version: int # Maximum protocol version client supports
features: List[str] # Optional feature flags
class ProtocolHelloOk(TypedDict):
"""Protocol version negotiation response.
Server's response to protocol negotiation.
"""
protocol_version: int # Negotiated protocol version
server_min_version: int # Server's minimum supported version
server_max_version: int # Server's maximum supported version
features: List[str] # Enabled feature flags
class GapInfo(TypedDict):
"""Information about a gap in the event sequence."""
expected_seq: int # Expected sequence number
received_seq: int # Received sequence number
missed_count: int # Number of events missed
class ResumeSnapshot(TypedDict, total=False):
"""Complete snapshot for session resumption.
Provides all necessary state for one-round-trip reconnection.
"""
cursor: int # Resume cursor position
sequence: int # Current sequence number for gap detection
events: List[Dict[str, Any]] # Replayed events since cursor
presence: List[Dict[str, Any]] # Current presence information
health: Dict[str, Any] # Gateway health status
session_state: Dict[str, Any] # Session-specific state
@@ -190,8 +190,6 @@ class OnRetryInput(HookInput):
max_retries: int = 3
error_message: str = ""
operation: str = "" # tool_call, llm_request, etc.
delay_seconds: float = 0.0 # Delay before retry
attempt: int = 0 # Current attempt number (0-based)
def to_dict(self) -> Dict[str, Any]:
base = super().to_dict()
@@ -206,9 +204,7 @@ class OnRetryInput(HookInput):
"retry_count": self.retry_count,
"max_retries": self.max_retries,
"error_message": self.error_message,
"operation": self.operation,
"delay_seconds": self.delay_seconds,
"attempt": self.attempt
"operation": self.operation
})
return base
@@ -83,8 +83,7 @@ class HookRunner:
self,
event: HookEvent,
input_data: HookInput,
target: Optional[str] = None,
_hooks: Optional[List["HookDefinition"]] = None
target: Optional[str] = None
) -> List[HookExecutionResult]:
"""
Execute all hooks for an event.
@@ -93,12 +92,11 @@ class HookRunner:
event: The event to execute hooks for
input_data: Input data for the hooks
target: Optional target to filter hooks (e.g., tool name)
_hooks: Internal parameter to avoid duplicate get_hooks() calls
Returns:
List of execution results
"""
hooks = _hooks if _hooks is not None else self._registry.get_hooks(event, target)
hooks = self._registry.get_hooks(event, target)
if not hooks:
return []
@@ -155,14 +153,8 @@ class HookRunner:
"Use 'await runner.execute(event, input_data, target)' instead in async contexts."
)
# Fast path: no hooks → no loop creation needed
# This mirrors the same check in execute() but avoids event loop creation overhead
hooks = self._registry.get_hooks(event, target)
if not hooks:
return []
# No running loop and hooks exist — safe to create one
return asyncio.run(self.execute(event, input_data, target, _hooks=hooks))
# No running loop — safe to create one
return asyncio.run(self.execute(event, input_data, target))
async def _execute_parallel(
self,
Whitespace-only changes.
+47 -84
View File
@@ -24,7 +24,6 @@ import xml.etree.ElementTree as ET
from ..errors import AgentErrorKind, FailoverDecision, IdleTimeoutBreaker
# Gap 2: Tool call execution imports
from ..tools.call_executor import ToolCall, create_tool_call_executor
from ..tools.schema import annotation_to_json_schema, get_parameter_requirements
# Display functions - lazy loaded to avoid importing rich at startup
# These are only needed when output=verbose
_display_module = None
@@ -5750,6 +5749,8 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
if content:
response_text += content
live.update(_get_display_functions()['display_generating'](response_text, start_time))
if content:
response_text += content
else:
# Use retry wrapper for non-streaming calls
response = self._completion_with_retry(**completion_params)
@@ -5845,6 +5846,8 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
if content:
response_text += content
live.update(_get_display_functions()['display_generating'](response_text, start_time))
if content:
response_text += content
else:
# Use retry wrapper for non-streaming async calls
response = await self._acompletion_with_retry(**completion_params)
@@ -5883,74 +5886,34 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
function_name = function_or_name
logging.debug(f"Attempting to generate tool definition for: {function_name}")
# First try to get from the tool registry (preferred method)
try:
from ..tools.registry import get_registry
registry = get_registry()
tool = registry.get(function_name)
if tool:
logging.debug(f"Found tool in registry: {function_name}")
# If it's a BaseTool, get its schema
if hasattr(tool, 'get_schema'):
tool_def = tool.get_schema()
# Apply same normalization as the callable path
if (
isinstance(tool_def, dict)
and isinstance(tool_def.get("function"), dict)
and isinstance(tool_def["function"].get("parameters"), dict)
):
tool_def = tool_def.copy()
tool_def["function"] = tool_def["function"].copy()
tool_def["function"]["parameters"] = self._fix_array_schemas(
tool_def["function"]["parameters"]
)
return tool_def
# If it's a callable, use it as func
if callable(tool):
func = tool
else:
logging.debug(f"Tool {function_name} in registry is not callable")
return None
else:
logging.debug(f"Tool {function_name} not found in registry, falling back to globals/__main__")
# Fall back to globals and __main__ for backward compatibility
tool_def_name = f"{function_name}_definition"
tool_def = globals().get(tool_def_name)
if not tool_def:
import __main__
tool_def = getattr(__main__, tool_def_name, None)
if tool_def:
return tool_def
func = globals().get(function_name)
if not func:
import __main__
func = getattr(__main__, function_name, None)
if not func or not callable(func):
logging.debug(f"Function {function_name} not found or not callable")
return None
except ImportError:
logging.debug("Tool registry not available, falling back to globals/__main__")
# Fall back to globals and __main__ when registry unavailable
tool_def_name = f"{function_name}_definition"
tool_def = globals().get(tool_def_name)
if not tool_def:
import __main__
tool_def = getattr(__main__, tool_def_name, None)
if tool_def:
return tool_def
func = globals().get(function_name)
if not func:
import __main__
func = getattr(__main__, function_name, None)
if not func or not callable(func):
logging.debug(f"Function {function_name} not found or not callable")
return None
# First try to get the tool definition if it exists
tool_def_name = f"{function_name}_definition"
tool_def = globals().get(tool_def_name)
logging.debug(f"Looking for {tool_def_name} in globals: {tool_def is not None}")
if not tool_def:
import __main__
tool_def = getattr(__main__, tool_def_name, None)
logging.debug(f"Looking for {tool_def_name} in __main__: {tool_def is not None}")
if tool_def:
logging.debug(f"Found tool definition: {tool_def}")
return tool_def
# Try to find the function
func = globals().get(function_name)
logging.debug(f"Looking for {function_name} in globals: {func is not None}")
if not func:
import __main__
func = getattr(__main__, function_name, None)
logging.debug(f"Looking for {function_name} in __main__: {func is not None}")
if not func or not callable(func):
logging.debug(f"Function {function_name} not found or not callable")
return None
import inspect
from typing import get_type_hints
# Handle Langchain and CrewAI tools
if inspect.isclass(func) and hasattr(func, 'run') and not hasattr(func, '_run'):
original_func = func
@@ -5998,26 +5961,26 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
logging.debug(f"Parameter descriptions: {param_descriptions}")
# Get type hints for proper schema generation
try:
hints = get_type_hints(func) if getattr(func, "__annotations__", None) else {}
except (NameError, TypeError, AttributeError):
hints = getattr(func, "__annotations__", {}) or {}
for name, param in parameters_list:
# Get type annotation
param_type = hints.get(name, param.annotation if param.annotation != inspect.Parameter.empty else str)
param_type = "string" # Default type
if param.annotation != inspect.Parameter.empty:
if param.annotation == int:
param_type = "integer"
elif param.annotation == float:
param_type = "number"
elif param.annotation == bool:
param_type = "boolean"
elif param.annotation == list:
param_type = "array"
elif param.annotation == dict:
param_type = "object"
# Use new schema utility for proper type handling
prop_schema = annotation_to_json_schema(param_type)
parameters["properties"][name] = {
"type": param_type,
"description": param_descriptions.get(name, "Parameter description not available")
}
# Add description from docstring
prop_schema["description"] = param_descriptions.get(name, "Parameter description not available")
parameters["properties"][name] = prop_schema
# Check if required using improved logic
if get_parameter_requirements(sig, name):
if param.default == inspect.Parameter.empty:
parameters["required"].append(name)
logging.debug(f"Generated parameters: {parameters}")
@@ -141,6 +141,22 @@ class LiteLLMAdapter:
return self._convert_response_to_standard_format(response)
def _convert_messages_to_prompt(self, messages: List[Dict[str, Any]]) -> str:
"""Convert OpenAI-style messages to prompt string."""
if not messages:
return ""
# If single message, return its content
if len(messages) == 1:
return messages[0].get('content', '')
# For multiple messages, use the last user message as prompt
for msg in reversed(messages):
if msg.get('role') == 'user':
return msg.get('content', '')
return messages[-1].get('content', '') if messages else ''
async def _convert_stream_response(self, response_stream) -> AsyncIterator[Dict[str, Any]]:
"""Convert LLM stream response to standard format."""
# Implementation depends on the actual stream format from LLM.get_response_async
@@ -246,74 +246,3 @@ class SecurityConfig:
def get_bind_address(self) -> str:
"""Get recommended bind address based on config."""
return "127.0.0.1" if self.bind_localhost_only else "0.0.0.0"
def load_sse_security_from_env():
"""Load optional SSE security settings from environment."""
import os
token = os.environ.get("MCP_SSE_AUTH_TOKEN") or os.environ.get("MCP_AUTH_TOKEN")
origins_raw = os.environ.get("MCP_SSE_ALLOWED_ORIGINS")
if not token and not origins_raw:
return None, None
origins = (
[origin.strip() for origin in origins_raw.split(",") if origin.strip()]
if origins_raw
else None
)
config = SecurityConfig(
require_auth=bool(token),
validate_origin=origins is not None,
allowed_origins=origins or SecurityConfig().allowed_origins,
)
return config, token
def build_sse_security_app(app, security: Optional["SecurityConfig"] = None):
"""Wrap a Starlette app with SSE security middleware when configured."""
config = security
token = None
if config is None:
config, token = load_sse_security_from_env()
elif config.require_auth:
import os
token = os.environ.get("MCP_SSE_AUTH_TOKEN") or os.environ.get("MCP_AUTH_TOKEN")
if config is None or (not config.require_auth and not config.validate_origin):
return app
class _SSESecurityMiddleware:
def __init__(self, inner, sec: SecurityConfig, expected_token: Optional[str]):
self._inner = inner
self._sec = sec
self._token = expected_token
async def __call__(self, scope, receive, send):
if scope.get("type") != "http":
await self._inner(scope, receive, send)
return
headers = {k.decode().lower(): v.decode() for k, v in scope.get("headers", [])}
if self._sec.require_auth and self._token:
auth = headers.get("authorization", "")
if not auth.startswith("Bearer ") or auth[7:] != self._token:
from starlette.responses import JSONResponse
response = JSONResponse({"error": "Unauthorized"}, status_code=401)
await response(scope, receive, send)
return
if self._sec.validate_origin:
origin = headers.get("origin") or None
if not self._sec.is_origin_allowed(origin):
from starlette.responses import JSONResponse
response = JSONResponse({"error": "Origin not allowed"}, status_code=403)
await response(scope, receive, send)
return
await self._inner(scope, receive, send)
return _SSESecurityMiddleware(app, config, token)
@@ -242,18 +242,12 @@ class ToolsMCPServer:
mcp.run()
def run_sse(
self,
host: str = "0.0.0.0",
port: int = 8080,
security: "Optional[SecurityConfig]" = None,
) -> None:
def run_sse(self, host: str = "0.0.0.0", port: int = 8080) -> None:
"""Run the MCP server using SSE transport.
Args:
host: Host to bind to
port: Port to listen on
security: Optional security config; when unset, reads MCP_SSE_* env vars
"""
try:
import uvicorn
@@ -266,8 +260,6 @@ class ToolsMCPServer:
f"SSE dependencies not available: {e}. "
"Install with: pip install uvicorn starlette"
)
from .mcp_security import SecurityConfig, build_sse_security_app
mcp = self.get_fastmcp()
@@ -294,8 +286,7 @@ class ToolsMCPServer:
Mount(messages_path, app=sse_transport.handle_post_message),
]
)
app = build_sse_security_app(app, security)
print(f"🚀 Starting MCP server '{self.name}' with SSE transport")
print(f"📡 SSE endpoint: http://{host}:{port}{sse_path}")
print(f"🛠️ Available tools: {', '.join(self.get_tool_names())}")
@@ -378,34 +378,27 @@ class MemoryCoreMixin:
def _emit_memory_event(self, event_type: str, memory_type: str, content: str, metadata: Dict):
"""Emit a memory-related event for monitoring/hooks."""
try:
# Helper to build event data lazily
def _build_event_data():
return {
'type': event_type,
'memory_type': memory_type,
'content_preview': content[:100] + ('...' if len(content) > 100 else ''),
'metadata': metadata,
'timestamp': datetime.now().isoformat(),
'provider': getattr(self, 'provider', 'unknown')
}
# Integrate with the existing event system
event_data = {
'type': event_type,
'memory_type': memory_type,
'content_preview': content[:100] + ('...' if len(content) > 100 else ''),
'metadata': metadata,
'timestamp': datetime.now().isoformat(),
'provider': getattr(self, 'provider', 'unknown')
}
# Try to emit to the EventBus if available
try:
from ..bus import get_default_bus
bus = get_default_bus()
# Fast path: only build expensive payload if someone is listening
if not bus.has_subscribers:
return
# Integrate with the existing event system
bus.publish(f"memory.{event_type}.{memory_type}", _build_event_data())
bus.publish(f"memory.{event_type}.{memory_type}", event_data)
except ImportError:
# EventBus not available, fall back to logging
logging.debug(f"Memory event: {event_type}.{memory_type} - {_build_event_data()}")
logging.debug(f"Memory event: {event_type}.{memory_type} - {event_data}")
except Exception as bus_error:
# EventBus failed, log the event instead
logging.debug(f"Memory event (bus failed): {event_type}.{memory_type} - {_build_event_data()}")
logging.debug(f"Memory event (bus failed): {event_type}.{memory_type} - {event_data}")
logging.debug(f"EventBus error: {bus_error}")
except Exception as e:
logging.debug(f"Failed to emit memory event: {e}")
@@ -115,16 +115,6 @@ class FileMemory:
"importance_threshold": 0.7, # Min importance for long-term
"auto_promote": True, # Auto-promote important short-term to long-term
}
@staticmethod
def _sanitise_user_id(user_id: str) -> str:
"""Reject path traversal in user_id before using it as a directory name."""
if not user_id or not isinstance(user_id, str):
return "default"
if ".." in user_id or "/" in user_id or "\\" in user_id:
raise ValueError("user_id must not contain path separators or parent references")
safe = user_id.strip()
return safe or "default"
def __init__(
self,
@@ -142,7 +132,7 @@ class FileMemory:
config: Configuration overrides
verbose: Verbosity level (0=quiet, 1=info, 2+=debug)
"""
self.user_id = self._sanitise_user_id(user_id)
self.user_id = user_id
self.verbose = verbose
# Set up paths
@@ -152,7 +142,7 @@ class FileMemory:
from ..paths import get_project_data_dir
self.base_path = get_project_data_dir() / "memory"
self.user_path = self.base_path / self.user_id
self.user_path = self.base_path / user_id
self.episodic_path = self.user_path / "episodic"
# Create directories
@@ -10,6 +10,7 @@ from praisonaiagents._logging import get_logger
from datetime import datetime, timezone
# Decomposed memory functionality - imported as mixins for backward compatibility
from .storage import StorageMixin
from .search import SearchMixin
from .core import MemoryCoreMixin
@@ -28,14 +29,9 @@ logger = get_logger(__name__, extra_data={"subsystem": "memory"})
TRACE_LEVEL = 5
logging.addLevelName(TRACE_LEVEL, 'TRACE')
# Cache boundary constants for prompt prefix caching optimization
# NOTE: This is a placeholder - current implementation does not provide
# functional prompt caching as it requires structured API metadata
CACHE_BOUNDARY = ""
class Memory(SearchMixin, MemoryCoreMixin):
class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
"""
A single-file memory manager covering:
- Short-term memory (STM) for ephemeral context
@@ -234,34 +230,6 @@ class Memory(SearchMixin, MemoryCoreMixin):
# Determine embedding dimensions for legacy compatibility
self.embedding_dimensions = self._get_embedding_dimensions(self.embedding_model)
# Copy adapter attributes to self for backward compatibility
# This ensures legacy code that checks hasattr(self, 'attribute') still works
if self.use_mem0 and hasattr(adapter, 'mem0_client'):
self.mem0_client = adapter.mem0_client
if self.use_rag and hasattr(adapter, 'collection'):
self.chroma_col = adapter.collection
self.chroma_client = adapter.client
if self.use_mongodb:
if hasattr(adapter, 'client'):
self.mongo_client = adapter.client
if hasattr(adapter, 'db'):
self.mongo_db = adapter.db
if hasattr(adapter, 'short_collection'):
self.mongo_short_term = adapter.short_collection
if hasattr(adapter, 'long_collection'):
self.mongo_long_term = adapter.long_collection
# CRITICAL FIX: Copy use_vector_search from adapter to self
# Without this, lines 579, 758, 857 will crash with AttributeError
if hasattr(adapter, 'use_vector_search'):
self.use_vector_search = adapter.use_vector_search
else:
self.use_vector_search = False
else:
# Initialize use_vector_search to False for non-MongoDB providers
self.use_vector_search = False
def _get_adapter_config(self) -> Dict[str, Any]:
"""Get configuration for adapter initialization."""
@@ -413,6 +381,80 @@ class Memory(SearchMixin, MemoryCoreMixin):
# -------------------------------------------------------------------------
# Initialization
# -------------------------------------------------------------------------
def _init_stm(self):
"""Creates or verifies short-term memory table."""
os.makedirs(os.path.dirname(self.short_db) or ".", exist_ok=True)
conn = self._get_stm_conn()
c = conn.cursor()
c.execute("""
CREATE TABLE IF NOT EXISTS short_mem (
id TEXT PRIMARY KEY,
content TEXT,
meta TEXT,
created_at REAL
)
""")
conn.commit()
def _init_ltm(self):
"""Creates or verifies long-term memory table."""
os.makedirs(os.path.dirname(self.long_db) or ".", exist_ok=True)
conn = self._get_ltm_conn()
c = conn.cursor()
c.execute("""
CREATE TABLE IF NOT EXISTS long_mem (
id TEXT PRIMARY KEY,
content TEXT,
meta TEXT,
created_at REAL
)
""")
conn.commit()
def _init_mem0(self):
"""Initialize Mem0 client for agent or user memory with optional graph support."""
mem_cfg = self.cfg.get("config", {})
api_key = mem_cfg.get("api_key", os.getenv("MEM0_API_KEY"))
org_id = mem_cfg.get("org_id")
proj_id = mem_cfg.get("project_id")
# Check if graph memory is enabled
graph_config = mem_cfg.get("graph_store")
use_graph = graph_config is not None
if use_graph:
# Initialize with graph memory support
from mem0 import Memory
self._log_verbose("Initializing Mem0 with graph memory support")
# Build Mem0 config with graph store
mem0_config = {}
# Add graph store configuration
mem0_config["graph_store"] = graph_config
# Add other configurations if provided
if "vector_store" in mem_cfg:
mem0_config["vector_store"] = mem_cfg["vector_store"]
if "llm" in mem_cfg:
mem0_config["llm"] = mem_cfg["llm"]
if "embedder" in mem_cfg:
mem0_config["embedder"] = mem_cfg["embedder"]
# Initialize Memory with graph support
self.mem0_client = Memory.from_config(config_dict=mem0_config)
self.graph_enabled = True
self._log_verbose("Graph memory initialized successfully")
else:
# Use traditional MemoryClient
from mem0 import MemoryClient
self._log_verbose("Initializing Mem0 with traditional memory client")
if org_id and proj_id:
self.mem0_client = MemoryClient(api_key=api_key, org_id=org_id, project_id=proj_id)
else:
self.mem0_client = MemoryClient(api_key=api_key)
self.graph_enabled = False
def _init_chroma(self):
"""Initialize a local Chroma client for embedding-based search."""
@@ -449,6 +491,105 @@ class Memory(SearchMixin, MemoryCoreMixin):
self._log_verbose(f"Failed to initialize ChromaDB: {e}", logging.ERROR)
self.use_rag = False
def _init_mongodb(self):
"""Initialize MongoDB client for memory storage."""
try:
# Import MongoClient locally to handle optional dependency
from pymongo import MongoClient
mongo_cfg = self.cfg.get("config", {})
self.connection_string = mongo_cfg.get("connection_string", "mongodb://localhost:27017/")
self.database_name = mongo_cfg.get("database", "praisonai")
self.use_vector_search = mongo_cfg.get("use_vector_search", False)
# Initialize MongoDB client
self.mongo_client = MongoClient(
self.connection_string,
maxPoolSize=mongo_cfg.get("max_pool_size", 50),
minPoolSize=mongo_cfg.get("min_pool_size", 10),
maxIdleTimeMS=mongo_cfg.get("max_idle_time", 30000),
serverSelectionTimeoutMS=mongo_cfg.get("server_selection_timeout", 5000),
retryWrites=True,
retryReads=True
)
# Test connection
self.mongo_client.admin.command('ping')
# Setup database and collections
self.mongo_db = self.mongo_client[self.database_name]
self.mongo_short_term = self.mongo_db.short_term_memory
self.mongo_long_term = self.mongo_db.long_term_memory
self.mongo_entities = self.mongo_db.entity_memory
self.mongo_users = self.mongo_db.user_memory
# Create indexes for better performance
self._create_mongodb_indexes()
self._log_verbose("MongoDB initialized successfully")
except Exception as e:
self._log_verbose(f"Failed to initialize MongoDB: {e}", logging.ERROR)
self.use_mongodb = False
def _create_mongodb_indexes(self):
"""Create MongoDB indexes for better performance."""
try:
# Text search indexes
self.mongo_short_term.create_index([("content", "text")])
self.mongo_long_term.create_index([("content", "text")])
# Compound indexes for filtering
self.mongo_short_term.create_index([("created_at", -1), ("metadata.quality", -1)])
self.mongo_long_term.create_index([("created_at", -1), ("metadata.quality", -1)])
# User-specific indexes
self.mongo_users.create_index([("user_id", 1), ("created_at", -1)])
# Entity indexes
self.mongo_entities.create_index([("entity_name", 1), ("entity_type", 1)])
# Vector search indexes for Atlas (if enabled)
if self.use_vector_search:
self._create_vector_search_indexes()
except Exception as e:
self._log_verbose(f"Warning: Could not create MongoDB indexes: {e}", logging.WARNING)
def _create_vector_search_indexes(self):
"""Create vector search indexes for Atlas."""
try:
vector_index_def = {
"mappings": {
"dynamic": True,
"fields": {
"embedding": {
"type": "knnVector",
"dimensions": self.embedding_dimensions,
"similarity": "cosine"
}
}
}
}
# Create vector indexes for both short and long term collections
try:
# Use SearchIndexModel for PyMongo 4.6+ compatibility
try:
from pymongo.operations import SearchIndexModel
search_index_model = SearchIndexModel(definition=vector_index_def, name="vector_index")
self.mongo_short_term.create_search_index(search_index_model)
self.mongo_long_term.create_search_index(search_index_model)
except ImportError:
# Fallback for older PyMongo versions
self.mongo_short_term.create_search_index(vector_index_def, "vector_index")
self.mongo_long_term.create_search_index(vector_index_def, "vector_index")
self._log_verbose("Vector search indexes created successfully")
except Exception as e:
self._log_verbose(f"Could not create vector search indexes: {e}", logging.WARNING)
except Exception as e:
self._log_verbose(f"Error creating vector search indexes: {e}", logging.WARNING)
def _get_embedding(self, text: str) -> List[float]:
"""Get embedding for text using the unified embedding module."""
@@ -1563,41 +1704,6 @@ class Memory(SearchMixin, MemoryCoreMixin):
entities = self.search_entity(q, limit=max_items)
user_mem = self.search_user_memory(user_id, q, limit=max_items) if user_id else []
# Apply stable sorting to ensure deterministic order for prompt caching
# Sort by timestamp descending, then by content hash for stable ordering
def _sort_memory_results(results: List[Any]) -> List[Any]:
"""Stable sort so identical query sets produce identical context strings."""
import hashlib
if not results:
return results
def sort_key(r):
if not isinstance(r, dict):
return (0.0, hashlib.sha256(str(r).encode()).hexdigest())
# Prefer created_at over timestamp for better determinism
timestamp = r.get("created_at") or r.get("timestamp") or 0
if isinstance(timestamp, str):
try:
from datetime import datetime
timestamp = datetime.fromisoformat(timestamp.replace("Z", "+00:00")).timestamp()
except ValueError:
timestamp = 0.0
timestamp = -(float(timestamp)) # Negative for descending order
# Use full content hash for true deterministic ordering
content = r.get("text", "") if isinstance(r, dict) else str(r)
content_hash = hashlib.sha256(content.encode()).hexdigest()
return (timestamp, content_hash)
return sorted(results, key=sort_key)
# Apply stable sorting to all memory results
short_term = _sort_memory_results(short_term)
long_term = _sort_memory_results(long_term)
entities = _sort_memory_results(entities)
user_mem = _sort_memory_results(user_mem)
# Add sections in order of priority
add_section("Short-term Memory Context", short_term)
add_section("Long-term Memory Context", long_term)
@@ -1607,51 +1713,6 @@ class Memory(SearchMixin, MemoryCoreMixin):
return "\n".join(lines) if lines else ""
def build_cache_optimized_context(
self,
task_descr: str,
user_id: Optional[str] = None,
additional: str = "",
max_items: int = 3,
include_cache_boundary: bool = True,
include_in_output: Optional[bool] = None
) -> Dict[str, str]:
"""
Build context with cache boundary markers for prompt prefix caching optimization.
Returns a dictionary with 'stable_prefix' and 'cache_boundary' keys.
The stable prefix contains deterministically ordered content that should
remain constant between turns for effective prompt caching.
Args:
task_descr: Task description for memory search
user_id: Optional user ID for personalized memory
additional: Additional context to include in search
max_items: Maximum items per memory category
include_cache_boundary: Whether to include cache boundary marker
include_in_output: Whether to include memory content in output
Returns:
Dict with 'stable_prefix' and 'cache_boundary' keys
"""
# Preserve original behavior: include_in_output=None means debug-level-only
# Don't change the default behavior for backward compatibility
# Build the stable context using existing method
stable_context = self.build_context_for_task(
task_descr=task_descr,
user_id=user_id,
additional=additional,
max_items=max_items,
include_in_output=include_in_output
)
result = {"stable_prefix": stable_context}
result["cache_boundary"] = CACHE_BOUNDARY if include_cache_boundary else ""
return result
# -------------------------------------------------------------------------
# Master Reset (Everything)
# -------------------------------------------------------------------------
@@ -14,9 +14,6 @@ from datetime import datetime
class SearchMixin:
"""Mixin class containing search and retrieval methods for the Memory class."""
# Allowed table names for SQLite queries to prevent SQL injection
_ALLOWED_SQLITE_TABLES = frozenset({"short_term", "long_term"})
def search_short_term(self, query: str, limit: int = 5, metadata_filter: Optional[Dict] = None,
min_quality: Optional[float] = None, user_id: Optional[str] = None, **kwargs) -> List[Dict[str, Any]]:
"""
@@ -173,10 +170,10 @@ class SearchMixin:
return self._format_vector_results(results)
def _search_mongodb(self, collection, query: str, limit: int, metadata_filter: Optional[Dict],
min_quality: Optional[float], user_id: Optional[str]) -> List[Dict[str, Any]]:
"""Search MongoDB collection with given parameters."""
if not collection:
def _search_mongodb_stm(self, query: str, limit: int, metadata_filter: Optional[Dict],
min_quality: Optional[float], user_id: Optional[str]) -> List[Dict[str, Any]]:
"""Search short-term memory in MongoDB."""
if not hasattr(self, 'stm_collection'):
return []
# Build search filter
@@ -194,35 +191,45 @@ class SearchMixin:
search_filter["metadata.user_id"] = user_id
# Perform search with text score
cursor = collection.find(
cursor = self.stm_collection.find(
search_filter,
{"score": {"$meta": "textScore"}}
).sort([("score", {"$meta": "textScore"})]).limit(limit)
return [self._format_mongodb_result(doc) for doc in cursor]
def _search_mongodb_stm(self, query: str, limit: int, metadata_filter: Optional[Dict],
min_quality: Optional[float], user_id: Optional[str]) -> List[Dict[str, Any]]:
"""Search short-term memory in MongoDB."""
collection = getattr(self, 'stm_collection', None)
return self._search_mongodb(collection, query, limit, metadata_filter, min_quality, user_id)
def _search_mongodb_ltm(self, query: str, limit: int, metadata_filter: Optional[Dict],
min_quality: Optional[float], user_id: Optional[str]) -> List[Dict[str, Any]]:
"""Search long-term memory in MongoDB."""
collection = getattr(self, 'ltm_collection', None)
return self._search_mongodb(collection, query, limit, metadata_filter, min_quality, user_id)
def _search_sqlite(self, conn, table: str, query: str, limit: int, metadata_filter: Optional[Dict],
min_quality: Optional[float], user_id: Optional[str]) -> List[Dict[str, Any]]:
"""Search SQLite table with given parameters."""
if not conn:
if not hasattr(self, 'ltm_collection'):
return []
# Validate table name to prevent SQL injection
if table not in self._ALLOWED_SQLITE_TABLES:
allowed = ", ".join(sorted(self._ALLOWED_SQLITE_TABLES))
raise ValueError(f"Invalid table name: {table}. Allowed tables: {allowed}")
# Build search filter
search_filter = {"$text": {"$search": query}}
# Add additional filters
if metadata_filter:
for key, value in metadata_filter.items():
search_filter[f"metadata.{key}"] = value
if min_quality is not None:
search_filter["quality_score"] = {"$gte": min_quality}
if user_id:
search_filter["metadata.user_id"] = user_id
# Perform search with text score
cursor = self.ltm_collection.find(
search_filter,
{"score": {"$meta": "textScore"}}
).sort([("score", {"$meta": "textScore"})]).limit(limit)
return [self._format_mongodb_result(doc) for doc in cursor]
def _search_sqlite_stm(self, query: str, limit: int, metadata_filter: Optional[Dict],
min_quality: Optional[float], user_id: Optional[str]) -> List[Dict[str, Any]]:
"""Search short-term memory in SQLite."""
conn = self._get_stm_conn()
# Build WHERE clause
where_clauses = ["content LIKE ?"]
@@ -246,7 +253,7 @@ class SearchMixin:
query_sql = f"""
SELECT id, content, metadata, timestamp, quality_score
FROM {table}
FROM short_term
WHERE {where_clause}
ORDER BY quality_score DESC, timestamp DESC
LIMIT ?
@@ -259,17 +266,45 @@ class SearchMixin:
return [self._format_sqlite_result(dict(row)) for row in rows]
def _search_sqlite_stm(self, query: str, limit: int, metadata_filter: Optional[Dict],
min_quality: Optional[float], user_id: Optional[str]) -> List[Dict[str, Any]]:
"""Search short-term memory in SQLite."""
conn = self._get_stm_conn() # Let AttributeError propagate naturally
return self._search_sqlite(conn, "short_term", query, limit, metadata_filter, min_quality, user_id)
def _search_sqlite_ltm(self, query: str, limit: int, metadata_filter: Optional[Dict],
min_quality: Optional[float], user_id: Optional[str]) -> List[Dict[str, Any]]:
"""Search long-term memory in SQLite."""
conn = self._get_ltm_conn() # Let AttributeError propagate naturally
return self._search_sqlite(conn, "long_term", query, limit, metadata_filter, min_quality, user_id)
conn = self._get_ltm_conn()
# Build WHERE clause
where_clauses = ["content LIKE ?"]
params = [f"%{query}%"]
if min_quality is not None:
where_clauses.append("quality_score >= ?")
params.append(min_quality)
# Simple metadata filtering (JSON contains check)
if metadata_filter:
for key, value in metadata_filter.items():
where_clauses.append("metadata LIKE ?")
params.append(f'%"{key}": "{value}"%')
if user_id:
where_clauses.append("metadata LIKE ?")
params.append(f'%"user_id": "{user_id}"%')
where_clause = " AND ".join(where_clauses)
query_sql = f"""
SELECT id, content, metadata, timestamp, quality_score
FROM long_term
WHERE {where_clause}
ORDER BY quality_score DESC, timestamp DESC
LIMIT ?
"""
params.append(limit)
cursor = conn.execute(query_sql, params)
rows = cursor.fetchall()
return [self._format_sqlite_result(dict(row)) for row in rows]
def _format_vector_results(self, results: Dict) -> List[Dict[str, Any]]:
"""Format ChromaDB vector search results."""
@@ -0,0 +1,233 @@
"""
Storage and persistence functionality for Memory class.
This module contains methods related to data storage, database connections,
and persistence operations. Split from the main memory.py file for better maintainability.
"""
import os
import sqlite3
import threading
import logging
from typing import Any, Dict, List, Optional, Set
# Import shared utility functions
from .utils import _check_chromadb, _get_chromadb, _check_pymongo, _get_pymongo
class StorageMixin:
"""Mixin class containing storage and persistence methods for the Memory class."""
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:
stm_db_path = self.cfg.get("short_db", "short_term_memory.db")
self._local.stm_conn = sqlite3.connect(stm_db_path, check_same_thread=False)
self._local.stm_conn.row_factory = sqlite3.Row
# Register 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:
ltm_db_path = self.cfg.get("long_db", "long_term_memory.db")
self._local.ltm_conn = sqlite3.connect(ltm_db_path, check_same_thread=False)
self._local.ltm_conn.row_factory = sqlite3.Row
# Register for cleanup
with self._connection_lock:
self._all_connections.add(self._local.ltm_conn)
return self._local.ltm_conn
def _init_stm(self):
"""Initialize short-term memory table."""
conn = self._get_stm_conn()
with self._write_lock:
conn.execute('''
CREATE TABLE IF NOT EXISTS short_term (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL,
metadata TEXT,
timestamp TEXT,
quality_score REAL DEFAULT 0.0
)
''')
conn.commit()
def _init_ltm(self):
"""Initialize long-term memory table."""
conn = self._get_ltm_conn()
with self._write_lock:
conn.execute('''
CREATE TABLE IF NOT EXISTS long_term (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL,
metadata TEXT,
timestamp TEXT,
quality_score REAL DEFAULT 0.0
)
''')
conn.commit()
def _init_chroma(self):
"""Initialize ChromaDB for vector storage."""
if not _check_chromadb():
return
chromadb = _get_chromadb()
chroma_settings = chromadb["settings"]
rag_db_path = self.cfg.get("rag_db_path", "rag_memory_db")
# Create directory if it doesn't exist
os.makedirs(rag_db_path, exist_ok=True)
self.chroma_client = chromadb["module"].PersistentClient(
path=rag_db_path,
settings=chroma_settings(anonymized_telemetry=False)
)
# Initialize collections
try:
self.stm_collection = self.chroma_client.get_collection("short_term_memory")
except ValueError: # ChromaDB raises ValueError for missing collections
self.stm_collection = self.chroma_client.create_collection("short_term_memory")
except Exception as e:
from .._logging import get_logger
logger = get_logger(__name__)
logger.error(f"Failed to initialize short_term_memory collection: {e}")
raise
try:
self.ltm_collection = self.chroma_client.get_collection("long_term_memory")
except ValueError: # ChromaDB raises ValueError for missing collections
self.ltm_collection = self.chroma_client.create_collection("long_term_memory")
except Exception as e:
from .._logging import get_logger
logger = get_logger(__name__)
logger.error(f"Failed to initialize long_term_memory collection: {e}")
raise
def _init_mongodb(self):
"""Initialize MongoDB for storage."""
if not _check_pymongo():
return
pymongo = _get_pymongo()
config = self.cfg.get("config", {})
connection_string = config.get("connection_string", "mongodb://localhost:27017/")
database_name = config.get("database", "praisonai")
# Connection pool settings
max_pool_size = config.get("max_pool_size", 50)
min_pool_size = config.get("min_pool_size", 10)
max_idle_time = config.get("max_idle_time", 30000)
server_selection_timeout = config.get("server_selection_timeout", 5000)
self.mongo_client = pymongo["MongoClient"](
connection_string,
maxPoolSize=max_pool_size,
minPoolSize=min_pool_size,
maxIdleTimeMS=max_idle_time,
serverSelectionTimeoutMS=server_selection_timeout
)
self.mongo_db = self.mongo_client[database_name]
self.stm_collection = self.mongo_db.short_term_memory
self.ltm_collection = self.mongo_db.long_term_memory
# Create indexes for better performance
self._create_mongodb_indexes()
# Initialize vector search if enabled
use_vector_search = config.get("use_vector_search", False)
if use_vector_search:
self._create_vector_search_indexes()
def _create_mongodb_indexes(self):
"""Create MongoDB indexes for better query performance."""
try:
# Text indexes for search
self.stm_collection.create_index([("content", "text")])
self.ltm_collection.create_index([("content", "text")])
# Compound indexes for common queries
self.stm_collection.create_index([("timestamp", -1), ("quality_score", -1)])
self.ltm_collection.create_index([("timestamp", -1), ("quality_score", -1)])
logging.info("MongoDB indexes created successfully")
except Exception as e:
logging.warning(f"Failed to create MongoDB indexes: {e}")
def _create_vector_search_indexes(self):
"""Create MongoDB Atlas Vector Search indexes."""
try:
# Vector search indexes would be created via MongoDB Atlas UI
# or using the Atlas Admin API
logging.info("Vector search indexes configuration completed")
except Exception as e:
logging.warning(f"Failed to configure vector search indexes: {e}")
def reset_short_term(self):
"""Clear all short-term memory."""
if self.use_rag and hasattr(self, 'stm_collection'):
try:
# Clear ChromaDB collection
self.stm_collection.delete()
# Recreate collection
self.stm_collection = self.chroma_client.create_collection("short_term_memory")
self._log_verbose("Short-term RAG memory reset")
except Exception as e:
logging.warning(f"Failed to reset ChromaDB STM: {e}")
if self.use_mongodb and hasattr(self, 'stm_collection'):
try:
self.stm_collection.delete_many({})
self._log_verbose("Short-term MongoDB memory reset")
except Exception as e:
logging.warning(f"Failed to reset MongoDB STM: {e}")
# Clear SQLite STM
try:
conn = self._get_stm_conn()
with self._write_lock:
conn.execute("DELETE FROM short_term")
conn.commit()
self._log_verbose("Short-term SQLite memory reset")
except Exception as e:
logging.warning(f"Failed to reset SQLite STM: {e}")
def reset_long_term(self):
"""Clear all long-term memory."""
if self.use_rag and hasattr(self, 'ltm_collection'):
try:
# Clear ChromaDB collection
self.ltm_collection.delete()
# Recreate collection
self.ltm_collection = self.chroma_client.create_collection("long_term_memory")
self._log_verbose("Long-term RAG memory reset")
except Exception as e:
logging.warning(f"Failed to reset ChromaDB LTM: {e}")
if self.use_mongodb and hasattr(self, 'ltm_collection'):
try:
self.ltm_collection.delete_many({})
self._log_verbose("Long-term MongoDB memory reset")
except Exception as e:
logging.warning(f"Failed to reset MongoDB LTM: {e}")
# Clear SQLite LTM
try:
conn = self._get_ltm_conn()
with self._write_lock:
conn.execute("DELETE FROM long_term")
conn.commit()
self._log_verbose("Long-term SQLite memory reset")
except Exception as e:
logging.warning(f"Failed to reset SQLite LTM: {e}")
File diff suppressed because it is too large. Load diff
@@ -34,7 +34,6 @@ __all__ = [
"PermissionAction",
"PermissionMode",
"PermissionResult",
"PersistentApproval",
"DoomLoopDetector",
]
@@ -61,10 +60,6 @@ def __getattr__(name: str):
from .rules import PermissionResult
return PermissionResult
if name == "PersistentApproval":
from .rules import PersistentApproval
return PersistentApproval
if name == "DoomLoopDetector":
from .doom_loop import DoomLoopDetector
return DoomLoopDetector
@@ -111,11 +111,6 @@ class PermissionManager:
except IOError as e:
logger.error(f"Failed to save rules: {e}")
def save_rules(self):
"""Public method to save rules to disk."""
with self._lock:
self._save_rules()
def _load_approvals(self):
"""Load approvals from disk."""
path = self._get_approvals_path()
@@ -364,71 +359,3 @@ class PermissionManager:
self._approvals = [PersistentApproval.from_dict(a) for a in data.get("approvals", [])]
if "agent_name" in data:
self.agent_name = data["agent_name"]
def load_rules_from_config(self, permissions_config: Dict[str, Any], priority_base: int = 50, persist: bool = False):
"""
Load permission rules from configuration (YAML/CLI/Python).
Args:
permissions_config: Dictionary mapping patterns to actions or detailed configs
e.g., {"read:*": "allow", "bash:rm *": {"action": "deny", "description": "..."}}
priority_base: Base priority for these rules (default: 50, between default and user rules)
persist: Whether to persist these rules to disk (default: False for ephemeral CI/YAML rules)
"""
if not permissions_config:
return
with self._lock:
incoming_rules = []
for pattern, config in permissions_config.items():
try:
if isinstance(config, str):
# Simple format: pattern -> action
rule = PermissionRule.from_config(pattern, config, priority=priority_base)
elif isinstance(config, dict):
# Detailed format: pattern -> {action, description, ...}
action = config.get("action", "ask")
rule = PermissionRule.from_config(
pattern,
action,
description=config.get("description"),
is_regex=config.get("is_regex", False),
priority=config.get("priority", priority_base),
agent_name=config.get("agent_name"),
enabled=config.get("enabled", True),
)
else:
logger.warning(
"Invalid permission config for pattern '%s': %r. "
"Use 'allow|deny|ask' or {action, description, is_regex, priority, agent_name, enabled}.",
pattern,
config,
)
continue
except (TypeError, ValueError) as e:
logger.warning(
"Skipping invalid permission rule for pattern '%s': %s. "
"Valid actions are: allow, deny, ask.",
pattern,
e,
)
continue
incoming_rules.append(rule)
# Remove existing rules with same pattern to avoid duplicates
# (pattern, agent_name, is_regex) uniquely identifies a rule for matching
incoming_keys = {(r.pattern, r.agent_name, r.is_regex) for r in incoming_rules}
self._rules = [
r for r in self._rules
if (r.pattern, r.agent_name, r.is_regex) not in incoming_keys
]
self._rules.extend(incoming_rules)
# Re-sort by priority
self._rules.sort(key=lambda r: r.priority, reverse=True)
# Only persist if explicitly requested (not for ephemeral CI/YAML rules)
if persist:
self._save_rules()
@@ -114,29 +114,6 @@ class PermissionRule:
enabled=data.get("enabled", True),
created_at=data.get("created_at", time.time()),
)
@classmethod
def from_config(cls, pattern: str, action: str, **kwargs) -> "PermissionRule":
"""
Create rule from configuration (YAML/CLI/Python).
Args:
pattern: The permission pattern (e.g., "read:*", "bash:git *")
action: The action string (allow/deny/ask)
**kwargs: Optional attributes like description, priority, is_regex
Returns:
PermissionRule instance
"""
return cls(
pattern=pattern,
action=PermissionAction(action),
description=kwargs.get("description", f"Rule for {pattern}"),
is_regex=kwargs.get("is_regex", False),
priority=kwargs.get("priority", 0),
agent_name=kwargs.get("agent_name"),
enabled=kwargs.get("enabled", True),
)
@dataclass
Whitespace-only changes.
@@ -16,7 +16,6 @@ Features:
import asyncio
import inspect
import logging
import sys
from praisonaiagents._logging import get_logger
from typing import Callable, Optional, Union, TYPE_CHECKING
@@ -94,12 +93,6 @@ class ApprovalCallback:
return False
# Default: require explicit approval (return False)
# However, auto-approve when not in an interactive environment (e.g., CI/scripts)
if sys.stdin is None or not sys.stdin.isatty():
logger.debug(f"Auto-approving plan in non-interactive environment: {plan.name}")
plan.approve()
return True
logger.debug(f"Plan requires explicit approval: {plan.name}")
return False
@@ -134,12 +127,6 @@ class ApprovalCallback:
return False
# Default: require explicit approval (return False)
# However, auto-approve when not in an interactive environment (e.g., CI/scripts)
if sys.stdin is None or not sys.stdin.isatty():
logger.debug(f"Auto-approving plan in non-interactive environment: {plan.name}")
plan.approve()
return True
logger.debug(f"Plan requires explicit approval: {plan.name}")
return False
@@ -196,13 +196,8 @@ class Process:
except (json.JSONDecodeError, ValueError, TypeError) as e:
raise Exception(f"Failed to parse response: {response}") from e
def _create_loop_subtasks(self, loop_task: Task, decision_mode: bool = False):
"""Create subtasks for a loop task from input file.
Args:
loop_task: The loop task to create subtasks for
decision_mode: If True, create decision-type subtasks with conditions (for start tasks)
"""
def _create_loop_subtasks(self, loop_task: Task):
"""Create subtasks for a loop task from input file."""
if not loop_task.input_file:
logging.warning(f"_create_loop_subtasks called for {loop_task.name} but no input_file specified")
return
@@ -234,45 +229,20 @@ class Process:
task_count += 1
logging.debug(f"Creating subtask from CSV row {i+1}: {task_desc}")
# Configure task based on decision_mode
if decision_mode:
# For start tasks: create decision-type tasks with conditions and inherited next_tasks
inherited_next_tasks = loop_task.next_tasks if loop_task.next_tasks else []
row_name = f"{loop_task.name}_{task_count}" if loop_task.name else task_desc
row_task = Task(
description=f"{loop_task.description}\n{task_desc}" if loop_task.description else task_desc,
agent=loop_task.agent,
name=row_name,
expected_output=getattr(loop_task, 'expected_output', None),
on_task_complete=loop_task.callback, # Inherit callback from parent loop task
is_start=(task_count == 1),
task_type="decision", # Change to decision type for start tasks
next_tasks=inherited_next_tasks, # Inherit parent's next tasks
condition={
"done": inherited_next_tasks if inherited_next_tasks else [],
"retry": [row_name],
"exit": [] # Empty list for exit condition
}
)
else:
# For regular loop tasks: create basic task-type subtasks
row_task = Task(
description=f"{loop_task.description}\n{task_desc}" if loop_task.description else task_desc,
agent=loop_task.agent,
name=f"{loop_task.name}_{task_count}" if loop_task.name else task_desc,
expected_output=getattr(loop_task, 'expected_output', None),
on_task_complete=loop_task.callback, # Inherit callback from parent loop task
is_start=(task_count == 1),
task_type="task"
)
row_task = Task(
description=f"{loop_task.description}\n{task_desc}" if loop_task.description else task_desc,
agent=loop_task.agent,
name=f"{loop_task.name}_{task_count}" if loop_task.name else task_desc,
expected_output=getattr(loop_task, 'expected_output', None),
on_task_complete=loop_task.callback, # Inherit callback from parent loop task
is_start=(task_count == 1),
task_type="task"
)
self.tasks[row_task.id] = row_task
new_tasks.append(row_task)
if previous_task:
previous_task.next_tasks = [row_task.name]
if decision_mode:
previous_task.condition["done"] = [row_task.name] # Use "done" consistently for decision tasks
previous_task = row_task
logging.info(f"Created {task_count} subtasks from CSV file for {loop_task.name}")
@@ -284,42 +254,21 @@ class Process:
for i, line in enumerate(lines):
if not line.strip(): # Skip empty lines
continue
# Configure task based on decision_mode
if decision_mode:
# For start tasks: create tasks with conditions
row_task = Task(
description=f"{loop_task.description}\n{line.strip()}" if loop_task.description else line.strip(),
agent=loop_task.agent,
name=f"{loop_task.name}_{i+1}" if loop_task.name else line.strip(),
expected_output=getattr(loop_task, 'expected_output', None),
on_task_complete=loop_task.callback, # Inherit callback from parent loop task
is_start=(i == 0),
task_type="task",
condition={
"complete": ["next"],
"retry": ["current"]
}
)
else:
# For regular loop tasks: create basic tasks
row_task = Task(
description=f"{loop_task.description}\n{line.strip()}" if loop_task.description else line.strip(),
agent=loop_task.agent,
name=f"{loop_task.name}_{i+1}" if loop_task.name else line.strip(),
expected_output=getattr(loop_task, 'expected_output', None),
on_task_complete=loop_task.callback, # Inherit callback from parent loop task
is_start=(i == 0),
task_type="task"
)
row_task = Task(
description=f"{loop_task.description}\n{line.strip()}" if loop_task.description else line.strip(),
agent=loop_task.agent,
name=f"{loop_task.name}_{i+1}" if loop_task.name else line.strip(),
expected_output=getattr(loop_task, 'expected_output', None),
on_task_complete=loop_task.callback, # Inherit callback from parent loop task
is_start=(i == 0),
task_type="task"
)
self.tasks[row_task.id] = row_task
new_tasks.append(row_task)
if previous_task:
previous_task.next_tasks = [row_task.name]
if decision_mode:
previous_task.condition["complete"] = [row_task.name]
previous_task = row_task
logging.info(f"Created {len(new_tasks)} subtasks from text file for {loop_task.name}")
@@ -327,12 +276,7 @@ class Process:
if new_tasks and loop_task.next_tasks:
# Connect last subtask to loop task's next tasks
last_task = new_tasks[-1]
if decision_mode:
# For decision mode, ensure the last task points to parent's next tasks
if not last_task.next_tasks:
last_task.next_tasks = loop_task.next_tasks
else:
last_task.next_tasks = loop_task.next_tasks
last_task.next_tasks = loop_task.next_tasks
except Exception as e:
logging.error(f"Failed to create subtasks for loop task {loop_task.name}: {e}")
@@ -618,6 +562,7 @@ class Process:
current_task = start_task
visited_tasks = set()
loop_data = {} # Store loop-specific data
# TODO: start task with loop feature is not available in aworkflow method
@@ -787,6 +732,9 @@ Subtask: {st.name}
logging.debug(f"Task next_tasks: {current_task.next_tasks}")
yield task_id
visited_tasks.add(task_id)
# Reset description to original after execution to prevent context accumulation
if hasattr(current_task, '_original_description'):
current_task.description = current_task._original_description
# Only end workflow if no next_tasks AND no conditions
if not current_task.next_tasks and not current_task.condition and not any(
@@ -824,6 +772,22 @@ Subtask: {st.name}
logging.debug(f"=== Skipping reset for loop/decision/subtask/parallel or rerun=False: {subtask_name} ===")
logging.debug(f"Keeping status as: {self.tasks[task_id].status}")
# Handle loop progression
if current_task.task_type == "loop":
loop_key = f"loop_{current_task.name}"
if loop_key in loop_data:
loop_info = loop_data[loop_key]
loop_info["index"] += 1
has_more = loop_info["remaining"] > 0
# Update result to trigger correct condition
if current_task.result:
result = current_task.result.raw
if has_more:
result += "\nmore"
else:
result += "\ndone"
current_task.result.raw = result
# Determine next task based on result
next_task = None
@@ -1148,29 +1112,104 @@ Provide a JSON with the structure:
if start_task and start_task.task_type == "loop" and not start_task.input_file:
start_task.input_file = "tasks.csv"
# --- If loop + input_file, read file & create tasks using consolidated helper
# --- If loop + input_file, read file & create tasks
if start_task and start_task.task_type == "loop" and getattr(start_task, "input_file", None):
try:
parent_loop_task = start_task
parent_input_file = parent_loop_task.input_file
self._create_loop_subtasks(parent_loop_task, decision_mode=True)
# Get the first created subtask as the new start task
subtasks = [
t for t in self.tasks.values()
if t.name.startswith(parent_loop_task.name + "_")
]
if subtasks:
# Mark parent loop task as completed and find start subtask
parent_loop_task.status = "completed"
parent_loop_task._subtasks_created = True
start_task = next((t for t in subtasks if t.is_start), subtasks[0])
logging.info(f"Created {len(subtasks)} tasks from: {parent_input_file}")
file_ext = os.path.splitext(start_task.input_file)[1].lower()
new_tasks = []
if file_ext == ".csv":
with open(start_task.input_file, "r", encoding="utf-8") as f:
reader = csv.reader(f, quotechar='"', escapechar='\\') # Handle quoted/escaped fields
previous_task = None
task_count = 0
for i, row in enumerate(reader):
if not row: # Skip truly empty rows
continue
# Properly handle Q&A pairs with potential commas
task_desc = row[0].strip() if row else ""
if len(row) > 1:
# Preserve all fields in case of multiple commas
question = row[0].strip()
answer = ",".join(field.strip() for field in row[1:])
task_desc = f"Question: {question}\nAnswer: {answer}"
if not task_desc: # Skip rows with empty content
continue
task_count += 1
logging.debug(f"Processing CSV row {i+1}: {task_desc}")
# Inherit next_tasks from parent loop task
inherited_next_tasks = start_task.next_tasks if start_task.next_tasks else []
row_task = Task(
description=f"{start_task.description}\n{task_desc}" if start_task.description else task_desc,
agent=start_task.agent,
name=f"{start_task.name}_{task_count}" if start_task.name else task_desc,
expected_output=getattr(start_task, 'expected_output', None),
on_task_complete=start_task.callback, # Inherit callback from parent loop task
is_start=(task_count == 1),
task_type="decision", # Change to decision type
next_tasks=inherited_next_tasks, # Inherit parent's next tasks
condition={
"done": inherited_next_tasks if inherited_next_tasks else ["next"], # Use full inherited_next_tasks
"retry": ["current"],
"exit": [] # Empty list for exit condition
}
)
self.tasks[row_task.id] = row_task
new_tasks.append(row_task)
if previous_task:
previous_task.next_tasks = [row_task.name]
previous_task.condition["done"] = [row_task.name] # Use "done" consistently
previous_task = row_task
# For the last task in the loop, ensure it points to parent's next tasks
if task_count > 0 and not row_task.next_tasks:
row_task.next_tasks = inherited_next_tasks
logging.info(f"Processed {task_count} rows from CSV file")
else:
# If not CSV, read lines
with open(start_task.input_file, "r", encoding="utf-8") as f:
lines = f.read().splitlines()
previous_task = None
for i, line in enumerate(lines):
row_task = Task(
description=f"{start_task.description}\n{line.strip()}" if start_task.description else line.strip(),
agent=start_task.agent,
name=f"{start_task.name}_{i+1}" if start_task.name else line.strip(),
expected_output=getattr(start_task, 'expected_output', None),
on_task_complete=start_task.callback, # Inherit callback from parent loop task
is_start=(i == 0),
task_type="task",
condition={
"complete": ["next"],
"retry": ["current"]
}
)
self.tasks[row_task.id] = row_task
new_tasks.append(row_task)
if previous_task:
previous_task.next_tasks = [row_task.name]
previous_task.condition["complete"] = [row_task.name]
previous_task = row_task
if new_tasks:
start_task = new_tasks[0]
logging.info(f"Created {len(new_tasks)} tasks from: {start_task.input_file}")
except Exception as e:
logging.error(f"Failed to read file tasks: {e}")
# end of start task handling
current_task = start_task
visited_tasks = set()
loop_data = {} # Store loop-specific data
while current_task:
current_iter += 1
@@ -1217,7 +1256,7 @@ Tasks by type:
break # Exit immediately to prevent task reset
# Handle loop task file reading at runtime using consolidated helper
# Handle loop task file reading at runtime
if (current_task.task_type == "loop" and
current_task is not start_task and
getattr(current_task, "_subtasks_created", False) is not True):
@@ -1227,18 +1266,66 @@ Tasks by type:
if getattr(current_task, "input_file", None):
try:
self._create_loop_subtasks(current_task, decision_mode=False)
# Update current task to point to first subtask
subtasks = [
t for t in self.tasks.values()
if t.name.startswith(current_task.name + "_")
]
if subtasks:
# Find first subtask by is_start flag or first in list
first_subtask = next((t for t in subtasks if t.is_start), subtasks[0])
current_task.next_tasks = [first_subtask.name]
file_ext = os.path.splitext(current_task.input_file)[1].lower()
new_tasks = []
if file_ext == ".csv":
with open(current_task.input_file, "r", encoding="utf-8") as f:
reader = csv.reader(f)
previous_task = None
for i, row in enumerate(reader):
if row: # Skip empty rows
task_desc = row[0] # Take first column
row_task = Task(
description=f"{current_task.description}\n{task_desc}" if current_task.description else task_desc,
agent=current_task.agent,
name=f"{current_task.name}_{i+1}" if current_task.name else task_desc,
expected_output=getattr(current_task, 'expected_output', None),
on_task_complete=current_task.callback, # Inherit callback from parent loop task
is_start=(i == 0),
task_type="task",
condition={
"complete": ["next"],
"retry": ["current"]
}
)
self.tasks[row_task.id] = row_task
new_tasks.append(row_task)
if previous_task:
previous_task.next_tasks = [row_task.name]
previous_task.condition["complete"] = [row_task.name]
previous_task = row_task
else:
with open(current_task.input_file, "r", encoding="utf-8") as f:
lines = f.read().splitlines()
previous_task = None
for i, line in enumerate(lines):
row_task = Task(
description=f"{current_task.description}\n{line.strip()}" if current_task.description else line.strip(),
agent=current_task.agent,
name=f"{current_task.name}_{i+1}" if current_task.name else line.strip(),
expected_output=getattr(current_task, 'expected_output', None),
on_task_complete=current_task.callback, # Inherit callback from parent loop task
is_start=(i == 0),
task_type="task",
condition={
"complete": ["next"],
"retry": ["current"]
}
)
self.tasks[row_task.id] = row_task
new_tasks.append(row_task)
if previous_task:
previous_task.next_tasks = [row_task.name]
previous_task.condition["complete"] = [row_task.name]
previous_task = row_task
if new_tasks:
current_task.next_tasks = [new_tasks[0].name]
current_task._subtasks_created = True
logging.info(f"Created {len(subtasks)} tasks from: {current_task.input_file} for loop task {current_task.name}")
logging.info(f"Created {len(new_tasks)} tasks from: {current_task.input_file} for loop task {current_task.name}")
except Exception as e:
logging.error(f"Failed to read file tasks for loop task {current_task.name}: {e}")
@@ -1364,6 +1451,9 @@ Subtask: {st.name}
logging.debug(f"Task next_tasks: {current_task.next_tasks}")
yield task_id
visited_tasks.add(task_id)
# Reset description to original after execution to prevent context accumulation
if hasattr(current_task, '_original_description'):
current_task.description = current_task._original_description
# Only end workflow if no next_tasks AND no conditions
if not current_task.next_tasks and not current_task.condition and not any(
@@ -1399,6 +1489,22 @@ Subtask: {st.name}
logging.debug(f"Keeping status as: {self.tasks[task_id].status}")
# Handle loop progression
if current_task.task_type == "loop":
loop_key = f"loop_{current_task.name}"
if loop_key in loop_data:
loop_info = loop_data[loop_key]
loop_info["index"] += 1
has_more = loop_info["remaining"] > 0
# Update result to trigger correct condition
if current_task.result:
result = current_task.result.raw
if has_more:
result += "\nmore"
else:
result += "\ndone"
current_task.result.raw = result
# Determine next task based on result
next_task = None
@@ -1,400 +0,0 @@
# Runtime System Documentation
This module provides runtime execution abstractions and protocols for standardizing agent execution across different harness types.
## Components
### 1. Agent Runtime System
The runtime system provides a pluggable abstraction layer for agent execution, allowing agents to run through different runtime implementations while maintaining a consistent interface.
#### Overview
The runtime system consists of:
- **AgentRuntimeProtocol**: Core protocol defining the runtime interface
- **RuntimeRegistry**: Plugin registry for runtime discovery and management
- **Built-in PraisonAI Runtime**: Default embedded runtime wrapping existing agent logic
- **Entry Points**: Support for plugin runtime registration
#### Architecture
Following AGENTS.md protocol-driven design:
- **Core protocols only** in `praisonaiagents/runtime/`
- **Lightweight abstractions** with minimal dependencies
- **Async-first** design with proper typing
- **Plugin registry** for extensibility
#### Usage
##### Basic Runtime Resolution
```python
from praisonaiagents.runtime import resolve_runtime
# Get the built-in runtime
runtime = resolve_runtime("praisonai")
# Execute a turn
result = await runtime.run_turn("Hello world")
print(result.content)
```
##### Streaming Execution
```python
# Stream response deltas
async for delta in runtime.stream_turn("Tell me a story"):
if delta.type == "text":
print(delta.content, end="")
```
##### Runtime Registration
```python
from praisonaiagents.runtime import register_runtime
class MyCustomRuntime:
def supports(self, model_ref=None):
return True
async def run_turn(self, prompt, **kwargs):
# Custom implementation
return RuntimeResult(content="Custom response")
async def stream_turn(self, prompt, **kwargs):
yield RuntimeDelta(type="text", content="Streaming...")
# Register runtime
register_runtime("my-runtime", lambda: MyCustomRuntime())
```
##### Plugin Runtime (Entry Points)
Add to your package's `pyproject.toml`:
```toml
[project.entry-points."praisonai.runtimes"]
my-runtime = "my_package.runtime:MyRuntimeClass"
```
#### Built-in Runtime
The `praisonai` runtime is the default built-in runtime that wraps the existing agent ChatMixin and execution logic. It:
- Supports all models that the LLM subsystem supports
- Delegates to existing Agent execution paths
- Provides backward compatibility
- Handles streaming and non-streaming execution
#### Protocol Compliance
All runtimes must implement `AgentRuntimeProtocol`:
```python
class MyRuntime:
def supports(self, model_ref: Optional[str] = None) -> bool:
"""Check if runtime supports the model."""
...
async def run_turn(
self,
prompt: str,
*,
system_prompt: Optional[str] = None,
model_ref: Optional[str] = None,
**kwargs
) -> RuntimeResult:
"""Execute a single turn."""
...
async def stream_turn(
self,
prompt: str,
**kwargs
) -> AsyncIterator[RuntimeDelta]:
"""Stream response deltas."""
...
```
#### Registry API
The runtime registry provides thread-safe registration and resolution:
```python
from praisonaiagents.runtime import (
register_runtime,
list_runtimes,
resolve_runtime,
is_runtime_available
)
# List available runtimes
runtimes = list_runtimes()
# Check availability
if is_runtime_available("my-runtime"):
runtime = resolve_runtime("my-runtime")
```
#### Error Handling
Runtimes should handle errors gracefully and return `RuntimeResult` objects with error information rather than raising exceptions:
```python
try:
# Runtime execution logic
result = await some_operation()
return RuntimeResult(content=result, metadata={"success": True})
except Exception as e:
return RuntimeResult(
content="",
error=str(e),
metadata={"success": False}
)
```
### 2. Runtime Tool Result Middleware
This module provides runtime-scoped tool result middleware for plugin harnesses. Plugin harnesses return tool results in vendor-specific shapes, and this middleware normalizes them before they reach hooks and memory adapters.
#### Overview
The middleware system follows the protocol-driven design of PraisonAI Agents:
- **Core Protocol**: `RuntimeToolResultMiddleware` - defines the normalization interface
- **Standardized Format**: `NormalizedToolResult` - consistent result format for downstream consumers
- **Registry**: `RuntimeRegistry` - manages per-runtime middleware instances
- **Zero Overhead**: Native `praisonai` runtime bypasses middleware to avoid allocation
#### Architecture
```
Tool Execution Flow:
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Plugin Harness │───▶│ Runtime │───▶│ Global Hooks │───▶│ Memory │
│ (vendor result) │ │ Middleware │ │ (normalized) │ │ Persistence │
└─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘
│
▼
┌─────────────────┐
│ NormalizedTool │
│ Result │
└─────────────────┘
```
#### For Plugin Authors
##### 1. Implement the Middleware Protocol
```python
from praisonaiagents.runtime import RuntimeToolResultMiddleware, NormalizedToolResult, MiddlewareContext
class MyHarnessMiddleware:
@property
def runtime_id(self) -> str:
return "my_plugin_harness"
def normalize(self, result: Any, tool_name: str, ctx: MiddlewareContext) -> NormalizedToolResult:
# Convert your vendor-specific result to standard format
if isinstance(result, MyVendorResult):
return NormalizedToolResult(
content=result.data,
success=result.status == "ok",
error_message=result.error_msg if result.status != "ok" else None,
metadata={
"vendor": "my_vendor",
"tool_version": result.version,
"execution_id": result.exec_id
},
execution_time_ms=ctx.execution_time_ms,
raw_result=result # For debugging/advanced use cases
)
# Fallback for unknown result types
return NormalizedToolResult(content=result)
```
##### 2. Register Your Middleware
```python
from praisonaiagents.runtime import register_middleware
# During plugin initialization
middleware = MyHarnessMiddleware()
register_middleware("my_plugin_harness", middleware)
```
##### 3. Set Runtime ID on Agent
```python
from praisonaiagents import Agent
# Your plugin harness should set the runtime_id on agents it creates
agent = Agent(name="MyAgent", tools=[...])
agent._runtime_id = "my_plugin_harness" # This triggers middleware lookup
```
#### Standard Result Format
The `NormalizedToolResult` provides a consistent interface for downstream consumers:
```python
@dataclass
class NormalizedToolResult:
# Core result data
content: Any # The actual tool result (can be any type)
# Standardized metadata
success: bool = True
error_message: Optional[str] = None
# Rich metadata for downstream consumers
metadata: Dict[str, Any] = field(default_factory=dict)
# Execution context
execution_time_ms: float = 0.0
timestamp: float = field(default_factory=time.time)
# Original raw result for debugging/advanced use cases
raw_result: Optional[Any] = None
```
#### Common Patterns
##### Error Handling
```python
def normalize(self, result, tool_name, ctx):
if isinstance(result, MyErrorResult):
return NormalizedToolResult(
content=None,
success=False,
error_message=result.error_description,
metadata={
"error_code": result.code,
"error_category": result.category,
"retryable": result.is_retryable
},
raw_result=result
)
```
##### Rich Metadata
```python
def normalize(self, result, tool_name, ctx):
return NormalizedToolResult(
content=result.data,
success=True,
metadata={
# Vendor identification
"vendor": "my_vendor",
"vendor_version": "2.1.0",
# Execution metadata
"tool_version": result.tool_version,
"execution_id": result.exec_id,
"cache_hit": result.from_cache,
# Performance metadata
"network_time_ms": result.network_latency,
"processing_time_ms": result.processing_time,
# Context metadata
"region": result.execution_region,
"model": result.model_used if hasattr(result, 'model_used') else None
},
raw_result=result
)
```
##### Conditional Normalization
```python
def normalize(self, result, tool_name, ctx):
# Different handling based on tool type
if tool_name.startswith("search_"):
return self._normalize_search_result(result, ctx)
elif tool_name.startswith("db_"):
return self._normalize_db_result(result, ctx)
else:
return self._normalize_generic_result(result, ctx)
```
#### Performance Considerations
- **Native Runtime**: The `praisonai` runtime bypasses middleware entirely to avoid allocation overhead
- **Lazy Loading**: Middleware modules use lazy loading to minimize startup time
- **Error Isolation**: Middleware failures don't break tool execution - they log warnings and continue
- **Thread Safety**: The registry is thread-safe for concurrent agent execution
## Testing
The runtime system includes comprehensive tests:
- Protocol compliance tests
- Registry functionality tests
- Built-in runtime tests
- Integration tests
- Middleware tests
Run tests with:
```bash
pytest praisonaiagents/runtime/tests/
```
Use the provided test utilities to verify your middleware:
```python
from praisonaiagents.runtime.middleware import MiddlewareContext
def test_my_middleware():
middleware = MyHarnessMiddleware()
ctx = MiddlewareContext(
tool_name="test_tool",
runtime_id="my_plugin_harness",
agent_id="test_agent",
execution_time_ms=100.0
)
result = MyVendorResult(status="ok", data="test")
normalized = middleware.normalize(result, "test_tool", ctx)
assert normalized.success is True
assert normalized.content == "test"
assert normalized.metadata["vendor"] == "my_vendor"
```
## Registry Management
The registry supports runtime management for testing and plugin lifecycle:
```python
from praisonaiagents.runtime import get_default_registry
registry = get_default_registry()
# List registered runtimes
runtimes = registry.list_runtimes()
# Check if middleware is registered
if registry.has_middleware("my_runtime"):
middleware = registry.get_middleware("my_runtime")
# Unregister for cleanup
registry.unregister("my_runtime")
```
## Future Extensions
The runtime system is designed to support:
- CLI backend migration to runtime abstraction
- Model-scoped runtime selection policies
- Turn-time runtime resolution for handoffs
- Runtime capability matrix and hook compatibility
- Prepared turn contexts with runtime plans
@@ -1,100 +0,0 @@
"""
Runtime execution components for PraisonAI agents.
This module provides runtime execution abstractions and protocols for
standardizing agent execution across different harness types.
Key Components:
- Runtime execution contexts: PreparedTurnContext, TurnRuntimeProtocol
- Agent runtime registry: AgentRuntimeProtocol, RuntimeRegistry
- Tool result middleware: RuntimeToolResultMiddleware, NormalizedToolResult
- Registry patterns for runtime resolution and middleware management
"""
from .._lazy import create_lazy_getattr_with_groups
__all__ = [
# Runtime execution contexts
'PreparedTurnContext',
'TurnRuntimeProtocol',
'TurnContextBuilderProtocol',
'ModelReference',
'ToolSchema',
'TranscriptWindow',
'DeliveryChannels',
'SessionCorrelation',
'RuntimeMode',
'create_default_model_ref',
'create_empty_transcript',
'create_default_delivery',
'create_session_correlation',
'DefaultTurnContextBuilder',
'default_context_builder',
# Tool result middleware
"RuntimeToolResultMiddleware",
"NormalizedToolResult",
"MiddlewareContext",
# Middleware registry management
"MiddlewareRegistry",
"get_default_middleware_registry",
"register_middleware",
"get_middleware",
# Agent runtime APIs
'AgentRuntimeProtocol',
'RuntimeConfig',
'RuntimeResult',
'RuntimeDelta',
'RuntimeRegistry',
'register_runtime',
'list_runtimes',
'resolve_runtime',
]
# Grouped lazy imports for efficient loading
_LAZY_GROUPS = {
'turn_context': {
'PreparedTurnContext': ('praisonaiagents.runtime.turn_context', 'PreparedTurnContext'),
'ModelReference': ('praisonaiagents.runtime.turn_context', 'ModelReference'),
'ToolSchema': ('praisonaiagents.runtime.turn_context', 'ToolSchema'),
'TranscriptWindow': ('praisonaiagents.runtime.turn_context', 'TranscriptWindow'),
'DeliveryChannels': ('praisonaiagents.runtime.turn_context', 'DeliveryChannels'),
'SessionCorrelation': ('praisonaiagents.runtime.turn_context', 'SessionCorrelation'),
'RuntimeMode': ('praisonaiagents.runtime.turn_context', 'RuntimeMode'),
'create_default_model_ref': ('praisonaiagents.runtime.turn_context', 'create_default_model_ref'),
'create_empty_transcript': ('praisonaiagents.runtime.turn_context', 'create_empty_transcript'),
'create_default_delivery': ('praisonaiagents.runtime.turn_context', 'create_default_delivery'),
'create_session_correlation': ('praisonaiagents.runtime.turn_context', 'create_session_correlation'),
},
'protocols': {
'TurnRuntimeProtocol': ('praisonaiagents.runtime.protocols', 'TurnRuntimeProtocol'),
'TurnContextBuilderProtocol': ('praisonaiagents.runtime.protocols', 'TurnContextBuilderProtocol'),
'AgentRuntimeProtocol': ('praisonaiagents.runtime.protocols', 'AgentRuntimeProtocol'),
'RuntimeConfig': ('praisonaiagents.runtime.protocols', 'RuntimeConfig'),
'RuntimeResult': ('praisonaiagents.runtime.protocols', 'RuntimeResult'),
'RuntimeDelta': ('praisonaiagents.runtime.protocols', 'RuntimeDelta'),
},
'context_builder': {
'DefaultTurnContextBuilder': ('praisonaiagents.runtime.context_builder', 'DefaultTurnContextBuilder'),
'default_context_builder': ('praisonaiagents.runtime.context_builder', 'default_context_builder'),
},
'middleware': {
'RuntimeToolResultMiddleware': ('praisonaiagents.runtime.middleware', 'RuntimeToolResultMiddleware'),
'NormalizedToolResult': ('praisonaiagents.runtime.middleware', 'NormalizedToolResult'),
'MiddlewareContext': ('praisonaiagents.runtime.middleware', 'MiddlewareContext'),
},
'middleware_registry': {
'MiddlewareRegistry': ('praisonaiagents.runtime.middleware_registry', 'MiddlewareRegistry'),
'get_default_middleware_registry': ('praisonaiagents.runtime.middleware_registry', 'get_default_middleware_registry'),
'register_middleware': ('praisonaiagents.runtime.middleware_registry', 'register_middleware'),
'get_middleware': ('praisonaiagents.runtime.middleware_registry', 'get_middleware'),
},
'registry': {
'RuntimeRegistry': ('praisonaiagents.runtime.registry', 'RuntimeRegistry'),
'register_runtime': ('praisonaiagents.runtime.registry', 'register_runtime'),
'list_runtimes': ('praisonaiagents.runtime.registry', 'list_runtimes'),
'resolve_runtime': ('praisonaiagents.runtime.registry', 'resolve_runtime'),
},
}
# Create the __getattr__ function using centralized utility
__getattr__ = create_lazy_getattr_with_groups(_LAZY_GROUPS, __name__)
Loaded 100 of 414 files, more files were not shown because too many files have changed in this diff. Show more