mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-07 17:33:33 -10:00
Compare commits
161
Commits
No files matched your search
@@ -151,24 +151,40 @@ 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.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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
core.setOutput('is_fork', 'false');
|
||||
|
||||
@@ -180,17 +196,19 @@ jobs:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Fetch PR branch and Setup Remote
|
||||
if: github.event.issue.pull_request
|
||||
if: steps.check_fork.outputs.is_pr == 'true'
|
||||
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: |
|
||||
# Fetch PR head from base repo and put it into local branch
|
||||
git fetch origin pull/${{ github.event.issue.number }}/head:"${PR_BRANCH}"
|
||||
|
||||
git fetch origin "pull/${PR_NUMBER}/head:${PR_BRANCH}"
|
||||
git checkout "${PR_BRANCH}"
|
||||
git fetch origin main
|
||||
|
||||
# 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
|
||||
@@ -200,11 +218,13 @@ 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' || 'NOTE: The branch is under MervinPraison/PraisonAI (not a fork). You are able to make modifications and push directly to this branch.\n\n' }}
|
||||
${{ 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) || '' }}
|
||||
You are working on the PraisonAI SDK. Follow AGENTS.md strictly.
|
||||
|
||||
STEP 0 — SETUP GIT IDENTITY & AUTH (GLOBAL — required for all repos):
|
||||
@@ -233,15 +253,9 @@ 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.
|
||||
|
||||
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.
|
||||
${{ 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-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.
|
||||
@@ -267,10 +281,7 @@ jobs:
|
||||
- For docs changes: verify files exist and are valid markdown/MDX
|
||||
- Ensure no regressions
|
||||
|
||||
STEP 5 — CREATE PR:
|
||||
- Commit with descriptive message, push, and create PR using `gh pr create`
|
||||
- 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".
|
||||
${{ 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' }}
|
||||
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:*)
|
||||
|
||||
@@ -22,8 +22,6 @@ 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,9 +36,6 @@ 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,9 +37,6 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
persist-credentials: false
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
|
||||
- name: Set up Git
|
||||
|
||||
@@ -48,9 +48,6 @@ 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
|
||||
|
||||
@@ -60,16 +60,16 @@ jobs:
|
||||
- name: Run PraisonAI integration tests
|
||||
run: |
|
||||
cd PraisonAI/src/praisonai
|
||||
python -m pytest tests/integration/test_aiui_* -v --timeout=300
|
||||
python -m pytest tests/integration/test_aiui_* -v
|
||||
|
||||
- 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 --timeout=300
|
||||
python -m pytest tests/integration/test_agentic_roundtrip.py -v
|
||||
fi
|
||||
if [ -f tests/test_feature_sdk_backends.py ]; then
|
||||
python -m pytest tests/test_feature_sdk_backends.py -v --timeout=300
|
||||
python -m pytest tests/test_feature_sdk_backends.py -v
|
||||
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 --timeout=600
|
||||
python -m pytest tests/integration/test_aiui_host_agentic.py -v
|
||||
else
|
||||
echo "OPENAI_API_KEY not available, skipping agentic tests"
|
||||
fi
|
||||
@@ -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:
|
||||
|
||||
@@ -26,11 +26,8 @@ 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
|
||||
@@ -48,11 +45,8 @@ 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
|
||||
@@ -449,11 +443,8 @@ 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
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
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"
|
||||
@@ -37,10 +37,8 @@ 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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -13,10 +13,8 @@ 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
|
||||
@@ -81,10 +79,8 @@ 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
|
||||
|
||||
@@ -33,10 +33,8 @@ 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -45,10 +45,8 @@ 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
|
||||
|
||||
@@ -88,6 +88,9 @@ issue_details.json
|
||||
.internal
|
||||
.praisonai
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
|
||||
# Rust / Cargo
|
||||
target/
|
||||
Cargo.lock
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# 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/
|
||||
@@ -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 under 4μ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 around 14μs. This reduces overhead, improves responsiveness, and helps multi-agent systems scale efficiently in real-world production workloads.
|
||||
|
||||
| Performance Metric | PraisonAI |
|
||||
|--------------------|-----------|
|
||||
| Avg Instantiation Time | **3.77 μs** |
|
||||
| Avg Instantiation Time | **14 μs** |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -59,6 +59,8 @@ 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>
|
||||
@@ -240,6 +242,7 @@ 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>
|
||||
@@ -515,10 +518,12 @@ 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>
|
||||
@@ -527,6 +532,7 @@ 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
|
||||
|
||||
@@ -609,7 +615,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) -> 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, tool_call_id: str = None, run_id: str = None) -> str</code>
|
||||
|
||||
# UI
|
||||
|
||||
@@ -698,7 +704,7 @@ Methods:
|
||||
|
||||
Types:
|
||||
```python
|
||||
from praisonaiagents import AgentAppConfig, AgentAppProtocol, AgentFlow, AgentManager, AgentOSConfig, AgentOSProtocol, AgentTeam, AutoApproveBackend, EmbeddingResult, aembedding, aembeddings, embedding, embeddings, get_dimensions
|
||||
from praisonaiagents import AgentAppConfig, AgentAppProtocol, AgentFlow, AgentManager, AgentOSConfig, AgentOSProtocol, AgentTeam, AutoApproveBackend, EmbeddingResult, __version__, aembedding, aembeddings, embedding, embeddings, get_dimensions
|
||||
```
|
||||
|
||||
Methods:
|
||||
@@ -789,11 +795,17 @@ 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>
|
||||
@@ -834,6 +846,8 @@ 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>
|
||||
@@ -846,6 +860,9 @@ 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>
|
||||
@@ -880,10 +897,12 @@ 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>
|
||||
@@ -914,6 +933,9 @@ 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>
|
||||
@@ -1004,6 +1026,14 @@ 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>
|
||||
@@ -1302,7 +1332,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, getRegistry, getTool, getToolsRegistry, get_registry, get_tool, registerTool, register_tool, resetToolsRegistry, validateTool, validate_tool } from "./tools/registry";
|
||||
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 type { InstallHints, PraisonTool, RedactionHooks, RegisteredTool, ToolCapabilities, ToolExecutionContext, ToolExecutionResult, ToolFactory, ToolHooks, ToolInstallStatus, ToolLimits, ToolLogger, ToolMetadata, ToolMiddleware, ToolParameterProperty, ToolParameterSchema } from "./tools/registry";
|
||||
export { registerBuiltinTools, tools } from "./tools/tools";
|
||||
export { // Classes
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.55" \
|
||||
"praisonai>=4.6.68" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.55" \
|
||||
"praisonai>=4.6.68" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.55" \
|
||||
"praisonai>=4.6.68" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Example of using LLMConfig with Agent for model fallback configuration."""
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, 'src/praisonai-agents')
|
||||
|
||||
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()
|
||||
@@ -32,7 +32,7 @@ workflow = AgentFlow(
|
||||
),
|
||||
],
|
||||
# Workflow-level consolidated params
|
||||
output=WorkflowOutputConfig(output="verbose", stream=True),
|
||||
output=WorkflowOutputConfig(verbose=True, stream=True),
|
||||
planning=WorkflowPlanningConfig(enabled=True, reasoning=True),
|
||||
memory=WorkflowMemoryConfig(backend="file"),
|
||||
# Agent-like params
|
||||
|
||||
@@ -5,12 +5,12 @@ Demonstrates web search with consolidated params.
|
||||
Presets: duckduckgo, tavily, google, bing, serper, search_only, fetch_only
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents import Agent, WebConfig
|
||||
|
||||
# Basic: Enable web search with preset
|
||||
# Basic: Enable web search with preset (search only — fetch needs Claude)
|
||||
agent = Agent(
|
||||
instructions="You are a research assistant with web access.",
|
||||
web="duckduckgo", # Presets: duckduckgo, tavily, google, bing, serper
|
||||
web=WebConfig(search=True, fetch=False, search_provider="duckduckgo"),
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -6,10 +6,18 @@ It includes self-reflection for improving code quality and multiple specialized
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, Task, AgentTeam
|
||||
from praisonaiagents.tools import CodeInterpreter
|
||||
|
||||
# Initialize code execution tool
|
||||
code_interpreter = 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}"
|
||||
|
||||
# Custom code analysis tool
|
||||
def analyze_code_complexity(code: str) -> str:
|
||||
@@ -32,9 +40,7 @@ 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.",
|
||||
reflection=True,
|
||||
|
||||
|
||||
output="silent",
|
||||
)
|
||||
|
||||
code_reviewer = Agent(
|
||||
@@ -52,7 +58,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=[code_interpreter]
|
||||
tools=[run_python_code]
|
||||
)
|
||||
|
||||
# Example tasks
|
||||
@@ -87,7 +93,7 @@ def demonstrate_code_agent():
|
||||
workflow = AgentTeam(
|
||||
agents=[code_writer, code_reviewer, code_executor],
|
||||
tasks=[write_task, review_task, execute_task],
|
||||
process="sequential", output="verbose"
|
||||
process="sequential", output="silent"
|
||||
)
|
||||
|
||||
# Run the workflow
|
||||
@@ -106,10 +112,8 @@ general_code_agent = Agent(
|
||||
3. Explain code concepts
|
||||
4. Refactor code for better performance
|
||||
5. Add tests and documentation""",
|
||||
tools=[code_interpreter, analyze_code_complexity],
|
||||
reflection=True,
|
||||
|
||||
|
||||
tools=[run_python_code, analyze_code_complexity],
|
||||
output="silent",
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -41,21 +41,25 @@ def calculate_statistics(numbers: list) -> str:
|
||||
"""Calculate basic statistics for a list of numbers."""
|
||||
if not numbers:
|
||||
return "No numbers provided"
|
||||
|
||||
n = len(numbers)
|
||||
mean = sum(numbers) / n
|
||||
sorted_nums = sorted(numbers)
|
||||
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)
|
||||
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 numbers) / n
|
||||
variance = sum((x - mean)**2 for x in nums) / n
|
||||
std_dev = math.sqrt(variance)
|
||||
|
||||
|
||||
return f"""Statistics:
|
||||
- Count: {n}
|
||||
- Mean: {mean:.4f}
|
||||
- Median: {median:.4f}
|
||||
- Standard Deviation: {std_dev:.4f}
|
||||
- Min: {min(numbers)}
|
||||
- Max: {max(numbers)}"""
|
||||
- Min: {min(nums)}
|
||||
- Max: {max(nums)}"""
|
||||
|
||||
# Create specialized math agents
|
||||
calculation_agent = Agent(
|
||||
@@ -66,8 +70,7 @@ 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(
|
||||
@@ -82,8 +85,7 @@ 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(
|
||||
@@ -91,7 +93,8 @@ 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."
|
||||
instructions="Explain mathematical concepts using simple language, provide examples, and check for understanding.",
|
||||
output="silent",
|
||||
)
|
||||
|
||||
# Example workflow for solving complex problems
|
||||
@@ -126,7 +129,8 @@ 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="verbose"
|
||||
process="sequential",
|
||||
output="silent",
|
||||
)
|
||||
|
||||
return workflow.start()
|
||||
@@ -148,8 +152,7 @@ math_assistant = Agent(
|
||||
Always show your work step by step.""",
|
||||
tools=[basic_calculator, solve_quadratic, calculate_statistics],
|
||||
reflection=True,
|
||||
|
||||
|
||||
output="silent",
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/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,7 +6,6 @@ to different next steps based on output content.
|
||||
"""
|
||||
|
||||
from praisonaiagents import AgentFlow, Task
|
||||
from praisonaiagents import AgentFlowManager
|
||||
|
||||
# Create a workflow with branching
|
||||
workflow = AgentFlow(
|
||||
@@ -36,16 +35,9 @@ workflow = AgentFlow(
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 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"
|
||||
)
|
||||
|
||||
result = workflow.run("", llm="gpt-4o-mini", verbose=True)
|
||||
print("Workflow completed!")
|
||||
for step_result in result["results"]:
|
||||
print(f" {step_result['step']}: {step_result['status']}")
|
||||
for step in result.get("steps", []):
|
||||
name = step.get("step", step.get("name", "unknown"))
|
||||
status = step.get("status", "completed")
|
||||
print(f" {name}: {status}")
|
||||
@@ -6,9 +6,8 @@ functions that return StepResult with stop_workflow=True.
|
||||
"""
|
||||
|
||||
from praisonaiagents import (
|
||||
Workflow, Task, WorkflowContext, StepResult
|
||||
AgentFlow, Task, WorkflowContext, StepResult
|
||||
)
|
||||
from praisonaiagents import AgentFlowManager
|
||||
|
||||
# Custom validator that can stop the workflow
|
||||
def validate_data(context: WorkflowContext) -> StepResult:
|
||||
@@ -50,20 +49,13 @@ 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 = 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)
|
||||
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'))}")
|
||||
|
||||
print("\n=== Testing with valid data ===")
|
||||
workflow.variables["data"] = {"value": 42}
|
||||
result = manager.execute("Data Processing", default_llm="gpt-4o-mini")
|
||||
|
||||
for step_result in result["results"]:
|
||||
print(f" {step_result['step']}: {step_result['status']}")
|
||||
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')}")
|
||||
@@ -6,7 +6,6 @@ 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(
|
||||
@@ -30,18 +29,12 @@ workflow = AgentFlow(
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 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"
|
||||
)
|
||||
|
||||
result = workflow.run("", llm="gpt-4o-mini", verbose=True)
|
||||
print("Workflow completed!")
|
||||
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")
|
||||
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")
|
||||
@@ -53,7 +53,7 @@ workflow = AgentFlow(
|
||||
# Step 4: Function - formats output
|
||||
format_output
|
||||
],
|
||||
default_llm="gpt-4o-mini" # Used for action-based steps
|
||||
llm="gpt-4o-mini" # Used for action-based steps
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -5,7 +5,7 @@ Demonstrates running multiple agents concurrently and
|
||||
combining their results with an aggregator agent.
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, Workflow
|
||||
from praisonaiagents import Agent, AgentFlow
|
||||
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, Workflow
|
||||
from praisonaiagents import Agent, AgentFlow
|
||||
from praisonaiagents.workflows import route
|
||||
|
||||
# Create classifier agent
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
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())
|
||||
@@ -1,6 +1,6 @@
|
||||
# Documentation Parity Tracker (Python)
|
||||
|
||||
> **Categories:** 60 | **Documented:** 57 | **Parity:** 95.0%
|
||||
> **Categories:** 64 | **Documented:** 61 | **Parity:** 95.3%
|
||||
|
||||
This report compares **Python SDK feature categories** against **Python documentation** (docs/concepts, docs/features, etc.).
|
||||
|
||||
@@ -8,72 +8,76 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Feature Categories | 60 |
|
||||
| **Documented Categories** | **57** |
|
||||
| Feature Categories | 64 |
|
||||
| **Documented Categories** | **61** |
|
||||
| **Undocumented Categories** | **3** |
|
||||
| **Parity** | **95.0%** |
|
||||
| **Parity** | **95.3%** |
|
||||
|
||||
## Documented Categories
|
||||
|
||||
| Category | Features | Docs | Lines |
|
||||
|----------|----------|------|-------|
|
||||
| ✅ AGUI | 1 | 2 | 420 |
|
||||
| ✅ Agent | 19 | 30 | 8367 |
|
||||
| ✅ Agent-to-Agent (A2A) | 1 | 3 | 625 |
|
||||
| ✅ Approval | 1 | 2 | 889 |
|
||||
| ✅ Agent | 21 | 34 | 9360 |
|
||||
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2880 |
|
||||
| ✅ Agent-to-User (A2U) | 1 | 3 | 547 |
|
||||
| ✅ Approval | 1 | 3 | 1283 |
|
||||
| ✅ Audio | 2 | 10 | 649 |
|
||||
| ✅ Auto Generation | 3 | 6 | 1386 |
|
||||
| ✅ Autonomy | 3 | 4 | 1146 |
|
||||
| ✅ Bots | 5 | 2 | 573 |
|
||||
| ✅ Auto Generation | 4 | 6 | 1724 |
|
||||
| ✅ Autonomy | 3 | 5 | 1919 |
|
||||
| ✅ Bots | 7 | 14 | 5228 |
|
||||
| ✅ Budget | 1 | 1 | 287 |
|
||||
| ✅ CLI | 5 | 98 | 24103 |
|
||||
| ✅ Chunking | 2 | 2 | 285 |
|
||||
| ✅ Citations | 2 | 1 | 202 |
|
||||
| ✅ Code Execution | 3 | 13 | 3395 |
|
||||
| ✅ Code Execution | 3 | 13 | 3551 |
|
||||
| ✅ Conditions | 1 | 3 | 1086 |
|
||||
| ✅ Configuration | 2 | 7 | 1753 |
|
||||
| ✅ Context Management | 13 | 29 | 7576 |
|
||||
| ✅ Database | 1 | 31 | 3037 |
|
||||
| ✅ Configuration | 3 | 7 | 1812 |
|
||||
| ✅ Context Management | 16 | 31 | 9294 |
|
||||
| ✅ Database | 1 | 34 | 4339 |
|
||||
| ✅ Display | 6 | 2 | 1175 |
|
||||
| ✅ Embeddings | 6 | 23 | 1966 |
|
||||
| ✅ 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 |
|
||||
| ✅ Evaluation | 1 | 6 | 2539 |
|
||||
| ✅ Events | 1 | 2 | 704 |
|
||||
| ✅ Execution | 3 | 3 | 1059 |
|
||||
| ✅ Files | 2 | 6 | 1100 |
|
||||
| ✅ Flow | 1 | 2 | 435 |
|
||||
| ✅ Gateway | 6 | 10 | 3924 |
|
||||
| ✅ Guardrails | 4 | 4 | 2008 |
|
||||
| ✅ Handoffs | 11 | 5 | 2155 |
|
||||
| ✅ Hooks | 2 | 6 | 1916 |
|
||||
| ✅ Image | 1 | 11 | 1096 |
|
||||
| ✅ 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 |
|
||||
| ✅ 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 |
|
||||
| ✅ Query | 1 | 2 | 616 |
|
||||
| ✅ RAG | 5 | 14 | 2910 |
|
||||
| ✅ RAG | 5 | 15 | 3190 |
|
||||
| ✅ Realtime | 2 | 5 | 584 |
|
||||
| ✅ Reflection | 3 | 2 | 469 |
|
||||
| ✅ Retrieval | 2 | 5 | 941 |
|
||||
| ✅ Retrieval | 2 | 5 | 944 |
|
||||
| ✅ Routing | 1 | 2 | 597 |
|
||||
| ✅ Sandbox | 5 | 3 | 1002 |
|
||||
| ✅ Security | 1 | 1 | 875 |
|
||||
| ✅ Sessions | 2 | 8 | 2128 |
|
||||
| ✅ Skills | 5 | 5 | 2002 |
|
||||
| ✅ Tasks | 2 | 5 | 2590 |
|
||||
| ✅ Telemetry | 1 | 2 | 541 |
|
||||
| ✅ Sandbox | 5 | 6 | 2369 |
|
||||
| ✅ Security | 1 | 3 | 2518 |
|
||||
| ✅ Sessions | 4 | 9 | 3826 |
|
||||
| ✅ Skills | 5 | 9 | 3415 |
|
||||
| ✅ Tasks | 2 | 6 | 3064 |
|
||||
| ✅ Telemetry | 1 | 2 | 560 |
|
||||
| ✅ Templates | 1 | 7 | 1411 |
|
||||
| ✅ Tools | 4 | 97 | 17827 |
|
||||
| ✅ Tools | 12 | 113 | 23809 |
|
||||
| ✅ Tracing | 3 | 2 | 139 |
|
||||
| ✅ Video | 2 | 5 | 460 |
|
||||
| ✅ Video | 2 | 6 | 510 |
|
||||
| ✅ Vision | 2 | 1 | 283 |
|
||||
| ✅ Web | 4 | 6 | 1267 |
|
||||
| ✅ Workflows | 4 | 9 | 3579 |
|
||||
| ✅ Web | 4 | 6 | 1297 |
|
||||
| ✅ Workflows | 4 | 11 | 4302 |
|
||||
|
||||
## Stub Documentation (Need Content)
|
||||
|
||||
@@ -94,21 +98,18 @@ These categories have documentation files but < 50 lines (stubs):
|
||||
|
||||
These docs exist but don't match any implemented feature category:
|
||||
|
||||
- ℹ️ 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)
|
||||
- ℹ️ AI SDK (2 docs, 527 lines)
|
||||
- ℹ️ Callbacks (1 docs, 1122 lines)
|
||||
- ℹ️ Chat (3 docs, 634 lines)
|
||||
- ℹ️ Deep Research (2 docs, 437 lines)
|
||||
- ℹ️ Deployment (29 docs, 5931 lines)
|
||||
- ℹ️ Jobs (1 docs, 358 lines)
|
||||
- ℹ️ Deployment (29 docs, 6013 lines)
|
||||
- ℹ️ Jobs (2 docs, 961 lines)
|
||||
- ℹ️ Middleware (1 docs, 60 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)
|
||||
- ℹ️ 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)
|
||||
- ℹ️ Vector Store (11 docs, 733 lines)
|
||||
|
||||
---
|
||||
|
||||
@@ -1,35 +1,70 @@
|
||||
# PraisonAI Agents - Benchmark Results
|
||||
|
||||
**Generated:** 2025-12-18 14:40:22
|
||||
**Iterations:** 100
|
||||
**Test:** Agent instantiation (without tools)
|
||||
**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")`
|
||||
|
||||
## Results
|
||||
## Agent Instantiation Time
|
||||
|
||||
| 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 |
|
||||
| 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) |
|
||||
|
||||
## Package Versions
|
||||
|
||||
| Package | Version |
|
||||
|---------|--------|
|
||||
| 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 |
|
||||
|---------|---------|
|
||||
| PraisonAI | 1.6.60 |
|
||||
| Agno | 2.4.2 |
|
||||
| OpenAI Agents SDK | 0.7.0 |
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
PraisonAI-only (fast; avoids CrewAI/LangGraph import hang):
|
||||
|
||||
```bash
|
||||
cd praisonai-agents
|
||||
python benchmarks/simple_benchmark.py
|
||||
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
|
||||
```
|
||||
@@ -0,0 +1,62 @@
|
||||
# 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
|
||||
('llm', 'gpt-4o-mini', 'llm="gpt-4o-mini"'),
|
||||
('llm', 'openai/gpt-4o-mini', 'llm="openai/gpt-4o-mini" (LiteLLM)'),
|
||||
('model', 'gpt-4o-mini', 'model="gpt-4o-mini"'),
|
||||
('model', 'openai/gpt-4o-mini', 'model="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 `llm="gpt-4o-mini"` (OpenAI SDK) for fastest instantiation\n')
|
||||
f.write('3. Use `model="gpt-4o-mini"` 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.",
|
||||
llm="gpt-4o-mini",
|
||||
model="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.",
|
||||
llm="openai/gpt-4o-mini",
|
||||
model="openai/gpt-4o-mini",
|
||||
tools=tools,
|
||||
output="silent"
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ import argparse
|
||||
|
||||
|
||||
ITERATIONS = 100
|
||||
WARMUP = 10
|
||||
|
||||
|
||||
def sample_tool(city: Literal['nyc', 'sf']):
|
||||
@@ -23,8 +24,10 @@ def sample_tool(city: Literal['nyc', 'sf']):
|
||||
return 'sunny'
|
||||
|
||||
|
||||
def measure_instantiation(create_fn, iterations=ITERATIONS):
|
||||
def measure_instantiation(create_fn, iterations=ITERATIONS, warmup=WARMUP):
|
||||
"""Measure average instantiation time in microseconds."""
|
||||
for _ in range(warmup):
|
||||
create_fn()
|
||||
times = []
|
||||
for _ in range(iterations):
|
||||
start = time.perf_counter()
|
||||
@@ -48,11 +51,11 @@ def run_benchmark():
|
||||
from praisonaiagents import Agent as PraisonAgent
|
||||
|
||||
results['PraisonAI'] = measure_instantiation(
|
||||
lambda: PraisonAgent(name='Test', llm='gpt-4o-mini', output="silent")
|
||||
lambda: PraisonAgent(name='Test', model='gpt-4o-mini', output="silent")
|
||||
)
|
||||
|
||||
results['PraisonAI (LiteLLM)'] = measure_instantiation(
|
||||
lambda: PraisonAgent(name='Test', llm='openai/gpt-4o-mini', output="silent")
|
||||
lambda: PraisonAgent(name='Test', model='openai/gpt-4o-mini', output="silent")
|
||||
)
|
||||
|
||||
# Other frameworks for comparison
|
||||
|
||||
@@ -114,6 +114,9 @@ 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'),
|
||||
@@ -473,7 +476,7 @@ _LAZY_IMPORTS = {
|
||||
'GatewayClientProtocol': ('praisonaiagents.gateway.protocols', 'GatewayClientProtocol'),
|
||||
'GatewayEvent': ('praisonaiagents.gateway.protocols', 'GatewayEvent'),
|
||||
'GatewayMessage': ('praisonaiagents.gateway.protocols', 'GatewayMessage'),
|
||||
'EventType': ('praisonaiagents.gateway.protocols', 'EventType'),
|
||||
'GatewayEventType': ('praisonaiagents.gateway.protocols', 'EventType'),
|
||||
'GatewayConfig': ('praisonaiagents.gateway.config', 'GatewayConfig'),
|
||||
'SessionConfig': ('praisonaiagents.gateway.config', 'SessionConfig'),
|
||||
|
||||
@@ -775,6 +778,9 @@ def warmup(include_litellm: bool = False, include_openai: bool = True) -> dict:
|
||||
# ============================================================================
|
||||
|
||||
__all__ = [
|
||||
# Version information
|
||||
'__version__',
|
||||
|
||||
# Core classes - the essentials
|
||||
'Agent',
|
||||
'AgentTeam', # Primary class for multi-agent coordination (v1.0+)
|
||||
|
||||
Whitespace-only changes.
@@ -111,32 +111,3 @@ 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}"
|
||||
)
|
||||
@@ -1,49 +0,0 @@
|
||||
"""
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
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,6 +7,7 @@ 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
|
||||
@@ -16,7 +17,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
|
||||
from .tool_execution import ToolExecutionMixin, BackoffPolicy
|
||||
from .chat_handler import ChatHandlerMixin
|
||||
from .session_manager import SessionManagerMixin
|
||||
from .async_safety import AsyncSafeState
|
||||
@@ -313,6 +314,38 @@ 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."""
|
||||
@@ -518,7 +551,7 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
|
||||
backstory: Optional[str] = None,
|
||||
instructions: Optional[str] = None,
|
||||
# LLM configuration
|
||||
llm: Optional[Union[str, Any]] = None,
|
||||
llm: Optional[Union[str, Any]] = None, # Can be string, dict, or LLMConfig object
|
||||
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)
|
||||
@@ -556,7 +589,8 @@ 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")
|
||||
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
|
||||
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
|
||||
@@ -570,9 +604,10 @@ 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") or LLM object.
|
||||
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"]).
|
||||
Defaults to OPENAI_MODEL_NAME env var or "gpt-4o-mini".
|
||||
model: Alias for llm parameter.
|
||||
model: Alias for llm parameter. Also accepts LLMConfig objects.
|
||||
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.
|
||||
@@ -657,13 +692,15 @@ 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: 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.
|
||||
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.
|
||||
tool_search: Progressive tool disclosure configuration. Accepts:
|
||||
- bool: False=disabled (default), True=auto mode
|
||||
- str: Mode ("auto", "on", "off")
|
||||
@@ -718,16 +755,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
|
||||
from ..config.loader import apply_config_defaults, get_default, _defaults_has_any_values
|
||||
|
||||
# Apply config defaults for LLM if not explicitly set
|
||||
if llm is None and model is None:
|
||||
if llm is None and model is None and _defaults_has_any_values():
|
||||
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:
|
||||
if base_url is None and _defaults_has_any_values():
|
||||
config_base_url = get_default("base_url")
|
||||
if config_base_url:
|
||||
base_url = config_base_url
|
||||
@@ -989,7 +1026,7 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
|
||||
_on_budget_exceeded = 'stop'
|
||||
# Default to False when no ExecutionConfig provided
|
||||
parallel_tool_calls = False
|
||||
# (already set from parameter, no need to override)
|
||||
_exec_config = ExecutionConfig() # Default config for non-execution case
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Resolve TEMPLATES param - FAST PATH
|
||||
@@ -1392,7 +1429,7 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
|
||||
# String could be LLM prompt - passthrough for later processing
|
||||
_guardrails_config = guardrails
|
||||
else:
|
||||
from .._resolver_helpers import resolve_guardrails as _resolve_guardrails
|
||||
from ..config.param_resolver import resolve_guardrails as _resolve_guardrails
|
||||
_guardrails_config = _resolve_guardrails(
|
||||
value=guardrails,
|
||||
config_class=GuardrailConfig,
|
||||
@@ -1478,10 +1515,8 @@ 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 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))
|
||||
# Initialize loop guard lazily on first tool execution (zero init overhead)
|
||||
self._loop_guard = None
|
||||
|
||||
# If instructions are provided, use them to set role, goal, and backstory
|
||||
if instructions:
|
||||
@@ -1527,6 +1562,8 @@ 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
|
||||
|
||||
@@ -1541,7 +1578,22 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
|
||||
|
||||
# Handle llm= deprecation: model= is the preferred parameter name
|
||||
# llm= still works but shows deprecation warning
|
||||
if llm is not None and model is None:
|
||||
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:
|
||||
warn_deprecated_param(
|
||||
"llm",
|
||||
since="1.0.0",
|
||||
@@ -1549,9 +1601,23 @@ 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:
|
||||
llm = model # model= takes precedence
|
||||
# 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
|
||||
|
||||
# Store rate limiter (optional, zero overhead when None)
|
||||
self._rate_limiter = rate_limiter
|
||||
@@ -1565,102 +1631,74 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
|
||||
self.base_url = base_url
|
||||
self.api_key = api_key
|
||||
|
||||
# If base_url is provided, always create a custom LLM instance
|
||||
# If base_url is provided, defer custom LLM instance creation
|
||||
if base_url:
|
||||
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 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
|
||||
# If the user passes a dictionary (for advanced configuration)
|
||||
elif isinstance(llm, dict) and "model" in llm:
|
||||
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)
|
||||
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
|
||||
elif isinstance(llm, str) and "/" in llm:
|
||||
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
|
||||
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
|
||||
# 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
|
||||
@@ -1760,6 +1798,8 @@ 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
|
||||
@@ -1855,20 +1895,24 @@ 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)
|
||||
@@ -1898,17 +1942,21 @@ 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,
|
||||
@@ -2032,10 +2080,26 @@ Your Goal: {self.goal}
|
||||
# Backend - external managed agent backend for hybrid execution
|
||||
self.backend = backend
|
||||
|
||||
# CLI Backend - external CLI backend for delegating full turns
|
||||
# CLI Backend - external CLI backend for delegating full turns (DEPRECATED)
|
||||
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
|
||||
@@ -2103,6 +2167,7 @@ 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),
|
||||
@@ -4954,6 +5019,22 @@ 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."
|
||||
@@ -4991,6 +5072,22 @@ 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."
|
||||
@@ -5079,17 +5176,163 @@ Answer:"""
|
||||
|
||||
raise TypeError(f"Invalid cli_backend type: {type(cli_backend)}. Expected CliBackendProtocol instance or factory callable.")
|
||||
|
||||
async def _chat_via_cli_backend(self, prompt: str, **kwargs) -> Optional[str]:
|
||||
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]:
|
||||
"""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
|
||||
"""
|
||||
if not self._cli_backend:
|
||||
# Use provided backend or fall back to instance backend
|
||||
backend = cli_backend or self._cli_backend
|
||||
if not backend:
|
||||
raise RuntimeError("CLI backend not configured")
|
||||
|
||||
try:
|
||||
@@ -5128,7 +5371,7 @@ Answer:"""
|
||||
images = None
|
||||
|
||||
# Execute CLI backend
|
||||
result = await self._cli_backend.execute(
|
||||
result = await backend.execute(
|
||||
prompt=prompt,
|
||||
session=session_binding,
|
||||
images=images,
|
||||
|
||||
@@ -87,12 +87,41 @@ Your Goal: {self.goal}"""
|
||||
|
||||
# Add memory context if memory is enabled
|
||||
if self._memory_instance:
|
||||
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()
|
||||
# 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()
|
||||
|
||||
# Add learn context if learn is enabled (auto-inject when memory="learn")
|
||||
learn_context = self.get_learn_context()
|
||||
@@ -106,6 +135,60 @@ 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
|
||||
@@ -145,10 +228,7 @@ Your Goal: {self.goal}"""
|
||||
except ImportError:
|
||||
pass # Trust module not available, skip security instructions
|
||||
|
||||
# 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)
|
||||
# Note: Caching is done BEFORE session context injection to avoid cross-user leakage
|
||||
return system_prompt
|
||||
|
||||
def _build_response_format(self, schema_model):
|
||||
@@ -452,6 +532,17 @@ 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,
|
||||
@@ -869,7 +960,22 @@ Your Goal: {self.goal}"""
|
||||
|
||||
return CompactionRoute.TRUNCATE_TOOLS, truncated_msgs
|
||||
|
||||
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):
|
||||
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):
|
||||
start_time = time.time()
|
||||
|
||||
# --- Proactive Context Budget Management (default-on) ---
|
||||
@@ -1112,7 +1218,8 @@ 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
|
||||
_retry_depth=_retry_depth + 1,
|
||||
_fallback_index=_fallback_index
|
||||
)
|
||||
except Exception as compression_error:
|
||||
logging.error(f"[{self.name}] Context compression failed: {compression_error}")
|
||||
@@ -1123,9 +1230,34 @@ Your Goal: {self.goal}"""
|
||||
# Don't retry without actual credential rotation
|
||||
|
||||
elif classification.should_fallback_model:
|
||||
# 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
|
||||
# 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
|
||||
|
||||
elif classification.is_retryable and classification.backoff_seconds > 0:
|
||||
if _retry_depth < 2: # Limit retry attempts
|
||||
@@ -1134,7 +1266,8 @@ 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
|
||||
_retry_depth=_retry_depth + 1,
|
||||
_fallback_index=_fallback_index
|
||||
)
|
||||
|
||||
# Include remediation hints for unimplemented recovery actions
|
||||
@@ -1142,7 +1275,10 @@ Your Goal: {self.goal}"""
|
||||
if classification.should_rotate_credential:
|
||||
user_message += " Credential rotation is not yet implemented."
|
||||
if classification.should_fallback_model:
|
||||
user_message += " Model fallback is not yet implemented."
|
||||
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."
|
||||
|
||||
# Create LLMError with classification context
|
||||
error = LLMError(
|
||||
@@ -1180,7 +1316,8 @@ Your Goal: {self.goal}"""
|
||||
response_format=None,
|
||||
stream_callback=None,
|
||||
emit_events=True,
|
||||
_retry_depth=0
|
||||
_retry_depth=0,
|
||||
_fallback_index=0
|
||||
):
|
||||
"""
|
||||
Handle LLM errors in async context using structured classification.
|
||||
@@ -1245,7 +1382,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
|
||||
stream_callback, emit_events, _retry_depth + 1, _fallback_index
|
||||
)
|
||||
except Exception as compression_error:
|
||||
logging.error(f"[{self.name}] Context compression failed: {compression_error}")
|
||||
@@ -1256,9 +1393,39 @@ Your Goal: {self.goal}"""
|
||||
# Don't retry without actual credential rotation
|
||||
|
||||
elif classification.should_fallback_model:
|
||||
# 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
|
||||
# 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
|
||||
|
||||
elif classification.is_retryable and classification.backoff_seconds > 0:
|
||||
if _retry_depth < 2: # Limit retry attempts
|
||||
@@ -1275,7 +1442,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
|
||||
stream_callback, emit_events, _retry_depth + 1, _fallback_index
|
||||
)
|
||||
|
||||
# Include remediation hints for unimplemented recovery actions
|
||||
@@ -1283,7 +1450,10 @@ Your Goal: {self.goal}"""
|
||||
if classification.should_rotate_credential:
|
||||
user_message += " Credential rotation is not yet implemented."
|
||||
if classification.should_fallback_model:
|
||||
user_message += " Model fallback is not yet implemented."
|
||||
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."
|
||||
|
||||
# Create LLMError with classification context
|
||||
error = LLMError(
|
||||
@@ -1327,7 +1497,7 @@ Your Goal: {self.goal}"""
|
||||
"""
|
||||
Execute unified chat completion using composition instead of runtime class mutation.
|
||||
|
||||
This method provides the same functionality as UnifiedChatMixin but uses
|
||||
This method provides a unified dispatch for all LLM providers using
|
||||
composition for safety and maintainability.
|
||||
"""
|
||||
from ..llm import create_llm_dispatcher
|
||||
@@ -1451,7 +1621,7 @@ Your Goal: {self.goal}"""
|
||||
"""
|
||||
Execute unified async chat completion using composition instead of runtime class mutation.
|
||||
|
||||
This method provides the same functionality as UnifiedChatMixin but uses
|
||||
This method provides a unified async dispatch for all LLM providers using
|
||||
composition for safety and maintainability.
|
||||
"""
|
||||
from ..llm import create_llm_dispatcher
|
||||
@@ -1836,7 +2006,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) -> str:
|
||||
def _truncate_tool_output(self, tool_name: str, output: str, tool_call_id: str | None = None) -> str:
|
||||
"""
|
||||
Truncate tool output according to configured budget.
|
||||
|
||||
@@ -1845,6 +2015,7 @@ 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
|
||||
@@ -1853,7 +2024,8 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
return output
|
||||
|
||||
try:
|
||||
return self.context_manager.truncate_tool_output(tool_name, output)
|
||||
run_id = getattr(self, '_run_id', None)
|
||||
return self.context_manager.truncate_tool_output(tool_name, output, tool_call_id, run_id)
|
||||
except Exception as e:
|
||||
logging.warning(f"Tool output truncation error: {e}")
|
||||
return output
|
||||
@@ -2496,8 +2668,9 @@ 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 custom LLMs during reflection, always use non-streaming to ensure complete responses
|
||||
use_stream = self.stream if not self._using_custom_llm else False
|
||||
# For reflection, always use non-streaming to ensure compatibility with sync adapters
|
||||
# and to avoid streaming complexity during regeneration process
|
||||
use_stream = 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 ""
|
||||
|
||||
@@ -317,8 +317,9 @@ class MemoryMixin:
|
||||
return
|
||||
|
||||
try:
|
||||
from ..session import get_default_session_store
|
||||
self._session_store = get_default_session_store()
|
||||
if self._session_store is None:
|
||||
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)
|
||||
|
||||
@@ -5,15 +5,10 @@ Provides safe code execution capabilities to agents via the sandbox framework.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, Union
|
||||
from typing import Optional, Dict, Any, Union, TYPE_CHECKING
|
||||
|
||||
from ..sandbox import (
|
||||
SandboxConfig,
|
||||
SandboxManager,
|
||||
SandboxResult,
|
||||
check_code_safety,
|
||||
format_warnings,
|
||||
)
|
||||
if TYPE_CHECKING:
|
||||
from ..sandbox import SandboxConfig, SandboxManager, SandboxResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -26,27 +21,33 @@ class SandboxMixin:
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Extract sandbox config from kwargs
|
||||
self.sandbox_config: Optional[SandboxConfig] = kwargs.pop('sandbox', None)
|
||||
sandbox = kwargs.pop('sandbox', None)
|
||||
|
||||
# Convert boolean True to default config
|
||||
if self.sandbox_config is True:
|
||||
self.sandbox_config = SandboxConfig.subprocess()
|
||||
if sandbox is True:
|
||||
from ..sandbox import SandboxConfig
|
||||
sandbox = SandboxConfig.subprocess()
|
||||
elif sandbox is False:
|
||||
sandbox = None
|
||||
|
||||
self.sandbox_config: Optional['SandboxConfig'] = sandbox
|
||||
|
||||
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
|
||||
@@ -57,7 +58,7 @@ class SandboxMixin:
|
||||
language: str = "python",
|
||||
check_security: bool = True,
|
||||
**kwargs
|
||||
) -> SandboxResult:
|
||||
) -> 'SandboxResult':
|
||||
"""Execute code safely in configured sandbox.
|
||||
|
||||
Args:
|
||||
@@ -79,6 +80,7 @@ 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)
|
||||
@@ -97,7 +99,7 @@ class SandboxMixin:
|
||||
language: str = "python",
|
||||
check_security: bool = True,
|
||||
**kwargs
|
||||
) -> SandboxResult:
|
||||
) -> 'SandboxResult':
|
||||
"""Synchronous wrapper for execute_code.
|
||||
|
||||
Args:
|
||||
@@ -117,7 +119,7 @@ class SandboxMixin:
|
||||
command: Union[str, list],
|
||||
check_security: bool = True,
|
||||
**kwargs
|
||||
) -> SandboxResult:
|
||||
) -> 'SandboxResult':
|
||||
"""Run a shell command in the sandbox.
|
||||
|
||||
Args:
|
||||
@@ -139,6 +141,7 @@ 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,21 +7,10 @@ Reuses existing telemetry/token_collector for token tracking.
|
||||
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
from typing import Optional, List, Dict, Any, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
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.
|
||||
@@ -179,45 +168,3 @@ 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,6 +14,7 @@ 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
|
||||
@@ -24,6 +25,30 @@ 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."""
|
||||
|
||||
@@ -231,9 +256,10 @@ class ToolExecutionMixin:
|
||||
arguments.update(res.output.modified_data)
|
||||
|
||||
# Loop guard check - prevent tool execution loops with graduated response
|
||||
if hasattr(self, '_loop_guard') and self._loop_guard:
|
||||
if hasattr(self, '_ensure_loop_guard'):
|
||||
loop_guard = self._ensure_loop_guard()
|
||||
from ..escalation.loop_guard import GuardAction
|
||||
decision = self._loop_guard.check(function_name, arguments, is_pre_execution=True)
|
||||
decision = loop_guard.check(function_name, arguments, is_pre_execution=True)
|
||||
|
||||
if decision.action == GuardAction.WARN:
|
||||
# Inject warning into tool result so LLM sees guidance
|
||||
@@ -245,7 +271,6 @@ 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,
|
||||
@@ -273,32 +298,159 @@ class ToolExecutionMixin:
|
||||
if blocked_result is not None:
|
||||
result = blocked_result
|
||||
else:
|
||||
# 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}
|
||||
# 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:
|
||||
with with_injection_context(state):
|
||||
result = self._execute_tool_with_circuit_breaker(function_name, arguments)
|
||||
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)
|
||||
|
||||
# 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__}
|
||||
)
|
||||
|
||||
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}")
|
||||
|
||||
# Apply prompt injection protection for external tools
|
||||
# Zero-cost for trusted tools, wraps external content in security markers
|
||||
@@ -312,7 +464,7 @@ class ToolExecutionMixin:
|
||||
|
||||
if self.context_manager:
|
||||
# Use context-aware truncation with configured budget
|
||||
truncated = self._truncate_tool_output(function_name, result_str)
|
||||
truncated = self._truncate_tool_output(function_name, result_str, tool_call_id)
|
||||
else:
|
||||
# Apply default limit even without context management
|
||||
# This prevents runaway tool outputs from causing overflow
|
||||
@@ -323,6 +475,21 @@ 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
|
||||
|
||||
@@ -331,7 +498,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)
|
||||
result = self._truncate_dict_fields(result, function_name, max_field_chars, tool_call_id)
|
||||
else:
|
||||
result = truncated
|
||||
except Exception as e:
|
||||
@@ -373,6 +540,15 @@ 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(),
|
||||
@@ -382,6 +558,7 @@ 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)
|
||||
@@ -394,11 +571,12 @@ class ToolExecutionMixin:
|
||||
self._doom_loop_tracker.mark_progress(f"tool:{function_name}")
|
||||
|
||||
# Record tool execution in loop guard
|
||||
if hasattr(self, '_loop_guard') and self._loop_guard:
|
||||
if hasattr(self, '_ensure_loop_guard'):
|
||||
loop_guard = self._ensure_loop_guard()
|
||||
is_success = result is not None and not (isinstance(result, dict) and result.get('error'))
|
||||
self._loop_guard.record(function_name, arguments, is_success)
|
||||
loop_guard.record(function_name, arguments, is_success)
|
||||
# Handle warning injection for WARN decisions
|
||||
decision = self._loop_guard.check(function_name, arguments, is_pre_execution=False)
|
||||
decision = 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}"
|
||||
@@ -417,22 +595,20 @@ class ToolExecutionMixin:
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
# Emit tool call end with error
|
||||
# Emit tool call end with error for exceptions that escape the retry loop
|
||||
_duration_ms = (_time.time() - _tool_start_time) * 1000
|
||||
_trace_emitter.tool_call_end(self.name, function_name, None, _duration_ms, str(e))
|
||||
|
||||
# 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
|
||||
# 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
|
||||
|
||||
def _trigger_after_agent_hook(self, prompt, response, start_time, tools_used=None):
|
||||
"""Trigger AFTER_AGENT hook and return response."""
|
||||
@@ -531,7 +707,7 @@ class ToolExecutionMixin:
|
||||
response=response,
|
||||
)
|
||||
|
||||
def _truncate_dict_fields(self, data: dict, tool_name: str, max_field_chars: int = None) -> dict:
|
||||
def _truncate_dict_fields(self, data: dict, tool_name: str, max_field_chars: int = None, tool_call_id: str = 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)
|
||||
@@ -546,13 +722,30 @@ class ToolExecutionMixin:
|
||||
tail_limit = int(max_field_chars * 0.15)
|
||||
head = value[:head_limit]
|
||||
tail = value[-tail_limit:] if tail_limit > 0 else ""
|
||||
result[key] = f"{head}\n...[{len(value):,} chars, showing first/last portions]...\n{tail}"
|
||||
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
|
||||
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)
|
||||
result[key] = self._truncate_dict_fields(value, tool_name, max_field_chars, tool_call_id)
|
||||
elif isinstance(value, list):
|
||||
result[key] = [
|
||||
self._truncate_dict_fields(item, tool_name, max_field_chars) if isinstance(item, dict)
|
||||
self._truncate_dict_fields(item, tool_name, max_field_chars, tool_call_id) 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
|
||||
]
|
||||
@@ -584,12 +777,22 @@ 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:
|
||||
needs_approval = approve_all or tool_name in DEFAULT_DANGEROUS_TOOLS
|
||||
# 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
|
||||
)
|
||||
if needs_approval:
|
||||
request = ApprovalRequest(
|
||||
tool_name=tool_name,
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
"""
|
||||
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,12 +78,37 @@ 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, '_loop_guard') and self._loop_guard:
|
||||
self._loop_guard.reset_turn()
|
||||
if hasattr(self, '_ensure_loop_guard'):
|
||||
self._ensure_loop_guard().reset_turn()
|
||||
|
||||
try:
|
||||
# CLI Backend routing - delegate entire turn if configured
|
||||
if hasattr(self, '_cli_backend') and self._cli_backend is not None:
|
||||
# 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__)
|
||||
return await self._chat_via_cli_backend(
|
||||
prompt=prompt,
|
||||
temperature=temperature,
|
||||
|
||||
@@ -40,6 +40,21 @@ 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
|
||||
|
||||
|
||||
@@ -245,7 +260,20 @@ Expected Output: {task.expected_output}."""
|
||||
# Add memory context if available
|
||||
if task.memory:
|
||||
try:
|
||||
memory_context = task.memory.build_context_for_task(task.description)
|
||||
# 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)
|
||||
if memory_context:
|
||||
# Log detailed memory context for debugging
|
||||
logger.debug(f"Memory context for task '{task.description}': {memory_context}")
|
||||
@@ -652,7 +680,7 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
_stream = _output_config.stream
|
||||
else:
|
||||
_verbose = 0
|
||||
_stream = True
|
||||
_stream = False
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Resolve EXECUTION param using canonical resolver
|
||||
@@ -1028,6 +1056,65 @@ 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:
|
||||
@@ -1046,53 +1133,11 @@ 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):
|
||||
# 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
|
||||
# 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
|
||||
|
||||
task.status = "completed"
|
||||
# Run execute_callback for memory operations
|
||||
@@ -1106,20 +1151,6 @@ 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:
|
||||
@@ -1334,52 +1365,11 @@ 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):
|
||||
# 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
|
||||
# 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
|
||||
|
||||
task.status = "completed"
|
||||
# Run execute_callback for memory operations
|
||||
@@ -1389,20 +1379,6 @@ 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)
|
||||
|
||||
@@ -1993,6 +1969,8 @@ 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:
|
||||
@@ -2071,7 +2049,9 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
|
||||
# Add GET endpoint to list available agents
|
||||
@app.get(f"{path}/list")
|
||||
async def list_agents():
|
||||
async def list_agents(request: Request):
|
||||
if not _authorise_launch_request(request):
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
return {
|
||||
"agents": [
|
||||
{"name": agent.display_name, "id": agent.display_name.lower().replace(' ', '_')}
|
||||
@@ -2086,6 +2066,8 @@ 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,3 +53,4 @@ class AgentAppConfig:
|
||||
workers: int = 1
|
||||
timeout: int = 60
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
api_key: Optional[str] = None
|
||||
@@ -74,11 +74,15 @@ 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,9 +194,13 @@ class ApprovalRegistry:
|
||||
|
||||
def is_yaml_approved(self, tool_name: str) -> bool:
|
||||
try:
|
||||
return tool_name in self._yaml_approved_tools.get()
|
||||
if tool_name not in self._yaml_approved_tools.get():
|
||||
return False
|
||||
except LookupError:
|
||||
return False
|
||||
if self.get_risk_level(tool_name) == "critical":
|
||||
return False
|
||||
return True
|
||||
|
||||
# ── Env-var check ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -24,6 +24,19 @@ if TYPE_CHECKING:
|
||||
HealthResult,
|
||||
EmailProtocol,
|
||||
EmailInbox,
|
||||
SupportsPresentation,
|
||||
PlatformCapabilities,
|
||||
)
|
||||
from .presentation import (
|
||||
MessagePresentation,
|
||||
PresentationBlock,
|
||||
PresentationButton,
|
||||
PresentationAction,
|
||||
SelectOption,
|
||||
PresentationLimits,
|
||||
ActionType,
|
||||
ButtonStyle,
|
||||
BlockType,
|
||||
)
|
||||
from .protocols import (
|
||||
BotProtocol,
|
||||
@@ -41,6 +54,19 @@ from .protocols import (
|
||||
HealthResult,
|
||||
EmailProtocol,
|
||||
EmailInbox,
|
||||
SupportsPresentation,
|
||||
PlatformCapabilities,
|
||||
)
|
||||
from .presentation import (
|
||||
MessagePresentation,
|
||||
PresentationBlock,
|
||||
PresentationButton,
|
||||
PresentationAction,
|
||||
SelectOption,
|
||||
PresentationLimits,
|
||||
ActionType,
|
||||
ButtonStyle,
|
||||
BlockType,
|
||||
)
|
||||
from .config import BotConfig, BotOSConfig
|
||||
|
||||
@@ -62,4 +88,15 @@ __all__ = [
|
||||
"HealthResult",
|
||||
"EmailProtocol",
|
||||
"EmailInbox",
|
||||
"MessagePresentation",
|
||||
"PresentationBlock",
|
||||
"PresentationButton",
|
||||
"PresentationAction",
|
||||
"SelectOption",
|
||||
"PresentationLimits",
|
||||
"SupportsPresentation",
|
||||
"ActionType",
|
||||
"ButtonStyle",
|
||||
"BlockType",
|
||||
"PlatformCapabilities",
|
||||
]
|
||||
@@ -91,6 +91,16 @@ 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)
|
||||
@@ -101,6 +111,16 @@ 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"}:
|
||||
@@ -136,11 +156,17 @@ 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,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
"""
|
||||
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,6 +22,7 @@ from typing import (
|
||||
List,
|
||||
Optional,
|
||||
Protocol,
|
||||
TypedDict,
|
||||
Union,
|
||||
runtime_checkable,
|
||||
)
|
||||
@@ -30,6 +31,105 @@ 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."""
|
||||
|
||||
@@ -318,6 +418,80 @@ 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.
|
||||
@@ -331,6 +505,8 @@ 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
|
||||
@@ -341,6 +517,8 @@ 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."""
|
||||
@@ -353,6 +531,8 @@ 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,
|
||||
}
|
||||
|
||||
|
||||
@@ -388,6 +568,24 @@ 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)."""
|
||||
@@ -499,6 +697,33 @@ 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.
|
||||
@@ -613,6 +838,55 @@ 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
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
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,6 +170,17 @@ 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
|
||||
|
||||
@@ -194,6 +205,10 @@ 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)
|
||||
@@ -245,6 +260,15 @@ 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 {},
|
||||
@@ -321,6 +345,11 @@ 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,6 +33,10 @@ __all__ = [
|
||||
"CompactionStrategy",
|
||||
# Results
|
||||
"CompactionResult",
|
||||
# Protocols
|
||||
"ToolResultPrunerProtocol",
|
||||
"MessageFormatterProtocol",
|
||||
"SummaryBuilderProtocol",
|
||||
]
|
||||
|
||||
|
||||
@@ -54,4 +58,16 @@ 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,12 +5,13 @@ Manages context window by compacting messages when needed.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import List, Dict, Any, Optional, Callable, Awaitable
|
||||
from typing import List, Dict, Any, Optional, Callable, Awaitable, Tuple
|
||||
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:
|
||||
@@ -34,7 +35,10 @@ class ContextCompactor:
|
||||
preserve_system: bool = True,
|
||||
preserve_recent: int = 5,
|
||||
config: Optional[CompactionConfig] = None,
|
||||
llm_summarize_fn: Optional[Callable[[str], Awaitable[str]]] = 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
|
||||
):
|
||||
"""
|
||||
Initialize the compactor.
|
||||
@@ -45,30 +49,43 @@ class ContextCompactor:
|
||||
strategy: Compaction strategy to use
|
||||
preserve_system: Keep system messages
|
||||
preserve_recent: Number of recent messages to preserve
|
||||
<<<<<<< HEAD
|
||||
config: Optional CompactionConfig to override defaults
|
||||
config: Optional CompactionConfig for advanced settings
|
||||
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
|
||||
"""
|
||||
# Initialize config first
|
||||
self.config = config or CompactionConfig()
|
||||
# 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
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
# Set instance attributes from config to ensure consistency
|
||||
self.max_tokens = self.config.max_tokens
|
||||
self.target_tokens = self.config.target_tokens
|
||||
self.strategy = strategy
|
||||
self.preserve_system = self.config.preserve_system
|
||||
self.preserve_recent = self.config.preserve_recent
|
||||
self.llm_summarize_fn = llm_summarize_fn
|
||||
|
||||
# Track previous summaries for iterative update
|
||||
# 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
|
||||
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."""
|
||||
@@ -96,12 +113,26 @@ 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."""
|
||||
return self.count_total_tokens(messages) > self.max_tokens
|
||||
"""
|
||||
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
|
||||
|
||||
def compact(
|
||||
self,
|
||||
messages: List[Dict[str, Any]]
|
||||
messages: List[Dict[str, Any]],
|
||||
focus_topic: str = ""
|
||||
) -> tuple[List[Dict[str, Any]], CompactionResult]:
|
||||
"""
|
||||
Compact messages to fit within token limit (synchronous version).
|
||||
@@ -111,31 +142,49 @@ 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:
|
||||
return messages, CompactionResult(
|
||||
result = 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(messages)
|
||||
compacted = self._truncate(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.SLIDING:
|
||||
compacted = self._sliding_window(messages)
|
||||
compacted = self._sliding_window(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.SUMMARIZE:
|
||||
compacted = self._summarize(messages)
|
||||
compacted = self._summarize(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.SMART:
|
||||
compacted = self._smart_compact(messages)
|
||||
compacted = self._smart_compact(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.PRUNE:
|
||||
compacted = self._prune(messages)
|
||||
compacted = self._prune(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.LLM_SUMMARIZE:
|
||||
if self.llm_summarize_fn:
|
||||
# For sync calls with LLM function, we need to run async
|
||||
@@ -144,17 +193,17 @@ class ContextCompactor:
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
# If in async context, fallback to naive summarization
|
||||
compacted = self._summarize(messages)
|
||||
compacted = self._summarize(processed_messages)
|
||||
except RuntimeError:
|
||||
# No running loop, safe to create one
|
||||
compacted = asyncio.run(self._llm_summarize_async(messages))
|
||||
compacted = asyncio.run(self._llm_summarize_async(processed_messages, focus_topic))
|
||||
except Exception:
|
||||
# Fallback to naive summarization if async fails
|
||||
compacted = self._summarize(messages)
|
||||
compacted = self._summarize(processed_messages)
|
||||
else:
|
||||
compacted = self._llm_summarize(messages)
|
||||
compacted = self._llm_summarize(processed_messages, focus_topic)
|
||||
else:
|
||||
compacted = self._truncate(messages)
|
||||
compacted = self._truncate(processed_messages)
|
||||
|
||||
compacted_tokens = self.count_total_tokens(compacted)
|
||||
|
||||
@@ -163,49 +212,75 @@ class ContextCompactor:
|
||||
compacted_tokens=compacted_tokens,
|
||||
messages_removed=len(messages) - len(compacted),
|
||||
messages_kept=len(compacted),
|
||||
strategy_used=self.strategy
|
||||
strategy_used=self.strategy,
|
||||
tool_results_pruned=tool_results_pruned,
|
||||
previous_summary_reused=getattr(self, '_used_previous_summary', False)
|
||||
)
|
||||
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]]
|
||||
messages: List[Dict[str, Any]],
|
||||
focus_topic: str = ""
|
||||
) -> 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:
|
||||
return messages, CompactionResult(
|
||||
result = 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(messages)
|
||||
compacted = self._truncate(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.SLIDING:
|
||||
compacted = self._sliding_window(messages)
|
||||
compacted = self._sliding_window(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.SUMMARIZE:
|
||||
compacted = self._summarize(messages)
|
||||
compacted = self._summarize(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.SMART:
|
||||
compacted = self._smart_compact(messages)
|
||||
compacted = self._smart_compact(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.PRUNE:
|
||||
compacted = self._prune(messages)
|
||||
compacted = self._prune(processed_messages)
|
||||
elif self.strategy == CompactionStrategy.LLM_SUMMARIZE:
|
||||
compacted = await self._llm_summarize_async(messages)
|
||||
compacted = await self._llm_summarize_async(processed_messages, focus_topic)
|
||||
else:
|
||||
compacted = self._truncate(messages)
|
||||
compacted = self._truncate(processed_messages)
|
||||
|
||||
compacted_tokens = self.count_total_tokens(compacted)
|
||||
|
||||
@@ -214,11 +289,35 @@ class ContextCompactor:
|
||||
compacted_tokens=compacted_tokens,
|
||||
messages_removed=len(messages) - len(compacted),
|
||||
messages_kept=len(compacted),
|
||||
strategy_used=self.strategy
|
||||
strategy_used=self.strategy,
|
||||
tool_results_pruned=tool_results_pruned,
|
||||
previous_summary_reused=getattr(self, '_used_previous_summary', False)
|
||||
)
|
||||
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 = []
|
||||
@@ -244,11 +343,17 @@ class ContextCompactor:
|
||||
|
||||
# If still over limit, truncate more
|
||||
while self.count_total_tokens(result) > self.target_tokens and len(result) > 1:
|
||||
# Remove oldest non-system message
|
||||
# Remove oldest message (respecting preserve_system setting)
|
||||
removed = False
|
||||
for i, msg in enumerate(result):
|
||||
if msg.get("role") != "system":
|
||||
# Skip system messages only if preserve_system is True
|
||||
if not (self.preserve_system and 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
|
||||
|
||||
@@ -360,72 +465,19 @@ class ContextCompactor:
|
||||
result.extend(recent)
|
||||
return result
|
||||
|
||||
def _llm_summarize(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
def _llm_summarize(self, messages: List[Dict[str, Any]], focus_topic: str = "") -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Use LLM to summarize older messages with anti-injection framing.
|
||||
Use LLM to summarize older messages with iterative support.
|
||||
|
||||
Note: This implementation uses structured templates and anti-injection prefixes
|
||||
to prevent the model from treating summarized content as active instructions.
|
||||
"""
|
||||
result = []
|
||||
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.
|
||||
|
||||
# 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 []
|
||||
|
||||
if older:
|
||||
if self.config.structured_template:
|
||||
structured = self._build_structured_summary(older)
|
||||
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 structured:
|
||||
# Apply anti-injection prefix
|
||||
prefixed = f"{self.config.compaction_prefix}\n\n{structured}"
|
||||
|
||||
# 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}"
|
||||
|
||||
# Store for next iteration
|
||||
if self.config.iterative_update:
|
||||
self._previous_summary = structured
|
||||
|
||||
result.append({
|
||||
"role": "system",
|
||||
"content": prefixed,
|
||||
"_compacted": True,
|
||||
"_original_count": len(older),
|
||||
"_anti_injection": True,
|
||||
})
|
||||
|
||||
result.extend(recent)
|
||||
return result
|
||||
|
||||
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
|
||||
@@ -436,33 +488,169 @@ class ContextCompactor:
|
||||
|
||||
# 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]
|
||||
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:
|
||||
summary_parts.append(f"[{role}]: {content[:150]}...")
|
||||
|
||||
if summary_parts:
|
||||
focus_hint = f"\nFocus on: {focus_topic}" if focus_topic else ""
|
||||
|
||||
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])
|
||||
)
|
||||
|
||||
# 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}"
|
||||
|
||||
summary_msg = {
|
||||
"role": "system",
|
||||
"content": summary,
|
||||
"_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)
|
||||
|
||||
result.extend(recent)
|
||||
return result
|
||||
|
||||
async def _llm_summarize_async(self, messages: List[Dict[str, Any]], focus_topic: str = "") -> 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
|
||||
system_msgs = [m for m in messages if m.get("role") == "system"]
|
||||
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:]
|
||||
|
||||
# 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
|
||||
|
||||
if older and self.llm_summarize_fn:
|
||||
try:
|
||||
# Format messages for summarization
|
||||
history_text = self._format_messages_for_summary(older)
|
||||
# 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)
|
||||
|
||||
# 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. "
|
||||
"Focus on facts and actions taken, not general conversation.\n\n"
|
||||
f"{history_text}"
|
||||
f"Focus on facts and actions taken, not general conversation.{focus_hint}"
|
||||
f"{iterative_hint}\n\n{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": f"[Previous conversation summary]\n{summary}",
|
||||
"content": summary_content,
|
||||
"_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
|
||||
@@ -506,133 +694,41 @@ 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:
|
||||
"""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]}...")
|
||||
"""
|
||||
Delegate message formatting to protocol implementation.
|
||||
|
||||
return "\n".join(formatted)
|
||||
If no formatter is provided, returns basic string representation.
|
||||
"""
|
||||
if self.message_formatter:
|
||||
return self.message_formatter.format_for_summary(messages)
|
||||
return str(messages)
|
||||
|
||||
def _build_structured_summary(self, messages: List[Dict[str, Any]]) -> str:
|
||||
"""
|
||||
Build a structured summary using the configured template.
|
||||
Delegate structured summary building to protocol implementation.
|
||||
|
||||
Args:
|
||||
messages: Messages to summarize
|
||||
|
||||
Returns:
|
||||
Structured summary string
|
||||
If no summary builder is provided, returns basic summary.
|
||||
"""
|
||||
# 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"
|
||||
)
|
||||
if self.summary_builder:
|
||||
return self.summary_builder.build_structured_summary(messages)
|
||||
return f"Summary of {len(messages)} messages"
|
||||
|
||||
def _merge_summaries(self, previous: str, current: str) -> str:
|
||||
"""
|
||||
Merge previous summary with current one for iterative updates.
|
||||
Delegate summary merging to protocol implementation.
|
||||
|
||||
Args:
|
||||
previous: Previous summary content
|
||||
current: Current summary content
|
||||
|
||||
Returns:
|
||||
Merged summary
|
||||
If no summary builder is provided, returns current summary only.
|
||||
"""
|
||||
# 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}"
|
||||
if self.summary_builder:
|
||||
return self.summary_builder.merge_summaries(previous, current)
|
||||
return current
|
||||
|
||||
def get_stats(self, messages: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Get statistics about messages."""
|
||||
|
||||
@@ -50,3 +50,28 @@ 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")
|
||||
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
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,6 +18,16 @@ 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."""
|
||||
@@ -30,6 +40,13 @@ 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."""
|
||||
@@ -45,5 +62,9 @@ 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,6 +26,7 @@ __all__ = [
|
||||
"OutputPreset",
|
||||
"ExecutionPreset",
|
||||
# Config classes
|
||||
"LLMConfig",
|
||||
"MemoryConfig",
|
||||
"KnowledgeConfig",
|
||||
"PlanningConfig",
|
||||
@@ -110,6 +111,7 @@ _MODULE_MAP = {
|
||||
"WebSearchProvider": "feature_configs",
|
||||
"OutputPreset": "feature_configs",
|
||||
"ExecutionPreset": "feature_configs",
|
||||
"LLMConfig": "feature_configs",
|
||||
"MemoryConfig": "feature_configs",
|
||||
"KnowledgeConfig": "feature_configs",
|
||||
"PlanningConfig": "feature_configs",
|
||||
|
||||
@@ -724,6 +724,9 @@ 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
|
||||
@@ -766,7 +769,7 @@ class ExecutionConfig:
|
||||
parallel_tool_calls: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Post-initialization processing with deprecation warnings."""
|
||||
"""Post-initialization processing with deprecation warnings and validation."""
|
||||
# Handle context_compaction serialization round-trip
|
||||
if isinstance(self.context_compaction, dict):
|
||||
from ..context.policy import ContextCompactionPolicy
|
||||
@@ -774,29 +777,47 @@ class ExecutionConfig:
|
||||
self.context_compaction
|
||||
)
|
||||
|
||||
# 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
|
||||
# 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.")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
@@ -805,6 +826,9 @@ 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,
|
||||
@@ -843,6 +867,69 @@ 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:
|
||||
"""
|
||||
@@ -1089,8 +1176,8 @@ class MultiAgentOutputConfig:
|
||||
# Verbosity level (0=silent, 1=minimal, 2+=verbose)
|
||||
verbose: int = 0
|
||||
|
||||
# Streaming output
|
||||
stream: bool = True
|
||||
# Streaming output - False by default for sync multi-agent compatibility
|
||||
stream: bool = False
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
@@ -1265,79 +1352,49 @@ def resolve_memory(value: MemoryParam) -> Optional[MemoryConfig]:
|
||||
"""
|
||||
Resolve memory= parameter following precedence ladder.
|
||||
|
||||
Precedence: Instance > Config > Dict > String > Bool > Default
|
||||
Delegates to the canonical resolver in param_resolver.py with
|
||||
special handling for backward compatibility.
|
||||
|
||||
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')
|
||||
"""
|
||||
# Default: disabled
|
||||
if value is None:
|
||||
return None
|
||||
from .param_resolver import resolve_memory as _resolve
|
||||
|
||||
# Bool: False = disabled, True = defaults
|
||||
if value is False:
|
||||
return None
|
||||
if value is True:
|
||||
return MemoryConfig()
|
||||
|
||||
# String: backend shorthand
|
||||
# Special case: handle string backends not in MEMORY_PRESETS
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
backend = MemoryBackend(value.lower())
|
||||
# Try to resolve with canonical resolver first
|
||||
return _resolve(value, MemoryConfig)
|
||||
except ValueError:
|
||||
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):
|
||||
# Fall back to old behavior for custom/unknown backends
|
||||
try:
|
||||
backend = MemoryBackend(backend.lower())
|
||||
backend = MemoryBackend(value.lower())
|
||||
except ValueError:
|
||||
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"),
|
||||
)
|
||||
backend = value # Allow custom backend strings
|
||||
return MemoryConfig(backend=backend)
|
||||
|
||||
# Config: passthrough
|
||||
if isinstance(value, MemoryConfig):
|
||||
return value
|
||||
# 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
|
||||
|
||||
# Instance (MemoryManager): return as-is (caller handles)
|
||||
# This is the highest precedence - user provided a pre-configured instance
|
||||
return value
|
||||
# Default: use canonical resolver
|
||||
return _resolve(value, MemoryConfig)
|
||||
|
||||
|
||||
def resolve_knowledge(value: KnowledgeParam) -> Optional[KnowledgeConfig]:
|
||||
"""
|
||||
Resolve knowledge= parameter following precedence ladder.
|
||||
|
||||
Precedence: Instance > Config > Array > Dict > String > Bool > Default
|
||||
Delegates to the canonical resolver in param_resolver.py.
|
||||
Kept for backward compatibility with tests.
|
||||
|
||||
Args:
|
||||
value: Knowledge parameter in any supported form
|
||||
@@ -1345,164 +1402,91 @@ def resolve_knowledge(value: KnowledgeParam) -> Optional[KnowledgeConfig]:
|
||||
Returns:
|
||||
KnowledgeConfig if enabled, None if disabled
|
||||
"""
|
||||
# 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
|
||||
from .param_resolver import resolve_knowledge as _resolve
|
||||
return _resolve(value, KnowledgeConfig)
|
||||
|
||||
|
||||
def resolve_planning(value: PlanningParam) -> Optional[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
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
def resolve_reflection(value: ReflectionParam) -> Optional[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
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
def resolve_guardrails(value: GuardrailParam) -> Optional[GuardrailConfig]:
|
||||
"""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):
|
||||
"""
|
||||
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):
|
||||
return GuardrailConfig(validator=value)
|
||||
if isinstance(value, dict):
|
||||
return GuardrailConfig(**value)
|
||||
if isinstance(value, GuardrailConfig):
|
||||
return value
|
||||
return value
|
||||
|
||||
# Default: use canonical resolver
|
||||
return _resolve(value, GuardrailConfig)
|
||||
|
||||
|
||||
def resolve_web(value: WebParam) -> Optional[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
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
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."""
|
||||
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
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
def resolve_autonomy(value: AutonomyParam) -> Optional[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
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
def resolve_tool_search(value: ToolSearchParam) -> Optional[ToolSearchConfig]:
|
||||
"""Resolve tool_search= parameter following precedence ladder."""
|
||||
"""
|
||||
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
|
||||
if value is None or value is False:
|
||||
return None
|
||||
if value is True:
|
||||
@@ -1585,8 +1569,6 @@ __all__ = [
|
||||
"resolve_reflection",
|
||||
"resolve_guardrails",
|
||||
"resolve_web",
|
||||
"resolve_output",
|
||||
"resolve_execution",
|
||||
"resolve_caching",
|
||||
"resolve_autonomy",
|
||||
"resolve_tool_search",
|
||||
|
||||
@@ -83,6 +83,7 @@ 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():
|
||||
@@ -418,11 +419,27 @@ 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
|
||||
global _config_cache, _config_loaded, _defaults_has_values
|
||||
_config_cache = None
|
||||
_config_loaded = False
|
||||
_defaults_has_values = None
|
||||
|
||||
|
||||
def validate_config(config: Dict[str, Any], raise_on_error: bool = False) -> List[str]:
|
||||
@@ -603,6 +620,8 @@ 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:
|
||||
@@ -612,6 +631,10 @@ 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
|
||||
"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"},
|
||||
# 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"},
|
||||
# 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": True},
|
||||
"minimal": {"verbose": 1, "stream": True},
|
||||
"verbose": {"verbose": 2, "stream": False},
|
||||
"minimal": {"verbose": 1, "stream": False},
|
||||
"silent": {"verbose": 0, "stream": False},
|
||||
}
|
||||
|
||||
|
||||
@@ -94,65 +94,6 @@ 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."""
|
||||
@@ -785,7 +726,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) -> str:
|
||||
def truncate_tool_output(self, tool_name: str, output: str, tool_call_id: str = None, run_id: str = None) -> str:
|
||||
"""Truncate tool output according to its budget."""
|
||||
max_tokens = self.get_tool_budget(tool_name)
|
||||
|
||||
@@ -805,6 +746,17 @@ 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,6 +22,7 @@ from .protocols import (
|
||||
PushChannelProtocol,
|
||||
PresenceProtocol,
|
||||
DeliveryGuaranteeProtocol,
|
||||
OutboundDeliveryProtocol,
|
||||
ChannelInfo,
|
||||
PresenceInfo,
|
||||
)
|
||||
@@ -89,6 +90,7 @@ __all__ = [
|
||||
"PushChannelProtocol",
|
||||
"PresenceProtocol",
|
||||
"DeliveryGuaranteeProtocol",
|
||||
"OutboundDeliveryProtocol",
|
||||
"ChannelInfo",
|
||||
"PresenceInfo",
|
||||
# Config (always available)
|
||||
|
||||
@@ -17,14 +17,33 @@ 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."""
|
||||
@@ -33,7 +52,10 @@ 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,
|
||||
}
|
||||
|
||||
|
||||
@@ -206,6 +228,7 @@ 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
|
||||
"""
|
||||
|
||||
@@ -222,12 +245,23 @@ 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."""
|
||||
"""Post-initialization to set bind_host from host if not specified and validate values."""
|
||||
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)."""
|
||||
@@ -243,6 +277,7 @@ 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(),
|
||||
}
|
||||
|
||||
@@ -365,6 +400,22 @@ 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),
|
||||
@@ -372,6 +423,13 @@ 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,6 +17,7 @@ from enum import Enum
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Awaitable,
|
||||
Callable,
|
||||
Dict,
|
||||
List,
|
||||
@@ -27,9 +28,24 @@ from typing import (
|
||||
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):
|
||||
@@ -53,6 +69,7 @@ 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)
|
||||
@@ -84,6 +101,65 @@ 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
|
||||
@@ -148,6 +224,7 @@ 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]]
|
||||
@@ -157,10 +234,11 @@ 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."""
|
||||
return {
|
||||
data = {
|
||||
"content": self.content,
|
||||
"sender_id": self.sender_id,
|
||||
"session_id": self.session_id,
|
||||
@@ -169,10 +247,20 @@ 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"),
|
||||
@@ -181,6 +269,7 @@ class GatewayMessage:
|
||||
timestamp=data.get("timestamp", time.time()),
|
||||
metadata=data.get("metadata", {}),
|
||||
reply_to=data.get("reply_to"),
|
||||
presentation=presentation,
|
||||
)
|
||||
|
||||
|
||||
@@ -688,6 +777,126 @@ 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)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -904,3 +1113,83 @@ 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
|
||||
"""
|
||||
...
|
||||
@@ -83,7 +83,8 @@ class HookRunner:
|
||||
self,
|
||||
event: HookEvent,
|
||||
input_data: HookInput,
|
||||
target: Optional[str] = None
|
||||
target: Optional[str] = None,
|
||||
_hooks: Optional[List["HookDefinition"]] = None
|
||||
) -> List[HookExecutionResult]:
|
||||
"""
|
||||
Execute all hooks for an event.
|
||||
@@ -92,11 +93,12 @@ 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 = self._registry.get_hooks(event, target)
|
||||
hooks = _hooks if _hooks is not None else self._registry.get_hooks(event, target)
|
||||
|
||||
if not hooks:
|
||||
return []
|
||||
@@ -153,8 +155,14 @@ class HookRunner:
|
||||
"Use 'await runner.execute(event, input_data, target)' instead in async contexts."
|
||||
)
|
||||
|
||||
# No running loop — safe to create one
|
||||
return asyncio.run(self.execute(event, input_data, target))
|
||||
# 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))
|
||||
|
||||
async def _execute_parallel(
|
||||
self,
|
||||
|
||||
Whitespace-only changes.
@@ -24,6 +24,7 @@ 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
|
||||
@@ -5749,8 +5750,6 @@ 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)
|
||||
@@ -5846,8 +5845,6 @@ 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)
|
||||
@@ -5886,34 +5883,74 @@ 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 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
|
||||
# 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
|
||||
|
||||
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
|
||||
@@ -5961,26 +5998,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:
|
||||
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"
|
||||
# Get type annotation
|
||||
param_type = hints.get(name, param.annotation if param.annotation != inspect.Parameter.empty else str)
|
||||
|
||||
parameters["properties"][name] = {
|
||||
"type": param_type,
|
||||
"description": param_descriptions.get(name, "Parameter description not available")
|
||||
}
|
||||
# Use new schema utility for proper type handling
|
||||
prop_schema = annotation_to_json_schema(param_type)
|
||||
|
||||
if param.default == inspect.Parameter.empty:
|
||||
# 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):
|
||||
parameters["required"].append(name)
|
||||
|
||||
logging.debug(f"Generated parameters: {parameters}")
|
||||
|
||||
@@ -141,22 +141,6 @@ 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,3 +246,74 @@ 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,12 +242,18 @@ class ToolsMCPServer:
|
||||
|
||||
mcp.run()
|
||||
|
||||
def run_sse(self, host: str = "0.0.0.0", port: int = 8080) -> None:
|
||||
def run_sse(
|
||||
self,
|
||||
host: str = "0.0.0.0",
|
||||
port: int = 8080,
|
||||
security: "Optional[SecurityConfig]" = None,
|
||||
) -> 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
|
||||
@@ -260,6 +266,8 @@ 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()
|
||||
|
||||
@@ -286,7 +294,8 @@ 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,27 +378,34 @@ 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:
|
||||
# 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')
|
||||
}
|
||||
# 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')
|
||||
}
|
||||
|
||||
# Try to emit to the EventBus if available
|
||||
try:
|
||||
from ..bus import get_default_bus
|
||||
bus = get_default_bus()
|
||||
bus.publish(f"memory.{event_type}.{memory_type}", event_data)
|
||||
|
||||
# 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())
|
||||
except ImportError:
|
||||
# EventBus not available, fall back to logging
|
||||
logging.debug(f"Memory event: {event_type}.{memory_type} - {event_data}")
|
||||
logging.debug(f"Memory event: {event_type}.{memory_type} - {_build_event_data()}")
|
||||
except Exception as bus_error:
|
||||
# EventBus failed, log the event instead
|
||||
logging.debug(f"Memory event (bus failed): {event_type}.{memory_type} - {event_data}")
|
||||
logging.debug(f"Memory event (bus failed): {event_type}.{memory_type} - {_build_event_data()}")
|
||||
logging.debug(f"EventBus error: {bus_error}")
|
||||
except Exception as e:
|
||||
logging.debug(f"Failed to emit memory event: {e}")
|
||||
|
||||
@@ -115,6 +115,16 @@ 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,
|
||||
@@ -132,7 +142,7 @@ class FileMemory:
|
||||
config: Configuration overrides
|
||||
verbose: Verbosity level (0=quiet, 1=info, 2+=debug)
|
||||
"""
|
||||
self.user_id = user_id
|
||||
self.user_id = self._sanitise_user_id(user_id)
|
||||
self.verbose = verbose
|
||||
|
||||
# Set up paths
|
||||
@@ -142,7 +152,7 @@ class FileMemory:
|
||||
from ..paths import get_project_data_dir
|
||||
self.base_path = get_project_data_dir() / "memory"
|
||||
|
||||
self.user_path = self.base_path / user_id
|
||||
self.user_path = self.base_path / self.user_id
|
||||
self.episodic_path = self.user_path / "episodic"
|
||||
|
||||
# Create directories
|
||||
|
||||
@@ -10,7 +10,6 @@ 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
|
||||
|
||||
@@ -29,9 +28,14 @@ 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(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
|
||||
class Memory(SearchMixin, MemoryCoreMixin):
|
||||
"""
|
||||
A single-file memory manager covering:
|
||||
- Short-term memory (STM) for ephemeral context
|
||||
@@ -230,6 +234,34 @@ class Memory(StorageMixin, 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."""
|
||||
@@ -381,80 +413,6 @@ class Memory(StorageMixin, 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."""
|
||||
@@ -491,105 +449,6 @@ class Memory(StorageMixin, 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."""
|
||||
@@ -1704,6 +1563,41 @@ class Memory(StorageMixin, 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)
|
||||
@@ -1713,6 +1607,51 @@ class Memory(StorageMixin, 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,6 +14,9 @@ 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]]:
|
||||
"""
|
||||
@@ -170,10 +173,10 @@ class SearchMixin:
|
||||
|
||||
return self._format_vector_results(results)
|
||||
|
||||
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'):
|
||||
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:
|
||||
return []
|
||||
|
||||
# Build search filter
|
||||
@@ -191,120 +194,82 @@ class SearchMixin:
|
||||
search_filter["metadata.user_id"] = user_id
|
||||
|
||||
# Perform search with text score
|
||||
cursor = self.stm_collection.find(
|
||||
cursor = 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."""
|
||||
if not hasattr(self, 'ltm_collection'):
|
||||
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:
|
||||
return []
|
||||
|
||||
# Build search filter
|
||||
search_filter = {"$text": {"$search": query}}
|
||||
# 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}")
|
||||
|
||||
# Add additional filters
|
||||
if metadata_filter:
|
||||
for key, value in metadata_filter.items():
|
||||
search_filter[f"metadata.{key}"] = value
|
||||
# Build WHERE clause
|
||||
where_clauses = ["content LIKE ?"]
|
||||
params = [f"%{query}%"]
|
||||
|
||||
if min_quality is not None:
|
||||
search_filter["quality_score"] = {"$gte": min_quality}
|
||||
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:
|
||||
search_filter["metadata.user_id"] = user_id
|
||||
where_clauses.append("metadata LIKE ?")
|
||||
params.append(f'%"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)
|
||||
where_clause = " AND ".join(where_clauses)
|
||||
|
||||
return [self._format_mongodb_result(doc) for doc in cursor]
|
||||
query_sql = f"""
|
||||
SELECT id, content, metadata, timestamp, quality_score
|
||||
FROM {table}
|
||||
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 _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 ?"]
|
||||
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 short_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]
|
||||
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()
|
||||
|
||||
# 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]
|
||||
conn = self._get_ltm_conn() # Let AttributeError propagate naturally
|
||||
return self._search_sqlite(conn, "long_term", query, limit, metadata_filter, min_quality, user_id)
|
||||
|
||||
def _format_vector_results(self, results: Dict) -> List[Dict[str, Any]]:
|
||||
"""Format ChromaDB vector search results."""
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
"""
|
||||
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}")
|
||||
@@ -27,7 +27,7 @@ from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from ..task.task import Task
|
||||
from ..workflows.results import StepResult, StepError, WorkflowResult, StepStatus, ErrorStrategy
|
||||
from ..workflows.results import StepError, WorkflowResult, StepStatus, ErrorStrategy
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ __all__ = [
|
||||
"PermissionAction",
|
||||
"PermissionMode",
|
||||
"PermissionResult",
|
||||
"PersistentApproval",
|
||||
"DoomLoopDetector",
|
||||
]
|
||||
|
||||
@@ -60,6 +61,10 @@ 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,6 +111,11 @@ 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()
|
||||
@@ -359,3 +364,71 @@ 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,6 +114,29 @@ 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,6 +16,7 @@ Features:
|
||||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
import sys
|
||||
from praisonaiagents._logging import get_logger
|
||||
from typing import Callable, Optional, Union, TYPE_CHECKING
|
||||
|
||||
@@ -93,6 +94,12 @@ 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
|
||||
|
||||
@@ -127,6 +134,12 @@ 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,8 +196,13 @@ 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):
|
||||
"""Create subtasks for a loop task from input file."""
|
||||
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)
|
||||
"""
|
||||
if not loop_task.input_file:
|
||||
logging.warning(f"_create_loop_subtasks called for {loop_task.name} but no input_file specified")
|
||||
return
|
||||
@@ -229,20 +234,45 @@ class Process:
|
||||
task_count += 1
|
||||
logging.debug(f"Creating subtask from CSV row {i+1}: {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=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"
|
||||
)
|
||||
# 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"
|
||||
)
|
||||
|
||||
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}")
|
||||
@@ -254,21 +284,42 @@ class Process:
|
||||
for i, line in enumerate(lines):
|
||||
if not line.strip(): # Skip empty lines
|
||||
continue
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
# 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"
|
||||
)
|
||||
|
||||
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}")
|
||||
@@ -276,7 +327,12 @@ class Process:
|
||||
if new_tasks and loop_task.next_tasks:
|
||||
# Connect last subtask to loop task's next tasks
|
||||
last_task = new_tasks[-1]
|
||||
last_task.next_tasks = loop_task.next_tasks
|
||||
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
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to create subtasks for loop task {loop_task.name}: {e}")
|
||||
@@ -562,7 +618,6 @@ 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
|
||||
|
||||
@@ -732,9 +787,6 @@ 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(
|
||||
@@ -772,22 +824,6 @@ 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
|
||||
@@ -1112,104 +1148,29 @@ 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
|
||||
# --- If loop + input_file, read file & create tasks using consolidated helper
|
||||
if start_task and start_task.task_type == "loop" and getattr(start_task, "input_file", None):
|
||||
try:
|
||||
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}")
|
||||
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}")
|
||||
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
|
||||
@@ -1256,7 +1217,7 @@ Tasks by type:
|
||||
break # Exit immediately to prevent task reset
|
||||
|
||||
|
||||
# Handle loop task file reading at runtime
|
||||
# Handle loop task file reading at runtime using consolidated helper
|
||||
if (current_task.task_type == "loop" and
|
||||
current_task is not start_task and
|
||||
getattr(current_task, "_subtasks_created", False) is not True):
|
||||
@@ -1266,66 +1227,18 @@ Tasks by type:
|
||||
|
||||
if getattr(current_task, "input_file", None):
|
||||
try:
|
||||
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]
|
||||
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]
|
||||
current_task._subtasks_created = True
|
||||
logging.info(f"Created {len(new_tasks)} tasks from: {current_task.input_file} for loop task {current_task.name}")
|
||||
logging.info(f"Created {len(subtasks)} 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}")
|
||||
|
||||
@@ -1451,9 +1364,6 @@ 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(
|
||||
@@ -1489,22 +1399,6 @@ 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
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
# 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
|
||||
|
||||
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")
|
||||
```
|
||||
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
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
|
||||
- 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",
|
||||
]
|
||||
|
||||
# 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'),
|
||||
},
|
||||
'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'),
|
||||
},
|
||||
}
|
||||
|
||||
# Create the __getattr__ function using centralized utility
|
||||
__getattr__ = create_lazy_getattr_with_groups(_LAZY_GROUPS, __name__)
|
||||
@@ -0,0 +1,169 @@
|
||||
"""AgentRuntimeConfig dataclass for model-scoped runtime selection.
|
||||
|
||||
Protocol-driven design following AGENTS.md:
|
||||
- Lightweight dataclass for configuration
|
||||
- No heavy implementations (those live in wrapper layer)
|
||||
- Support for per-model and per-provider runtime configuration
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict, Any, Union
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentRuntimeConfig:
|
||||
"""Model-scoped runtime configuration for PraisonAI Agents.
|
||||
|
||||
Supports the resolution order:
|
||||
per-model runtime → per-provider default → auto → built-in default
|
||||
|
||||
This replaces agent-level cli_backend with model-scoped runtime selection.
|
||||
"""
|
||||
# Runtime identifier (e.g., "claude-code", "codex-cli", "praisonai")
|
||||
runtime: Optional[str] = None
|
||||
|
||||
# Runtime-specific configuration overrides
|
||||
config_overrides: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Provider-level default runtime (for YAML providers.<name>.runtime_default)
|
||||
provider_default: Optional[str] = None
|
||||
|
||||
# Enable auto-selection if no explicit runtime is specified
|
||||
enable_auto_selection: bool = True
|
||||
|
||||
# Metadata for runtime resolution
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate runtime configuration on initialization."""
|
||||
# Ensure config_overrides is a dictionary
|
||||
if not isinstance(self.config_overrides, dict):
|
||||
raise TypeError("config_overrides must be a dictionary")
|
||||
|
||||
# Ensure metadata is a dictionary
|
||||
if not isinstance(self.metadata, dict):
|
||||
raise TypeError("metadata must be a dictionary")
|
||||
|
||||
@classmethod
|
||||
def from_runtime_id(cls, runtime_id: str, **kwargs) -> "AgentRuntimeConfig":
|
||||
"""Create AgentRuntimeConfig from a runtime ID string.
|
||||
|
||||
Args:
|
||||
runtime_id: Runtime identifier (e.g., "claude-code")
|
||||
**kwargs: Additional configuration options
|
||||
|
||||
Returns:
|
||||
AgentRuntimeConfig instance
|
||||
"""
|
||||
return cls(runtime=runtime_id, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, config_dict: Dict[str, Any]) -> "AgentRuntimeConfig":
|
||||
"""Create AgentRuntimeConfig from a dictionary.
|
||||
|
||||
Args:
|
||||
config_dict: Configuration dictionary
|
||||
|
||||
Returns:
|
||||
AgentRuntimeConfig instance
|
||||
|
||||
Raises:
|
||||
TypeError: If config_dict is not a dictionary
|
||||
ValueError: If required fields are missing or invalid
|
||||
"""
|
||||
if not isinstance(config_dict, dict):
|
||||
raise TypeError("config_dict must be a dictionary")
|
||||
|
||||
# Extract known fields
|
||||
runtime = config_dict.get("runtime")
|
||||
config_overrides = config_dict.get("config_overrides", {})
|
||||
provider_default = config_dict.get("provider_default")
|
||||
enable_auto_selection = config_dict.get("enable_auto_selection", True)
|
||||
metadata = config_dict.get("metadata", {})
|
||||
|
||||
# Validate types
|
||||
if config_overrides is not None and not isinstance(config_overrides, dict):
|
||||
raise TypeError("config_overrides must be a dictionary")
|
||||
|
||||
if metadata is not None and not isinstance(metadata, dict):
|
||||
raise TypeError("metadata must be a dictionary")
|
||||
|
||||
return cls(
|
||||
runtime=runtime,
|
||||
config_overrides=config_overrides,
|
||||
provider_default=provider_default,
|
||||
enable_auto_selection=enable_auto_selection,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert AgentRuntimeConfig to a dictionary.
|
||||
|
||||
Returns:
|
||||
Dictionary representation of the configuration
|
||||
"""
|
||||
return {
|
||||
"runtime": self.runtime,
|
||||
"config_overrides": self.config_overrides.copy(),
|
||||
"provider_default": self.provider_default,
|
||||
"enable_auto_selection": self.enable_auto_selection,
|
||||
"metadata": self.metadata.copy()
|
||||
}
|
||||
|
||||
def merge_overrides(self, overrides: Dict[str, Any]) -> "AgentRuntimeConfig":
|
||||
"""Create a new AgentRuntimeConfig with merged configuration overrides.
|
||||
|
||||
Args:
|
||||
overrides: Configuration overrides to merge
|
||||
|
||||
Returns:
|
||||
New AgentRuntimeConfig instance with merged overrides
|
||||
"""
|
||||
if not isinstance(overrides, dict):
|
||||
raise TypeError("overrides must be a dictionary")
|
||||
|
||||
# Create new config overrides by merging
|
||||
new_config_overrides = {**self.config_overrides, **overrides}
|
||||
|
||||
return AgentRuntimeConfig(
|
||||
runtime=self.runtime,
|
||||
config_overrides=new_config_overrides,
|
||||
provider_default=self.provider_default,
|
||||
enable_auto_selection=self.enable_auto_selection,
|
||||
metadata=self.metadata.copy()
|
||||
)
|
||||
|
||||
def with_runtime(self, runtime_id: str) -> "AgentRuntimeConfig":
|
||||
"""Create a new AgentRuntimeConfig with a different runtime ID.
|
||||
|
||||
Args:
|
||||
runtime_id: New runtime identifier
|
||||
|
||||
Returns:
|
||||
New AgentRuntimeConfig instance with updated runtime
|
||||
"""
|
||||
return AgentRuntimeConfig(
|
||||
runtime=runtime_id,
|
||||
config_overrides=self.config_overrides.copy(),
|
||||
provider_default=self.provider_default,
|
||||
enable_auto_selection=self.enable_auto_selection,
|
||||
metadata=self.metadata.copy()
|
||||
)
|
||||
|
||||
def is_explicit(self) -> bool:
|
||||
"""Check if this configuration specifies an explicit runtime.
|
||||
|
||||
Returns:
|
||||
True if runtime is explicitly specified, False otherwise
|
||||
"""
|
||||
return self.runtime is not None and self.runtime != ""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of AgentRuntimeConfig."""
|
||||
return (
|
||||
f"AgentRuntimeConfig("
|
||||
f"runtime={self.runtime!r}, "
|
||||
f"config_overrides={self.config_overrides!r}, "
|
||||
f"provider_default={self.provider_default!r}, "
|
||||
f"enable_auto_selection={self.enable_auto_selection!r})"
|
||||
)
|
||||
@@ -0,0 +1,452 @@
|
||||
"""
|
||||
Context Builder for PreparedTurnContext.
|
||||
|
||||
This module provides the DefaultTurnContextBuilder that prepares
|
||||
PreparedTurnContext instances from agent configuration and request
|
||||
parameters. It consolidates the scattered context preparation logic
|
||||
from chat_mixin.py, chat_handler.py, and other execution paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
from .turn_context import (
|
||||
PreparedTurnContext,
|
||||
ModelReference,
|
||||
ToolSchema,
|
||||
TranscriptWindow,
|
||||
DeliveryChannels,
|
||||
SessionCorrelation,
|
||||
RuntimeMode,
|
||||
)
|
||||
from .protocols import TurnContextBuilderProtocol
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..agent.protocols import AgentProtocol
|
||||
from ..streaming.events import StreamEventEmitter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DefaultTurnContextBuilder:
|
||||
"""
|
||||
Default implementation of turn context building.
|
||||
|
||||
This builder extracts and normalizes configuration from Agent instances
|
||||
to create PreparedTurnContext objects. It consolidates the logic that
|
||||
was previously scattered across multiple mixins.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._cache: Dict[str, Any] = {}
|
||||
|
||||
def build_context(
|
||||
self,
|
||||
agent: AgentProtocol,
|
||||
prompt: str,
|
||||
**kwargs: Any
|
||||
) -> PreparedTurnContext:
|
||||
"""
|
||||
Build a prepared turn context from agent and request.
|
||||
|
||||
Args:
|
||||
agent: The agent instance
|
||||
prompt: The user prompt for this turn
|
||||
**kwargs: Additional request parameters including:
|
||||
- model: Override model ID
|
||||
- tools: Override tools list
|
||||
- stream: Enable streaming
|
||||
- temperature: Model temperature
|
||||
- max_tokens: Token limit
|
||||
- session_id: Session identifier
|
||||
- turn_id: Turn identifier
|
||||
|
||||
Returns:
|
||||
A prepared turn context ready for execution
|
||||
"""
|
||||
try:
|
||||
# Extract runtime mode from kwargs
|
||||
runtime_mode = self._determine_runtime_mode(kwargs)
|
||||
|
||||
# Build model reference
|
||||
model_ref = self._build_model_reference(agent, kwargs)
|
||||
|
||||
# Normalize tools
|
||||
tools = self._build_tool_schemas(agent, kwargs)
|
||||
|
||||
# Build transcript window
|
||||
transcript = self._build_transcript_window(agent, prompt, kwargs)
|
||||
|
||||
# Setup delivery channels
|
||||
delivery = self._build_delivery_channels(agent, kwargs)
|
||||
|
||||
# Create correlation IDs
|
||||
correlation = self._build_session_correlation(agent, kwargs)
|
||||
|
||||
# Create the context
|
||||
context = PreparedTurnContext(
|
||||
model_ref=model_ref,
|
||||
agent_runtime=agent,
|
||||
tools=tools,
|
||||
transcript=transcript,
|
||||
delivery=delivery,
|
||||
correlation=correlation,
|
||||
runtime_mode=runtime_mode,
|
||||
turn_metadata=self._extract_turn_metadata(agent, kwargs),
|
||||
created_at=time.time()
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Built turn context: {context.get_message_count()} messages, "
|
||||
f"{len(context.tools)} tools, mode={context.runtime_mode.value}"
|
||||
)
|
||||
|
||||
return context
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to build turn context: {e}")
|
||||
raise
|
||||
|
||||
def _determine_runtime_mode(self, kwargs: Dict[str, Any]) -> RuntimeMode:
|
||||
"""Determine runtime mode from request parameters."""
|
||||
is_stream = kwargs.get('stream', False)
|
||||
is_async = kwargs.get('async_execution', False)
|
||||
|
||||
if is_async and is_stream:
|
||||
return RuntimeMode.ASYNC_STREAM
|
||||
elif is_async:
|
||||
return RuntimeMode.ASYNC
|
||||
elif is_stream:
|
||||
return RuntimeMode.STREAM
|
||||
else:
|
||||
return RuntimeMode.SYNC
|
||||
|
||||
def _build_model_reference(
|
||||
self,
|
||||
agent: AgentProtocol,
|
||||
kwargs: Dict[str, Any]
|
||||
) -> ModelReference:
|
||||
"""Build model reference from agent configuration and overrides."""
|
||||
# Get model ID (kwargs override, then agent default)
|
||||
model_id = kwargs.get('model') or getattr(agent, 'model', 'gpt-3.5-turbo')
|
||||
|
||||
# Determine provider from model ID or agent config
|
||||
provider = self._determine_provider(model_id, agent)
|
||||
|
||||
# Get model capabilities
|
||||
supports_streaming = kwargs.get('stream', False) or getattr(agent, '_supports_streaming', True)
|
||||
supports_tools = len(getattr(agent, 'tools', [])) > 0
|
||||
supports_system_prompts = getattr(agent, 'use_system_prompt', True)
|
||||
|
||||
# Build model config - agent defaults first, kwargs override
|
||||
model_config = {}
|
||||
if hasattr(agent, 'model_config'):
|
||||
model_config.update(agent.model_config)
|
||||
if 'temperature' in kwargs:
|
||||
model_config['temperature'] = kwargs['temperature']
|
||||
if 'max_tokens' in kwargs:
|
||||
model_config['max_tokens'] = kwargs['max_tokens']
|
||||
|
||||
return ModelReference(
|
||||
model_id=model_id,
|
||||
provider=provider,
|
||||
supports_streaming=supports_streaming,
|
||||
supports_tools=supports_tools,
|
||||
supports_system_prompts=supports_system_prompts,
|
||||
max_tokens=kwargs.get('max_tokens'),
|
||||
temperature=kwargs.get('temperature'),
|
||||
model_config=model_config
|
||||
)
|
||||
|
||||
def _determine_provider(self, model_id: str, agent: AgentProtocol) -> str:
|
||||
"""Determine provider from model ID or agent configuration."""
|
||||
# Check if agent has explicit provider
|
||||
if hasattr(agent, 'provider'):
|
||||
return agent.provider
|
||||
|
||||
# Infer from model ID
|
||||
if model_id.startswith('gpt-'):
|
||||
return 'openai'
|
||||
elif model_id.startswith('claude-'):
|
||||
return 'anthropic'
|
||||
elif model_id.startswith('gemini-'):
|
||||
return 'google'
|
||||
elif 'llama' in model_id.lower():
|
||||
return 'meta'
|
||||
else:
|
||||
return 'openai' # Default fallback
|
||||
|
||||
def _build_tool_schemas(
|
||||
self,
|
||||
agent: AgentProtocol,
|
||||
kwargs: Dict[str, Any]
|
||||
) -> List[ToolSchema]:
|
||||
"""Build normalized tool schemas from agent tools."""
|
||||
# Get tools from kwargs override or agent - respect explicit empty list
|
||||
if 'tools' in kwargs:
|
||||
raw_tools = kwargs['tools']
|
||||
else:
|
||||
raw_tools = getattr(agent, 'tools', [])
|
||||
|
||||
if not raw_tools:
|
||||
return []
|
||||
|
||||
normalized_tools = []
|
||||
for tool in raw_tools:
|
||||
try:
|
||||
schema = self._normalize_tool_schema(tool)
|
||||
if schema:
|
||||
normalized_tools.append(schema)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to normalize tool {tool}: {e}")
|
||||
continue
|
||||
|
||||
return normalized_tools
|
||||
|
||||
def _normalize_tool_schema(self, tool: Any) -> Optional[ToolSchema]:
|
||||
"""Normalize a single tool into ToolSchema format."""
|
||||
if tool is None:
|
||||
return None
|
||||
|
||||
# Handle different tool formats
|
||||
if callable(tool):
|
||||
# Function tool
|
||||
return self._normalize_function_tool(tool)
|
||||
elif isinstance(tool, dict):
|
||||
# Dictionary tool (OpenAI format, custom format, etc.)
|
||||
return self._normalize_dict_tool(tool)
|
||||
elif hasattr(tool, 'name') and hasattr(tool, 'description'):
|
||||
# Object with name/description attributes
|
||||
return self._normalize_object_tool(tool)
|
||||
else:
|
||||
logger.warning(f"Unknown tool format: {type(tool)}")
|
||||
return None
|
||||
|
||||
def _normalize_function_tool(self, func: callable) -> ToolSchema:
|
||||
"""Normalize a callable function into ToolSchema."""
|
||||
import inspect
|
||||
|
||||
name = getattr(func, '__name__', 'unknown_function')
|
||||
description = getattr(func, '__doc__', '') or f"Function: {name}"
|
||||
|
||||
# Try to extract parameters from function signature
|
||||
try:
|
||||
sig = inspect.signature(func)
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
|
||||
for param_name, param in sig.parameters.items():
|
||||
if param_name == 'self':
|
||||
continue
|
||||
|
||||
prop = {"type": "string"} # Default type
|
||||
if param.annotation != inspect.Parameter.empty:
|
||||
prop["type"] = self._python_type_to_json_type(param.annotation)
|
||||
|
||||
parameters["properties"][param_name] = prop
|
||||
|
||||
if param.default == inspect.Parameter.empty:
|
||||
parameters["required"].append(param_name)
|
||||
|
||||
except Exception:
|
||||
# Fallback to empty parameters
|
||||
parameters = {"type": "object", "properties": {}}
|
||||
|
||||
return ToolSchema(
|
||||
name=name,
|
||||
description=description.strip(),
|
||||
parameters=parameters,
|
||||
callable=func,
|
||||
source_type="function"
|
||||
)
|
||||
|
||||
def _normalize_dict_tool(self, tool_dict: Dict[str, Any]) -> ToolSchema:
|
||||
"""Normalize a dictionary tool into ToolSchema."""
|
||||
if 'function' in tool_dict:
|
||||
# OpenAI format: {"type": "function", "function": {...}}
|
||||
func_def = tool_dict['function']
|
||||
return ToolSchema(
|
||||
name=func_def.get('name', 'unknown'),
|
||||
description=func_def.get('description', ''),
|
||||
parameters=func_def.get('parameters', {}),
|
||||
source_type="openai"
|
||||
)
|
||||
else:
|
||||
# Direct format: {"name": ..., "description": ..., "parameters": ...}
|
||||
return ToolSchema(
|
||||
name=tool_dict.get('name', 'unknown'),
|
||||
description=tool_dict.get('description', ''),
|
||||
parameters=tool_dict.get('parameters', {}),
|
||||
source_type="custom"
|
||||
)
|
||||
|
||||
def _normalize_object_tool(self, tool_obj: Any) -> ToolSchema:
|
||||
"""Normalize a tool object into ToolSchema."""
|
||||
return ToolSchema(
|
||||
name=getattr(tool_obj, 'name', 'unknown'),
|
||||
description=getattr(tool_obj, 'description', ''),
|
||||
parameters=getattr(tool_obj, 'parameters', {}),
|
||||
callable=getattr(tool_obj, '__call__', None),
|
||||
source_type="object"
|
||||
)
|
||||
|
||||
def _python_type_to_json_type(self, py_type: type) -> str:
|
||||
"""Convert Python type annotation to JSON schema type."""
|
||||
if py_type == str:
|
||||
return "string"
|
||||
elif py_type == int:
|
||||
return "integer"
|
||||
elif py_type == float:
|
||||
return "number"
|
||||
elif py_type == bool:
|
||||
return "boolean"
|
||||
elif py_type == list:
|
||||
return "array"
|
||||
elif py_type == dict:
|
||||
return "object"
|
||||
else:
|
||||
return "string" # Default fallback
|
||||
|
||||
def _build_transcript_window(
|
||||
self,
|
||||
agent: AgentProtocol,
|
||||
prompt: str,
|
||||
kwargs: Dict[str, Any]
|
||||
) -> TranscriptWindow:
|
||||
"""Build transcript window from agent conversation history."""
|
||||
# Get system prompt
|
||||
system_prompt = None
|
||||
if hasattr(agent, '_build_system_prompt'):
|
||||
try:
|
||||
system_prompt = agent._build_system_prompt()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to build system prompt: {e}")
|
||||
|
||||
# Get conversation history - deep copy to prevent cross-agent mutations
|
||||
messages = []
|
||||
if hasattr(agent, 'chat_history') and agent.chat_history:
|
||||
messages = copy.deepcopy(agent.chat_history)
|
||||
|
||||
# Add current prompt as user message
|
||||
if prompt:
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
|
||||
# Estimate token count (rough approximation)
|
||||
total_tokens = self._estimate_tokens(messages, system_prompt)
|
||||
|
||||
return TranscriptWindow(
|
||||
messages=messages,
|
||||
total_tokens=total_tokens,
|
||||
system_prompt=system_prompt,
|
||||
context_metadata={
|
||||
"original_prompt": prompt,
|
||||
"history_length": len(messages) - (1 if prompt else 0)
|
||||
}
|
||||
)
|
||||
|
||||
def _estimate_tokens(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
system_prompt: Optional[str]
|
||||
) -> int:
|
||||
"""Rough token estimation for transcript."""
|
||||
total_chars = 0
|
||||
|
||||
if system_prompt:
|
||||
total_chars += len(system_prompt)
|
||||
|
||||
for msg in messages:
|
||||
content = msg.get('content', '')
|
||||
if isinstance(content, str):
|
||||
total_chars += len(content)
|
||||
|
||||
# Rough approximation: 1 token ≈ 4 characters
|
||||
return total_chars // 4
|
||||
|
||||
def _build_delivery_channels(
|
||||
self,
|
||||
agent: AgentProtocol,
|
||||
kwargs: Dict[str, Any]
|
||||
) -> DeliveryChannels:
|
||||
"""Build delivery channels configuration."""
|
||||
enable_streaming = kwargs.get('stream', False)
|
||||
enable_metrics = kwargs.get('enable_metrics', False)
|
||||
|
||||
# Get stream emitter from agent if available
|
||||
stream_emitter = None
|
||||
if enable_streaming:
|
||||
if hasattr(agent, '_stream_emitter'):
|
||||
stream_emitter = agent._stream_emitter
|
||||
elif hasattr(agent, 'stream_emitter'):
|
||||
stream_emitter = agent.stream_emitter
|
||||
|
||||
# Get callbacks
|
||||
callbacks = kwargs.get('callbacks', [])
|
||||
async_callbacks = kwargs.get('async_callbacks', [])
|
||||
|
||||
return DeliveryChannels(
|
||||
stream_emitter=stream_emitter,
|
||||
callbacks=callbacks,
|
||||
async_callbacks=async_callbacks,
|
||||
enable_streaming=enable_streaming,
|
||||
enable_metrics=enable_metrics
|
||||
)
|
||||
|
||||
def _build_session_correlation(
|
||||
self,
|
||||
agent: AgentProtocol,
|
||||
kwargs: Dict[str, Any]
|
||||
) -> SessionCorrelation:
|
||||
"""Build session correlation identifiers."""
|
||||
# Use provided IDs or generate new ones
|
||||
session_id = kwargs.get('session_id')
|
||||
if not session_id and hasattr(agent, 'session_id'):
|
||||
session_id = agent.session_id
|
||||
if not session_id:
|
||||
session_id = f"session-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
turn_id = kwargs.get('turn_id') or f"turn-{uuid.uuid4().hex[:8]}"
|
||||
agent_id = kwargs.get('agent_id') or getattr(agent, 'name', 'agent')
|
||||
run_id = kwargs.get('run_id') or f"run-{uuid.uuid4().hex[:8]}"
|
||||
parent_id = kwargs.get('parent_id')
|
||||
|
||||
return SessionCorrelation(
|
||||
session_id=session_id,
|
||||
turn_id=turn_id,
|
||||
agent_id=agent_id,
|
||||
run_id=run_id,
|
||||
parent_id=parent_id,
|
||||
trace_metadata=kwargs.get('trace_metadata', {})
|
||||
)
|
||||
|
||||
def _extract_turn_metadata(
|
||||
self,
|
||||
agent: AgentProtocol,
|
||||
kwargs: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Extract additional turn metadata."""
|
||||
metadata = {}
|
||||
|
||||
# Add agent metadata
|
||||
if hasattr(agent, 'role'):
|
||||
metadata['agent_role'] = agent.role
|
||||
if hasattr(agent, 'goal'):
|
||||
metadata['agent_goal'] = agent.goal
|
||||
|
||||
# Add request metadata
|
||||
if 'metadata' in kwargs:
|
||||
metadata.update(kwargs['metadata'])
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
# Global instance for convenience
|
||||
default_context_builder = DefaultTurnContextBuilder()
|
||||
Loaded 100 of 388 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user