mirror of
https://github.com/MervinPraison/PraisonAI-Tools.git
synced 2026-11-09 03:39:37 -10:00
Compare commits
No files matched your search
@@ -0,0 +1,12 @@
|
||||
# PraisonAI-Tools environment variables (examples)
|
||||
#
|
||||
# Copy to `.env` and fill in the values for the tools you use.
|
||||
# Never commit real secrets.
|
||||
|
||||
# ── Stripe (StripeTool) ─────────────────────────────────────────────
|
||||
# Stripe secret key. Prefer least-privilege *restricted* keys (rk_...).
|
||||
# Use test keys (sk_test_/rk_test_) for development and live keys
|
||||
# (sk_live_/rk_live_) only in production. STRIPE_API_KEY is accepted as a
|
||||
# fallback if STRIPE_SECRET_KEY is unset.
|
||||
STRIPE_SECRET_KEY=sk_test_your_key_here
|
||||
# STRIPE_API_KEY=sk_test_your_key_here
|
||||
@@ -0,0 +1,290 @@
|
||||
name: "Claude Code Action"
|
||||
description: "Run Claude Code in GitHub Actions workflows"
|
||||
|
||||
inputs:
|
||||
anthropic_api_key:
|
||||
description: "Anthropic API key"
|
||||
required: false
|
||||
claude_code_oauth_token:
|
||||
description: "Claude Code OAuth Token for Claude Pro"
|
||||
required: false
|
||||
github_token:
|
||||
description: "GitHub token for Claude to operate with"
|
||||
required: false
|
||||
default: ${{ github.token }}
|
||||
trigger_phrase:
|
||||
description: "The trigger phrase to look for in comments, issue/PR bodies, and issue titles"
|
||||
required: false
|
||||
default: "@claude"
|
||||
assignee_trigger:
|
||||
description: "The assignee username that triggers the action (e.g. @claude). Only used for issue assignment"
|
||||
required: false
|
||||
max_turns:
|
||||
description: "Maximum number of conversation turns Claude can take"
|
||||
required: false
|
||||
timeout_minutes:
|
||||
description: "Timeout in minutes for execution"
|
||||
required: false
|
||||
default: "30"
|
||||
model:
|
||||
description: "Model to use (provider-specific format required for Bedrock/Vertex)"
|
||||
required: false
|
||||
use_bedrock:
|
||||
description: "Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API"
|
||||
required: false
|
||||
default: "false"
|
||||
use_vertex:
|
||||
description: "Use Google Vertex AI with OIDC authentication instead of direct Anthropic API"
|
||||
required: false
|
||||
default: "false"
|
||||
allowed_tools:
|
||||
description: "Additional tools for Claude to use (the base GitHub tools will always be included)"
|
||||
required: false
|
||||
default: ""
|
||||
disallowed_tools:
|
||||
description: "Tools that Claude should never use"
|
||||
required: false
|
||||
default: ""
|
||||
custom_instructions:
|
||||
description: "Additional custom instructions to include in the prompt for Claude"
|
||||
required: false
|
||||
default: ""
|
||||
mcp_config:
|
||||
description: "Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers"
|
||||
required: false
|
||||
default: ""
|
||||
claude_env:
|
||||
description: "Custom environment variables to pass to Claude Code execution (YAML format)"
|
||||
required: false
|
||||
default: ""
|
||||
direct_prompt:
|
||||
description: "Direct prompt for Claude to execute automatically without needing a trigger (for automated workflows)"
|
||||
required: false
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Install Claude Code
|
||||
shell: bash
|
||||
run: npm install -g @anthropic-ai/claude-code
|
||||
|
||||
- name: Install GitHub MCP Server
|
||||
shell: bash
|
||||
run: |
|
||||
claude mcp add-json github '{
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"run",
|
||||
"-i",
|
||||
"--rm",
|
||||
"-e",
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN",
|
||||
"ghcr.io/github/github-mcp-server:sha-ff3036d"
|
||||
],
|
||||
"env": {
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "${{ inputs.github_token }}"
|
||||
}
|
||||
}'
|
||||
|
||||
- name: Extract GitHub Context and Create Prompt
|
||||
shell: bash
|
||||
id: prepare_context
|
||||
env:
|
||||
# Pass user-controlled inputs via environment variables to prevent script injection (GHSL-2025-093)
|
||||
EVENT_COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
EVENT_ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
EVENT_ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
EVENT_PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
EVENT_REVIEW_BODY: ${{ github.event.review.body }}
|
||||
EVENT_ASSIGNEE_LOGIN: ${{ github.event.assignee.login }}
|
||||
INPUT_TRIGGER_PHRASE: ${{ inputs.trigger_phrase }}
|
||||
INPUT_ASSIGNEE_TRIGGER: ${{ inputs.assignee_trigger }}
|
||||
INPUT_DIRECT_PROMPT: ${{ inputs.direct_prompt }}
|
||||
EVENT_ACTION: ${{ github.event.action }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
REPO_NAME: ${{ github.repository }}
|
||||
run: |
|
||||
echo "🔍 Extracting GitHub context from event: $EVENT_NAME"
|
||||
|
||||
# Function to check for trigger phrase
|
||||
check_trigger() {
|
||||
local text="$1"
|
||||
local trigger="$INPUT_TRIGGER_PHRASE"
|
||||
if [[ "$text" == *"$trigger"* ]]; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Extract context based on event type
|
||||
TRIGGER_FOUND="false"
|
||||
USER_REQUEST=""
|
||||
CONTEXT_INFO=""
|
||||
|
||||
case "$EVENT_NAME" in
|
||||
"issue_comment")
|
||||
COMMENT_BODY="$EVENT_COMMENT_BODY"
|
||||
ISSUE_TITLE="$EVENT_ISSUE_TITLE"
|
||||
ISSUE_NUMBER="$EVENT_ISSUE_NUMBER"
|
||||
|
||||
if check_trigger "$COMMENT_BODY"; then
|
||||
TRIGGER_FOUND="true"
|
||||
USER_REQUEST="$COMMENT_BODY"
|
||||
CONTEXT_INFO="Issue Comment on #$ISSUE_NUMBER: $ISSUE_TITLE"
|
||||
fi
|
||||
;;
|
||||
|
||||
"pull_request_review_comment")
|
||||
COMMENT_BODY="$EVENT_COMMENT_BODY"
|
||||
PR_TITLE="$EVENT_PR_TITLE"
|
||||
PR_NUMBER="$EVENT_PR_NUMBER"
|
||||
|
||||
if check_trigger "$COMMENT_BODY"; then
|
||||
TRIGGER_FOUND="true"
|
||||
USER_REQUEST="$COMMENT_BODY"
|
||||
CONTEXT_INFO="PR Comment on #$PR_NUMBER: $PR_TITLE"
|
||||
fi
|
||||
;;
|
||||
|
||||
"pull_request_review")
|
||||
REVIEW_BODY="$EVENT_REVIEW_BODY"
|
||||
PR_TITLE="$EVENT_PR_TITLE"
|
||||
PR_NUMBER="$EVENT_PR_NUMBER"
|
||||
|
||||
if check_trigger "$REVIEW_BODY"; then
|
||||
TRIGGER_FOUND="true"
|
||||
USER_REQUEST="$REVIEW_BODY"
|
||||
CONTEXT_INFO="PR Review on #$PR_NUMBER: $PR_TITLE"
|
||||
fi
|
||||
;;
|
||||
|
||||
"issues")
|
||||
ISSUE_BODY="$EVENT_ISSUE_BODY"
|
||||
ISSUE_TITLE="$EVENT_ISSUE_TITLE"
|
||||
ISSUE_NUMBER="$EVENT_ISSUE_NUMBER"
|
||||
|
||||
if check_trigger "$ISSUE_TITLE" || check_trigger "$ISSUE_BODY"; then
|
||||
TRIGGER_FOUND="true"
|
||||
USER_REQUEST="$ISSUE_BODY"
|
||||
CONTEXT_INFO="Issue #$ISSUE_NUMBER: $ISSUE_TITLE"
|
||||
elif [[ "$EVENT_ACTION" == "assigned" && -n "$INPUT_ASSIGNEE_TRIGGER" ]]; then
|
||||
ASSIGNEE="$EVENT_ASSIGNEE_LOGIN"
|
||||
if [[ "$ASSIGNEE" == "$INPUT_ASSIGNEE_TRIGGER" ]]; then
|
||||
TRIGGER_FOUND="true"
|
||||
USER_REQUEST="$ISSUE_BODY"
|
||||
CONTEXT_INFO="Issue #$ISSUE_NUMBER assigned to $ASSIGNEE: $ISSUE_TITLE"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# Check for direct prompt override
|
||||
if [[ -n "$INPUT_DIRECT_PROMPT" ]]; then
|
||||
TRIGGER_FOUND="true"
|
||||
USER_REQUEST="$INPUT_DIRECT_PROMPT"
|
||||
CONTEXT_INFO="Automated GitHub workflow"
|
||||
fi
|
||||
|
||||
if [[ "$TRIGGER_FOUND" != "true" ]]; then
|
||||
echo "❌ No trigger phrase found or direct prompt provided. Exiting gracefully."
|
||||
echo "SKIP_EXECUTION=true" >> $GITHUB_ENV
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "✅ Trigger found! Context: $CONTEXT_INFO"
|
||||
|
||||
# Create comprehensive prompt
|
||||
mkdir -p /tmp/claude-action
|
||||
cat > /tmp/claude-action/github-context-prompt.txt << EOF
|
||||
You are Claude Code, an AI assistant helping with GitHub workflows and code.
|
||||
|
||||
Repository: $REPO_NAME
|
||||
Context: $CONTEXT_INFO
|
||||
Event: $EVENT_NAME
|
||||
|
||||
User Request:
|
||||
$USER_REQUEST
|
||||
|
||||
Please analyze the request and provide helpful assistance.
|
||||
|
||||
CRITICAL INSTRUCTION: If your task involves modifying codebase files to add a feature or fix a bug:
|
||||
|
||||
STEP 0 — SETUP GIT IDENTITY:
|
||||
git config user.name "MervinPraison"
|
||||
git config user.email "[email protected]"
|
||||
|
||||
STEP 1 — READ GUIDELINES:
|
||||
MANDATORY: Before writing any code, you MUST carefully read "src/praisonai-agents/AGENTS.md" to understand this repository's extremely specific Architecture, Protocol-First design rules, and Agent-Centric Philosophy.
|
||||
|
||||
STEP 2 — ARCHITECTURE VALIDATION & ROUTING (MANDATORY before writing code):
|
||||
Before implementing anything, answer these questions:
|
||||
- CORE vs WRAPPER vs TOOLS ROUTING:
|
||||
1. Core SDK (praisonaiagents/): Only core protocols, base classes, decorators. No heavy implementations.
|
||||
2. Wrapper (praisonai/): CLI, heavy implementations, optional dependencies.
|
||||
3. Tools (PraisonAI-Tools): Specific integrations, external tools (e.g. SurrealDB, Slack), and community extensions MUST go to the `MervinPraison/PraisonAI-Tools` repository. If the feature is a tool, clone `https://github.com/MervinPraison/PraisonAI-Tools`, implement it there, and create the PR using `gh pr create -R MervinPraison/PraisonAI-Tools`.
|
||||
- Does it duplicate existing functionality? Check if Agent already supports this via existing params (reflection, planning, tools, hooks, memory).
|
||||
- Does it inherit from Agent properly? New agent types MUST inherit Agent, not wrap it with composition.
|
||||
- Does it add new dependencies? Only optional deps allowed, must be lazy-imported.
|
||||
- Will agent.py grow larger? If the change adds >50 lines to agent.py, find a way to extract instead.
|
||||
- Is there a name collision with existing exports in __init__.py?
|
||||
If ANY of these conceptual checks fail (excluding routing), add a comment to the issue explaining why and close it. Do NOT create a PR.
|
||||
|
||||
STEP 3 — IMPLEMENT:
|
||||
- Create a new git branch appropriately named (e.g. \`feature/issue-<number>\`)
|
||||
- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper
|
||||
- Keep changes small and backward-compatible
|
||||
|
||||
STEP 4 — TEST:
|
||||
- Run: cd src/praisonai-agents && PYTHONPATH=. python -m pytest tests/ -x -q --timeout=30
|
||||
- Ensure no regressions
|
||||
|
||||
STEP 5 — CREATE PR:
|
||||
- Commit with descriptive message, push the branch
|
||||
- Use the GitHub CLI (\`gh pr create --title "..." --body "Fixes #<number>"\`) to open a Pull Request
|
||||
CRITICAL: You MUST create the PR automatically using \`gh pr create\`. Do NOT just provide a link.
|
||||
|
||||
Respond naturally and helpfully to the user's request using the available tools.
|
||||
EOF
|
||||
|
||||
echo "PROMPT_FILE=/tmp/claude-action/github-context-prompt.txt" >> $GITHUB_ENV
|
||||
echo "SKIP_EXECUTION=false" >> $GITHUB_ENV
|
||||
|
||||
- name: Run Claude Code
|
||||
if: env.SKIP_EXECUTION != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "🚀 Running Claude Code with GitHub context..."
|
||||
|
||||
# Build command arguments
|
||||
CMD_ARGS=("-p" "--verbose" "--output-format" "stream-json")
|
||||
|
||||
# Add max turns if specified
|
||||
if [[ -n "${{ inputs.max_turns }}" ]]; then
|
||||
CMD_ARGS+=("--max-turns" "${{ inputs.max_turns }}")
|
||||
fi
|
||||
|
||||
# Add allowed tools (include GitHub tools by default)
|
||||
TOOLS="mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues,mcp__github__create_comment,Read,Write,Edit,Bash"
|
||||
if [[ -n "${{ inputs.allowed_tools }}" ]]; then
|
||||
TOOLS="$TOOLS,${{ inputs.allowed_tools }}"
|
||||
fi
|
||||
CMD_ARGS+=("--allowedTools" "$TOOLS")
|
||||
|
||||
# Add disallowed tools
|
||||
if [[ -n "${{ inputs.disallowed_tools }}" ]]; then
|
||||
CMD_ARGS+=("--disallowedTools" "${{ inputs.disallowed_tools }}")
|
||||
fi
|
||||
|
||||
echo "📝 Executing Claude Code with prompt from file..."
|
||||
|
||||
# Execute Claude Code with timeout, using stdin for the prompt
|
||||
TIMEOUT_SECONDS=$((${{ inputs.timeout_minutes }} * 60))
|
||||
timeout $TIMEOUT_SECONDS claude "${CMD_ARGS[@]}" < "${{ env.PROMPT_FILE }}"
|
||||
|
||||
echo "✅ Claude Code execution completed"
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ inputs.claude_code_oauth_token }}
|
||||
GITHUB_TOKEN: ${{ inputs.github_token }}
|
||||
@@ -0,0 +1,91 @@
|
||||
name: "Claude Issue Triage Action"
|
||||
description: "Automatically triage GitHub issues using Claude Code"
|
||||
|
||||
inputs:
|
||||
timeout_minutes:
|
||||
description: "Timeout in minutes for execution"
|
||||
required: false
|
||||
default: "5"
|
||||
claude_code_oauth_token:
|
||||
description: "Claude Code OAuth token for Pro subscription access"
|
||||
required: true
|
||||
github_token:
|
||||
description: "GitHub token with repo and issues permissions"
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Checkout repository code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Create prompt
|
||||
id: generate_prompt
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p /tmp/claude-prompts
|
||||
cat > /tmp/claude-prompts/claude-issue-triage-prompt.txt << 'EOF'
|
||||
You're an issue triage assistant for GitHub issues. Your task is to analyze the issue and select appropriate labels from the provided list.
|
||||
|
||||
IMPORTANT: Don't post any comments or messages to the issue. Your only action should be to apply labels.
|
||||
|
||||
Issue Information:
|
||||
- REPO: ${{ github.repository }}
|
||||
- ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
|
||||
TASK OVERVIEW:
|
||||
|
||||
1. First, fetch the list of labels available in this repository by running: `gh label list`. Run exactly this command with nothing else.
|
||||
|
||||
2. Next, use the GitHub tools to get context about the issue:
|
||||
- You have access to these tools:
|
||||
- mcp__github__get_issue: Use this to retrieve the current issue's details including title, description, and existing labels
|
||||
- mcp__github__get_issue_comments: Use this to read any discussion or additional context provided in the comments
|
||||
- mcp__github__update_issue: Use this to apply labels to the issue (do not use this for commenting)
|
||||
- mcp__github__search_issues: Use this to find similar issues that might provide context for proper categorization and to identify potential duplicate issues
|
||||
- mcp__github__list_issues: Use this to understand patterns in how other issues are labeled
|
||||
- Start by using mcp__github__get_issue to get the issue details
|
||||
|
||||
3. Analyze the issue content, considering:
|
||||
- The issue title and description
|
||||
- The type of issue (bug report, feature request, question, etc.)
|
||||
- Technical areas mentioned
|
||||
- Severity or priority indicators
|
||||
- User impact
|
||||
- Components affected
|
||||
|
||||
4. Select appropriate labels from the available labels list provided above:
|
||||
- Choose labels that accurately reflect the issue's nature
|
||||
- Be specific but comprehensive
|
||||
- Select priority labels if you can determine urgency (high-priority, med-priority, or low-priority)
|
||||
- Consider platform labels (android, ios) if applicable
|
||||
- If you find similar issues using mcp__github__search_issues, consider using a "duplicate" label if appropriate. Only do so if the issue is a duplicate of another OPEN issue.
|
||||
|
||||
5. Apply the selected labels:
|
||||
- Use mcp__github__update_issue to apply your selected labels
|
||||
- DO NOT post any comments explaining your decision
|
||||
- DO NOT communicate directly with users
|
||||
- If no labels are clearly applicable, do not apply any labels
|
||||
|
||||
IMPORTANT GUIDELINES:
|
||||
- Be thorough in your analysis
|
||||
- Only select labels from the provided list above
|
||||
- DO NOT post any comments to the issue
|
||||
- Your ONLY action should be to apply labels using mcp__github__update_issue
|
||||
- It's okay to not add any labels if none are clearly applicable
|
||||
EOF
|
||||
|
||||
echo "PROMPT<<DELIMITER" >> $GITHUB_OUTPUT
|
||||
cat /tmp/claude-prompts/claude-issue-triage-prompt.txt >> $GITHUB_OUTPUT
|
||||
echo "DELIMITER" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Run Claude Code
|
||||
uses: ./.github/actions/claude-code-action
|
||||
with:
|
||||
direct_prompt: ${{ steps.generate_prompt.outputs.PROMPT }}
|
||||
allowed_tools: "Bash(gh label list),mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues"
|
||||
timeout_minutes: ${{ inputs.timeout_minutes }}
|
||||
claude_code_oauth_token: ${{ inputs.claude_code_oauth_token }}
|
||||
github_token: ${{ inputs.github_token }}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"templateRepo": "MervinPraison/github-automation-template",
|
||||
"stack": "claude",
|
||||
"profile": "tools",
|
||||
"mode": "hub",
|
||||
"hubRef": "v1.1.15",
|
||||
"templateVersion": "1.1.6",
|
||||
"installedAt": "2026-09-07T12:54:25Z"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# Final architecture review prompt (referenced by merge-gate.js via gate-config fields).
|
||||
# Most FINAL review text is assembled dynamically from gate-config.js at runtime.
|
||||
|
||||
See `finalClaudeScope`, `finalClaudeProductValue` in `.github/scripts/gate-config.js`.
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env node
|
||||
const chain = require('./bot-pr-review-chain.js');
|
||||
|
||||
let failed = 0;
|
||||
function assert(name, cond) {
|
||||
if (!cond) {
|
||||
console.error('FAIL:', name);
|
||||
failed++;
|
||||
} else {
|
||||
console.log('ok:', name);
|
||||
}
|
||||
}
|
||||
|
||||
assert('chainKickPosted requires both markers', chain.chainKickPosted([
|
||||
{ user: { login: 'MervinPraison' }, body: '@coderabbitai review' },
|
||||
{ user: { login: 'MervinPraison' }, body: '/review' },
|
||||
]));
|
||||
assert('chainKickPosted false with only coderabbit', !chain.chainKickPosted([
|
||||
{ user: { login: 'MervinPraison' }, body: '@coderabbitai review' },
|
||||
]));
|
||||
assert('coderabbitKickPosted detects coderabbit', chain.coderabbitKickPosted([
|
||||
{ user: { login: 'MervinPraison' }, body: '@coderabbitai review' },
|
||||
]));
|
||||
assert('qodoKickPosted detects qodo', chain.qodoKickPosted([
|
||||
{ user: { login: 'github-actions[bot]' }, body: '/review' },
|
||||
]));
|
||||
assert('ignores coderabbit bot comment', !chain.coderabbitKickPosted([
|
||||
{ user: { login: 'coderabbitai[bot]' }, body: '@coderabbitai review' },
|
||||
]));
|
||||
assert('bot PR author match', chain.isBotOpenedPr({ user: { login: 'praisonai-triage-agent[bot]' } }));
|
||||
assert('generic bot type match', chain.isBotOpenedPr({ user: { login: 'my-bot', type: 'Bot' } }));
|
||||
assert('human author excluded', !chain.isBotOpenedPr({ user: { login: 'MervinPraison', type: 'User' } }));
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
module.exports = {
|
||||
repoFullName: 'MervinPraison/PraisonAI-Tools',
|
||||
triggerLogins: ['MervinPraison', 'github-actions[bot]'],
|
||||
productPathPrefixes: ['praisonai_tools/', 'tests/'],
|
||||
// Workflows stay gated; pyproject.toml is normal for optional tool deps.
|
||||
sensitivePathPatterns: [
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Load per-repo gate configuration (installed as .github/scripts/gate-config.js).
|
||||
*/
|
||||
function loadGateConfig() {
|
||||
try {
|
||||
return require('./gate-config.js');
|
||||
} catch (err) {
|
||||
if (err.code !== 'MODULE_NOT_FOUND') throw err;
|
||||
throw new Error(
|
||||
'Missing .github/scripts/gate-config.js — run install.sh from github-automation-template'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { loadGateConfig };
|
||||
@@ -41,6 +41,39 @@ const withClaudeReply = [
|
||||
assert('not stale when Claude replied after FINAL', !mg.isStaleFinalAfterPush(withClaudeReply, '2026-06-12T09:00:00Z'));
|
||||
assert('claude final reply detected', mg.isClaudeFinalReplyComment(withClaudeReply[1]));
|
||||
|
||||
const mervinReply = {
|
||||
user: { login: 'MervinPraison' },
|
||||
body: "**Claude finished @MervinPraison's task** —— [View job](https://github.com/)",
|
||||
created_at: '2026-06-12T08:30:00Z',
|
||||
};
|
||||
assert('mervin claude final reply detected', mg.isClaudeFinalReplyComment(mervinReply));
|
||||
assert(
|
||||
'not stale when MervinPraison posted Claude finished',
|
||||
!mg.isStaleFinalAfterPush([...finals, mervinReply], '2026-06-12T09:00:00Z')
|
||||
);
|
||||
|
||||
const conflictRebaseComments = [
|
||||
{
|
||||
user: { login: 'MervinPraison' },
|
||||
body: '@claude You are the FINAL architecture reviewer.',
|
||||
created_at: '2026-06-12T08:00:00Z',
|
||||
},
|
||||
{
|
||||
user: { login: 'MervinPraison' },
|
||||
body: '@claude this PR has merge conflicts with `main`.',
|
||||
created_at: '2026-06-12T08:10:00Z',
|
||||
},
|
||||
{
|
||||
user: { login: 'MervinPraison' },
|
||||
body: 'Rebase onto main complete. Force-pushed to branch.',
|
||||
created_at: '2026-06-12T08:20:00Z',
|
||||
},
|
||||
];
|
||||
assert(
|
||||
'allow stale-FINAL recovery after conflict rebase push',
|
||||
!mg.shouldSkipStaleFinalRecovery(conflictRebaseComments, '2026-06-12T08:25:00Z').skip
|
||||
);
|
||||
|
||||
assert('cancelled detect-and-trigger does not block', mg.OPTIONAL_CANCELLED_CHECKS.has('detect-and-trigger'));
|
||||
|
||||
// Stale-FINAL recovery guards (PR #2560 push loop)
|
||||
|
||||
@@ -67,7 +67,11 @@ function isClaudeTriggerNoise(c) {
|
||||
|
||||
function isClaudeFinalReplyComment(c) {
|
||||
const login = (c.user?.login || '').toLowerCase();
|
||||
if (!login.includes('praisonai-triage')) return false;
|
||||
const fromAutomation =
|
||||
login.includes('praisonai-triage') ||
|
||||
login.includes('github-actions') ||
|
||||
AUTO_ACTORS.some((a) => a.toLowerCase() === login);
|
||||
if (!fromAutomation) return false;
|
||||
return (c.body || '').includes('Claude finished');
|
||||
}
|
||||
|
||||
@@ -98,10 +102,38 @@ function isConflictRebaseCompletionComment(c) {
|
||||
return (
|
||||
body.includes('rebase complete') ||
|
||||
body.includes('rebase onto') ||
|
||||
body.includes('force-pushed') ||
|
||||
(body.includes('conflict') && body.includes('resolved'))
|
||||
);
|
||||
}
|
||||
|
||||
/** HEAD landed after conflict rebase but no FINAL @claude since that push. */
|
||||
function needsFinalReReviewAfterConflictRebase(comments, headPushedAt) {
|
||||
if (!headPushedAt) return false;
|
||||
const headTime = new Date(headPushedAt).getTime();
|
||||
const conflictTriggers = comments.filter(isConflictRebaseTriggerComment);
|
||||
if (conflictTriggers.length === 0) return false;
|
||||
|
||||
const latestConflict = conflictTriggers.reduce((a, b) =>
|
||||
new Date(a.created_at) > new Date(b.created_at) ? a : b
|
||||
);
|
||||
const conflictTime = new Date(latestConflict.created_at).getTime();
|
||||
if (headTime <= conflictTime) return false;
|
||||
|
||||
const rebaseDone = comments.some(
|
||||
(c) =>
|
||||
new Date(c.created_at).getTime() > conflictTime && isConflictRebaseCompletionComment(c)
|
||||
);
|
||||
if (!rebaseDone) return false;
|
||||
|
||||
const finalsAfterHead = comments.filter(
|
||||
(c) =>
|
||||
isFinalClaudeTriggerComment(c) &&
|
||||
new Date(c.created_at).getTime() >= headTime - 60000
|
||||
);
|
||||
return finalsAfterHead.length === 0;
|
||||
}
|
||||
|
||||
function conflictRebaseQuiescent(comments, headPushedAt) {
|
||||
const conflictTriggers = comments.filter(isConflictRebaseTriggerComment);
|
||||
if (conflictTriggers.length === 0) return true;
|
||||
@@ -260,6 +292,9 @@ function shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusherLogin =
|
||||
if (headPusherLogin && isClaudeAutomationLogin(headPusherLogin)) {
|
||||
return { skip: true, reason: 'head pushed by Claude automation' };
|
||||
}
|
||||
if (needsFinalReReviewAfterConflictRebase(comments, headPushedAt)) {
|
||||
return { skip: false, reason: '' };
|
||||
}
|
||||
if (isPushSoonAfterLatestFinal(comments, headPushedAt)) {
|
||||
return {
|
||||
skip: true,
|
||||
@@ -867,6 +902,7 @@ module.exports = {
|
||||
hasRecentConflictComment,
|
||||
isConflictRebaseTriggerComment,
|
||||
isConflictRebaseCompletionComment,
|
||||
needsFinalReReviewAfterConflictRebase,
|
||||
conflictRebaseQuiescent,
|
||||
hasHumanChangesRequested,
|
||||
hasAnyChangesRequested,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env node
|
||||
const chain = require('./pr-review-chain.js');
|
||||
|
||||
let failed = 0;
|
||||
function assert(name, cond) {
|
||||
if (!cond) {
|
||||
console.error('FAIL:', name);
|
||||
failed++;
|
||||
} else {
|
||||
console.log('ok:', name);
|
||||
}
|
||||
}
|
||||
|
||||
const coderabbit = {
|
||||
user: { login: 'coderabbitai[bot]' },
|
||||
body: '<!-- summarize by coderabbit.ai --> summary',
|
||||
created_at: '2026-07-02T07:16:00Z',
|
||||
};
|
||||
const greptile = {
|
||||
user: { login: 'greptile-apps[bot]' },
|
||||
body: '<h3>Greptile Summary</h3><p>Looks good</p>',
|
||||
created_at: '2026-07-02T07:19:00Z',
|
||||
};
|
||||
const copilotKick = {
|
||||
user: { login: 'MervinPraison' },
|
||||
body: '@copilot Do a thorough review',
|
||||
created_at: '2026-07-02T07:20:00Z',
|
||||
};
|
||||
const copilotReply = {
|
||||
user: { login: 'copilot-swe-agent' },
|
||||
body: 'Copilot review here',
|
||||
created_at: '2026-07-02T07:22:00Z',
|
||||
};
|
||||
|
||||
assert('prior not ready without greptile', !chain.priorReviewersReady([coderabbit]).ready);
|
||||
assert('prior ready with coderabbit + greptile', chain.priorReviewersReady([coderabbit, greptile]).ready);
|
||||
assert('copilot not ready before trigger', !chain.copilotReviewReady([coderabbit, greptile]).ready);
|
||||
assert(
|
||||
'copilot ready after reply',
|
||||
chain.copilotReviewReady([coderabbit, greptile, copilotKick, copilotReply]).ready
|
||||
);
|
||||
assert(
|
||||
'claude not ready before copilot',
|
||||
!chain.claudeFinalReady([coderabbit, greptile]).ready
|
||||
);
|
||||
assert(
|
||||
'claude ready after full chain',
|
||||
chain.claudeFinalReady([coderabbit, greptile, copilotKick, copilotReply]).ready
|
||||
);
|
||||
assert(
|
||||
'claude timeout fallback when copilot triggered but silent',
|
||||
chain.claudeFinalReady([coderabbit, greptile, copilotKick], [], { allowCopilotTimeout: true }).ready
|
||||
);
|
||||
assert(
|
||||
'no timeout fallback without copilot trigger',
|
||||
!chain.claudeFinalReady([coderabbit, greptile], [], { allowCopilotTimeout: true }).ready
|
||||
);
|
||||
assert(
|
||||
'skipCopilot proceeds after prior reviewers',
|
||||
chain.claudeFinalReady([coderabbit, greptile], [], { skipCopilot: true }).ready
|
||||
);
|
||||
const prev = process.env.REVIEW_CHAIN_SKIP_COPILOT;
|
||||
process.env.REVIEW_CHAIN_SKIP_COPILOT = '1';
|
||||
assert(
|
||||
'REVIEW_CHAIN_SKIP_COPILOT env skips copilot',
|
||||
chain.claudeFinalReady([coderabbit, greptile], [], {}).ready
|
||||
);
|
||||
process.env.REVIEW_CHAIN_SKIP_COPILOT = prev;
|
||||
|
||||
const geminiReview = {
|
||||
user: { login: 'gemini-code-assist[bot]' },
|
||||
body: '## Code Review\n\nThis pull request decomposes the large main.py file',
|
||||
submitted_at: '2026-07-03T08:08:00Z',
|
||||
};
|
||||
assert(
|
||||
'gemini pull review counts as prior reviewer',
|
||||
chain.hasGeminiReview([], [geminiReview])
|
||||
);
|
||||
assert(
|
||||
'prior ready with gemini review only (no gemini comment)',
|
||||
chain.priorReviewersReady([coderabbit, greptile], [geminiReview], { optionalWaitMs: 0 }).ready
|
||||
);
|
||||
|
||||
const oldPr = new Date(Date.now() - 31 * 60 * 1000).toISOString();
|
||||
assert(
|
||||
'prior not ready without coderabbit before timeout',
|
||||
!chain.priorReviewersReady([greptile], [], { prCreatedAt: new Date().toISOString(), requiredWaitMs: 30 * 60 * 1000 }).ready
|
||||
);
|
||||
assert(
|
||||
'prior timeout when coderabbit silent after 30m',
|
||||
chain.priorReviewersReady([greptile], [], { prCreatedAt: oldPr, requiredWaitMs: 30 * 60 * 1000 }).ready
|
||||
);
|
||||
assert(
|
||||
'claude FINAL timeout when required reviewer silent and no prior FINAL',
|
||||
chain.claudeFinalReady([greptile], [], { prCreatedAt: oldPr, requiredWaitMs: 30 * 60 * 1000 }).ready
|
||||
);
|
||||
assert(
|
||||
'claude FINAL timeout auto-skips copilot without explicit skipCopilot',
|
||||
chain.claudeFinalReady([greptile], [], { prCreatedAt: oldPr, requiredWaitMs: 30 * 60 * 1000 }).copilotSkipped
|
||||
);
|
||||
const finalClaude = {
|
||||
user: { login: 'github-actions[bot]' },
|
||||
body: '@claude You are the FINAL architecture reviewer for this PR.',
|
||||
created_at: '2026-07-02T07:25:00Z',
|
||||
};
|
||||
assert(
|
||||
'claude FINAL not re-posted when already triggered',
|
||||
!chain.claudeFinalReady([greptile, finalClaude], [], { prCreatedAt: oldPr, requiredWaitMs: 0 }).ready
|
||||
);
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -29,4 +29,53 @@ try {
|
||||
|
||||
assert('PACKAGE_PATHS includes pyproject', rg.PACKAGE_PATHS.includes('pyproject.toml'));
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
(async () => {
|
||||
const noonUtc = new Date('2026-07-08T12:00:00Z');
|
||||
const mockGithub = {
|
||||
rest: {
|
||||
actions: {
|
||||
listWorkflowRuns: async () => ({
|
||||
data: {
|
||||
workflow_runs: [
|
||||
{
|
||||
conclusion: 'success',
|
||||
created_at: '2026-07-08T09:00:00Z',
|
||||
status: 'completed',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
assert('PATCH_RELEASE_INTERVAL_DAYS is 3', rg.PATCH_RELEASE_INTERVAL_DAYS === 3);
|
||||
assert(
|
||||
'hasSuccessfulReleaseWithinDays blocks recent release',
|
||||
await rg.hasSuccessfulReleaseWithinDays(mockGithub, 'o', 'r', noonUtc)
|
||||
);
|
||||
|
||||
const oldReleaseGithub = {
|
||||
rest: {
|
||||
actions: {
|
||||
listWorkflowRuns: async () => ({
|
||||
data: {
|
||||
workflow_runs: [
|
||||
{
|
||||
conclusion: 'success',
|
||||
created_at: '2026-07-04T09:00:00Z',
|
||||
status: 'completed',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
assert(
|
||||
'hasSuccessfulReleaseWithinDays allows after 3 days',
|
||||
!(await rg.hasSuccessfulReleaseWithinDays(oldReleaseGithub, 'o', 'r', noonUtc))
|
||||
);
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
})();
|
||||
@@ -6,6 +6,8 @@ const https = require('https');
|
||||
const config = require('./gate-config.js');
|
||||
|
||||
const PACKAGE_PATHS = config.packagePaths;
|
||||
/** Minimum days between successful patch auto-releases. */
|
||||
const PATCH_RELEASE_INTERVAL_DAYS = 3;
|
||||
const ACTIVE_RELEASE_STATUSES = new Set([
|
||||
'queued', 'in_progress', 'waiting', 'pending', 'requested',
|
||||
]);
|
||||
@@ -61,8 +63,18 @@ function utcDayStart(now = new Date()) {
|
||||
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||
}
|
||||
|
||||
async function hasSuccessfulReleaseToday(github, owner, repo, now = new Date()) {
|
||||
const dayStart = utcDayStart(now);
|
||||
function releaseIntervalStart(now = new Date(), intervalDays = PATCH_RELEASE_INTERVAL_DAYS) {
|
||||
return new Date(now.getTime() - intervalDays * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
async function hasSuccessfulReleaseWithinDays(
|
||||
github,
|
||||
owner,
|
||||
repo,
|
||||
now = new Date(),
|
||||
intervalDays = PATCH_RELEASE_INTERVAL_DAYS,
|
||||
) {
|
||||
const windowStart = releaseIntervalStart(now, intervalDays);
|
||||
const runs = await github.rest.actions.listWorkflowRuns({
|
||||
owner,
|
||||
repo,
|
||||
@@ -71,10 +83,15 @@ async function hasSuccessfulReleaseToday(github, owner, repo, now = new Date())
|
||||
per_page: 30,
|
||||
});
|
||||
return runs.data.workflow_runs.some(
|
||||
(r) => r.conclusion === 'success' && new Date(r.created_at) >= dayStart
|
||||
(r) => r.conclusion === 'success' && new Date(r.created_at) >= windowStart
|
||||
);
|
||||
}
|
||||
|
||||
/** @deprecated Use hasSuccessfulReleaseWithinDays — kept for selftests. */
|
||||
async function hasSuccessfulReleaseToday(github, owner, repo, now = new Date()) {
|
||||
return hasSuccessfulReleaseWithinDays(github, owner, repo, now, 1);
|
||||
}
|
||||
|
||||
async function lastGreenCiSha(github, owner, repo) {
|
||||
const runs = await github.rest.actions.listWorkflowRuns({
|
||||
owner,
|
||||
@@ -116,9 +133,11 @@ async function evaluateReleasePreflight(github, owner, repo, options, core) {
|
||||
}
|
||||
|
||||
const referenceTime = options.now instanceof Date ? options.now : new Date();
|
||||
if (await hasSuccessfulReleaseToday(github, owner, repo, referenceTime)) {
|
||||
const day = referenceTime.toISOString().slice(0, 10);
|
||||
reasons.push(`already released today (UTC ${day}); max one patch release per day`);
|
||||
if (await hasSuccessfulReleaseWithinDays(github, owner, repo, referenceTime)) {
|
||||
reasons.push(
|
||||
`already released within last ${PATCH_RELEASE_INTERVAL_DAYS} days; `
|
||||
+ `max one patch release every ${PATCH_RELEASE_INTERVAL_DAYS} days`
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -196,6 +215,9 @@ module.exports = {
|
||||
bumpPatch,
|
||||
readVersionsFromTree,
|
||||
pypiVersionExists,
|
||||
PATCH_RELEASE_INTERVAL_DAYS,
|
||||
releaseIntervalStart,
|
||||
hasSuccessfulReleaseWithinDays,
|
||||
hasSuccessfulReleaseToday,
|
||||
utcDayStart,
|
||||
evaluateReleasePreflight,
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
name: Auto Issue Comment
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
trigger-claude:
|
||||
# Skip if issue already has 'claude' label to avoid double-trigger
|
||||
if: |
|
||||
!contains(toJSON(github.event.issue.labels), 'claude')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Add claude label to trigger analysis
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const labels = context.payload.issue.labels.map(l => l.name);
|
||||
if (labels.includes('claude')) {
|
||||
console.log('Issue already has claude label, skipping');
|
||||
return;
|
||||
}
|
||||
await github.rest.issues.addLabels({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: ['claude']
|
||||
});
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude Read this issue (title + body) and all existing comments. Implement or fix in `praisonai_tools/` with minimal changes; follow README and existing tool patterns (lazy optional deps, clear errors). Open a PR when you have commits.\n\n🤖 Auto-triage: label `claude` was added for filtering.'
|
||||
});
|
||||
@@ -1,14 +1,5 @@
|
||||
name: Auto PR Comment
|
||||
|
||||
# Review chain: CodeRabbit/Qodo/Gemini review first → Copilot → Claude (final)
|
||||
# For human PRs: CodeRabbit/Qodo auto-review → this workflow triggers Copilot → then Claude
|
||||
# For bot PRs: CodeRabbit/Qodo skip bots, so we trigger them explicitly first
|
||||
#
|
||||
# IMPORTANT: @copilot ignores bot comments. All @copilot mentions must use
|
||||
# PAT_TOKEN so the comment posts as MervinPraison (a real user).
|
||||
#
|
||||
# Claude trigger runs IN-BAND (same workflow) to avoid GitHub Actions
|
||||
# blocking bot-triggered workflows with action_required approval gates.
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
@@ -17,793 +8,26 @@ on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
pull_request_target:
|
||||
types: [synchronize]
|
||||
types: [opened, synchronize]
|
||||
schedule:
|
||||
- cron: '30 3,9,15,21 * * *'
|
||||
- cron: '30 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number for manual FINAL (leave empty to sweep all open PRs)'
|
||||
required: false
|
||||
type: number
|
||||
|
||||
env:
|
||||
REVIEW_CHAIN_SKIP_COPILOT: '1'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
MAX_POLL_ATTEMPTS: 20
|
||||
POLL_DELAY_MS: 30000
|
||||
CLAUDE_TRIGGER_LOGINS: '["MervinPraison","github-actions[bot]"]'
|
||||
# GH_TOKEN (user PAT) posts as MervinPraison for @copilot; fallback to github.token for @claude
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
CLAUDE_UI_SCOPE: 'SCOPE: Focus ONLY on PraisonAI-Tools (praisonai_tools/, tests). Do not expand into praisonaiagents or the monorepo unless the issue explicitly requires it.'
|
||||
CLAUDE_FINAL_PHASES_COMPACT: "**Phase 1: Review per AGENTS.md** (lazy imports, backward compat, import-time <200ms; SDK/product value gate — genuine agent-tool value, correct tool vs SDK vs Plugins layering, no scope creep)\n**Phase 2: FIX valid issues** found by prior reviewers — push to THIS branch\n**Phase 3: Final verdict** — approve PR if ready, else request changes"
|
||||
CLAUDE_FINAL_PHASES_DETAILED: "**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions (lazy imports, import-time <200ms)\n4. SDK/product value: review whether the change genuinely adds value — correct tool layering (agent-callable integrations here; lifecycle plugins in PraisonAI-Plugins; core SDK in praisonaiagents). Reject scope creep; request changes or recommend closing if value is unclear\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items"
|
||||
|
||||
jobs:
|
||||
# ================================================================
|
||||
# HUMAN PRs: Trigger @copilot AFTER CodeRabbit posts its summary
|
||||
# ================================================================
|
||||
copilot-after-coderabbit:
|
||||
if: |
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
github.event.comment.user.login == 'coderabbitai[bot]' &&
|
||||
contains(github.event.comment.body, 'summary by coderabbit')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
outputs:
|
||||
pr_number: ${{ steps.trigger.outputs.pr_number }}
|
||||
triggered: ${{ steps.trigger.outputs.triggered }}
|
||||
steps:
|
||||
- name: Post Copilot review request (as user via PAT)
|
||||
id: trigger
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
// Check for duplicates — posted by MervinPraison (PAT) or github-actions[bot] (legacy)
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@copilot') &&
|
||||
(c.user.login === 'MervinPraison' || c.user.login === 'github-actions[bot]')
|
||||
);
|
||||
if (alreadyPosted) {
|
||||
console.log('Copilot already triggered, skipping');
|
||||
core.setOutput('triggered', 'false');
|
||||
core.setOutput('pr_number', context.issue.number.toString());
|
||||
return;
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@copilot Do a thorough review of this PR. Read ALL existing reviewer comments above from Qodo, Coderabbit, and Gemini first — incorporate their findings.\n\nReview areas:\n1. **Bloat check**: Are changes minimal and focused? Any unnecessary code or scope creep?\n2. **Security**: Any hardcoded secrets, unsafe eval/exec, missing input validation?\n3. **Performance**: Any module-level heavy imports? Hot-path regressions?\n4. **Tests**: Are tests included? Do they cover the changes adequately?\n5. **Backward compat**: Any public API changes without deprecation?\n6. **Code quality**: DRY violations, naming conventions, error handling?\n7. **Address reviewer feedback**: If Qodo, Coderabbit, or Gemini flagged valid issues, include them in your review\n8. Suggest specific improvements with code examples where possible'
|
||||
});
|
||||
core.setOutput('triggered', 'true');
|
||||
core.setOutput('pr_number', context.issue.number.toString());
|
||||
|
||||
# Fallback: Trigger @copilot after Qodo review (if Coderabbit doesn't fire)
|
||||
copilot-after-qodo:
|
||||
if: |
|
||||
github.event_name == 'pull_request_review' &&
|
||||
github.event.review.user.login == 'qodo-code-review[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
outputs:
|
||||
pr_number: ${{ steps.trigger.outputs.pr_number }}
|
||||
triggered: ${{ steps.trigger.outputs.triggered }}
|
||||
steps:
|
||||
- name: Post Copilot review request (as user via PAT)
|
||||
id: trigger
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@copilot') &&
|
||||
(c.user.login === 'MervinPraison' || c.user.login === 'github-actions[bot]')
|
||||
);
|
||||
if (alreadyPosted) {
|
||||
console.log('Copilot already triggered, skipping');
|
||||
core.setOutput('triggered', 'false');
|
||||
core.setOutput('pr_number', context.issue.number.toString());
|
||||
return;
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@copilot Do a thorough review of this PR. Read ALL existing reviewer comments above first.\n\nReview areas:\n1. **Bloat check**: Are changes minimal and focused?\n2. **Security**: Any hardcoded secrets, unsafe eval/exec, missing input validation?\n3. **Performance**: Any module-level heavy imports? Hot-path regressions?\n4. **Tests**: Are tests included? Do they cover the changes adequately?\n5. **Backward compat**: Any public API changes without deprecation?\n6. **Code quality**: DRY violations, naming conventions, error handling?\n7. Suggest specific improvements with code examples where possible'
|
||||
});
|
||||
core.setOutput('triggered', 'true');
|
||||
core.setOutput('pr_number', context.issue.number.toString());
|
||||
|
||||
# ================================================================
|
||||
# FALLBACK: Claude trigger for cases where Copilot chain fails/skips
|
||||
# Ensures Claude always runs after sufficient review time
|
||||
# ================================================================
|
||||
claude-fallback-timeout:
|
||||
if: |
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.action == 'opened'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
review-chain:
|
||||
# Grant union of callee job permissions (repos with default_workflow_permissions: read
|
||||
# reject reusable calls when the caller ceiling is lower than any callee job).
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Wait for review window (15 min)
|
||||
run: sleep 900
|
||||
|
||||
- name: Check if Claude already triggered
|
||||
id: check_claude
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
core.setOutput('comments_count', comments.data.length.toString());
|
||||
|
||||
const botReviews = comments.data.filter(c =>
|
||||
['coderabbitai[bot]', 'qodo-code-review[bot]', 'gemini-code-assist[bot]',
|
||||
'copilot-swe-agent', 'Copilot', 'greptile-apps[bot]'].some(bot =>
|
||||
c.user.login.toLowerCase().includes(bot.toLowerCase())
|
||||
)
|
||||
);
|
||||
core.setOutput('review_count', botReviews.length.toString());
|
||||
|
||||
- name: Aggregate reviews and trigger Claude
|
||||
if: steps.check_claude.outputs.already_triggered != 'true'
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const comments = await github.rest.issues.listComments({issue_number: pr, owner: context.repo.owner, repo: context.repo.repo, per_page: 100});
|
||||
const reviews = {coderabbit: [], qodo: [], gemini: [], copilot: [], greptile: [], others: []};
|
||||
comments.data.forEach(c => {
|
||||
const login = c.user.login.toLowerCase();
|
||||
const body = c.body.substring(0, 500);
|
||||
if (login.includes('coderabbit')) reviews.coderabbit.push(body);
|
||||
else if (login.includes('qodo')) reviews.qodo.push(body);
|
||||
else if (login.includes('gemini')) reviews.gemini.push(body);
|
||||
else if (login.includes('copilot')) reviews.copilot.push(body);
|
||||
else if (login.includes('greptile')) reviews.greptile.push(body);
|
||||
else if (c.user.type === 'Bot') reviews.others.push(body);
|
||||
});
|
||||
const summaryParts = [];
|
||||
if (reviews.coderabbit.length) summaryParts.push('CodeRabbit: ' + reviews.coderabbit.length + ' comments');
|
||||
if (reviews.qodo.length) summaryParts.push('Qodo: ' + reviews.qodo.length + ' comments');
|
||||
if (reviews.gemini.length) summaryParts.push('Gemini: ' + reviews.gemini.length + ' comments');
|
||||
if (reviews.copilot.length) summaryParts.push('Copilot: ' + reviews.copilot.length + ' comments');
|
||||
if (reviews.greptile.length) summaryParts.push('Greptile: ' + reviews.greptile.length + ' comments');
|
||||
const reviewSummary = summaryParts.length ? summaryParts.join(' | ') : 'No bot reviews detected';
|
||||
const scope = process.env.CLAUDE_UI_SCOPE;
|
||||
const phases = process.env.CLAUDE_FINAL_PHASES_COMPACT;
|
||||
const claudeBody = '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI-Tools (not a fork), you are able to make modifications to this branch and push directly. ' + scope + ' Read ALL prior reviewer comments. ' + reviewSummary + '.\n\n' +
|
||||
'Review Context: ' + comments.data.length + ' total comments, ' +
|
||||
(reviews.coderabbit.length + reviews.qodo.length + reviews.gemini.length + reviews.copilot.length + reviews.greptile.length) + ' bot reviews.\n\n' +
|
||||
phases;
|
||||
await github.rest.issues.createComment({issue_number: pr, owner: context.repo.owner, repo: context.repo.repo, body: claudeBody});
|
||||
console.log('Claude fallback triggered on PR #' + pr + ' with ' + reviewSummary);
|
||||
|
||||
# ================================================================
|
||||
# Claude FINAL review — polls for Copilot's response, then triggers
|
||||
# This runs IN-BAND under the PAT context (MervinPraison), avoiding
|
||||
# the action_required approval gate that blocks bot-triggered workflows.
|
||||
# ================================================================
|
||||
claude-after-copilot:
|
||||
needs: [copilot-after-coderabbit]
|
||||
if: |
|
||||
always() &&
|
||||
needs.copilot-after-coderabbit.result == 'success' &&
|
||||
needs.copilot-after-coderabbit.outputs.triggered == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Wait for Copilot to respond (poll)
|
||||
id: poll
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
|
||||
MAX_POLL_ATTEMPTS: ${{ env.MAX_POLL_ATTEMPTS }}
|
||||
POLL_DELAY_MS: ${{ env.POLL_DELAY_MS }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
// Poll for Copilot's response — check every 30s for up to 10 minutes
|
||||
const maxAttempts = parseInt(process.env.MAX_POLL_ATTEMPTS, 10);
|
||||
const delayMs = parseInt(process.env.POLL_DELAY_MS, 10);
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
console.log(`Poll attempt ${i + 1}/${maxAttempts}...`);
|
||||
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr,
|
||||
owner,
|
||||
repo,
|
||||
per_page: 50,
|
||||
sort: 'created',
|
||||
direction: 'desc'
|
||||
});
|
||||
|
||||
// Look for Copilot's response (copilot-swe-agent or Copilot)
|
||||
const copilotResponse = comments.data.find(c =>
|
||||
(c.user.login === 'copilot-swe-agent' ||
|
||||
c.user.login === 'Copilot' ||
|
||||
c.user.login.toLowerCase().includes('copilot')) &&
|
||||
c.user.type === 'Bot'
|
||||
);
|
||||
|
||||
if (copilotResponse) {
|
||||
console.log(`Copilot responded at ${copilotResponse.created_at}`);
|
||||
core.setOutput('copilot_responded', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
// Also check PR reviews for Copilot
|
||||
const reviews = await github.rest.pulls.listReviews({
|
||||
pull_number: pr,
|
||||
owner,
|
||||
repo
|
||||
});
|
||||
|
||||
const copilotReview = reviews.data.find(r =>
|
||||
r.user.login.toLowerCase().includes('copilot')
|
||||
);
|
||||
|
||||
if (copilotReview) {
|
||||
console.log(`Copilot review submitted at ${copilotReview.submitted_at}`);
|
||||
core.setOutput('copilot_responded', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
if (i < maxAttempts - 1) {
|
||||
console.log(`Copilot hasn't responded yet, waiting ${delayMs/1000}s...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Copilot did not respond within timeout, triggering Claude anyway');
|
||||
core.setOutput('copilot_responded', 'timeout');
|
||||
|
||||
- name: Check if Claude already triggered
|
||||
id: check_claude
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
|
||||
- name: Trigger Claude final review
|
||||
if: steps.check_claude.outputs.already_triggered != 'true'
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const scope = process.env.CLAUDE_UI_SCOPE;
|
||||
const phases = process.env.CLAUDE_FINAL_PHASES_DETAILED;
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI-Tools (not a fork), you are able to make modifications to this branch and push directly. ' + scope + ' Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n' + phases
|
||||
});
|
||||
console.log(`Claude triggered on PR #${pr}`);
|
||||
|
||||
- name: Sync pipeline status labels
|
||||
if: steps.check_claude.outputs.already_triggered != 'true'
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
|
||||
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
|
||||
# ================================================================
|
||||
# Claude after Qodo fallback path (same polling approach)
|
||||
# ================================================================
|
||||
claude-after-copilot-qodo:
|
||||
needs: [copilot-after-qodo]
|
||||
if: |
|
||||
always() &&
|
||||
needs.copilot-after-qodo.result == 'success' &&
|
||||
needs.copilot-after-qodo.outputs.triggered == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Wait for Copilot to respond (poll)
|
||||
id: poll
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
|
||||
MAX_POLL_ATTEMPTS: ${{ env.MAX_POLL_ATTEMPTS }}
|
||||
POLL_DELAY_MS: ${{ env.POLL_DELAY_MS }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const maxAttempts = parseInt(process.env.MAX_POLL_ATTEMPTS, 10);
|
||||
const delayMs = parseInt(process.env.POLL_DELAY_MS, 10);
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
console.log(`Poll attempt ${i + 1}/${maxAttempts}...`);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr, owner, repo,
|
||||
per_page: 50, sort: 'created', direction: 'desc'
|
||||
});
|
||||
const copilotResponse = comments.data.find(c =>
|
||||
(c.user.login === 'copilot-swe-agent' ||
|
||||
c.user.login === 'Copilot' ||
|
||||
c.user.login.toLowerCase().includes('copilot')) &&
|
||||
c.user.type === 'Bot'
|
||||
);
|
||||
if (copilotResponse) {
|
||||
console.log(`Copilot responded at ${copilotResponse.created_at}`);
|
||||
core.setOutput('copilot_responded', 'true');
|
||||
return;
|
||||
}
|
||||
const reviews = await github.rest.pulls.listReviews({
|
||||
pull_number: pr, owner, repo
|
||||
});
|
||||
const copilotReview = reviews.data.find(r =>
|
||||
r.user.login.toLowerCase().includes('copilot')
|
||||
);
|
||||
if (copilotReview) {
|
||||
console.log(`Copilot review submitted at ${copilotReview.submitted_at}`);
|
||||
core.setOutput('copilot_responded', 'true');
|
||||
return;
|
||||
}
|
||||
if (i < maxAttempts - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
console.log('Copilot did not respond within timeout, triggering Claude anyway');
|
||||
core.setOutput('copilot_responded', 'timeout');
|
||||
|
||||
- name: Check if Claude already triggered
|
||||
id: check_claude
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
|
||||
- name: Trigger Claude final review
|
||||
if: steps.check_claude.outputs.already_triggered != 'true'
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const scope = process.env.CLAUDE_UI_SCOPE;
|
||||
const phases = process.env.CLAUDE_FINAL_PHASES_DETAILED;
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI-Tools (not a fork), you are able to make modifications to this branch and push directly. ' + scope + ' Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n' + phases
|
||||
});
|
||||
console.log(`Claude triggered on PR #${pr}`);
|
||||
|
||||
# ================================================================
|
||||
# Claude after Gemini — restores the trigger lost from the deleted
|
||||
# chain-claude-after-copilot.yml workflow.
|
||||
# Fires when Gemini posts a review comment on a PR or issue.
|
||||
# ================================================================
|
||||
claude-after-gemini:
|
||||
if: |
|
||||
github.event_name == 'issue_comment' &&
|
||||
(
|
||||
github.event.comment.user.login == 'gemini-code-assist[bot]' ||
|
||||
contains(github.event.comment.body, 'Review completed by Gemini CLI') ||
|
||||
contains(github.event.comment.body, 'The fix is ready for review. Please test')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check if Claude already triggered
|
||||
id: check_claude
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
|
||||
- name: Post Claude analysis request (Lead Engineer)
|
||||
if: steps.check_claude.outputs.already_triggered != 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const scope = process.env.CLAUDE_UI_SCOPE;
|
||||
const phases = process.env.CLAUDE_FINAL_PHASES_DETAILED;
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the Lead Engineer. If the branch is under MervinPraison/PraisonAI-Tools (not a fork), you are able to make modifications to this branch and push directly. ' + scope + ' Read ALL analysis and reviews above carefully (Gemini, CodeRabbit, Qodo, Copilot, etc).\n\n' + phases
|
||||
});
|
||||
|
||||
# ================================================================
|
||||
# Recovery: re-post FINAL @claude after push or when chain missed
|
||||
# ================================================================
|
||||
claude-review-recovery:
|
||||
if: |
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.action == 'synchronize'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Re-post FINAL Claude review when stale or missing
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pr-review-chain.js'));
|
||||
const pr = context.payload.pull_request.number;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
const comments = await mergeGate.listAllComments(github, owner, repo, pr);
|
||||
const headPushedAt = (await mergeGate.getHeadCommitDate(github, owner, repo, pr))
|
||||
|| context.payload.pull_request.updated_at;
|
||||
const headPusher = context.payload.sender?.login || null;
|
||||
|
||||
const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(comments);
|
||||
const isStale = mergeGate.isStaleFinalAfterPush(comments, headPushedAt);
|
||||
|
||||
if (mergeGate.shouldSkipFinalRecovery(comments, headPushedAt)) {
|
||||
core.info('Recent @claude within 35min and FINAL not stale — skip recovery');
|
||||
return;
|
||||
}
|
||||
|
||||
if (await mergeGate.hasInProgressClaudeAssistant(github, owner, repo, pr)) {
|
||||
core.info('Claude workflow in progress — skip recovery');
|
||||
return;
|
||||
}
|
||||
|
||||
const finalBody = mergeGate.FINAL_CLAUDE_REVIEW_BODY;
|
||||
const staleOpts = { allowStaleRepost: true, skipCopilot: true, optionalWaitMs: 0 };
|
||||
const missingOpts = { allowCopilotTimeout: true, optionalWaitMs: 0 };
|
||||
|
||||
if (hasFinal && isStale) {
|
||||
const gate = mergeGate.shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusher);
|
||||
if (gate.skip) {
|
||||
core.info(`Skip stale-FINAL recovery: ${gate.reason}`);
|
||||
return;
|
||||
}
|
||||
const result = await chain.maybeTriggerClaudeFinal(
|
||||
github, owner, repo, pr, finalBody, core, staleOpts
|
||||
);
|
||||
if (result.posted) core.info(`Posted stale-FINAL recovery @claude on PR #${pr}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasFinal) {
|
||||
core.info('FINAL Claude review current on HEAD — skip recovery');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await chain.maybeTriggerClaudeFinal(
|
||||
github, owner, repo, pr, finalBody, core, missingOpts
|
||||
);
|
||||
if (result.posted) core.info(`Posted missing FINAL Claude review on PR #${pr}`);
|
||||
|
||||
- name: Sync pipeline status labels
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const pr = context.payload.pull_request.number;
|
||||
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
|
||||
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
|
||||
# Scheduled + manual: post missing or stale FINAL @claude on all open PRs
|
||||
post-missing-claude-final:
|
||||
if: |
|
||||
github.event_name == 'schedule' ||
|
||||
(github.event_name == 'workflow_dispatch' && !inputs.pr_number)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Post missing/stale FINAL @claude on open PRs
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pr-review-chain.js'));
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner, repo, state: 'open', per_page: 100,
|
||||
});
|
||||
let posted = 0;
|
||||
for (const pr of prs) {
|
||||
if (pr.draft) continue;
|
||||
const comments = await mergeGate.listAllComments(github, owner, repo, pr.number);
|
||||
const headPushedAt = (await mergeGate.getHeadCommitDate(github, owner, repo, pr.number)) || pr.updated_at;
|
||||
const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(comments);
|
||||
const isStale = mergeGate.isStaleFinalAfterPush(comments, headPushedAt);
|
||||
|
||||
if (hasFinal && !isStale) continue;
|
||||
if (mergeGate.hasRecentClaudeTrigger(comments, 10)) {
|
||||
core.info(`PR #${pr.number}: @claude within 10min — skip`);
|
||||
continue;
|
||||
}
|
||||
if (await mergeGate.hasInProgressClaudeAssistant(github, owner, repo, pr.number)) {
|
||||
core.info(`PR #${pr.number}: Claude in progress — skip`);
|
||||
continue;
|
||||
}
|
||||
if (hasFinal && isStale) {
|
||||
const gate = mergeGate.shouldSkipStaleFinalRecovery(comments, headPushedAt);
|
||||
if (gate.skip) {
|
||||
core.info(`PR #${pr.number}: skip stale — ${gate.reason}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const opts = hasFinal && isStale
|
||||
? { allowStaleRepost: true, skipCopilot: true, optionalWaitMs: 0 }
|
||||
: { allowCopilotTimeout: true, optionalWaitMs: 0 };
|
||||
const result = await chain.maybeTriggerClaudeFinal(
|
||||
github, owner, repo, pr.number,
|
||||
mergeGate.FINAL_CLAUDE_REVIEW_BODY, core, opts
|
||||
);
|
||||
if (result.posted) {
|
||||
posted++;
|
||||
core.info(`Posted Claude FINAL on PR #${pr.number}`);
|
||||
}
|
||||
await ps.syncPipelineLabels(github, owner, repo, pr.number, core);
|
||||
if (posted >= 10) break;
|
||||
}
|
||||
core.info(`Missing/stale Claude FINAL sweep complete (${posted} posted)`);
|
||||
|
||||
# ================================================================
|
||||
# BOT PRs: CodeRabbit → Claude directly (skip Copilot when no user PAT)
|
||||
# Copilot ignores github-actions[bot] comments; bot PRs use this path.
|
||||
# ================================================================
|
||||
claude-after-coderabbit-bot-pr:
|
||||
if: |
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
github.event.comment.user.login == 'coderabbitai[bot]' &&
|
||||
contains(github.event.comment.body, 'summary by coderabbit')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Trigger Claude final review on bot PR
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = context.issue.number;
|
||||
const { data: pull } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr,
|
||||
});
|
||||
const author = pull.user.login;
|
||||
const isBot = author === 'github-actions[bot]' ||
|
||||
author === 'praisonai-triage-agent[bot]' ||
|
||||
pull.user.type === 'Bot';
|
||||
if (!isBot) {
|
||||
console.log('Human PR — Copilot chain handles Claude trigger');
|
||||
return;
|
||||
}
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 100,
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
if (alreadyPosted) {
|
||||
console.log('Claude already triggered on PR #' + pr);
|
||||
return;
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the FINAL architecture reviewer on this bot-authored PR. Push fixes directly to THIS branch (do NOT open a new PR). ' + process.env.CLAUDE_UI_SCOPE + ' Read ALL CodeRabbit/Qodo comments above.\n\n' + process.env.CLAUDE_FINAL_PHASES_COMPACT
|
||||
});
|
||||
console.log('Claude final review triggered on bot PR #' + pr);
|
||||
|
||||
# ================================================================
|
||||
# Manual: trigger Claude final review on any PR
|
||||
# ================================================================
|
||||
claude-manual-dispatch:
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.pr_number
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Trigger Claude final review
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ inputs.pr_number }}
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 100,
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
if (alreadyPosted) {
|
||||
console.log('Claude already triggered on PR #' + pr);
|
||||
return;
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI-Tools (not a fork), you are able to make modifications to this branch and push directly. ' + process.env.CLAUDE_UI_SCOPE + ' Read all prior reviewer comments.\n\n' + process.env.CLAUDE_FINAL_PHASES_COMPACT
|
||||
});
|
||||
console.log('Claude manually triggered on PR #' + pr);
|
||||
|
||||
# ================================================================
|
||||
# BOT PRs: kick CodeRabbit + Qodo
|
||||
# ================================================================
|
||||
bot-pr-trigger-reviews:
|
||||
if: |
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.action == 'opened' &&
|
||||
(
|
||||
github.event.pull_request.user.login == 'github-actions[bot]' ||
|
||||
github.event.pull_request.user.login == 'praisonai-triage-agent[bot]' ||
|
||||
github.event.pull_request.user.type == 'Bot'
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Trigger CodeRabbit and Qodo reviews
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/bot-pr-review-chain.js'));
|
||||
const pr = context.payload.pull_request.number;
|
||||
await chain.kickReviewChain(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
|
||||
- name: Sync pipeline status labels
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const pr = context.payload.pull_request.number;
|
||||
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
|
||||
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.20
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Bot PR Recovery
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/15 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'Specific PR number to kick (optional)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
recover:
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.20
|
||||
pr_number: ${{ github.event.inputs.pr_number || '' }}
|
||||
@@ -1,17 +1,14 @@
|
||||
name: Auto CI Failure → Claude
|
||||
|
||||
# Detects internal PRs with failing CI and posts @claude with extracted failure details.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
workflows: [CI, Optimized Test Suite]
|
||||
types: [completed]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'Optional PR number to process (default: scan blocked PRs)'
|
||||
required: false
|
||||
type: string
|
||||
schedule:
|
||||
@@ -21,10 +18,6 @@ concurrency:
|
||||
group: ci-failure-scan-${{ github.repository }}-${{ github.event.pull_request.number || github.event.workflow_run.id || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# PAT posts as MervinPraison so issue_comment triggers claude.yml (GITHUB_TOKEN cannot chain workflows)
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
issues: write
|
||||
@@ -32,71 +25,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
detect-and-trigger:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Detect CI failures and trigger Claude
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ciFix = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/ci-failure-claude.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const baseRepo = `${owner}/${repo}`;
|
||||
|
||||
async function processPR(prNumber) {
|
||||
return ciFix.maybeTriggerCiFixClaude(github, owner, repo, prNumber, core);
|
||||
}
|
||||
|
||||
let results = [];
|
||||
|
||||
if (context.eventName === 'workflow_run') {
|
||||
const wr = context.payload.workflow_run;
|
||||
const result = await ciFix.processWorkflowRunFailure(
|
||||
github, owner, repo, wr, core
|
||||
);
|
||||
results.push({ event: 'workflow_run', workflow: wr.name, ...result });
|
||||
} else if (context.eventName === 'pull_request') {
|
||||
results.push({
|
||||
pr: context.payload.pull_request.number,
|
||||
...(await processPR(context.payload.pull_request.number)),
|
||||
});
|
||||
} else {
|
||||
let prNumbers = [];
|
||||
if (context.eventName === 'workflow_dispatch' && context.payload.inputs?.pr_number) {
|
||||
prNumbers = [Number(context.payload.inputs.pr_number)];
|
||||
} else {
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
for (const pr of prs) {
|
||||
if (pr.draft) continue;
|
||||
const headRepo = pr.head?.repo?.full_name;
|
||||
if (headRepo && headRepo !== baseRepo) continue;
|
||||
const { data: issue } = await github.rest.issues.get({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
});
|
||||
const labels = issue.labels.map((l) => l.name);
|
||||
if (labels.includes('pipeline/blocked:ci') || labels.includes(ciFix.CI_FIX_LABEL)) {
|
||||
prNumbers.push(pr.number);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const num of prNumbers) {
|
||||
results.push({ pr: num, ...(await processPR(num)) });
|
||||
}
|
||||
}
|
||||
|
||||
core.summary.addRaw('```json\n' + JSON.stringify(results, null, 2) + '\n```');
|
||||
await core.summary.write();
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.20
|
||||
pr_number: ${{ github.event.inputs.pr_number || '' }}
|
||||
@@ -1,46 +1,30 @@
|
||||
name: Claude PR Merge Gate
|
||||
|
||||
# Read-only Claude assesses merge readiness; GH_TOKEN merges (no @claude, no gh pr review).
|
||||
# Does not overlap with merge-conflict-claude.yml or auto-pr-comment @claude chains.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Scan for merge-ready PRs every 10 minutes (assess runs only when candidates exist).
|
||||
- cron: '*/10 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'Optional PR number (scan all open internal PRs if empty)'
|
||||
required: false
|
||||
type: string
|
||||
merge_method:
|
||||
description: 'Merge method'
|
||||
required: false
|
||||
type: choice
|
||||
default: merge
|
||||
options:
|
||||
- merge
|
||||
- squash
|
||||
- rebase
|
||||
options: [merge, squash, rebase]
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Claude Assistant
|
||||
- CI
|
||||
types:
|
||||
- completed
|
||||
types: [completed]
|
||||
repository_dispatch:
|
||||
types:
|
||||
- claude-merge-gate
|
||||
types: [claude-merge-gate]
|
||||
|
||||
concurrency:
|
||||
group: claude-merge-gate-${{ github.repository }}-${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || github.event.workflow_run.id || 'scan' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
MAX_CANDIDATES: 3
|
||||
DISPATCH_PR_NUMBER: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -59,7 +43,9 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected]
|
||||
with:
|
||||
hub_ref: v1.1.20
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
@@ -67,465 +53,44 @@ jobs:
|
||||
app-id: ${{ secrets.CLAUDE_APP_ID }}
|
||||
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- name: Dispatch merge gate when PR pipeline is ready
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const mergeGate = require(path.join(process.env.HUB_SCRIPTS, 'merge-gate.js'));
|
||||
const wr = context.payload.workflow_run;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
if (wr.conclusion !== 'success') {
|
||||
core.info(`Skip dispatch: ${wr.name} conclusion=${wr.conclusion}`);
|
||||
return;
|
||||
}
|
||||
if (wr.conclusion !== 'success') return;
|
||||
const allowed = ['Claude Assistant', 'CI'];
|
||||
if (!allowed.includes(wr.name)) {
|
||||
core.info(`Skip dispatch: workflow ${wr.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allowed.includes(wr.name)) return;
|
||||
const prNumber = await mergeGate.resolvePrNumberFromWorkflowRun(github, owner, repo, wr);
|
||||
if (!prNumber) {
|
||||
core.info('Skip dispatch: could not resolve PR from workflow run');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!prNumber) return;
|
||||
const fromClaude = wr.name === 'Claude Assistant';
|
||||
const check = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core, {
|
||||
skipGlobalClaudeRunCheck: fromClaude,
|
||||
skipRecentClaudeCooldown: fromClaude,
|
||||
}
|
||||
);
|
||||
if (!check.ready) {
|
||||
core.info(`PR #${prNumber} not ready: ${check.reasons.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const check = await mergeGate.evaluatePipelineQuiescent(github, owner, repo, prNumber, core, {
|
||||
skipGlobalClaudeRunCheck: fromClaude,
|
||||
skipRecentClaudeCooldown: fromClaude,
|
||||
});
|
||||
if (!check.ready) return;
|
||||
await github.rest.repos.createDispatchEvent({
|
||||
owner,
|
||||
repo,
|
||||
event_type: 'claude-merge-gate',
|
||||
owner, repo, event_type: 'claude-merge-gate',
|
||||
client_payload: { pr_number: prNumber },
|
||||
});
|
||||
core.info(`Dispatched merge gate for PR #${prNumber} after ${wr.name}`);
|
||||
|
||||
scan-candidates:
|
||||
merge-gate:
|
||||
if: github.event_name != 'workflow_run'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
outputs:
|
||||
matrix: ${{ steps.scan.outputs.matrix }}
|
||||
has_candidates: ${{ steps.scan.outputs.has_candidates }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.CLAUDE_APP_ID }}
|
||||
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- name: Scan open PRs for merge gate eligibility
|
||||
id: scan
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const maxCandidates = parseInt(process.env.MAX_CANDIDATES || '5', 10);
|
||||
const inputPr = String(process.env.DISPATCH_PR_NUMBER || '').trim();
|
||||
|
||||
const candidates = [];
|
||||
const skipped = [];
|
||||
|
||||
if (inputPr) {
|
||||
const n = parseInt(inputPr, 10);
|
||||
if (Number.isNaN(n)) {
|
||||
throw new Error(`Invalid pr_number input: ${inputPr}`);
|
||||
}
|
||||
const result = await mergeGate.evaluatePipelineQuiescent(github, owner, repo, n, core);
|
||||
if (result.ready) {
|
||||
candidates.push({ pr_number: n, head_sha: result.headSha });
|
||||
} else {
|
||||
skipped.push({ pr: n, reasons: result.reasons });
|
||||
}
|
||||
} else {
|
||||
const prNumbers = await mergeGate.listPrNumbersForMergeGateScan(
|
||||
github, owner, repo, core
|
||||
);
|
||||
const selected = await mergeGate.selectMergeGateCandidates(
|
||||
github, owner, repo, prNumbers, maxCandidates, core
|
||||
);
|
||||
candidates.push(...selected.candidates);
|
||||
skipped.push(...selected.skipped);
|
||||
}
|
||||
|
||||
core.info('Skipped: ' + JSON.stringify(skipped));
|
||||
core.setOutput('matrix', JSON.stringify({ include: candidates }));
|
||||
core.setOutput('has_candidates', candidates.length > 0 ? 'true' : 'false');
|
||||
|
||||
core.summary.addRaw('## Merge gate scan\n');
|
||||
core.summary.addRaw(`Candidates: ${candidates.length}\n`);
|
||||
core.summary.addRaw('```json\n' + JSON.stringify({ candidates, skipped }, null, 2) + '\n```');
|
||||
await core.summary.write();
|
||||
|
||||
if (inputPr && skipped.length === 1 && skipped[0].pr === parseInt(inputPr, 10)) {
|
||||
const reasons = skipped[0].reasons;
|
||||
const body = [
|
||||
'**Merge gate scan** — not eligible for auto-merge.',
|
||||
'',
|
||||
...reasons.map((r) => `- \`${r}\``),
|
||||
'',
|
||||
'**Actions:** wait for CI and the Claude review chain, or add label `needs-manual-review` and merge manually.',
|
||||
'**Opt out:** label `no-auto-merge`.',
|
||||
].join('\n');
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: skipped[0].pr, body,
|
||||
});
|
||||
} else if (inputPr && candidates.length === 1) {
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: candidates[0].pr_number,
|
||||
body: '**Merge gate scan** — eligible for assessment. Claude merge gate will assess and may auto-merge if `MERGE_GATE_VERDICT: APPROVE`.',
|
||||
});
|
||||
}
|
||||
|
||||
await ps.syncOpenPullRequests(github, owner, repo, { maxPrs: 10 }, core);
|
||||
|
||||
claude-assess:
|
||||
needs: scan-candidates
|
||||
if: needs.scan-candidates.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.scan-candidates.outputs.matrix) }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.CLAUDE_APP_ID }}
|
||||
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Mark merge gate active
|
||||
id: mark_active
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
core.setOutput('started_at', new Date().toISOString());
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: ${{ matrix.pr_number }},
|
||||
labels: ['claude-merge-gate-active'],
|
||||
});
|
||||
|
||||
- name: Stash merge gate helpers
|
||||
run: cp .github/scripts/merge-gate.js /tmp/merge-gate.js
|
||||
|
||||
- name: Fetch PR branch
|
||||
env:
|
||||
PR_NUMBER: ${{ matrix.pr_number }}
|
||||
run: |
|
||||
git fetch origin "pull/${PR_NUMBER}/head:pr-${PR_NUMBER}"
|
||||
git checkout "pr-${PR_NUMBER}"
|
||||
|
||||
- name: Claude merge gate assessment (read-only)
|
||||
id: assess
|
||||
uses: ./.github/actions/claude-code-action
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
CLAUDE_CODE_SUBAGENT_MODEL: inherit
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: claude-sonnet-4-6
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ steps.app-token.outputs.token }}
|
||||
model: claude-opus-4-8
|
||||
trigger_phrase: '@claude-merge-gate-internal'
|
||||
direct_prompt: |
|
||||
You are the MERGE GATE reviewer for PraisonAI PR #${{ matrix.pr_number }}.
|
||||
READ-ONLY: do not modify code, commit, push, rebase, or create branches.
|
||||
|
||||
CRITICAL OUTPUT RULE:
|
||||
Post exactly one PR comment via GitHub MCP containing either:
|
||||
MERGE_GATE_VERDICT: APPROVE
|
||||
or
|
||||
MERGE_GATE_VERDICT: BLOCK
|
||||
followed by a short rationale on the next lines.
|
||||
|
||||
NEVER include @claude in any comment. NEVER mention merge conflict resolution.
|
||||
|
||||
Review checklist:
|
||||
1. Read AGENTS.md and the PR diff on the current branch.
|
||||
2. Read all PR comments and reviews (FINAL architecture reviewer / Lead Engineer must have run).
|
||||
3. Confirm SDK value gate: change strengthens praisonaiagents (no scope creep).
|
||||
4. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
|
||||
5. If agent.py adds >100 lines or new Agent __init__ params, respond MERGE_GATE_VERDICT: BLOCK (manual review required).
|
||||
6. BLOCK if labels security/breaking-change/needs-manual-review/release, sensitive paths (.github/workflows, auth, pyproject.toml), secrets in diff, PR >800 lines or >30 files, or SDK changes without tests. Label `merge-gate-ci-only` exempts CI-only changes under `.github/workflows/`, `.github/actions/`, or merge-gate scripts.
|
||||
7. Confirm no CHANGES_REQUESTED reviews and CI green on HEAD ${{ matrix.head_sha }}.
|
||||
8. If ready to merge: MERGE_GATE_VERDICT: APPROVE. Otherwise: MERGE_GATE_VERDICT: BLOCK with reasons.
|
||||
|
||||
Do not approve or merge via GitHub UI/API — only post the verdict comment.
|
||||
disallowed_tools: |
|
||||
Bash
|
||||
Edit
|
||||
Write
|
||||
Replace
|
||||
allowed_tools: |
|
||||
View
|
||||
GlobTool
|
||||
GrepTool
|
||||
Read
|
||||
timeout_minutes: 15
|
||||
|
||||
- name: Post fallback BLOCK if Opus did not comment
|
||||
id: fallback_verdict
|
||||
if: steps.assess.outcome != 'success'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const mergeGate = require('/tmp/merge-gate.js');
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prNumber = ${{ matrix.pr_number }};
|
||||
|
||||
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
|
||||
const minCreatedAt = new Date(Date.now() - 25 * 60 * 1000).toISOString();
|
||||
const existing = mergeGate.findMergeGateVerdict(
|
||||
ctx.comments, minCreatedAt, ctx.headPushedAt, { excludeAutomatedFallback: true }
|
||||
);
|
||||
if (existing) {
|
||||
core.info(`Opus verdict already posted: ${existing}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const check = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core, { forMergeStep: true }
|
||||
);
|
||||
const lines = [
|
||||
'MERGE_GATE_VERDICT: BLOCK',
|
||||
'',
|
||||
'Automated fallback — Opus merge gate assessment did not complete.',
|
||||
];
|
||||
if (check.ready) {
|
||||
lines.push('Deterministic gates passed, but Opus MERGE_GATE_VERDICT is required before merge.');
|
||||
} else {
|
||||
lines.push('Blockers:', ...check.reasons.map((r) => `- ${r}`));
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: prNumber,
|
||||
body: lines.join('\n'),
|
||||
});
|
||||
core.info('Posted fallback BLOCK — Opus verdict required');
|
||||
|
||||
- name: Require Opus MERGE_GATE_VERDICT
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const mergeGate = require('/tmp/merge-gate.js');
|
||||
const comments = await mergeGate.listAllComments(
|
||||
github, context.repo.owner, context.repo.repo, ${{ matrix.pr_number }}
|
||||
);
|
||||
const ctx = await mergeGate.loadPrContext(
|
||||
github, context.repo.owner, context.repo.repo, ${{ matrix.pr_number }}
|
||||
);
|
||||
const minCreatedAt = new Date(Date.now() - 25 * 60 * 1000).toISOString();
|
||||
const verdict = mergeGate.findMergeGateVerdict(
|
||||
comments, minCreatedAt, ctx.headPushedAt, { excludeAutomatedFallback: true }
|
||||
);
|
||||
if (!verdict) {
|
||||
core.setFailed(
|
||||
'Opus merge gate assessment must post MERGE_GATE_VERDICT on this PR (automated fallback APPROVE is not accepted).'
|
||||
);
|
||||
return;
|
||||
}
|
||||
core.info(`Opus merge gate verdict: ${verdict}`);
|
||||
core.setOutput('verdict', verdict);
|
||||
|
||||
- name: Remove merge gate active label
|
||||
if: always()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const prNumber = ${{ matrix.pr_number }};
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
name: 'claude-merge-gate-active',
|
||||
});
|
||||
core.info(`Removed claude-merge-gate-active from PR #${prNumber}`);
|
||||
} catch (e) {
|
||||
core.info(`Label claude-merge-gate-active already removed or missing on PR #${prNumber}`);
|
||||
}
|
||||
|
||||
- name: Fail job if assessment failed
|
||||
if: steps.assess.outcome == 'failure'
|
||||
run: exit 1
|
||||
|
||||
merge-only:
|
||||
needs: [scan-candidates, claude-assess]
|
||||
if: |
|
||||
always() &&
|
||||
needs.scan-candidates.outputs.has_candidates == 'true' &&
|
||||
needs.claude-assess.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.scan-candidates.outputs.matrix) }}
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.CLAUDE_APP_ID }}
|
||||
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- name: Re-check gates and merge
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
MERGE_METHOD: ${{ github.event.inputs.merge_method || 'merge' }}
|
||||
SCAN_HEAD_SHA: ${{ matrix.head_sha }}
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prNumber = ${{ matrix.pr_number }};
|
||||
const scanHeadSha = process.env.SCAN_HEAD_SHA;
|
||||
|
||||
const removeLabels = async () => {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner, repo, issue_number: prNumber, name: 'claude-merge-gate-active',
|
||||
});
|
||||
} catch (e) {
|
||||
core.info('Label claude-merge-gate-active already removed or missing');
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
|
||||
const comments = ctx.comments;
|
||||
const verdictMin = new Date(Date.now() - 25 * 60 * 1000).toISOString();
|
||||
const verdict = mergeGate.findMergeGateVerdict(
|
||||
comments, verdictMin, ctx.headPushedAt, { excludeAutomatedFallback: true }
|
||||
);
|
||||
if (verdict !== 'APPROVE') {
|
||||
core.info(`Skip merge: verdict=${verdict || 'NONE'}`);
|
||||
await removeLabels();
|
||||
return;
|
||||
}
|
||||
|
||||
const check = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core, { forMergeStep: true }
|
||||
);
|
||||
if (!check.ready) {
|
||||
core.info(`Skip merge: ${check.reasons.join(', ')}`);
|
||||
await removeLabels();
|
||||
return;
|
||||
}
|
||||
|
||||
if (check.headSha !== scanHeadSha) {
|
||||
core.info(`Skip merge: HEAD moved ${scanHeadSha.slice(0, 7)} → ${check.headSha.slice(0, 7)}`);
|
||||
await removeLabels();
|
||||
return;
|
||||
}
|
||||
|
||||
const method = process.env.MERGE_METHOD || 'merge';
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
let merged = false;
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
await github.rest.pulls.merge({
|
||||
owner, repo, pull_number: prNumber, merge_method: method,
|
||||
});
|
||||
merged = true;
|
||||
break;
|
||||
} catch (e) {
|
||||
const msg = (e.message || '').toLowerCase();
|
||||
if (attempt < 3 && msg.includes('base branch was modified')) {
|
||||
core.info(`Merge attempt ${attempt} failed (base moved), retrying in 8s...`);
|
||||
await sleep(8000);
|
||||
const recheck = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core, { forMergeStep: true }
|
||||
);
|
||||
if (!recheck.ready) {
|
||||
core.info(`Skip merge retry: ${recheck.reasons.join(', ')}`);
|
||||
await removeLabels();
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
if (!merged) {
|
||||
core.info('Skip merge: exhausted retries');
|
||||
await removeLabels();
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number: prNumber, labels: ['auto-merged-by-gate'],
|
||||
});
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: prNumber,
|
||||
body: [
|
||||
'Merged by **Claude PR merge gate** (`claude-merge-gate.yml`).',
|
||||
`Verdict: MERGE_GATE_VERDICT: APPROVE`,
|
||||
`SHA: \`${check.headSha.slice(0, 7)}\``,
|
||||
`Method: ${method}`,
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
core.info(`Merged PR #${prNumber}`);
|
||||
} finally {
|
||||
await removeLabels();
|
||||
}
|
||||
statuses: write # the assess job records security/outsider-review
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.20
|
||||
pr_number: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }}
|
||||
merge_method: ${{ github.event.inputs.merge_method || 'merge' }}
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Claude OAuth
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
code:
|
||||
description: 'Authorization code (leave empty for step 1)'
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
actions: write # Required for cache management
|
||||
contents: read # Required for basic repository access
|
||||
|
||||
jobs:
|
||||
auth:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: grll/claude-code-login@v1
|
||||
with:
|
||||
code: ${{ inputs.code }}
|
||||
secrets_admin_pat: ${{ secrets.SECRETS_ADMIN_PAT }}
|
||||
+13
-156
@@ -5,163 +5,20 @@ on:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [assigned, labeled, opened]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
claude-response:
|
||||
env:
|
||||
ISSUE_NUMBER_RESOLVED: ${{ github.event.issue.number }}
|
||||
if: |
|
||||
(
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude'))
|
||||
) &&
|
||||
(
|
||||
!contains(github.actor, '[bot]') ||
|
||||
github.actor == 'github-actions[bot]' ||
|
||||
github.actor == 'praisonai-triage-agent[bot]'
|
||||
) &&
|
||||
github.actor != 'dependabot[bot]' &&
|
||||
github.actor != 'cursor[bot]' &&
|
||||
github.actor != 'renovate[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check for Fork PR
|
||||
id: check_fork
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const isPR = context.payload.issue?.pull_request
|
||||
|| context.eventName === 'pull_request_review'
|
||||
|| context.eventName === 'pull_request_review_comment';
|
||||
if (!isPR) {
|
||||
core.setOutput('is_pr', 'false');
|
||||
core.setOutput('is_fork', 'false');
|
||||
core.setOutput('merge_state', '');
|
||||
return;
|
||||
}
|
||||
const prNumber = 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,
|
||||
});
|
||||
let mergeState = '';
|
||||
try {
|
||||
const gql = await github.graphql(
|
||||
`query($owner: String!, $repo: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) { mergeStateStatus }
|
||||
}
|
||||
}`,
|
||||
{ owner: context.repo.owner, repo: context.repo.repo, number: prNumber }
|
||||
);
|
||||
mergeState = (gql.repository.pullRequest.mergeStateStatus || '').toUpperCase();
|
||||
} catch (e) {
|
||||
core.warning(`GraphQL mergeStateStatus failed: ${e.message}`);
|
||||
}
|
||||
if (!mergeState) {
|
||||
mergeState = (pr.data.mergeable_state || pr.data.merge_state_status || '').toUpperCase();
|
||||
}
|
||||
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', mergeState);
|
||||
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');
|
||||
return;
|
||||
}
|
||||
core.setOutput('is_fork', 'false');
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
ref: ${{ steps.check_fork.outputs.is_pr == 'true' && format('refs/pull/{0}/head', steps.check_fork.outputs.pr_number) || github.event.repository.default_branch }}
|
||||
|
||||
- name: Fetch PR branch
|
||||
if: steps.check_fork.outputs.is_pr == 'true' && steps.check_fork.outputs.is_fork != 'true'
|
||||
env:
|
||||
PR_BRANCH: ${{ steps.check_fork.outputs.pr_branch }}
|
||||
PR_NUMBER: ${{ steps.check_fork.outputs.pr_number }}
|
||||
run: |
|
||||
git fetch origin "pull/${PR_NUMBER}/head:${PR_BRANCH}"
|
||||
git checkout "${PR_BRANCH}"
|
||||
git fetch origin main
|
||||
|
||||
- name: Run Claude Code Action
|
||||
uses: anthropics/claude-code-action@beta
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ secrets.GH_TOKEN }}
|
||||
model: claude-opus-4-8
|
||||
trigger_phrase: "@claude"
|
||||
label_trigger: "claude"
|
||||
allowed_bots: "praisonai-triage-agent[bot]"
|
||||
timeout_minutes: "30"
|
||||
direct_prompt: |
|
||||
${{ steps.check_fork.outputs.is_fork == 'true' && 'CRITICAL: FORK PR — comment only, do not push.\n\n' || (steps.check_fork.outputs.is_pr == 'true' && 'NOTE: Internal PR on MervinPraison/PraisonAI-Tools — you may push to this branch.\n\n' || '') }}
|
||||
${{ steps.check_fork.outputs.is_pr == 'true' && format('CONTEXT: PR #{0} on branch {1}. merge_state={2}.\n\n', steps.check_fork.outputs.pr_number, steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.merge_state) || '' }}
|
||||
You are implementing changes in MervinPraison/PraisonAI-Tools (Python tools for PraisonAI). Follow README.md and existing patterns under `praisonai_tools/`.
|
||||
|
||||
Primary scope: `praisonai_tools/`, `tests/`, `examples/`, `docs/`. Do not change `praisonaiagents` in this repo (it is not vendored here).
|
||||
|
||||
STEP 0 — GIT & GITHUB CLI:
|
||||
git config --global user.name "MervinPraison"
|
||||
git config --global user.email "[email protected]"
|
||||
gh auth setup-git
|
||||
|
||||
STEP 1 — ISSUE / PR:
|
||||
Read the issue (or PR) and all comments. Implement what is described with minimal, focused changes.
|
||||
|
||||
STEP 2 — LAYER VALIDATION (mandatory before coding):
|
||||
This repo IS the **Tools** layer (`praisonai-tools`). Agent-callable integrations belong here.
|
||||
- **Implement here:** third-party API tools, optional/heavy deps, vertical integrations
|
||||
- **Route to PraisonAI (core):** new protocols, agent runtime, built-in tools → comment and link MervinPraison/PraisonAI issue
|
||||
- **Route to PraisonAI (wrapper):** CLI/YAML wiring only → MervinPraison/PraisonAI
|
||||
- **Route to PraisonAI-Plugins:** lifecycle hooks, guardrails, policies → MervinPraison/PraisonAI-Plugins
|
||||
- **Reject:** duplicating tools already in `praisonaiagents.tools` (Tavily, DuckDuckGo, etc.)
|
||||
If the issue belongs in another layer, explain why and do not implement here.
|
||||
|
||||
${{ 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\n- Commit and push: git push origin {0}\n', steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.pr_number) || '' }}
|
||||
${{ steps.check_fork.outputs.is_pr != 'true' && 'STEP 3 — BRANCH (single name; reuse in STEP 5):\nBRANCH="claude/issue-${{ env.ISSUE_NUMBER_RESOLVED }}-$(date +%Y%m%d-%H%M%S)"\ngit checkout -b "$BRANCH"\n' || '' }}
|
||||
|
||||
STEP 4 — TEST / VERIFY:
|
||||
Run targeted pytest or smoke checks relevant to your edits (e.g. pytest tests/unit -q).
|
||||
|
||||
${{ steps.check_fork.outputs.is_pr == 'true' && 'STEP 5 — SKIP: Already on an open PR. Do NOT run gh pr create.\n' || format('STEP 5 — COMMIT, PUSH, OPEN PR (mandatory if you produced commits):\ngit add -A\ngit commit -m "feat: <short summary> (fixes #{0})"\ngit push -u origin "$BRANCH"\ngh pr create --repo "{1}" --base main --head "$BRANCH" --title "feat: <short title>" --body "Fixes #{0}"\nCRITICAL: Do not stop after `git push`. You MUST complete `gh pr create` and post the PR URL on the issue.\n', env.ISSUE_NUMBER_RESOLVED, github.repository) }}
|
||||
|
||||
allowed_tools: |
|
||||
Bash(git:*)
|
||||
Bash(python:*)
|
||||
Bash(pip:*)
|
||||
Bash(pytest:*)
|
||||
Bash(gh:*)
|
||||
Bash(python -m pytest:*)
|
||||
Bash(python -m pip:*)
|
||||
View
|
||||
GlobTool
|
||||
GrepTool
|
||||
BatchTool
|
||||
Edit
|
||||
Replace
|
||||
mcp__github__get_issue
|
||||
mcp__github__get_issue_comments
|
||||
mcp__github__update_issue
|
||||
claude:
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.20
|
||||
@@ -5,14 +5,42 @@ on:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'praisonai_tools/docs_generator/**'
|
||||
- 'praisonai_tools/**'
|
||||
- 'pyproject.toml'
|
||||
- 'scripts/generate_reference_docs.py'
|
||||
workflow_run:
|
||||
workflows:
|
||||
- CI
|
||||
types:
|
||||
- completed
|
||||
branches:
|
||||
- main
|
||||
repository_dispatch:
|
||||
types: [trigger-docs-generation]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: generate-docs-praisonaidocs
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
generate-docs:
|
||||
if: |
|
||||
github.event_name != 'workflow_run' ||
|
||||
(
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.head_branch == 'main' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
!startsWith(github.event.workflow_run.head_commit.message, 'docs: auto-generate')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
DOCS_PUSH_TOKEN: ${{ secrets.DOCS_GITHUB_TOKEN || secrets.GH_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout PraisonAI Tools
|
||||
uses: actions/checkout@v4
|
||||
@@ -30,7 +58,7 @@ jobs:
|
||||
with:
|
||||
repository: MervinPraison/PraisonAIDocs
|
||||
path: praisonai-docs
|
||||
token: ${{ secrets.DOCS_GITHUB_TOKEN }}
|
||||
token: ${{ env.DOCS_PUSH_TOKEN }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
@@ -49,10 +77,14 @@ jobs:
|
||||
--package all \
|
||||
--docs-root $(pwd)/praisonai-docs \
|
||||
--source-root $(pwd)/praisonai-source
|
||||
|
||||
- name: Sync SDK reference navigation
|
||||
run: python3 praisonai-docs/scripts/sync_sdk_reference_nav.py
|
||||
env:
|
||||
PYTHONPATH: $(pwd)/praisonai-tools
|
||||
|
||||
- name: Commit and Push Docs
|
||||
if: env.DOCS_PUSH_TOKEN != ''
|
||||
run: |
|
||||
cd praisonai-docs
|
||||
git config --global user.name "github-actions[bot]"
|
||||
@@ -62,5 +94,6 @@ jobs:
|
||||
echo "No changes to commit"
|
||||
else
|
||||
git commit -m "docs: auto-generate SDK reference documentation"
|
||||
git push
|
||||
git pull --rebase origin main
|
||||
git push origin main
|
||||
fi
|
||||
@@ -1,7 +1,5 @@
|
||||
name: Auto Merge Conflict → Claude
|
||||
|
||||
# Detects internal PRs with mergeStateStatus=DIRTY and posts @claude to trigger claude.yml.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -12,11 +10,10 @@ on:
|
||||
- cron: '0 */6 * * *'
|
||||
|
||||
concurrency:
|
||||
group: merge-conflict-scan-${{ github.repository }}
|
||||
cancel-in-progress: false
|
||||
group: merge-conflict-scan-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# PAT posts as MervinPraison so issue_comment triggers claude.yml (GITHUB_TOKEN cannot chain workflows)
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
|
||||
permissions:
|
||||
@@ -27,138 +24,29 @@ jobs:
|
||||
detect-and-trigger:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected]
|
||||
with:
|
||||
hub_ref: v1.1.20
|
||||
- name: Detect conflicting PRs and trigger Claude
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mg = require(path.join(process.env.HUB_SCRIPTS, 'merge-gate.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const baseRepo = `${owner}/${repo}`;
|
||||
const isMainPush = context.eventName === 'push';
|
||||
const COOLDOWN_MS = 12 * 60 * 60 * 1000;
|
||||
const AUTO_ACTORS = ['github-actions[bot]', 'MervinPraison'];
|
||||
const LABEL = 'claude-conflict-pending';
|
||||
const COMMENT_BODY = [
|
||||
'@claude this PR has merge conflicts with `main`.',
|
||||
'Please rebase onto latest `main`, resolve conflicts (keep this PR\'s intent, merge in newer main logic),',
|
||||
'run targeted tests, and force-push with `--force-with-lease`.',
|
||||
'Comment which files you resolved.'
|
||||
].join(' ');
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function getMergeState(prNumber) {
|
||||
const query = `
|
||||
query($owner: String!, $repo: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) {
|
||||
mergeStateStatus
|
||||
isDraft
|
||||
headRef { repository { nameWithOwner } }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const result = await github.graphql(query, { owner, repo, number: prNumber });
|
||||
const prGql = result.repository.pullRequest;
|
||||
const status = (prGql?.mergeStateStatus || '').toUpperCase();
|
||||
if (status && status !== 'UNKNOWN') {
|
||||
return {
|
||||
status,
|
||||
isDraft: prGql.isDraft,
|
||||
headRepo: prGql.headRef?.repository?.nameWithOwner,
|
||||
};
|
||||
}
|
||||
if (attempt < 2) await sleep(10000);
|
||||
}
|
||||
const pr = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
||||
return {
|
||||
status: (pr.data.mergeable_state || '').toUpperCase(),
|
||||
isDraft: pr.data.draft,
|
||||
headRepo: pr.data.head.repo?.full_name,
|
||||
};
|
||||
const prs = await github.paginate(github.rest.pulls.list, { owner, repo, state: 'open', per_page: 50 });
|
||||
for (const pr of prs) {
|
||||
if (pr.draft) continue;
|
||||
if (pr.head?.repo?.full_name !== baseRepo) continue;
|
||||
const state = await mg.getMergeState(github, owner, repo, pr.number);
|
||||
if (state.status !== 'DIRTY') continue;
|
||||
const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number: pr.number, per_page: 100 });
|
||||
if (mg.hasRecentConflictComment(comments)) continue;
|
||||
const body = '@claude this PR has merge conflicts with `main`. Rebase onto latest main, resolve conflicts, run tests, force-push with --force-with-lease.';
|
||||
await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body });
|
||||
await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: ['claude-conflict-pending'] });
|
||||
}
|
||||
|
||||
function hasRecentAutoComment(comments) {
|
||||
const cutoff = Date.now() - COOLDOWN_MS;
|
||||
return comments.some((c) => {
|
||||
if (!AUTO_ACTORS.includes(c.user.login)) return false;
|
||||
const body = (c.body || '').toLowerCase();
|
||||
if (!body.includes('@claude') || !body.includes('merge conflict')) return false;
|
||||
return new Date(c.created_at).getTime() > cutoff;
|
||||
});
|
||||
}
|
||||
|
||||
async function processPR(prNumber) {
|
||||
const { status, isDraft, headRepo } = await getMergeState(prNumber);
|
||||
core.info(`PR #${prNumber}: mergeState=${status}, head=${headRepo}, draft=${isDraft}`);
|
||||
|
||||
const { data: issue } = await github.rest.issues.get({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
});
|
||||
const labels = issue.labels.map((l) => l.name);
|
||||
|
||||
if (status !== 'DIRTY' && labels.includes(LABEL)) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
name: LABEL,
|
||||
});
|
||||
core.info(`Removed ${LABEL} from PR #${prNumber}`);
|
||||
}
|
||||
|
||||
if (status !== 'DIRTY') return { skipped: true, reason: 'not dirty' };
|
||||
if (isDraft) return { skipped: true, reason: 'draft' };
|
||||
if (headRepo && headRepo !== baseRepo) return { skipped: true, reason: 'fork' };
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
if (!isMainPush && hasRecentAutoComment(comments)) {
|
||||
return { skipped: true, reason: 'recent auto comment' };
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: COMMENT_BODY,
|
||||
});
|
||||
if (!labels.includes(LABEL)) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels: [LABEL],
|
||||
});
|
||||
}
|
||||
return { triggered: true };
|
||||
}
|
||||
|
||||
let prNumbers = [];
|
||||
if (context.eventName === 'pull_request') {
|
||||
prNumbers = [context.payload.pull_request.number];
|
||||
} else {
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
prNumbers = prs.map((p) => p.number);
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const num of prNumbers) {
|
||||
results.push({ pr: num, ...(await processPR(num)) });
|
||||
}
|
||||
core.summary.addRaw('```json\n' + JSON.stringify(results, null, 2) + '\n```');
|
||||
await core.summary.write();
|
||||
@@ -2,11 +2,12 @@ name: Nightly Release Gate
|
||||
|
||||
# Preflight before PyPI Release (patch bump).
|
||||
# Primary: CI success on main (workflow_run).
|
||||
# Backstop: nightly cron at 00:00 UTC.
|
||||
# Backstop: cron every 3 days at 00:00 UTC.
|
||||
# Dedupe: max one successful patch release every 3 days (see release-gate.js).
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
- cron: '0 0 */3 * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
name: Pipeline Status Sync
|
||||
|
||||
# Sync pipeline/* stage and blocker labels on open internal PRs.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/10 * * * *'
|
||||
@@ -15,28 +13,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.CLAUDE_APP_ID }}
|
||||
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- name: Sync pipeline labels on open PRs
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
await ps.syncOpenPullRequests(
|
||||
github, context.repo.owner, context.repo.repo,
|
||||
{ maxPrs: 30, dispatchMergeGate: true }, core
|
||||
);
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.20
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
VERSION: ${{ steps.versions.outputs.new_version }}
|
||||
run: |
|
||||
TAG="v${VERSION}"
|
||||
git add pyproject.toml uv.lock
|
||||
git add pyproject.toml
|
||||
git commit -m "Release ${TAG}" || exit 0
|
||||
git tag -f "${TAG}"
|
||||
git pull --rebase origin main
|
||||
@@ -133,7 +133,9 @@ jobs:
|
||||
- name: Publish to PyPI
|
||||
if: inputs.dry_run != true
|
||||
env:
|
||||
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN || secrets.PYPI_API_TOKEN }}
|
||||
# PyPI API token auth: username __token__, password = pypi-… token (see pypi.org/help/#invalid-auth)
|
||||
UV_PUBLISH_USERNAME: __token__
|
||||
UV_PUBLISH_PASSWORD: ${{ secrets.PYPI_TOKEN || secrets.PYPI_API_TOKEN }}
|
||||
run: |
|
||||
uv publish
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
name: Sync Claude Secrets
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target_repo:
|
||||
description: 'Destination repo (owner/name)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Sync Claude secrets to target repo
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.SECRETS_ADMIN_PAT }}
|
||||
TARGET: ${{ inputs.target_repo }}
|
||||
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"
|
||||
@@ -24,6 +24,9 @@ venv/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Tool auto-discovery cache (generated at import time)
|
||||
._discovery_cache_v*.json
|
||||
|
||||
# Lock files
|
||||
poetry.lock
|
||||
uv.lock
|
||||
|
||||
@@ -531,6 +531,37 @@ agent = Agent(
|
||||
)
|
||||
```
|
||||
|
||||
### How `praisonai-tools` registers its own tools
|
||||
|
||||
The bundled tools in this package are **discovered automatically**. Every
|
||||
module under `praisonai_tools/tools/` is scanned (at import time, without
|
||||
executing the module) and its public top-level classes and functions become
|
||||
importable directly:
|
||||
|
||||
```python
|
||||
from praisonai_tools.tools import WeatherTool, get_weather # discovered
|
||||
from praisonai_tools import WeatherTool # same symbol
|
||||
```
|
||||
|
||||
Adding a new tool therefore means **adding one file** to
|
||||
`praisonai_tools/tools/` — there is no import map or `__all__` to edit, and
|
||||
imports stay lazy (a tool module and its optional dependencies are only loaded
|
||||
the first time one of its symbols is accessed).
|
||||
|
||||
By default the discovered public surface is the module's top-level classes and
|
||||
functions. If a module exposes tools through module-level aliases
|
||||
(e.g. `read_yaml = _yaml_tools.read_yaml`), declare a standard `__all__` in that
|
||||
module and the listed names are exported verbatim. Modules and names starting
|
||||
with `_` are treated as private and never exported.
|
||||
|
||||
The scan result is memoised in a small on-disk cache next to the package, so
|
||||
import stays fast; the cache is rebuilt automatically whenever a tool file is
|
||||
added, removed, or edited.
|
||||
|
||||
External packages that want their tools discovered by `praisonaiagents` should
|
||||
use the `[project.entry-points."praisonaiagents.tools"]` group shown above,
|
||||
which the core `praisonaiagents` registry resolves.
|
||||
|
||||
---
|
||||
|
||||
## 9. Video Editing Tools
|
||||
@@ -615,6 +646,23 @@ videos = yt.search("python tutorial", limit=5)
|
||||
transcript = yt.get_transcript("dQw4w9WgXcQ")
|
||||
```
|
||||
|
||||
### Meeting Intelligence Tools
|
||||
|
||||
```python
|
||||
from praisonai_tools import FirefliesTool
|
||||
|
||||
# Fireflies.ai meeting intelligence (curated GraphQL operations)
|
||||
fireflies = FirefliesTool() # Uses FIREFLIES_API_KEY env var
|
||||
meetings = fireflies.list_recent_meetings(limit=5)
|
||||
meetings = fireflies.list_recent_meetings(limit=5, after="5") # cursor pagination
|
||||
transcript = fireflies.get_transcript(meeting_id="abc123")
|
||||
results = fireflies.search(keywords="roadmap")
|
||||
```
|
||||
|
||||
`FirefliesTool` distinguishes a missing key (`{"error": "FIREFLIES_API_KEY required"}`)
|
||||
from a rejected key (HTTP 401/403), and surfaces `retry_after` on rate limits.
|
||||
Set `FIREFLIES_API_URL` to override the endpoint (previews/proxies).
|
||||
|
||||
### Data Tools
|
||||
|
||||
```python
|
||||
@@ -638,20 +686,23 @@ air = weather.get_air_quality("Tokyo")
|
||||
| TTSTool | `OPENAI_API_KEY` or `ELEVENLABS_API_KEY` |
|
||||
| YouTubeTool | `YOUTUBE_API_KEY` |
|
||||
| WeatherTool | `OPENWEATHER_API_KEY` |
|
||||
| FirefliesTool | `FIREFLIES_API_KEY` (optional `FIREFLIES_API_URL`) |
|
||||
| SwarmScoreTool | None (uses public API) |
|
||||
|
||||
### Using with PraisonAI Agents
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
from praisonai_tools import EmailTool, SlackTool, GitHubTool, WeatherTool
|
||||
from praisonai_tools import EmailTool, SlackTool, GitHubTool, WeatherTool, SwarmScoreTool
|
||||
|
||||
agent = Agent(
|
||||
instructions="You are a helpful assistant with access to email, slack, github, and weather",
|
||||
instructions="You are a helpful assistant with access to email, slack, github, weather, and trust ratings",
|
||||
tools=[
|
||||
EmailTool(provider="gmail"),
|
||||
SlackTool(),
|
||||
GitHubTool(),
|
||||
WeatherTool(),
|
||||
SwarmScoreTool(),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -701,6 +752,19 @@ pip install praisonai-tools[dev]
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
The `dev` extra installs the video/motion-graphics render deps
|
||||
(`playwright`, `imageio-ffmpeg`) so the full suite runs. To exercise the
|
||||
HTML render backend you also need the Chromium browser:
|
||||
|
||||
```bash
|
||||
pip install -e ".[dev]"
|
||||
playwright install chromium
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
If you use a minimal install without those deps, the video tests under
|
||||
`tests/unit/video/` are skipped automatically rather than failing.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<html>mock visualization</html>
|
||||
@@ -0,0 +1,378 @@
|
||||
# Inkog Security Analysis Integration
|
||||
|
||||
This document describes the integration of [Inkog](https://github.com/inkog-io/inkog) security analysis capabilities into PraisonAI-Tools.
|
||||
|
||||
## Overview
|
||||
|
||||
Inkog is a specialized security analysis tool for AI agent code that detects vulnerabilities unique to agentic systems. It performs static analysis to catch bugs that only break in agent code, such as:
|
||||
|
||||
- **Token bombing** - loops where the LLM controls termination, draining API budgets
|
||||
- **Recursive tool calling** - one user request fans out into thousands of LLM invocations
|
||||
- **Prompt injection sinks** - RAG output flowing into system prompts without review
|
||||
- **Missing oversight** - destructive tools firing without human approval
|
||||
- **Cross-tenant leakage** - global state shared between agent invocations
|
||||
- **MCP tool poisoning** - malicious tool descriptions hijacking agents
|
||||
|
||||
## Features
|
||||
|
||||
### Framework Support
|
||||
- **21+ frameworks**: LangChain, CrewAI, AutoGen, OpenAI Agents, LangGraph, DSPy, Phidata, and more
|
||||
- **Multiple languages**: Python, TypeScript, JavaScript, and no-code platforms
|
||||
- **MCP servers**: Audit Model Context Protocol servers for security issues
|
||||
|
||||
### Compliance Mapping
|
||||
- **EU AI Act** Articles 14/15 (human oversight requirements)
|
||||
- **NIST AI RMF** (risk management framework)
|
||||
- **ISO 42001** (AI management systems)
|
||||
- **OWASP LLM Top 10** (security vulnerabilities)
|
||||
|
||||
### Scan Types
|
||||
- **Core scan**: Fast static analysis with vulnerability detection
|
||||
- **Deep scan**: AI orchestrator-driven analysis with enhanced context
|
||||
- **Skill scan**: Security analysis of SKILL.md packages and agent tools
|
||||
- **MCP scan**: Audit MCP servers for tool poisoning and privilege escalation
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **Install Inkog CLI**:
|
||||
```bash
|
||||
# macOS (Homebrew)
|
||||
brew tap inkog-io/inkog && brew install inkog
|
||||
|
||||
# Or install via Go
|
||||
go install github.com/inkog-io/inkog/cmd/inkog@latest
|
||||
|
||||
# Or download binary from releases
|
||||
# https://github.com/inkog-io/inkog/releases
|
||||
```
|
||||
|
||||
2. **Get API Key**:
|
||||
- Visit [app.inkog.io](https://app.inkog.io) to get a free API key
|
||||
- Set environment variable: `export INKOG_API_KEY=your_key_here`
|
||||
|
||||
3. **Verify Installation**:
|
||||
```bash
|
||||
inkog -version
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```python
|
||||
from praisonai_tools import InkogTool
|
||||
|
||||
# Initialize the tool
|
||||
inkog = InkogTool()
|
||||
|
||||
# Scan current directory for vulnerabilities
|
||||
result = inkog.scan_directory(
|
||||
path=".",
|
||||
output_format="json",
|
||||
policy="balanced"
|
||||
)
|
||||
|
||||
# Analyze results
|
||||
analysis = inkog.analyze_findings(result)
|
||||
print(analysis)
|
||||
```
|
||||
|
||||
### Standalone Functions
|
||||
|
||||
```python
|
||||
from praisonai_tools.tools.inkog_tool import scan_agent_code, scan_skill_package
|
||||
|
||||
# Quick scan using standalone function
|
||||
result = scan_agent_code("./my-agent", policy="low-noise")
|
||||
|
||||
# Scan a skill package
|
||||
skill_result = scan_skill_package("./my-skill", deep=True)
|
||||
```
|
||||
|
||||
### Advanced Scanning
|
||||
|
||||
```python
|
||||
# Deep scan with AI orchestrator (requires Inkog Deep role)
|
||||
deep_result = inkog.scan_directory(
|
||||
path="./agent-code",
|
||||
output_format="json",
|
||||
policy="comprehensive",
|
||||
deep=True,
|
||||
severity="high"
|
||||
)
|
||||
|
||||
# Generate HTML report
|
||||
html_report = inkog.scan_directory(
|
||||
path="./agent-code",
|
||||
output_format="html",
|
||||
policy="balanced"
|
||||
)
|
||||
|
||||
# Save report to file
|
||||
with open("security_report.html", "w") as f:
|
||||
f.write(html_report)
|
||||
```
|
||||
|
||||
### MCP Server Auditing
|
||||
|
||||
```python
|
||||
# Scan MCP server by name
|
||||
mcp_result = inkog.mcp_scan("github")
|
||||
|
||||
# Scan MCP server with custom repository
|
||||
mcp_result = inkog.mcp_scan(
|
||||
"my-server",
|
||||
repo="https://github.com/org/my-mcp-server",
|
||||
deep=True
|
||||
)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- **`INKOG_API_KEY`** (required): API key from app.inkog.io
|
||||
- **`INKOG_SERVER_URL`** (optional): Custom server URL for enterprise deployments
|
||||
|
||||
### Security Policies
|
||||
|
||||
Choose the appropriate policy based on your use case:
|
||||
|
||||
- **`low-noise`**: Only exploitable vulnerabilities (good for CI/CD)
|
||||
- **`balanced`**: Vulnerabilities + risk patterns (default, good for code review)
|
||||
- **`comprehensive`**: All findings including hardening recommendations (good for security audits)
|
||||
|
||||
### Output Formats
|
||||
|
||||
- **`json`**: Structured data for programmatic processing
|
||||
- **`text`**: Human-readable terminal output
|
||||
- **`html`**: Interactive report with visualizations
|
||||
- **`sarif`**: SARIF format for GitHub Security tab integration
|
||||
|
||||
### Severity Levels
|
||||
|
||||
- **`critical`**: Immediate security threats
|
||||
- **`high`**: Serious vulnerabilities that should be addressed soon
|
||||
- **`medium`**: Moderate issues worth reviewing
|
||||
- **`low`**: Minor issues and best practice recommendations
|
||||
|
||||
## Integration Examples
|
||||
|
||||
### PraisonAI Agent Security Audit
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
from praisonai_tools import InkogTool
|
||||
|
||||
# Create an agent with security analysis capability
|
||||
security_agent = Agent(
|
||||
name="security_auditor",
|
||||
instructions="You analyze AI agent code for security vulnerabilities",
|
||||
tools=[InkogTool()]
|
||||
)
|
||||
|
||||
# Audit agent code
|
||||
audit_result = security_agent.start("""
|
||||
Scan the ./my-agent directory for security vulnerabilities.
|
||||
Focus on critical and high severity issues.
|
||||
Provide a summary of findings and remediation recommendations.
|
||||
""")
|
||||
|
||||
print(audit_result)
|
||||
```
|
||||
|
||||
### CI/CD Pipeline Integration
|
||||
|
||||
```python
|
||||
import sys
|
||||
from praisonai_tools.tools.inkog_tool import scan_agent_code
|
||||
|
||||
def security_check():
|
||||
"""Security check for CI/CD pipeline."""
|
||||
result = scan_agent_code(
|
||||
path=".",
|
||||
policy="low-noise", # Only critical vulnerabilities
|
||||
severity="critical"
|
||||
)
|
||||
|
||||
if "error" in result:
|
||||
print(f"Security scan failed: {result['error']}")
|
||||
return 1
|
||||
|
||||
critical_count = result.get("summary", {}).get("critical", 0)
|
||||
if critical_count > 0:
|
||||
print(f"❌ Found {critical_count} critical security issues")
|
||||
return 1
|
||||
|
||||
print("✅ No critical security vulnerabilities found")
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(security_check())
|
||||
```
|
||||
|
||||
### Multi-Agent Security Review
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent, AgentTeam
|
||||
from praisonai_tools import InkogTool
|
||||
|
||||
# Security analysis agent
|
||||
security_analyzer = Agent(
|
||||
name="security_analyzer",
|
||||
instructions="Analyze code for security vulnerabilities using Inkog",
|
||||
tools=[InkogTool()]
|
||||
)
|
||||
|
||||
# Code reviewer agent
|
||||
code_reviewer = Agent(
|
||||
name="code_reviewer",
|
||||
instructions="Review security findings and provide remediation advice"
|
||||
)
|
||||
|
||||
# Create security review team
|
||||
security_team = AgentTeam(
|
||||
name="security_review_team",
|
||||
agents=[security_analyzer, code_reviewer],
|
||||
tasks=[
|
||||
{"agent": "security_analyzer", "task": "Scan the codebase for vulnerabilities"},
|
||||
{"agent": "code_reviewer", "task": "Review findings and create remediation plan"}
|
||||
]
|
||||
)
|
||||
|
||||
# Run security review
|
||||
review_result = security_team.start("Perform comprehensive security review of ./agent-code")
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The tool provides detailed error messages for common issues:
|
||||
|
||||
```python
|
||||
result = inkog.scan_directory("./nonexistent")
|
||||
|
||||
if "error" in result:
|
||||
error_msg = result["error"]
|
||||
|
||||
if "not installed" in error_msg:
|
||||
print("Install inkog CLI first")
|
||||
elif "API_KEY" in error_msg:
|
||||
print("Set INKOG_API_KEY environment variable")
|
||||
elif "timeout" in error_msg:
|
||||
print("Scan timed out - try smaller directory or increase timeout")
|
||||
else:
|
||||
print(f"Scan error: {error_msg}")
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Development Workflow
|
||||
```bash
|
||||
# Add to your development script
|
||||
inkog . -policy balanced -output json > security_report.json
|
||||
```
|
||||
|
||||
### 2. Pre-commit Hooks
|
||||
```yaml
|
||||
# .pre-commit-config.yaml
|
||||
repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: inkog-security-scan
|
||||
name: Inkog Security Scan
|
||||
entry: inkog
|
||||
args: [".", "-policy", "low-noise", "-severity", "critical"]
|
||||
language: system
|
||||
always_run: true
|
||||
```
|
||||
|
||||
### 3. GitHub Actions
|
||||
```yaml
|
||||
name: Security Scan
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Install Inkog
|
||||
run: |
|
||||
curl -fsSL https://inkog.io/install.sh | sh
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Run Security Scan
|
||||
env:
|
||||
INKOG_API_KEY: ${{ secrets.INKOG_API_KEY }}
|
||||
run: inkog . -output sarif > results.sarif
|
||||
|
||||
- name: Upload SARIF
|
||||
uses: github/codeql-action/upload-sarif@v2
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
```
|
||||
|
||||
## Privacy & Security
|
||||
|
||||
Inkog uses "surgical redaction" to protect sensitive data:
|
||||
|
||||
### What Gets Redacted
|
||||
- AWS access keys, GitHub tokens, Stripe keys
|
||||
- Database connection strings with passwords
|
||||
- Private keys and JWT tokens
|
||||
- High-entropy strings (API keys)
|
||||
|
||||
### What is NOT Redacted
|
||||
- Prompts and templates (needed for injection detection)
|
||||
- Business logic (needed for flow analysis)
|
||||
- Configuration values (model names, etc.)
|
||||
- Normal strings that don't match credential patterns
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **"inkog CLI not installed"**
|
||||
- Install via Homebrew: `brew tap inkog-io/inkog && brew install inkog`
|
||||
- Or download from releases: https://github.com/inkog-io/inkog/releases
|
||||
|
||||
2. **"INKOG_API_KEY required"**
|
||||
- Get free key at https://app.inkog.io
|
||||
- Set environment variable: `export INKOG_API_KEY=your_key`
|
||||
|
||||
3. **"Deep scan requires Inkog Deep role"**
|
||||
- Deep scans need special account permissions
|
||||
- Use standard scan instead: `deep=False`
|
||||
|
||||
4. **"Scan timed out"**
|
||||
- Large codebases may timeout
|
||||
- Scan specific directories instead of entire project
|
||||
- Use policy="low-noise" for faster scans
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable verbose output for troubleshooting:
|
||||
|
||||
```python
|
||||
result = inkog.scan_directory(
|
||||
path=".",
|
||||
verbose=True # Shows detailed analysis steps
|
||||
)
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
- **Documentation**: https://docs.inkog.io
|
||||
- **GitHub Issues**: https://github.com/inkog-io/inkog/issues
|
||||
- **Community**: https://discord.gg/NuG4SSGRH
|
||||
- **Security**: security@inkog.io
|
||||
|
||||
## References
|
||||
|
||||
- [Inkog Repository](https://github.com/inkog-io/inkog)
|
||||
- [State of AI Agent Security 2026 Report](https://inkog.io/report)
|
||||
- [EU AI Act Compliance Guide](https://docs.inkog.io/compliance/eu-ai-act)
|
||||
- [OWASP LLM Top 10](https://owasp.org/www-project-top-10-for-large-language-model-applications/)
|
||||
@@ -0,0 +1,382 @@
|
||||
# NEXUS Prediction Market Integration
|
||||
|
||||
This document provides comprehensive information about integrating the NEXUS prediction market API with PraisonAI agents.
|
||||
|
||||
## Overview
|
||||
|
||||
NEXUS provides live Kalshi/Polymarket prediction market data and arbitrage opportunities through a validated API. The integration allows PraisonAI agents to:
|
||||
|
||||
- Access real-time prediction market data
|
||||
- Identify arbitrage opportunities between markets
|
||||
- Analyze market sentiment and trends
|
||||
- Build sophisticated trading and analysis workflows
|
||||
|
||||
## API Information
|
||||
|
||||
### Endpoints
|
||||
|
||||
- **Free Market Data**: `/kalshi?market={market_name}`
|
||||
- **Paid Arbitrage Analysis**: `/arb/check?markets={market1,market2}` ($0.02 USDC on eip155:8453)
|
||||
- **Agent Metadata**: `/.well-known/agent.json`
|
||||
|
||||
### Validation
|
||||
|
||||
The API is fully validated on Agentic Market:
|
||||
- **Validation URL**: https://agentic.market/validate?url=https://nexus-agent-xa12.onrender.com/arb/check
|
||||
- **API Card**: https://nexus-agent-xa12.onrender.com/.well-known/agent.json
|
||||
|
||||
### Usage Tracking
|
||||
|
||||
Include the `X-NEXUS-Source` header to track per-source call volume:
|
||||
```
|
||||
X-NEXUS-Source: your-bot-name
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install praisonai-tools
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```python
|
||||
from praisonai_tools.tools import NexusPredictionMarketTool
|
||||
|
||||
# Initialize the tool
|
||||
nexus = NexusPredictionMarketTool(source_name="my-agent")
|
||||
|
||||
# Get market data
|
||||
fed_data = nexus.get_market_data("Fed")
|
||||
print(fed_data)
|
||||
|
||||
# Check arbitrage opportunities (paid)
|
||||
arb_data = nexus.check_arbitrage_opportunities(["Fed", "BTC"])
|
||||
print(arb_data)
|
||||
```
|
||||
|
||||
### Agent Integration
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent, tool
|
||||
from praisonai_tools.tools import nexus_prediction_market_tool
|
||||
|
||||
@tool
|
||||
def get_market_data(market: str) -> str:
|
||||
"""Get prediction market data."""
|
||||
return nexus_prediction_market_tool("get_market_data", market=market)
|
||||
|
||||
agent = Agent(
|
||||
name="MarketAnalyst",
|
||||
tools=[get_market_data],
|
||||
instructions="Analyze prediction markets using NEXUS data."
|
||||
)
|
||||
|
||||
response = agent.start("What do Fed rate predictions show?")
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### Free Features
|
||||
|
||||
1. **Market Data Queries**
|
||||
- Real-time price data
|
||||
- Volume and liquidity information
|
||||
- Market sentiment indicators
|
||||
- Historical trends
|
||||
|
||||
2. **Agent Metadata**
|
||||
- API capabilities and endpoints
|
||||
- Service status and version info
|
||||
- Usage guidelines
|
||||
|
||||
### Paid Features
|
||||
|
||||
1. **Arbitrage Analysis** ($0.02 USDC on eip155:8453)
|
||||
- Cross-market opportunity detection
|
||||
- Profit potential calculations
|
||||
- Risk assessment
|
||||
- Execution recommendations
|
||||
|
||||
## Supported Markets
|
||||
|
||||
Common market identifiers include:
|
||||
|
||||
- `Fed` - Federal Reserve interest rate predictions
|
||||
- `BTC` - Bitcoin price predictions
|
||||
- `Election` - Political election outcomes
|
||||
- `ETH` - Ethereum price predictions
|
||||
- `Inflation` - Inflation rate predictions
|
||||
|
||||
*Note: Market availability may vary. Check current markets via the API.*
|
||||
|
||||
## API Reference
|
||||
|
||||
### NexusPredictionMarketTool Class
|
||||
|
||||
```python
|
||||
class NexusPredictionMarketTool:
|
||||
def __init__(self, source_name: Optional[str] = None, api_key: Optional[str] = None)
|
||||
def get_market_data(self, market: str) -> Dict[str, Any]
|
||||
def check_arbitrage_opportunities(self, markets: List[str]) -> Dict[str, Any]
|
||||
def get_agent_info(self) -> Dict[str, Any]
|
||||
def run(self, action: str, market: Optional[str] = None, markets: Optional[List[str]] = None) -> str
|
||||
```
|
||||
|
||||
### Function Interface
|
||||
|
||||
```python
|
||||
def nexus_prediction_market_tool(
|
||||
action: str,
|
||||
market: Optional[str] = None,
|
||||
markets: Optional[List[str]] = None,
|
||||
source_name: Optional[str] = None
|
||||
) -> str
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Market Analysis Agent
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
from praisonai_tools.tools import NexusPredictionMarketTool
|
||||
|
||||
class MarketAnalysisAgent:
|
||||
def __init__(self):
|
||||
self.nexus = NexusPredictionMarketTool(source_name="analysis-bot")
|
||||
|
||||
def analyze_market_sentiment(self, market):
|
||||
"""Analyze market sentiment for a specific prediction market."""
|
||||
data = self.nexus.get_market_data(market)
|
||||
|
||||
if data["success"]:
|
||||
market_data = data["data"]
|
||||
# Analyze price, volume, trends etc.
|
||||
return f"Market sentiment for {market}: {self.interpret_data(market_data)}"
|
||||
else:
|
||||
return f"Error getting data: {data['error']}"
|
||||
|
||||
def interpret_data(self, data):
|
||||
"""Custom logic to interpret market data."""
|
||||
# Implementation specific to your analysis needs
|
||||
pass
|
||||
```
|
||||
|
||||
### Multi-Agent Workflow
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent, AgentTeam, Task
|
||||
|
||||
# Data collector agent
|
||||
collector = Agent(
|
||||
name="DataCollector",
|
||||
tools=[get_market_data],
|
||||
instructions="Collect prediction market data from NEXUS."
|
||||
)
|
||||
|
||||
# Analyst agent
|
||||
analyst = Agent(
|
||||
name="Analyst",
|
||||
instructions="Analyze market data and provide insights."
|
||||
)
|
||||
|
||||
# Create workflow
|
||||
team = AgentTeam(agents=[collector, analyst])
|
||||
|
||||
# Define tasks
|
||||
collect_task = Task(
|
||||
name="collect_data",
|
||||
description="Collect data for Fed and BTC markets",
|
||||
agent=collector
|
||||
)
|
||||
|
||||
analyze_task = Task(
|
||||
name="analyze_trends",
|
||||
description="Analyze the collected market data",
|
||||
agent=analyst,
|
||||
dependencies=[collect_task]
|
||||
)
|
||||
|
||||
# Execute workflow
|
||||
results = team.run(tasks=[collect_task, analyze_task])
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The tool provides comprehensive error handling for common scenarios:
|
||||
|
||||
### Network Errors
|
||||
```python
|
||||
{
|
||||
"success": False,
|
||||
"error": "Failed to fetch market data: Connection timeout",
|
||||
"market": "Fed"
|
||||
}
|
||||
```
|
||||
|
||||
### Payment Required (Arbitrage Features)
|
||||
```python
|
||||
{
|
||||
"success": False,
|
||||
"error": "Payment required for arbitrage analysis. Cost: $0.02 USDC on eip155:8453",
|
||||
"markets": ["Fed", "BTC"],
|
||||
"payment_info": "This feature requires payment on eip155:8453 blockchain"
|
||||
}
|
||||
```
|
||||
|
||||
### Invalid Parameters
|
||||
```python
|
||||
{
|
||||
"success": False,
|
||||
"error": "Market parameter required for get_market_data action"
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Source Identification
|
||||
Always set a descriptive source name for tracking:
|
||||
```python
|
||||
nexus = NexusPredictionMarketTool(source_name="trading-bot-v1")
|
||||
```
|
||||
|
||||
### 2. Error Handling
|
||||
Always check the `success` field before processing results:
|
||||
```python
|
||||
result = nexus.get_market_data("Fed")
|
||||
if result["success"]:
|
||||
data = result["data"]
|
||||
# Process data
|
||||
else:
|
||||
print(f"Error: {result['error']}")
|
||||
```
|
||||
|
||||
### 3. Rate Limiting
|
||||
Implement appropriate delays between requests to avoid overwhelming the API:
|
||||
```python
|
||||
import time
|
||||
import asyncio
|
||||
|
||||
async def batch_collect_data(markets):
|
||||
data = {}
|
||||
for market in markets:
|
||||
result = nexus.get_market_data(market)
|
||||
data[market] = result
|
||||
await asyncio.sleep(1) # 1 second delay between requests
|
||||
return data
|
||||
```
|
||||
|
||||
### 4. Caching
|
||||
Cache results for frequently accessed data:
|
||||
```python
|
||||
from functools import lru_cache
|
||||
import time
|
||||
|
||||
@lru_cache(maxsize=100)
|
||||
def cached_market_data(market, timestamp):
|
||||
return nexus.get_market_data(market)
|
||||
|
||||
def get_cached_data(market, cache_duration=300): # 5 minutes
|
||||
timestamp = int(time.time() // cache_duration)
|
||||
return cached_market_data(market, timestamp)
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### 1. API Key Management
|
||||
If using paid features, securely manage your API keys:
|
||||
```python
|
||||
import os
|
||||
api_key = os.getenv("NEXUS_API_KEY")
|
||||
nexus = NexusPredictionMarketTool(api_key=api_key)
|
||||
```
|
||||
|
||||
### 2. Input Validation
|
||||
Validate market identifiers before making requests:
|
||||
```python
|
||||
VALID_MARKETS = ["Fed", "BTC", "ETH", "Election", "Inflation"]
|
||||
|
||||
def validate_market(market):
|
||||
if market not in VALID_MARKETS:
|
||||
raise ValueError(f"Invalid market: {market}")
|
||||
return market
|
||||
```
|
||||
|
||||
### 3. Rate Limiting
|
||||
Implement client-side rate limiting to prevent abuse:
|
||||
```python
|
||||
from time import time, sleep
|
||||
|
||||
class RateLimiter:
|
||||
def __init__(self, max_calls_per_minute=60):
|
||||
self.max_calls = max_calls_per_minute
|
||||
self.calls = []
|
||||
|
||||
def wait_if_needed(self):
|
||||
now = time()
|
||||
self.calls = [call_time for call_time in self.calls if now - call_time < 60]
|
||||
|
||||
if len(self.calls) >= self.max_calls:
|
||||
sleep(1)
|
||||
|
||||
self.calls.append(now)
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Connection Timeout**
|
||||
- Check internet connectivity
|
||||
- Verify the API endpoint is accessible
|
||||
- Implement retry logic with exponential backoff
|
||||
|
||||
2. **Invalid Market Identifier**
|
||||
- Verify the market name is correct
|
||||
- Check available markets via agent info endpoint
|
||||
- Use exact case-sensitive market names
|
||||
|
||||
3. **Payment Required Errors**
|
||||
- Ensure you have sufficient USDC balance on eip155:8453
|
||||
- Verify payment setup for arbitrage features
|
||||
- Contact NEXUS support if payment issues persist
|
||||
|
||||
4. **JSON Decode Errors**
|
||||
- Check API response format
|
||||
- Verify the endpoint returned valid JSON
|
||||
- Implement fallback error handling
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable debug logging to troubleshoot issues:
|
||||
```python
|
||||
import logging
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
nexus = NexusPredictionMarketTool(source_name="debug-agent")
|
||||
# Requests will now be logged with full details
|
||||
```
|
||||
|
||||
## Support and Resources
|
||||
|
||||
- **API Documentation**: https://nexus-agent-xa12.onrender.com/.well-known/agent.json
|
||||
- **Validation Check**: https://agentic.market/validate?url=https://nexus-agent-xa12.onrender.com/arb/check
|
||||
- **PraisonAI Documentation**: https://docs.praison.ai
|
||||
- **Issue Tracking**: Report issues via GitHub issues in the PraisonAI-Tools repository
|
||||
|
||||
## Contributing
|
||||
|
||||
To contribute improvements or report bugs:
|
||||
|
||||
1. Fork the PraisonAI-Tools repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Add tests for new functionality
|
||||
5. Submit a pull request
|
||||
|
||||
## License
|
||||
|
||||
This tool is part of the PraisonAI-Tools package and follows the same licensing terms.
|
||||
@@ -0,0 +1,141 @@
|
||||
# Tool-Registry Proxy Connector
|
||||
|
||||
An **optional, bring-your-own-account** connector that lets any PraisonAI agent
|
||||
discover and call a large catalogue of third-party API endpoints (SEO,
|
||||
enrichment, social data, scraping, ads) through a single proxy token — with
|
||||
**credentials injected server-side by the registry, never held by the agent**.
|
||||
|
||||
PraisonAI ships **no registry code, no bundled binary and no default endpoint**.
|
||||
You supply your own account token or a self-hosted base URL. The connector is
|
||||
disabled unless `TOOL_PROXY_URL` is configured.
|
||||
|
||||
## Overview
|
||||
|
||||
The connector mirrors PraisonAI's deferred tool-search bridge
|
||||
(`tool_search` / `tool_describe` / `tool_call`) with three functions:
|
||||
|
||||
| Function | Cost | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `registry_search(query)` | free | capability search over the catalogue |
|
||||
| `registry_describe(tool_id)` | free | params, price-per-call, example response |
|
||||
| `registry_call(tool_id, params)` | paid | invoke via proxy; credential injected server-side |
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install "praisonai-tools[registry-proxy]"
|
||||
```
|
||||
|
||||
## Setup (3 lines)
|
||||
|
||||
```bash
|
||||
export TOOL_PROXY_URL="https://your-registry.example.com" # or a self-hosted instance
|
||||
export TOOL_PROXY_TOKEN="your-proxy-token"
|
||||
# Optional: override the auth header for registries that expect a custom header.
|
||||
# Default is "Authorization" (sent as "Bearer <token>"); any other name (e.g.
|
||||
# "X-Treg-Token") sends the raw token in that header.
|
||||
export TOOL_PROXY_AUTH_HEADER="Authorization"
|
||||
# Optional: override the describe route for registries with a different layout.
|
||||
# Must contain "{tool_id}"; defaults to the live registry route below.
|
||||
export TOOL_PROXY_DESCRIBE_PATH="/catalog/endpoints/{tool_id}"
|
||||
```
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
from praisonai_tools.registry_proxy import (
|
||||
registry_search, registry_describe, registry_call,
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
instructions="SEO analyst",
|
||||
tools=[registry_search, registry_describe, registry_call],
|
||||
)
|
||||
agent.start("Find the top backlink sources for example.com and summarise")
|
||||
# → registry_search("backlinks for a domain") → describe → call
|
||||
# one token, no vendor keys in the agent
|
||||
```
|
||||
|
||||
## Spend safety
|
||||
|
||||
`registry_call` accepts optional budget guards. When a configured budget would
|
||||
be exceeded, the call is **denied and reported** as a tool-result error (never
|
||||
raised as an exception):
|
||||
|
||||
```python
|
||||
registry_call(
|
||||
"seo.backlinks",
|
||||
{"domain": "example.com"},
|
||||
max_cost_per_call=0.10, # deny if price-per-call exceeds this
|
||||
max_session_spend=5.00, # deny if cumulative spend would exceed this
|
||||
)
|
||||
```
|
||||
|
||||
Per-call price is read from `registry_describe`, so guards work without any
|
||||
vendor-specific configuration. Guards **fail closed**: if a budget is set but
|
||||
the price cannot be validated, the call is denied. Cumulative `max_session_spend`
|
||||
persists across separate `registry_call` invocations within the same process,
|
||||
scoped per `(proxy_url, token)` so distinct accounts never share a budget.
|
||||
|
||||
## HTTP methods
|
||||
|
||||
The registry enforces each endpoint's declared HTTP method on `/call/{id}`.
|
||||
`registry_call` derives the method automatically from `registry_describe`
|
||||
(the endpoint doc carries `method`): `GET` endpoints send `params` as the query
|
||||
string, `POST`/`PUT` send them as a JSON body. Pass `method="GET"` explicitly to
|
||||
skip the describe lookup when you already know the method. Only the standard
|
||||
`GET`/`POST`/`PUT`/`PATCH`/`DELETE` verbs are accepted; any other value (e.g. a
|
||||
mislabelled endpoint doc) is rejected as a tool-result error before any request.
|
||||
`tool_id` is URL-encoded before being placed in the describe/call route, so it
|
||||
cannot alter the requested path.
|
||||
|
||||
## Passthrough-URL calls
|
||||
|
||||
Passthrough-URL calls require the upstream to be **registered on the registry
|
||||
first**. Loopback/private upstreams (e.g. `127.0.0.1`, RFC-1918 addresses) are
|
||||
**refused by the registry's SSRF guard by design** — the base URL must be a
|
||||
public `http(s)` address. This is registry-side policy, not a connector setting.
|
||||
|
||||
## Credential safety
|
||||
|
||||
The proxy token is only sent to the endpoint it was configured for. If a caller
|
||||
(or a prompt-injected agent) supplies an explicit `proxy_url` without a matching
|
||||
`token`, the ambient `TOOL_PROXY_TOKEN` is **not** attached — preventing the
|
||||
token from being exfiltrated to an untrusted endpoint. For normal use, leave
|
||||
`proxy_url`/`token` unset and configure `TOOL_PROXY_URL`/`TOOL_PROXY_TOKEN` via
|
||||
the environment.
|
||||
|
||||
## Error taxonomy
|
||||
|
||||
All failures are returned as `{"error": ...}` tool results:
|
||||
|
||||
- Authentication failed (HTTP 401/403)
|
||||
- Insufficient balance (HTTP 402)
|
||||
- Upstream 4xx/5xx passed through
|
||||
- Request timeout
|
||||
- `httpx` not installed → install hint
|
||||
|
||||
## Tier 0 — Skills directory compatibility (zero code)
|
||||
|
||||
Registries that expose `skill install <name>` install skills into
|
||||
`./.claude/skills/`. PraisonAI **already scans** this directory, so
|
||||
registry-installed skills are discoverable by PraisonAI agents today with no
|
||||
additional code:
|
||||
|
||||
- `praisonaiagents/skills/discovery.py` — `.claude/skills` compatibility scan
|
||||
plus an ancestor walk for monorepos.
|
||||
|
||||
## Tier 2 — Environment / CLI passthrough (config-only)
|
||||
|
||||
A registry's `shell` / `run <vendor> -- args` inject vendor-CLI credentials at
|
||||
execution time. Using a PraisonAI environment definition, install the registry
|
||||
CLI and log in via a token env var in `setup:`; the agent's `execute_command`
|
||||
then has authenticated vendor CLIs (e.g. `run gh -- pr list`) without any key in
|
||||
the container. Sandbox / board workers inherit the same via the environment
|
||||
definition.
|
||||
|
||||
## Licensing note
|
||||
|
||||
Some registries are Apache-2.0 **with additional terms** prohibiting embedding
|
||||
in commercially distributed products without permission. This connector is
|
||||
therefore **optional and bring-your-own-account only**: it ships no registry
|
||||
code and no default endpoint — the same posture as other BYO-account connectors.
|
||||
@@ -0,0 +1,357 @@
|
||||
# SwarmScore Integration for PraisonAI
|
||||
|
||||
SwarmScore is a portable trust rating system for AI agents that provides cryptographically signed reputation scores based on verified execution history. This integration allows PraisonAI agents to leverage trust ratings for enhanced security and decision-making.
|
||||
|
||||
## Overview
|
||||
|
||||
SwarmScore provides:
|
||||
- **Portable Trust Ratings**: Cryptographically signed scores that travel with your agent across platforms
|
||||
- **Verified Execution History**: Trust based on actual performance, not self-reported metrics
|
||||
- **Consistency Tracking**: 80 jobs at 95% success rate beats 1 job at 100%
|
||||
- **Agent-to-Agent Discovery**: Machine-readable trust data for autonomous agent interactions
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install praisonai-tools[swarmscore]
|
||||
# or
|
||||
pip install praisonai-tools requests
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```python
|
||||
from praisonai_tools.tools import SwarmScoreTool
|
||||
|
||||
# Initialize the tool
|
||||
swarmscore = SwarmScoreTool()
|
||||
|
||||
# Load your agent's trust score
|
||||
result = swarmscore.load_swarmscore("your-agent-slug")
|
||||
|
||||
if result.success:
|
||||
score_data = result.output
|
||||
print(f"Trust Score: {score_data['score']}")
|
||||
print(f"Trust Tier: {score_data['tier']}")
|
||||
print(f"Success Rate: {score_data['success_rate']}%")
|
||||
else:
|
||||
print(f"Error: {result.error}")
|
||||
```
|
||||
|
||||
### Standalone Functions
|
||||
|
||||
```python
|
||||
from praisonai_tools.tools import (
|
||||
load_swarmscore_by_slug,
|
||||
verify_swarmscore_freshness,
|
||||
get_agent_discovery_manifest
|
||||
)
|
||||
|
||||
# Load score data
|
||||
try:
|
||||
score_data = load_swarmscore_by_slug("your-agent-slug")
|
||||
print(f"Score: {score_data['score']}")
|
||||
except Exception as e:
|
||||
print(f"Failed to load score: {e}")
|
||||
|
||||
# Verify score freshness
|
||||
try:
|
||||
verification = verify_swarmscore_freshness(score_data['verify_payload'])
|
||||
print(f"Verification: {verification}")
|
||||
except Exception as e:
|
||||
print(f"Verification failed: {e}")
|
||||
|
||||
# Get discovery manifest
|
||||
try:
|
||||
manifest = get_agent_discovery_manifest()
|
||||
print(f"Discovery data: {manifest}")
|
||||
except Exception as e:
|
||||
print(f"Failed to get manifest: {e}")
|
||||
```
|
||||
|
||||
## Integration Patterns
|
||||
|
||||
### 1. Trust-Based Decision Making
|
||||
|
||||
```python
|
||||
from praisonai_tools.tools import SwarmScoreTool
|
||||
|
||||
def execute_with_trust_level(agent_slug, task):
|
||||
"""Execute task based on agent's trust level."""
|
||||
swarmscore = SwarmScoreTool()
|
||||
result = swarmscore.load_swarmscore(agent_slug)
|
||||
|
||||
if not result.success:
|
||||
# Default to restricted mode if score unavailable
|
||||
return execute_restricted(task)
|
||||
|
||||
trust_score = result.output.get('score', 0)
|
||||
|
||||
if trust_score >= 80:
|
||||
# High trust - full autonomy
|
||||
return execute_autonomous(task)
|
||||
elif trust_score >= 60:
|
||||
# Medium trust - human oversight
|
||||
return execute_supervised(task)
|
||||
else:
|
||||
# Low trust - restricted operations
|
||||
return execute_restricted(task)
|
||||
```
|
||||
|
||||
### 2. Agent Authentication
|
||||
|
||||
```python
|
||||
def authenticate_agent(agent_slug):
|
||||
"""Verify agent identity and trust level."""
|
||||
swarmscore = SwarmScoreTool()
|
||||
|
||||
# Load and verify score
|
||||
load_result = swarmscore.load_swarmscore(agent_slug)
|
||||
if not load_result.success:
|
||||
return False, "Failed to load trust score"
|
||||
|
||||
# Verify freshness
|
||||
verify_payload = load_result.output.get('verify_payload')
|
||||
if verify_payload:
|
||||
verify_result = swarmscore.verify_swarmscore(verify_payload)
|
||||
if not verify_result.success:
|
||||
return False, "Trust score verification failed"
|
||||
|
||||
trust_score = load_result.output.get('score', 0)
|
||||
return trust_score >= 50, f"Trust score: {trust_score}"
|
||||
```
|
||||
|
||||
### 3. Multi-Agent Coordination
|
||||
|
||||
```python
|
||||
def find_trusted_collaborators(task_requirements):
|
||||
"""Find agents suitable for collaboration based on trust scores."""
|
||||
swarmscore = SwarmScoreTool()
|
||||
|
||||
# Get discovery manifest for agent lookup
|
||||
manifest_result = swarmscore.get_discovery_manifest()
|
||||
if not manifest_result.success:
|
||||
return []
|
||||
|
||||
trusted_agents = []
|
||||
discovery_data = manifest_result.output
|
||||
|
||||
# Example: Filter agents by trust score and capabilities
|
||||
for agent_id in discovery_data.get('agents', []):
|
||||
score_result = swarmscore.load_swarmscore(agent_id)
|
||||
if score_result.success:
|
||||
score_data = score_result.output
|
||||
if score_data.get('score', 0) >= 70:
|
||||
trusted_agents.append({
|
||||
'id': agent_id,
|
||||
'score': score_data['score'],
|
||||
'capabilities': score_data.get('capabilities', [])
|
||||
})
|
||||
|
||||
return trusted_agents
|
||||
```
|
||||
|
||||
## PraisonAI Agent Integration
|
||||
|
||||
### Using with PraisonAI Agent Tools
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent, tool
|
||||
from praisonai_tools.tools import SwarmScoreTool
|
||||
|
||||
@tool
|
||||
def check_agent_trust_score(agent_slug: str) -> str:
|
||||
"""Check the trust score for an agent."""
|
||||
swarmscore = SwarmScoreTool()
|
||||
result = swarmscore.load_swarmscore(agent_slug)
|
||||
|
||||
if result.success:
|
||||
score_data = result.output
|
||||
return f"Agent {agent_slug} has trust score {score_data.get('score', 'unknown')} with {score_data.get('success_rate', 'unknown')}% success rate"
|
||||
else:
|
||||
return f"Failed to load trust score for {agent_slug}: {result.error}"
|
||||
|
||||
# Create agent with SwarmScore capability
|
||||
agent = Agent(
|
||||
name="trust-aware-agent",
|
||||
instructions="You can check agent trust scores using the check_agent_trust_score tool.",
|
||||
tools=[check_agent_trust_score]
|
||||
)
|
||||
|
||||
# Use the agent
|
||||
response = agent.start("What is the trust score for agent 'trading-bot-v2'?")
|
||||
print(response)
|
||||
```
|
||||
|
||||
### Agent with Built-in Trust Management
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
from praisonai_tools.tools import SwarmScoreTool
|
||||
|
||||
class TrustAwareAgent(Agent):
|
||||
"""PraisonAI Agent with built-in SwarmScore integration."""
|
||||
|
||||
def __init__(self, agent_slug: str, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.agent_slug = agent_slug
|
||||
self.swarmscore = SwarmScoreTool()
|
||||
self._trust_score = None
|
||||
|
||||
async def check_trust_level(self):
|
||||
"""Check and cache current trust level."""
|
||||
result = self.swarmscore.load_swarmscore(self.agent_slug)
|
||||
if result.success:
|
||||
self._trust_score = result.output.get('score', 0)
|
||||
return self._trust_score
|
||||
|
||||
def can_execute_action(self, action_type: str) -> bool:
|
||||
"""Determine if action can be executed based on trust level."""
|
||||
if self._trust_score is None:
|
||||
return False # Default to safe
|
||||
|
||||
# Define trust requirements for different actions
|
||||
trust_requirements = {
|
||||
'read': 30,
|
||||
'write': 60,
|
||||
'execute': 80,
|
||||
'admin': 95
|
||||
}
|
||||
|
||||
required_trust = trust_requirements.get(action_type, 100)
|
||||
return self._trust_score >= required_trust
|
||||
|
||||
# Usage
|
||||
agent = TrustAwareAgent(
|
||||
agent_slug="my-agent-123",
|
||||
name="secure-agent",
|
||||
instructions="Execute tasks based on trust level"
|
||||
)
|
||||
|
||||
# Check trust before executing
|
||||
trust_score = await agent.check_trust_level()
|
||||
if agent.can_execute_action('execute'):
|
||||
print("Proceeding with high-trust execution")
|
||||
else:
|
||||
print("Restricted to low-trust operations")
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### SwarmScoreTool Class
|
||||
|
||||
#### Methods
|
||||
|
||||
**`__init__(api_base_url: str = "https://api.swarmsync.ai/v1/swarmscore/")`**
|
||||
- Initialize SwarmScore tool
|
||||
- `api_base_url`: Base URL for SwarmScore API
|
||||
|
||||
**`load_swarmscore(slug: str) -> ToolResult`**
|
||||
- Load SwarmScore data by agent slug
|
||||
- Returns: ToolResult with passport, certificate, and verification data
|
||||
|
||||
**`verify_swarmscore(verify_payload: Dict[str, Any]) -> ToolResult`**
|
||||
- Verify SwarmScore freshness using verification payload
|
||||
- Returns: ToolResult with verification status
|
||||
|
||||
**`get_discovery_manifest() -> ToolResult`**
|
||||
- Get machine-readable agent discovery manifest
|
||||
- Returns: ToolResult with discovery data
|
||||
|
||||
**`run(action: str, **kwargs) -> ToolResult`**
|
||||
- Execute SwarmScore action
|
||||
- Actions: 'load', 'verify', 'discover'
|
||||
|
||||
### Standalone Functions
|
||||
|
||||
**`load_swarmscore_by_slug(slug: str) -> Dict[str, Any]`**
|
||||
- Load SwarmScore data by agent slug
|
||||
- Raises: Exception if loading fails
|
||||
|
||||
**`verify_swarmscore_freshness(verify_payload: Dict[str, Any]) -> Dict[str, Any]`**
|
||||
- Verify SwarmScore freshness
|
||||
- Raises: Exception if verification fails
|
||||
|
||||
**`get_agent_discovery_manifest() -> Dict[str, Any]`**
|
||||
- Get agent discovery manifest
|
||||
- Raises: Exception if retrieval fails
|
||||
|
||||
## SwarmScore Data Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"score": 85,
|
||||
"tier": "gold",
|
||||
"jobs_completed": 150,
|
||||
"success_rate": 94.5,
|
||||
"consistency_score": 88,
|
||||
"signed_certificate": "...",
|
||||
"verify_payload": {
|
||||
"signature": "...",
|
||||
"timestamp": "...",
|
||||
"agent_id": "..."
|
||||
},
|
||||
"capabilities": ["trading", "analysis", "reporting"],
|
||||
"last_updated": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```python
|
||||
from praisonai_tools.tools import SwarmScoreTool
|
||||
|
||||
swarmscore = SwarmScoreTool()
|
||||
result = swarmscore.load_swarmscore("agent-123")
|
||||
|
||||
if not result.success:
|
||||
error_msg = result.error
|
||||
|
||||
if "network" in error_msg.lower():
|
||||
# Handle network issues
|
||||
print("Network connectivity problem")
|
||||
elif "not found" in error_msg.lower():
|
||||
# Handle missing agent
|
||||
print("Agent not registered in SwarmScore")
|
||||
else:
|
||||
# Handle other errors
|
||||
print(f"Unexpected error: {error_msg}")
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Cache Trust Scores**: Cache scores temporarily to avoid excessive API calls
|
||||
2. **Graceful Degradation**: Have fallback behavior when SwarmScore is unavailable
|
||||
3. **Verify Freshness**: Always verify score freshness for critical decisions
|
||||
4. **Trust Thresholds**: Define clear trust score requirements for different operations
|
||||
5. **Error Handling**: Handle network and API errors gracefully
|
||||
|
||||
## Getting Started with SwarmScore
|
||||
|
||||
1. **Register**: Create an account at [SwarmSync.AI](https://swarmsync.ai)
|
||||
2. **Get Your Slug**: Obtain your agent identifier from the dashboard
|
||||
3. **Start Building**: Begin tracking execution history to build trust
|
||||
4. **Integrate**: Use this tool to incorporate trust ratings into your workflows
|
||||
|
||||
## Resources
|
||||
|
||||
- [SwarmScore Documentation](https://swarmsync.ai/docs/protocol-specs/swarmscore)
|
||||
- [SwarmScore GitHub Spec](https://github.com/swarmsync-ai/swarmscore-spec)
|
||||
- [SwarmSync.AI Platform](https://swarmsync.ai)
|
||||
- [Example Implementation](../examples/swarmscore_example.py)
|
||||
|
||||
## Contributing
|
||||
|
||||
To contribute improvements to the SwarmScore integration:
|
||||
|
||||
1. Fork the PraisonAI-Tools repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Add tests for new functionality
|
||||
5. Submit a pull request
|
||||
|
||||
## License
|
||||
|
||||
This integration is part of PraisonAI-Tools and follows the same license terms.
|
||||
@@ -0,0 +1,261 @@
|
||||
"""
|
||||
Inkog Security Analysis Tool Example
|
||||
|
||||
This example demonstrates how to use the Inkog tool to analyze AI agent code
|
||||
for security vulnerabilities like token bombing, prompt injection, and more.
|
||||
|
||||
Prerequisites:
|
||||
1. Install inkog CLI: brew tap inkog-io/inkog && brew install inkog
|
||||
2. Get free API key from https://app.inkog.io
|
||||
3. Set environment variable: export INKOG_API_KEY=your_key_here
|
||||
|
||||
The Inkog tool can detect:
|
||||
- Token bombing attacks (loops where LLM controls termination)
|
||||
- Prompt injection vulnerabilities
|
||||
- Recursive tool calling without cycle detection
|
||||
- Missing human oversight for destructive operations
|
||||
- Cross-tenant data leakage
|
||||
- MCP tool poisoning
|
||||
- EU AI Act / NIST / OWASP compliance issues
|
||||
"""
|
||||
|
||||
import os
|
||||
from praisonai_tools import InkogTool, scan_agent_code
|
||||
|
||||
|
||||
def basic_security_scan():
|
||||
"""Perform a basic security scan of the current directory."""
|
||||
print("🔍 Basic Security Scan Example")
|
||||
print("=" * 50)
|
||||
|
||||
# Method 1: Using the class
|
||||
inkog = InkogTool()
|
||||
result = inkog.scan_directory(
|
||||
path=".",
|
||||
output_format="json",
|
||||
policy="balanced" # balanced, low-noise, comprehensive
|
||||
)
|
||||
|
||||
if "error" in result:
|
||||
print(f"❌ Scan failed: {result['error']}")
|
||||
return
|
||||
|
||||
# Analyze the results
|
||||
analysis = inkog.analyze_findings(result)
|
||||
print(analysis)
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
|
||||
|
||||
def advanced_security_scan():
|
||||
"""Perform an advanced security scan with deep analysis."""
|
||||
print("🔬 Advanced Security Scan Example (Deep Analysis)")
|
||||
print("=" * 60)
|
||||
|
||||
inkog = InkogTool()
|
||||
|
||||
# Deep scan provides enhanced analysis with AI orchestrator
|
||||
result = inkog.scan_directory(
|
||||
path=".",
|
||||
output_format="json",
|
||||
policy="comprehensive", # Show all findings including best practices
|
||||
deep=True, # Enable AI orchestrator analysis (requires Inkog Deep role)
|
||||
verbose=True
|
||||
)
|
||||
|
||||
if "error" in result:
|
||||
print(f"❌ Deep scan failed: {result['error']}")
|
||||
print("Note: Deep scans require the Inkog Deep role on your account")
|
||||
return
|
||||
|
||||
# Show detailed analysis
|
||||
analysis = inkog.analyze_findings(result)
|
||||
print(analysis)
|
||||
|
||||
# Show raw findings for debugging
|
||||
if "server_findings" in result:
|
||||
print(f"\n📊 Raw Findings ({len(result['server_findings'])} total):")
|
||||
for i, finding in enumerate(result["server_findings"][:3]): # Show first 3
|
||||
print(f"{i+1}. {finding.get('pattern', 'Unknown')} - {finding.get('severity', 'N/A')}")
|
||||
print(f" File: {finding.get('file', 'N/A')}:{finding.get('line', 'N/A')}")
|
||||
print(f" Message: {finding.get('message', 'N/A')}")
|
||||
print()
|
||||
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
def skill_package_scan():
|
||||
"""Scan a SKILL.md package or MCP server."""
|
||||
print("📦 Skill Package Security Scan Example")
|
||||
print("=" * 50)
|
||||
|
||||
inkog = InkogTool()
|
||||
|
||||
# Scan current directory as a skill package
|
||||
result = inkog.skill_scan(
|
||||
path=".",
|
||||
output_format="json",
|
||||
deep=False
|
||||
)
|
||||
|
||||
if "error" in result:
|
||||
print(f"❌ Skill scan failed: {result['error']}")
|
||||
return
|
||||
|
||||
analysis = inkog.analyze_findings(result)
|
||||
print(analysis)
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
|
||||
|
||||
def mcp_server_scan():
|
||||
"""Scan an MCP server from the registry."""
|
||||
print("🔌 MCP Server Security Scan Example")
|
||||
print("=" * 50)
|
||||
|
||||
inkog = InkogTool()
|
||||
|
||||
# Scan a popular MCP server (GitHub)
|
||||
result = inkog.mcp_scan(
|
||||
server_name="github",
|
||||
output_format="json"
|
||||
)
|
||||
|
||||
if "error" in result:
|
||||
print(f"❌ MCP scan failed: {result['error']}")
|
||||
return
|
||||
|
||||
analysis = inkog.analyze_findings(result)
|
||||
print(analysis)
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
|
||||
|
||||
def standalone_function_example():
|
||||
"""Example using standalone functions for quick scans."""
|
||||
print("⚡ Standalone Function Examples")
|
||||
print("=" * 40)
|
||||
|
||||
# Quick scan using standalone function
|
||||
print("Running quick security scan...")
|
||||
result = scan_agent_code(
|
||||
path=".",
|
||||
output_format="json",
|
||||
policy="low-noise", # Only critical vulnerabilities
|
||||
deep=False
|
||||
)
|
||||
|
||||
if isinstance(result, dict) and "error" not in result:
|
||||
tool = InkogTool()
|
||||
analysis = tool.analyze_findings(result)
|
||||
print(analysis)
|
||||
else:
|
||||
print(f"Scan result: {result}")
|
||||
|
||||
print("\n" + "=" * 40)
|
||||
|
||||
|
||||
def html_report_example():
|
||||
"""Generate an HTML security report."""
|
||||
print("📄 HTML Report Generation Example")
|
||||
print("=" * 45)
|
||||
|
||||
inkog = InkogTool()
|
||||
|
||||
# Generate HTML report
|
||||
html_report = inkog.scan_directory(
|
||||
path=".",
|
||||
output_format="html",
|
||||
policy="comprehensive",
|
||||
deep=False
|
||||
)
|
||||
|
||||
if isinstance(html_report, str) and not html_report.startswith("Error"):
|
||||
# Save report to file
|
||||
report_path = "inkog_security_report.html"
|
||||
with open(report_path, "w", encoding="utf-8") as f:
|
||||
f.write(html_report)
|
||||
print(f"✅ HTML report generated: {report_path}")
|
||||
print(f"📄 Report size: {len(html_report)} characters")
|
||||
else:
|
||||
print(f"❌ HTML report failed: {html_report}")
|
||||
|
||||
print("=" * 45)
|
||||
|
||||
|
||||
def compliance_scan_example():
|
||||
"""Example focused on compliance mapping."""
|
||||
print("🏛️ Compliance Mapping Example")
|
||||
print("=" * 40)
|
||||
|
||||
# Use comprehensive policy to see compliance mappings
|
||||
inkog = InkogTool()
|
||||
result = inkog.scan_directory(
|
||||
path=".",
|
||||
output_format="json",
|
||||
policy="comprehensive", # Shows compliance mappings
|
||||
deep=False
|
||||
)
|
||||
|
||||
if "error" in result:
|
||||
print(f"❌ Compliance scan failed: {result['error']}")
|
||||
return
|
||||
|
||||
# Look for compliance information
|
||||
findings = result.get("server_findings", [])
|
||||
compliance_findings = []
|
||||
|
||||
for finding in findings:
|
||||
if any(term in finding.get("message", "").lower()
|
||||
for term in ["eu ai act", "nist", "owasp", "iso", "article"]):
|
||||
compliance_findings.append(finding)
|
||||
|
||||
if compliance_findings:
|
||||
print(f"📋 Found {len(compliance_findings)} compliance-related findings:")
|
||||
for finding in compliance_findings[:3]: # Show first 3
|
||||
print(f"• {finding.get('pattern', 'Unknown')}")
|
||||
print(f" {finding.get('message', 'N/A')[:100]}...")
|
||||
else:
|
||||
print("✅ No specific compliance violations found")
|
||||
|
||||
print("=" * 40)
|
||||
|
||||
|
||||
def main():
|
||||
"""Run all examples."""
|
||||
print("Inkog Security Analysis Tool Examples")
|
||||
print("=" * 60)
|
||||
|
||||
# Check if inkog is available
|
||||
api_key = os.environ.get("INKOG_API_KEY")
|
||||
if not api_key:
|
||||
print("⚠️ WARNING: INKOG_API_KEY environment variable not set")
|
||||
print(" Get your free API key at https://app.inkog.io")
|
||||
print(" Set it with: export INKOG_API_KEY=your_key_here")
|
||||
print()
|
||||
|
||||
try:
|
||||
# Run examples in order of complexity
|
||||
basic_security_scan()
|
||||
standalone_function_example()
|
||||
skill_package_scan()
|
||||
html_report_example()
|
||||
compliance_scan_example()
|
||||
|
||||
# Advanced features (may require special access)
|
||||
print("\n🎓 Advanced Examples (may require Inkog Deep role):")
|
||||
print("-" * 50)
|
||||
advanced_security_scan()
|
||||
mcp_server_scan()
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Example failed: {e}")
|
||||
print("This may be due to:")
|
||||
print("1. Inkog CLI not installed")
|
||||
print("2. Missing INKOG_API_KEY environment variable")
|
||||
print("3. Network connectivity issues")
|
||||
print("4. Insufficient permissions for deep scans")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -194,7 +194,7 @@ def example_team_preset():
|
||||
|
||||
print("✓ Motion graphics team created successfully!")
|
||||
print(f" Agents: {[agent.name for agent in team.agents]}")
|
||||
print(f" Leader: {team.leader.name}")
|
||||
print(f" Coordinator: {team.agents[0].name}")
|
||||
print(f" Workspace: {team._motion_graphics_workspace}")
|
||||
|
||||
except ImportError as e:
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Example: Using NEXUS Prediction Market Tool with PraisonAI
|
||||
|
||||
This example demonstrates how to use the NEXUS prediction market tool to:
|
||||
1. Get free market data for prediction markets
|
||||
2. Check arbitrage opportunities between markets (paid feature)
|
||||
3. Build an intelligent prediction market analysis agent
|
||||
|
||||
NEXUS API Information:
|
||||
- Free endpoint: /kalshi?market={market_name}
|
||||
- Paid endpoint: /arb/check?markets={market1,market2} ($0.02 USDC on eip155:8453)
|
||||
- Validation: https://agentic.market/validate?url=https://nexus-agent-xa12.onrender.com/arb/check
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, tool
|
||||
from praisonai_tools.tools.nexus_prediction_market_tool import NexusPredictionMarketTool
|
||||
import json
|
||||
|
||||
|
||||
# Example 1: Direct tool usage
|
||||
def example_direct_usage():
|
||||
"""Example of using the NEXUS tool directly."""
|
||||
print("=== Direct NEXUS Tool Usage ===\n")
|
||||
|
||||
# Initialize the tool
|
||||
nexus_tool = NexusPredictionMarketTool(source_name="praisonai-example")
|
||||
|
||||
# Get market data for Fed predictions
|
||||
print("1. Getting Fed market data...")
|
||||
fed_data = nexus_tool.get_market_data("Fed")
|
||||
print(json.dumps(fed_data, indent=2))
|
||||
print()
|
||||
|
||||
# Get BTC market data
|
||||
print("2. Getting BTC market data...")
|
||||
btc_data = nexus_tool.get_market_data("BTC")
|
||||
print(json.dumps(btc_data, indent=2))
|
||||
print()
|
||||
|
||||
# Check arbitrage opportunities (paid feature)
|
||||
print("3. Checking arbitrage opportunities between Fed and BTC markets...")
|
||||
arb_data = nexus_tool.check_arbitrage_opportunities(["Fed", "BTC"])
|
||||
print(json.dumps(arb_data, indent=2))
|
||||
print()
|
||||
|
||||
# Get agent information
|
||||
print("4. Getting NEXUS agent information...")
|
||||
agent_info = nexus_tool.get_agent_info()
|
||||
print(json.dumps(agent_info, indent=2))
|
||||
|
||||
|
||||
# Example 2: Create a custom tool wrapper for agents
|
||||
@tool
|
||||
def get_prediction_market_data(market: str) -> str:
|
||||
"""Get prediction market data for a specific market.
|
||||
|
||||
Args:
|
||||
market: Market identifier (e.g., 'Fed', 'BTC', 'Election')
|
||||
|
||||
Returns:
|
||||
JSON string with market data including prices and trends
|
||||
"""
|
||||
nexus_tool = NexusPredictionMarketTool(source_name="praisonai-agent")
|
||||
return nexus_tool.run(action="get_market_data", market=market)
|
||||
|
||||
|
||||
@tool
|
||||
def check_prediction_market_arbitrage(markets: str) -> str:
|
||||
"""Check for arbitrage opportunities between prediction markets.
|
||||
|
||||
Args:
|
||||
markets: Comma-separated list of markets to analyze (e.g., 'Fed,BTC')
|
||||
|
||||
Returns:
|
||||
JSON string with arbitrage opportunities and analysis
|
||||
|
||||
Note:
|
||||
This is a paid feature requiring $0.02 USDC on eip155:8453
|
||||
"""
|
||||
markets_list = [m.strip() for m in markets.split(',')]
|
||||
nexus_tool = NexusPredictionMarketTool(source_name="praisonai-agent")
|
||||
return nexus_tool.run(action="check_arbitrage", markets=markets_list)
|
||||
|
||||
|
||||
# Example 3: Prediction market analysis agent
|
||||
def example_prediction_market_agent():
|
||||
"""Example of a prediction market analysis agent."""
|
||||
print("\n=== Prediction Market Analysis Agent ===\n")
|
||||
|
||||
# Create an agent with NEXUS prediction market tools
|
||||
market_agent = Agent(
|
||||
name="PredictionMarketAnalyst",
|
||||
instructions="""You are an expert prediction market analyst.
|
||||
|
||||
Your capabilities:
|
||||
- Analyze prediction market data from Kalshi and Polymarket via NEXUS API
|
||||
- Identify market trends and sentiment
|
||||
- Spot potential arbitrage opportunities
|
||||
- Provide insights on market dynamics
|
||||
|
||||
When analyzing markets:
|
||||
1. First get current market data
|
||||
2. Analyze the data for trends and insights
|
||||
3. Look for arbitrage opportunities if requested
|
||||
4. Provide clear explanations of your findings
|
||||
|
||||
Be thorough in your analysis and cite specific data points.""",
|
||||
|
||||
tools=[get_prediction_market_data, check_prediction_market_arbitrage],
|
||||
llm="gpt-4o-mini"
|
||||
)
|
||||
|
||||
# Example queries
|
||||
queries = [
|
||||
"Get the current Fed interest rate prediction market data and analyze what it shows about market sentiment.",
|
||||
"Compare Fed and BTC prediction markets. What insights can you derive?",
|
||||
"Check if there are any arbitrage opportunities between Fed and BTC markets."
|
||||
]
|
||||
|
||||
for i, query in enumerate(queries, 1):
|
||||
print(f"Query {i}: {query}")
|
||||
response = market_agent.start(query)
|
||||
print(f"Response: {response}\n")
|
||||
print("-" * 80)
|
||||
|
||||
|
||||
# Example 4: Multi-agent prediction market workflow
|
||||
def example_multi_agent_workflow():
|
||||
"""Example of a multi-agent workflow for comprehensive market analysis."""
|
||||
print("\n=== Multi-Agent Prediction Market Workflow ===\n")
|
||||
|
||||
# Data gathering agent
|
||||
data_agent = Agent(
|
||||
name="MarketDataCollector",
|
||||
instructions="""You collect prediction market data from NEXUS API.
|
||||
Focus on gathering comprehensive data for analysis.""",
|
||||
tools=[get_prediction_market_data]
|
||||
)
|
||||
|
||||
# Analysis agent
|
||||
analysis_agent = Agent(
|
||||
name="MarketAnalyst",
|
||||
instructions="""You analyze prediction market data to identify trends,
|
||||
sentiment, and trading opportunities. Provide detailed insights.""",
|
||||
tools=[]
|
||||
)
|
||||
|
||||
# Arbitrage agent
|
||||
arbitrage_agent = Agent(
|
||||
name="ArbitrageSpecialist",
|
||||
instructions="""You specialize in finding arbitrage opportunities
|
||||
between prediction markets. Assess risk and profitability.""",
|
||||
tools=[check_prediction_market_arbitrage]
|
||||
)
|
||||
|
||||
# Example workflow
|
||||
markets = ["Fed", "BTC", "Election"]
|
||||
|
||||
print("Step 1: Collecting market data...")
|
||||
market_data = {}
|
||||
for market in markets:
|
||||
print(f"Collecting data for {market} market...")
|
||||
data = data_agent.start(f"Get current market data for {market}")
|
||||
market_data[market] = data
|
||||
print(f"Data collected for {market}: {len(data)} characters")
|
||||
|
||||
print("\nStep 2: Analyzing market trends...")
|
||||
combined_data = "\n".join([f"{market}: {data}" for market, data in market_data.items()])
|
||||
analysis = analysis_agent.start(f"Analyze this prediction market data and identify key trends:\n{combined_data}")
|
||||
print(f"Analysis: {analysis[:200]}...")
|
||||
|
||||
print("\nStep 3: Checking arbitrage opportunities...")
|
||||
arb_analysis = arbitrage_agent.start("Check for arbitrage opportunities between Fed,BTC markets")
|
||||
print(f"Arbitrage analysis: {arb_analysis[:200]}...")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("NEXUS Prediction Market Tool Examples")
|
||||
print("=" * 50)
|
||||
|
||||
# Run examples
|
||||
try:
|
||||
example_direct_usage()
|
||||
example_prediction_market_agent()
|
||||
example_multi_agent_workflow()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error running examples: {e}")
|
||||
print("\nNote: Some features require API access or payment.")
|
||||
print("Free features: Market data queries")
|
||||
print("Paid features: Arbitrage analysis ($0.02 USDC on eip155:8453)")
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("Examples completed!")
|
||||
print("\nTo use in your own projects:")
|
||||
print("1. Install: pip install praisonai-tools")
|
||||
print("2. Import: from praisonai_tools.tools import NexusPredictionMarketTool")
|
||||
print("3. Use: tool = NexusPredictionMarketTool()")
|
||||
print("\nFor arbitrage features, ensure you have the required payment setup.")
|
||||
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
SwarmScore Integration Example for PraisonAI
|
||||
|
||||
This example demonstrates how to integrate SwarmScore trust ratings
|
||||
with PraisonAI agents for portable reputation management.
|
||||
|
||||
SwarmScore provides cryptographically signed trust ratings based on
|
||||
verified execution history, success rates, and consistency.
|
||||
|
||||
Requirements:
|
||||
pip install requests praisonai-tools
|
||||
|
||||
Usage:
|
||||
python swarmscore_example.py
|
||||
"""
|
||||
|
||||
import json
|
||||
from praisonai_tools.tools import SwarmScoreTool, load_swarmscore_by_slug
|
||||
|
||||
|
||||
def main():
|
||||
"""Demonstrate SwarmScore integration with PraisonAI."""
|
||||
print("🔍 SwarmScore Integration Example")
|
||||
print("=" * 50)
|
||||
|
||||
# Initialize SwarmScore tool
|
||||
swarmscore = SwarmScoreTool()
|
||||
|
||||
# Example agent slug (replace with actual agent identifier)
|
||||
agent_slug = "example-agent-123"
|
||||
|
||||
print(f"\n📊 Loading SwarmScore for agent: {agent_slug}")
|
||||
|
||||
# Load SwarmScore data
|
||||
result = swarmscore.load_swarmscore(agent_slug)
|
||||
|
||||
if result.success:
|
||||
print("✅ SwarmScore loaded successfully!")
|
||||
|
||||
# Display basic score information
|
||||
score_data = result.output
|
||||
if 'score' in score_data:
|
||||
print(f" Trust Score: {score_data['score']}")
|
||||
if 'tier' in score_data:
|
||||
print(f" Trust Tier: {score_data['tier']}")
|
||||
if 'jobs_completed' in score_data:
|
||||
print(f" Jobs Completed: {score_data['jobs_completed']}")
|
||||
if 'success_rate' in score_data:
|
||||
print(f" Success Rate: {score_data['success_rate']}%")
|
||||
|
||||
print("\n🔍 Full SwarmScore data:")
|
||||
print(json.dumps(score_data, indent=2))
|
||||
|
||||
# Verify score freshness
|
||||
if 'verify_payload' in score_data:
|
||||
print("\n🔐 Verifying score freshness...")
|
||||
verify_result = swarmscore.verify_swarmscore(score_data['verify_payload'])
|
||||
|
||||
if verify_result.success:
|
||||
print("✅ Score verification successful!")
|
||||
print(f" Verification data: {verify_result.output}")
|
||||
else:
|
||||
print(f"❌ Score verification failed: {verify_result.error}")
|
||||
else:
|
||||
print(f"❌ Failed to load SwarmScore: {result.error}")
|
||||
print("\n💡 This might happen if:")
|
||||
print(" • The agent slug doesn't exist in SwarmScore")
|
||||
print(" • The SwarmScore API is temporarily unavailable")
|
||||
print(" • Network connectivity issues")
|
||||
|
||||
# Get discovery manifest
|
||||
print("\n🌐 Loading agent discovery manifest...")
|
||||
manifest_result = swarmscore.get_discovery_manifest()
|
||||
|
||||
if manifest_result.success:
|
||||
print("✅ Discovery manifest loaded!")
|
||||
print(" This contains machine-readable data for agent-to-agent discovery")
|
||||
print(f" Manifest keys: {list(manifest_result.output.keys())}")
|
||||
else:
|
||||
print(f"❌ Failed to load manifest: {manifest_result.error}")
|
||||
|
||||
# Demonstrate standalone function usage
|
||||
print("\n🛠️ Using standalone functions:")
|
||||
try:
|
||||
# This will likely fail with the example slug, but shows the API
|
||||
score_data = load_swarmscore_by_slug(agent_slug)
|
||||
print(f"✅ Standalone function succeeded: {score_data}")
|
||||
except Exception as e:
|
||||
print(f"❌ Standalone function failed (expected): {e}")
|
||||
|
||||
print("\n📚 Next Steps:")
|
||||
print(" 1. Register your agent at https://swarmsync.ai")
|
||||
print(" 2. Get your agent slug from the SwarmSync dashboard")
|
||||
print(" 3. Replace 'example-agent-123' with your actual slug")
|
||||
print(" 4. Integrate SwarmScore checks into your agent workflows")
|
||||
print("\n 📖 Documentation: https://swarmsync.ai/docs/protocol-specs/swarmscore")
|
||||
|
||||
|
||||
def agent_workflow_example():
|
||||
"""Example of how to integrate SwarmScore into an agent workflow."""
|
||||
print("\n🤖 Agent Workflow Integration Example")
|
||||
print("=" * 45)
|
||||
|
||||
# Simulated agent execution flow
|
||||
agent_id = "my-trading-agent"
|
||||
|
||||
# 1. Load current trust score before executing task
|
||||
swarmscore = SwarmScoreTool()
|
||||
score_result = swarmscore.load_swarmscore(agent_id)
|
||||
|
||||
if score_result.success:
|
||||
trust_score = score_result.output.get('score', 0)
|
||||
print(f"🔍 Agent {agent_id} current trust score: {trust_score}")
|
||||
|
||||
# 2. Make decisions based on trust level
|
||||
if trust_score >= 80:
|
||||
print("✅ High trust - proceeding with full autonomy")
|
||||
task_approach = "autonomous"
|
||||
elif trust_score >= 60:
|
||||
print("⚠️ Medium trust - requesting human approval for critical actions")
|
||||
task_approach = "supervised"
|
||||
else:
|
||||
print("❌ Low trust - operating in restricted mode")
|
||||
task_approach = "restricted"
|
||||
|
||||
# 3. Execute task based on trust level
|
||||
print(f"🚀 Executing task with {task_approach} approach...")
|
||||
|
||||
# 4. After task completion, the score would be updated by SwarmSync
|
||||
# (This happens externally based on verified execution results)
|
||||
print("📝 Task completed - SwarmScore will be updated based on results")
|
||||
|
||||
else:
|
||||
print(f"⚠️ Could not load trust score: {score_result.error}")
|
||||
print("🔄 Falling back to default security settings")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
agent_workflow_example()
|
||||
@@ -1,189 +0,0 @@
|
||||
<style>
|
||||
.lx-highlight { position: relative; border-radius:3px; padding:1px 2px;}
|
||||
.lx-highlight .lx-tooltip {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
background: #333;
|
||||
color: #fff;
|
||||
text-align: left;
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
bottom: 125%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 12px;
|
||||
max-width: 240px;
|
||||
white-space: normal;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
|
||||
}
|
||||
.lx-highlight:hover .lx-tooltip { visibility: visible; opacity:1; }
|
||||
.lx-animated-wrapper { max-width: 100%; font-family: Arial, sans-serif; }
|
||||
.lx-controls {
|
||||
background: #fafafa; border: 1px solid #90caf9; border-radius: 8px;
|
||||
padding: 12px; margin-bottom: 16px;
|
||||
}
|
||||
.lx-button-row {
|
||||
display: flex; justify-content: center; gap: 8px; margin-bottom: 12px;
|
||||
}
|
||||
.lx-control-btn {
|
||||
background: #4285f4; color: white; border: none; border-radius: 4px;
|
||||
padding: 8px 16px; cursor: pointer; font-size: 13px; font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.lx-control-btn:hover { background: #3367d6; }
|
||||
.lx-progress-container {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.lx-progress-slider {
|
||||
width: 100%; margin: 0; appearance: none; height: 6px;
|
||||
background: #ddd; border-radius: 3px; outline: none;
|
||||
}
|
||||
.lx-progress-slider::-webkit-slider-thumb {
|
||||
appearance: none; width: 18px; height: 18px; background: #4285f4;
|
||||
border-radius: 50%; cursor: pointer;
|
||||
}
|
||||
.lx-progress-slider::-moz-range-thumb {
|
||||
width: 18px; height: 18px; background: #4285f4; border-radius: 50%;
|
||||
cursor: pointer; border: none;
|
||||
}
|
||||
.lx-status-text {
|
||||
text-align: center; font-size: 12px; color: #666; margin-top: 4px;
|
||||
}
|
||||
.lx-text-window {
|
||||
font-family: monospace; white-space: pre-wrap; border: 1px solid #90caf9;
|
||||
padding: 12px; max-height: 260px; overflow-y: auto; margin-bottom: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.lx-attributes-panel {
|
||||
background: #fafafa; border: 1px solid #90caf9; border-radius: 6px;
|
||||
padding: 8px 10px; margin-top: 8px; font-size: 13px;
|
||||
}
|
||||
.lx-current-highlight {
|
||||
border-bottom: 4px solid #ff4444;
|
||||
font-weight: bold;
|
||||
animation: lx-pulse 1s ease-in-out;
|
||||
}
|
||||
@keyframes lx-pulse {
|
||||
0% { text-decoration-color: #ff4444; }
|
||||
50% { text-decoration-color: #ff0000; }
|
||||
100% { text-decoration-color: #ff4444; }
|
||||
}
|
||||
.lx-legend {
|
||||
font-size: 12px; margin-bottom: 8px;
|
||||
padding-bottom: 8px; border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
.lx-label {
|
||||
display: inline-block;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
margin-right: 4px;
|
||||
color: #000;
|
||||
}
|
||||
.lx-attr-key {
|
||||
font-weight: 600;
|
||||
color: #1565c0;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.lx-attr-value {
|
||||
font-weight: 400;
|
||||
opacity: 0.85;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
/* Add optimizations with larger fonts and better readability for GIFs */
|
||||
.lx-gif-optimized .lx-text-window { font-size: 16px; line-height: 1.8; }
|
||||
.lx-gif-optimized .lx-attributes-panel { font-size: 15px; }
|
||||
.lx-gif-optimized .lx-current-highlight { text-decoration-thickness: 4px; }
|
||||
</style>
|
||||
<div class="lx-animated-wrapper lx-gif-optimized">
|
||||
<div class="lx-attributes-panel">
|
||||
<div class="lx-legend">Highlights Legend: <span class="lx-label" style="background-color:#D2E3FC;">extraction_0</span> <span class="lx-label" style="background-color:#C8E6C9;">extraction_1</span> <span class="lx-label" style="background-color:#FEF0C3;">extraction_2</span></div>
|
||||
<div id="attributesContainer"></div>
|
||||
</div>
|
||||
<div class="lx-text-window" id="textWindow">
|
||||
The <span class="lx-highlight lx-current-highlight" data-idx="0" style="background-color:#FEF0C3;">quarterly report</span> shows revenue of <span class="lx-highlight" data-idx="1" style="background-color:#D2E3FC;">$1.2M</span> and profit of <span class="lx-highlight" data-idx="2" style="background-color:#C8E6C9;">$300K</span>.
|
||||
</div>
|
||||
<div class="lx-controls">
|
||||
<div class="lx-button-row">
|
||||
<button class="lx-control-btn" onclick="playPause()">▶️ Play</button>
|
||||
<button class="lx-control-btn" onclick="prevExtraction()">⏮ Previous</button>
|
||||
<button class="lx-control-btn" onclick="nextExtraction()">⏭ Next</button>
|
||||
</div>
|
||||
<div class="lx-progress-container">
|
||||
<input type="range" id="progressSlider" class="lx-progress-slider"
|
||||
min="0" max="2" value="0"
|
||||
onchange="jumpToExtraction(this.value)">
|
||||
</div>
|
||||
<div class="lx-status-text">
|
||||
Entity <span id="entityInfo">1/3</span> |
|
||||
Pos <span id="posInfo">[38-43]</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const extractions = [{"index": 0, "class": "extraction_2", "text": "quarterly report", "color": "#FEF0C3", "startPos": 4, "endPos": 20, "beforeText": "The ", "extractionText": "quarterly report", "afterText": " shows revenue of $1.2M and profit of $300K.", "attributesHtml": "<div><strong>class:</strong> extraction_2</div><div><strong>attributes:</strong> {<span class=\"lx-attr-key\">index</span>: <span class=\"lx-attr-value\">2</span>, <span class=\"lx-attr-key\">original_text</span>: <span class=\"lx-attr-value\">quarterly report</span>, <span class=\"lx-attr-key\">tool</span>: <span class=\"lx-attr-value\">langextract_extract</span>}</div>"}, {"index": 1, "class": "extraction_0", "text": "$1.2M", "color": "#D2E3FC", "startPos": 38, "endPos": 43, "beforeText": "The quarterly report shows revenue of ", "extractionText": "$1.2M", "afterText": " and profit of $300K.", "attributesHtml": "<div><strong>class:</strong> extraction_0</div><div><strong>attributes:</strong> {<span class=\"lx-attr-key\">index</span>: <span class=\"lx-attr-value\">0</span>, <span class=\"lx-attr-key\">original_text</span>: <span class=\"lx-attr-value\">$1.2M</span>, <span class=\"lx-attr-key\">tool</span>: <span class=\"lx-attr-value\">langextract_extract</span>}</div>"}, {"index": 2, "class": "extraction_1", "text": "$300K", "color": "#C8E6C9", "startPos": 58, "endPos": 63, "beforeText": "The quarterly report shows revenue of $1.2M and profit of ", "extractionText": "$300K", "afterText": ".", "attributesHtml": "<div><strong>class:</strong> extraction_1</div><div><strong>attributes:</strong> {<span class=\"lx-attr-key\">index</span>: <span class=\"lx-attr-value\">1</span>, <span class=\"lx-attr-key\">original_text</span>: <span class=\"lx-attr-value\">$300K</span>, <span class=\"lx-attr-key\">tool</span>: <span class=\"lx-attr-value\">langextract_extract</span>}</div>"}];
|
||||
let currentIndex = 0;
|
||||
let isPlaying = false;
|
||||
let animationInterval = null;
|
||||
let animationSpeed = 1.0;
|
||||
|
||||
function updateDisplay() {
|
||||
const extraction = extractions[currentIndex];
|
||||
if (!extraction) return;
|
||||
|
||||
document.getElementById('attributesContainer').innerHTML = extraction.attributesHtml;
|
||||
document.getElementById('entityInfo').textContent = (currentIndex + 1) + '/' + extractions.length;
|
||||
document.getElementById('posInfo').textContent = '[' + extraction.startPos + '-' + extraction.endPos + ']';
|
||||
document.getElementById('progressSlider').value = currentIndex;
|
||||
|
||||
const playBtn = document.querySelector('.lx-control-btn');
|
||||
if (playBtn) playBtn.textContent = isPlaying ? '⏸ Pause' : '▶️ Play';
|
||||
|
||||
const prevHighlight = document.querySelector('.lx-text-window .lx-current-highlight');
|
||||
if (prevHighlight) prevHighlight.classList.remove('lx-current-highlight');
|
||||
const currentSpan = document.querySelector('.lx-text-window span[data-idx="' + currentIndex + '"]');
|
||||
if (currentSpan) {
|
||||
currentSpan.classList.add('lx-current-highlight');
|
||||
currentSpan.scrollIntoView({block: 'center', behavior: 'smooth'});
|
||||
}
|
||||
}
|
||||
|
||||
function nextExtraction() {
|
||||
currentIndex = (currentIndex + 1) % extractions.length;
|
||||
updateDisplay();
|
||||
}
|
||||
|
||||
function prevExtraction() {
|
||||
currentIndex = (currentIndex - 1 + extractions.length) % extractions.length;
|
||||
updateDisplay();
|
||||
}
|
||||
|
||||
function jumpToExtraction(index) {
|
||||
currentIndex = parseInt(index);
|
||||
updateDisplay();
|
||||
}
|
||||
|
||||
function playPause() {
|
||||
if (isPlaying) {
|
||||
clearInterval(animationInterval);
|
||||
isPlaying = false;
|
||||
} else {
|
||||
animationInterval = setInterval(nextExtraction, animationSpeed * 1000);
|
||||
isPlaying = true;
|
||||
}
|
||||
updateDisplay();
|
||||
}
|
||||
|
||||
window.playPause = playPause;
|
||||
window.nextExtraction = nextExtraction;
|
||||
window.prevExtraction = prevExtraction;
|
||||
window.jumpToExtraction = jumpToExtraction;
|
||||
|
||||
updateDisplay();
|
||||
})();
|
||||
</script>
|
||||
+26
-98
@@ -37,6 +37,15 @@ from importlib.metadata import version, PackageNotFoundError
|
||||
|
||||
from praisonai_tools.tools.base import BaseTool, ToolResult, ToolValidationError, validate_tool
|
||||
from praisonai_tools.tools.decorator import tool, FunctionTool, is_tool, get_tool_schema
|
||||
from praisonai_tools.catalogue import (
|
||||
ToolCatalogueEntry,
|
||||
list_tools,
|
||||
get_tool_names,
|
||||
)
|
||||
|
||||
# Imported at top (not below ``__getattr__``) to satisfy ruff E402. This only
|
||||
# reads the auto-discovered manifest - no tool module is eagerly imported.
|
||||
from praisonai_tools.tools import __all__ as _tools_all
|
||||
|
||||
try:
|
||||
__version__ = version("praisonai-tools")
|
||||
@@ -53,101 +62,20 @@ def __getattr__(name):
|
||||
except AttributeError:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
__all__ = [
|
||||
# Base classes for custom tools
|
||||
"BaseTool",
|
||||
"ToolResult",
|
||||
"ToolValidationError",
|
||||
"validate_tool",
|
||||
# Decorator for function-based tools
|
||||
"tool",
|
||||
"FunctionTool",
|
||||
"is_tool",
|
||||
"get_tool_schema",
|
||||
# Email Tool
|
||||
"EmailTool",
|
||||
"send_email",
|
||||
"read_emails",
|
||||
"search_emails",
|
||||
# Slack Tool
|
||||
"SlackTool",
|
||||
"send_slack_message",
|
||||
"get_slack_channels",
|
||||
"get_slack_history",
|
||||
# Discord Tool
|
||||
"DiscordTool",
|
||||
"send_discord_webhook",
|
||||
"send_discord_message",
|
||||
# GitHub Tool
|
||||
"GitHubTool",
|
||||
"search_github_repos",
|
||||
"get_github_repo",
|
||||
"create_github_issue",
|
||||
# Image Tool
|
||||
"ImageTool",
|
||||
"generate_image",
|
||||
# Weather Tool
|
||||
"WeatherTool",
|
||||
"get_weather",
|
||||
"get_forecast",
|
||||
"get_air_quality",
|
||||
# YouTube Tool
|
||||
"YouTubeTool",
|
||||
"search_youtube",
|
||||
"get_youtube_video",
|
||||
"get_youtube_transcript",
|
||||
# TTS Tool
|
||||
"TTSTool",
|
||||
"text_to_speech",
|
||||
"list_tts_voices",
|
||||
# Telegram Tool
|
||||
"TelegramTool",
|
||||
"send_telegram_message",
|
||||
# Notion Tool
|
||||
"NotionTool",
|
||||
"search_notion",
|
||||
"create_notion_page",
|
||||
# PostgreSQL Tool
|
||||
"PostgresTool",
|
||||
"query_postgres",
|
||||
"list_postgres_tables",
|
||||
# Reddit Tool
|
||||
"RedditTool",
|
||||
"search_reddit",
|
||||
"get_reddit_hot",
|
||||
# Docker Tool
|
||||
"DockerTool",
|
||||
"list_docker_containers",
|
||||
"run_docker_container",
|
||||
# Signal Tool
|
||||
"SignalTool",
|
||||
"send_signal_message",
|
||||
"check_signal_connection",
|
||||
# LINE Tool
|
||||
"LineTool",
|
||||
"send_line_message",
|
||||
"broadcast_line_message",
|
||||
# iMessage Tool
|
||||
"iMessageTool",
|
||||
"send_imessage",
|
||||
"check_imessage_availability",
|
||||
# WhatsApp Tool
|
||||
"WhatsAppTool",
|
||||
"send_whatsapp_message",
|
||||
# Marketplace Tools
|
||||
"PinchworkTool",
|
||||
"pinchwork_delegate",
|
||||
"AgentIDTool",
|
||||
"verify_agent_identity",
|
||||
"JoyTrustTool",
|
||||
"check_trust_score",
|
||||
"AgentFolioTool",
|
||||
"check_behavioral_trust",
|
||||
"verify_task_delegation_safety",
|
||||
# Composio
|
||||
"ComposioTool",
|
||||
"ComposioTools",
|
||||
"composio_execute",
|
||||
"composio_tools",
|
||||
"composio_list_apps",
|
||||
]
|
||||
# Catalogue/discovery helpers exported eagerly above (not tool symbols, so they
|
||||
# are not part of the auto-discovered manifest).
|
||||
_CATALOGUE_EXPORTS = [
|
||||
"ToolCatalogueEntry",
|
||||
"list_tools",
|
||||
"get_tool_names",
|
||||
]
|
||||
|
||||
# The remainder of ``__all__`` is derived from the tools package's
|
||||
# automatically-discovered manifest so the two levels cannot drift apart. Only
|
||||
# source files are parsed (via AST) to build it - no tool module is eagerly
|
||||
# imported here.
|
||||
__all__ = sorted(set(_tools_all) | set(_CATALOGUE_EXPORTS))
|
||||
|
||||
|
||||
def __dir__():
|
||||
return sorted(set(globals()) | set(__all__))
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Tool discovery / catalogue contract for PraisonAI Tools.
|
||||
|
||||
This module provides a single, programmatic way to enumerate every tool that
|
||||
``praisonai-tools`` ships, plus any third-party tool registered via the
|
||||
``praisonai.tools`` entry-point group. It exists so that the wider SDK (CLI,
|
||||
docs, YAML validators, third-party packages) can answer *"what tools can I
|
||||
actually use here?"* against a live catalogue derived from the real registry
|
||||
rather than a hand-maintained allowlist that drifts.
|
||||
|
||||
Usage:
|
||||
from praisonai_tools import list_tools
|
||||
|
||||
for entry in list_tools():
|
||||
print(entry.name, entry.module, entry.extras, entry.summary)
|
||||
|
||||
Third-party packages can register their own tools by declaring an entry point:
|
||||
|
||||
# pyproject.toml (third-party tool package)
|
||||
[project.entry-points."praisonai.tools"]
|
||||
my_tool = "my_pkg:MyTool"
|
||||
|
||||
Nothing here imports optional/heavy dependencies: the catalogue is built from
|
||||
the module map alone, so ``list_tools()`` is safe to call without any extras
|
||||
installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from functools import lru_cache
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
ENTRY_POINT_GROUP = "praisonai.tools"
|
||||
|
||||
# Directory that holds the first-party ``*_tool.py`` files. The catalogue is
|
||||
# derived by scanning this directory, so dropping a new ``*_tool.py`` that
|
||||
# defines a ``*Tool`` class makes the tool discoverable with no edits to any
|
||||
# hand-maintained list.
|
||||
_TOOLS_DIR = os.path.join(os.path.dirname(__file__), "tools")
|
||||
|
||||
# Map from the module (as recorded in ``_TOOL_MAP``) to the optional-dependency
|
||||
# extras required to use tools defined in it. Modules absent from this mapping
|
||||
# have no extra requirements (they rely only on the standard library or on the
|
||||
# base dependencies). Keep this aligned with ``[project.optional-dependencies]``
|
||||
# in ``pyproject.toml``.
|
||||
_MODULE_EXTRAS = {
|
||||
"wordpress_tool": ("wordpress",),
|
||||
"pinchwork_tool": ("pinchwork", "marketplace"),
|
||||
"agentid_tool": ("agentid", "marketplace"),
|
||||
"joy_trust_tool": ("joy-trust", "marketplace"),
|
||||
"agentfolio_tool": ("agentfolio", "marketplace"),
|
||||
"praisonai_tools.n8n.n8n_workflow": ("n8n",),
|
||||
"langextract_tool": ("langextract",),
|
||||
"swarmscore_tool": ("swarmscore",),
|
||||
"composio_tool": ("composio",),
|
||||
"stripe_tool": ("stripe",),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolCatalogueEntry:
|
||||
"""A single discoverable tool.
|
||||
|
||||
Attributes:
|
||||
name: Public name used to import/reference the tool, e.g. ``"QdrantTool"``.
|
||||
module: Dotted or relative module path that defines the tool.
|
||||
extras: Optional-dependency extras required to use the tool, e.g.
|
||||
``("swarmscore",)``. Empty when no extra install is needed.
|
||||
summary: A one-line human-readable description of the tool.
|
||||
"""
|
||||
|
||||
name: str
|
||||
module: str
|
||||
extras: Tuple[str, ...] = field(default_factory=tuple)
|
||||
summary: str = ""
|
||||
|
||||
|
||||
def _summary_for(name: str) -> str:
|
||||
"""Derive a concise one-line summary for a tool class name.
|
||||
|
||||
Fallback used only when a tool class does not declare a literal
|
||||
``description``. We avoid importing the tool (which could pull heavy/optional
|
||||
deps) and instead produce a stable, human-friendly summary from the name.
|
||||
"""
|
||||
if name.endswith("Tool"):
|
||||
base = name[:-4]
|
||||
else:
|
||||
base = name
|
||||
return f"{base} integration tool".strip()
|
||||
|
||||
|
||||
def _first_line(text: str) -> str:
|
||||
"""Return the first non-empty line of ``text`` (a tool ``description``)."""
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if line:
|
||||
return line
|
||||
return ""
|
||||
|
||||
|
||||
def _scan_module(path: str) -> "list[tuple[str, str]]":
|
||||
"""AST-scan a ``*_tool.py`` file for ``*Tool`` classes and descriptions.
|
||||
|
||||
Returns a list of ``(class_name, summary)`` tuples. The file is parsed, not
|
||||
imported, so this stays cheap and never triggers optional dependencies.
|
||||
Classes without a literal ``description`` fall back to a name-synthesised
|
||||
summary.
|
||||
"""
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
tree = ast.parse(fh.read(), filename=path)
|
||||
except (OSError, SyntaxError): # pragma: no cover - defensive
|
||||
return []
|
||||
|
||||
found = []
|
||||
for node in tree.body:
|
||||
if not isinstance(node, ast.ClassDef) or not node.name.endswith("Tool"):
|
||||
continue
|
||||
description: Optional[str] = None
|
||||
for stmt in node.body:
|
||||
# Accept both plain ``description = "..."`` and annotated
|
||||
# ``description: str = "..."`` class attributes.
|
||||
if isinstance(stmt, ast.Assign):
|
||||
targets = [t.id for t in stmt.targets if isinstance(t, ast.Name)]
|
||||
value = stmt.value
|
||||
elif isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name):
|
||||
targets = [stmt.target.id]
|
||||
value = stmt.value
|
||||
else:
|
||||
continue
|
||||
if (
|
||||
"description" in targets
|
||||
and isinstance(value, ast.Constant)
|
||||
and isinstance(value.value, str)
|
||||
):
|
||||
description = value.value
|
||||
summary = _first_line(description) if description else _summary_for(node.name)
|
||||
found.append((node.name, summary or _summary_for(node.name)))
|
||||
return found
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _scan_source_tree() -> "Dict[str, Tuple[str, str]]":
|
||||
"""Derive the first-party tool catalogue from the source tree.
|
||||
|
||||
Scans every ``praisonai_tools/tools/*_tool.py`` file for ``*Tool`` classes
|
||||
and returns ``{class_name: (module, summary)}``. Because the mapping is
|
||||
derived from the files that actually exist, phantom names (advertised tools
|
||||
with no file) are impossible by construction, and adding a tool is a single
|
||||
new-file change.
|
||||
"""
|
||||
derived: "Dict[str, Tuple[str, str]]" = {}
|
||||
try:
|
||||
filenames = sorted(os.listdir(_TOOLS_DIR))
|
||||
except OSError: # pragma: no cover - defensive
|
||||
return derived
|
||||
|
||||
for filename in filenames:
|
||||
if not filename.endswith("_tool.py"):
|
||||
continue
|
||||
module = filename[:-3] # strip ".py"; relative module name within tools
|
||||
path = os.path.join(_TOOLS_DIR, filename)
|
||||
for class_name, summary in _scan_module(path):
|
||||
derived.setdefault(class_name, (module, summary))
|
||||
return derived
|
||||
|
||||
|
||||
def _first_party_entries() -> "list[ToolCatalogueEntry]":
|
||||
"""Build catalogue entries for every first-party tool class.
|
||||
|
||||
The catalogue is source-derived: it is built by scanning the actual
|
||||
``*_tool.py`` files on disk (see ``_scan_source_tree``) rather than a
|
||||
hand-maintained dictionary, so it can never advertise a tool whose file does
|
||||
not exist. Summaries come from each tool's own ``description``.
|
||||
|
||||
A few tools live in nested packages (e.g. the n8n workflow tool under
|
||||
``praisonai_tools.n8n``) rather than flat ``*_tool.py`` files; those are
|
||||
still surfaced via ``_TOOL_MAP`` so their public names remain stable.
|
||||
"""
|
||||
entries: "Dict[str, ToolCatalogueEntry]" = {}
|
||||
|
||||
for name, (module, summary) in _scan_source_tree().items():
|
||||
entries[name] = ToolCatalogueEntry(
|
||||
name=name,
|
||||
module=module,
|
||||
extras=_MODULE_EXTRAS.get(module, ()),
|
||||
summary=summary,
|
||||
)
|
||||
|
||||
# Include tool classes defined outside the flat ``*_tool.py`` layout (e.g.
|
||||
# nested subpackages such as ``praisonai_tools.n8n``) that the scan cannot
|
||||
# reach, keeping their names stable. Only *nested* (absolute-module) tools
|
||||
# are merged here: flat ``*_tool.py`` classes are already source-derived by
|
||||
# the scan above, so restoring them from ``_TOOL_MAP`` would reintroduce the
|
||||
# hand-maintained bookkeeping this catalogue exists to eliminate.
|
||||
try:
|
||||
from praisonai_tools.tools import _TOOL_MAP
|
||||
except Exception: # pragma: no cover - defensive
|
||||
_TOOL_MAP = {}
|
||||
for name, module in _TOOL_MAP.items():
|
||||
if not name.endswith("Tool") or name in entries:
|
||||
continue
|
||||
if not module.startswith("praisonai_tools."):
|
||||
continue
|
||||
entries[name] = ToolCatalogueEntry(
|
||||
name=name,
|
||||
module=module,
|
||||
extras=_MODULE_EXTRAS.get(module, ()),
|
||||
summary=_summary_for(name),
|
||||
)
|
||||
|
||||
return list(entries.values())
|
||||
|
||||
|
||||
def _entry_point_entries() -> "list[ToolCatalogueEntry]":
|
||||
"""Build catalogue entries for third-party tools registered via entry points.
|
||||
|
||||
Third-party packages register under the ``praisonai.tools`` entry-point
|
||||
group. We do not import the target object (to keep discovery cheap and
|
||||
dependency-free); we record the declared name and module value instead.
|
||||
"""
|
||||
try:
|
||||
from importlib.metadata import entry_points
|
||||
except ImportError: # pragma: no cover - Python < 3.8
|
||||
return []
|
||||
|
||||
entries = []
|
||||
try:
|
||||
eps = entry_points()
|
||||
# importlib.metadata API differs across versions.
|
||||
if hasattr(eps, "select"):
|
||||
selected = eps.select(group=ENTRY_POINT_GROUP)
|
||||
else: # pragma: no cover - Python < 3.10 dict interface
|
||||
selected = eps.get(ENTRY_POINT_GROUP, [])
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return []
|
||||
|
||||
for ep in selected:
|
||||
entries.append(
|
||||
ToolCatalogueEntry(
|
||||
name=ep.name,
|
||||
module=getattr(ep, "value", "") or "",
|
||||
extras=(),
|
||||
summary=f"Third-party tool registered via {ENTRY_POINT_GROUP}",
|
||||
)
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def list_tools() -> "list[ToolCatalogueEntry]":
|
||||
"""Return the full catalogue of available tools.
|
||||
|
||||
Includes every first-party tool shipped with ``praisonai-tools`` plus any
|
||||
third-party tool registered via the ``praisonai.tools`` entry-point group.
|
||||
Entries are de-duplicated by name (first-party wins) and returned sorted by
|
||||
name.
|
||||
"""
|
||||
seen = {}
|
||||
for entry in _first_party_entries():
|
||||
seen[entry.name] = entry
|
||||
for entry in _entry_point_entries():
|
||||
seen.setdefault(entry.name, entry)
|
||||
return sorted(seen.values(), key=lambda e: e.name)
|
||||
|
||||
|
||||
def get_tool_names() -> "set[str]":
|
||||
"""Return the set of every available tool name.
|
||||
|
||||
Convenience helper for validators that only need membership checks (e.g. to
|
||||
replace a hand-maintained allowlist).
|
||||
"""
|
||||
return {entry.name for entry in list_tools()}
|
||||
+18
-1
@@ -11,6 +11,7 @@ def main():
|
||||
print("PraisonAI Developer Tools CLI")
|
||||
print("\nUsage: praisonai-tools [tool] [args]")
|
||||
print("\nAvailable tools:")
|
||||
print(" list List the available tool catalogue")
|
||||
print(" docs-generate Generate SDK reference documentation")
|
||||
print(" video AI-powered video editing tools")
|
||||
print(" fcp Final Cut Pro automation tools")
|
||||
@@ -19,7 +20,23 @@ def main():
|
||||
tool = sys.argv[1]
|
||||
tool_args = sys.argv[2:]
|
||||
|
||||
if tool == "docs-generate":
|
||||
if tool == "list":
|
||||
parser = argparse.ArgumentParser(prog="praisonai-tools list")
|
||||
parser.add_argument("--extras", action="store_true",
|
||||
help="Only show tools that require optional-dependency extras")
|
||||
args = parser.parse_args(tool_args)
|
||||
from .catalogue import list_tools
|
||||
entries = list_tools()
|
||||
if args.extras:
|
||||
entries = [e for e in entries if e.extras]
|
||||
width = max((len(e.name) for e in entries), default=0)
|
||||
for entry in entries:
|
||||
extras = f" [extras: {', '.join(entry.extras)}]" if entry.extras else ""
|
||||
print(f"{entry.name.ljust(width)} {entry.summary}{extras}")
|
||||
print(f"\n{len(entries)} tools")
|
||||
return 0
|
||||
|
||||
elif tool == "docs-generate":
|
||||
# Handle docs-generate with its own parser for better help
|
||||
parser = argparse.ArgumentParser(prog="praisonai-tools docs-generate")
|
||||
parser.add_argument("--package", choices=["praisonaiagents", "praisonai", "typescript", "all"],
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
import ast
|
||||
import re
|
||||
import json
|
||||
import uuid
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -111,45 +112,239 @@ class LayoutType(Enum):
|
||||
# UTILS
|
||||
# =============================================================================
|
||||
|
||||
# Generic containers whose *simple* single-level form is safe to keep as-is
|
||||
# (e.g. ``Optional[str]``, ``Union[str, int]``, ``Dict[str, Any]``). When these
|
||||
# wrap complex/nested types they are collapsed to just the container name.
|
||||
_SIMPLE_KEEP_CONTAINERS = ("Optional", "Union", "Dict", "List", "Tuple", "Sequence", "Set")
|
||||
|
||||
# Containers that should always collapse to just their name because their inner
|
||||
# contents commonly contain nested brackets or quoted literals that break MDX.
|
||||
_ALWAYS_COLLAPSE_CONTAINERS = ("Callable", "Literal", "Coroutine", "Awaitable", "Generator", "AsyncGenerator")
|
||||
|
||||
|
||||
def sanitize_type_for_mdx(type_str: Optional[str]) -> Optional[str]:
|
||||
"""Sanitize complex type annotations for MDX compatibility."""
|
||||
"""Sanitize complex type annotations for MDX compatibility.
|
||||
|
||||
Rules:
|
||||
- Simple types pass through unchanged (``str`` -> ``str``).
|
||||
- Forward references have their quotes removed
|
||||
(``Optional['Agent']`` -> ``Optional[Agent]``).
|
||||
- Containers known to hold nested brackets are collapsed to their bare
|
||||
name (``Callable[[X], Y]`` -> ``Callable``, ``Literal['a']`` ->
|
||||
``Literal``, ``Coroutine[...]`` -> ``Coroutine``).
|
||||
- A container wrapping another complex/nested type is collapsed to just
|
||||
its name (``Optional[Union[str, int]]`` -> ``Optional``).
|
||||
- A container wrapping only simple types is preserved
|
||||
(``Optional[str]`` -> ``Optional[str]``,
|
||||
``Union[str, int]`` -> ``Union[str, int]``).
|
||||
"""
|
||||
if not type_str:
|
||||
return type_str
|
||||
|
||||
|
||||
result = type_str.strip()
|
||||
result = re.sub(r"'([A-Z][a-zA-Z0-9_]*)'", r"\1", result)
|
||||
|
||||
while "[[" in result or " Union[" in result or " Optional[" in result:
|
||||
new_result = re.sub(r"\[\[(.*?)\]\]", r"[\1]", result)
|
||||
if new_result == result:
|
||||
break
|
||||
result = new_result
|
||||
|
||||
if "[" in result:
|
||||
return result.split("[")[0]
|
||||
|
||||
|
||||
# Remove quotes from forward references, e.g. 'Agent' -> Agent
|
||||
result = re.sub(r"'([A-Za-z_][a-zA-Z0-9_.]*)'", r"\1", result)
|
||||
|
||||
# No subscription -> nothing more to simplify.
|
||||
if "[" not in result:
|
||||
return result
|
||||
|
||||
container = result.split("[", 1)[0].strip()
|
||||
|
||||
# Containers that always collapse (Callable, Literal, Coroutine, ...).
|
||||
if container in _ALWAYS_COLLAPSE_CONTAINERS:
|
||||
return container
|
||||
|
||||
# For keep-able containers, inspect the inner content. If it contains nested
|
||||
# brackets (another generic) collapse to the bare container name; otherwise
|
||||
# keep the single-level form intact.
|
||||
if container in _SIMPLE_KEEP_CONTAINERS:
|
||||
inner = result[len(container) + 1:]
|
||||
if inner.endswith("]"):
|
||||
inner = inner[:-1]
|
||||
if "[" in inner:
|
||||
return container
|
||||
# A bare ``Any`` member makes the type uninformative for the table, so
|
||||
# collapse ``Union``/``Optional`` to just the container name.
|
||||
members = [m.strip() for m in inner.split(",")]
|
||||
if container in ("Union", "Optional") and "Any" in members:
|
||||
return container
|
||||
return result
|
||||
|
||||
# Unknown container with nested brackets -> keep the base name to stay safe.
|
||||
if "[[" in result:
|
||||
return container
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# Plain HTML tags. HTML is case-insensitive, so ``<div>`` and ``<DIV>`` are the
|
||||
# same element and both must be preserved (never escaped) by escape_mdx.
|
||||
_HTML_TAGS = {
|
||||
'div', 'span', 'p', 'a', 'br', 'hr', 'img', 'ul', 'ol', 'li',
|
||||
'table', 'tr', 'td', 'th', 'thead', 'tbody', 'code', 'pre',
|
||||
'strong', 'em', 'b', 'i', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
}
|
||||
|
||||
# Mintlify JSX components. Unlike HTML, JSX component names are CASE-SENSITIVE:
|
||||
# ``<Badge>`` is a valid component but ``<badge>`` is not and must be escaped as
|
||||
# prose. These are therefore matched exactly (no case folding).
|
||||
_MDX_COMPONENTS = {
|
||||
'Card', 'CardGroup', 'Note', 'Warning', 'Info', 'Tip', 'Check',
|
||||
'Danger', 'Accordion', 'AccordionGroup', 'Tab', 'Tabs', 'Step',
|
||||
'Steps', 'Frame', 'Icon', 'Badge', 'Tooltip', 'CodeGroup',
|
||||
'Expandable', 'ParamField', 'ResponseField', 'Columns', 'Column',
|
||||
'RequestExample', 'ResponseExample', 'Banner', 'Update', 'View',
|
||||
'Tree', 'Tile', 'Tiles', 'Panel', 'Color',
|
||||
}
|
||||
|
||||
# Combined allowlist of tags that must NOT be escaped by escape_mdx.
|
||||
VALID_MDX_TAGS = _HTML_TAGS | _MDX_COMPONENTS
|
||||
|
||||
# Lower-cased view of the HTML tags only, so ``<DIV>``/``<Div>`` resolve
|
||||
# case-insensitively while JSX components stay case-sensitive.
|
||||
_HTML_TAGS_LOWER = {t.lower() for t in _HTML_TAGS}
|
||||
|
||||
|
||||
def _is_valid_mdx_tag(tag: str) -> bool:
|
||||
"""Return True if ``tag`` is a tag that must be preserved (not escaped).
|
||||
|
||||
HTML tags are matched case-insensitively (HTML is case-insensitive) while
|
||||
Mintlify JSX components must match exactly, so a mis-cased component such as
|
||||
``badge`` is treated as prose and escaped rather than emitted as a broken
|
||||
component.
|
||||
"""
|
||||
return tag in _MDX_COMPONENTS or tag.lower() in _HTML_TAGS_LOWER
|
||||
|
||||
# Docstring section headers whose indented body is source code and should be
|
||||
# wrapped in a fenced code block so MDX/Mintlify renders it verbatim.
|
||||
_CODE_SECTION_HEADERS = ("usage", "example", "examples")
|
||||
|
||||
|
||||
def _wrap_docstring_code_examples(text: str) -> str:
|
||||
"""Wrap indented code under Usage/Example sections in fenced code blocks.
|
||||
|
||||
Detects a header line like ``Usage:`` followed by an indented block and
|
||||
converts that block into a ```python fenced code block so it survives MDX
|
||||
escaping untouched.
|
||||
|
||||
Existing fenced blocks are passed through verbatim, but any indented
|
||||
Usage/Example sections that appear *outside* of a fence (including ones
|
||||
that follow an unrelated fenced block earlier in the docstring) are still
|
||||
wrapped.
|
||||
"""
|
||||
lines = text.split('\n')
|
||||
out: List[str] = []
|
||||
i = 0
|
||||
n = len(lines)
|
||||
in_fence = False
|
||||
while i < n:
|
||||
line = lines[i]
|
||||
|
||||
# Pass through content inside an existing fenced code block untouched.
|
||||
if line.lstrip().startswith("```"):
|
||||
in_fence = not in_fence
|
||||
out.append(line)
|
||||
i += 1
|
||||
continue
|
||||
if in_fence:
|
||||
out.append(line)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
header = line.strip().rstrip(':').lower()
|
||||
is_header = (
|
||||
line.strip().endswith(':')
|
||||
and header in _CODE_SECTION_HEADERS
|
||||
and not line.startswith((' ', '\t'))
|
||||
)
|
||||
if is_header:
|
||||
# Collect the following indented block.
|
||||
block: List[str] = []
|
||||
j = i + 1
|
||||
# Skip a single blank separator line if present.
|
||||
while j < n and not lines[j].strip():
|
||||
block.append(lines[j])
|
||||
j += 1
|
||||
code_lines: List[str] = []
|
||||
while j < n and (lines[j].startswith((' ', '\t')) or not lines[j].strip()):
|
||||
code_lines.append(lines[j])
|
||||
j += 1
|
||||
# Trim trailing blank lines from the captured code.
|
||||
while code_lines and not code_lines[-1].strip():
|
||||
code_lines.pop()
|
||||
if code_lines:
|
||||
out.append(line)
|
||||
out.extend(block)
|
||||
import textwrap
|
||||
dedented = textwrap.dedent('\n'.join(code_lines))
|
||||
out.append("```python")
|
||||
out.extend(dedented.split('\n'))
|
||||
out.append("```")
|
||||
i = j
|
||||
continue
|
||||
out.append(line)
|
||||
i += 1
|
||||
return '\n'.join(out)
|
||||
|
||||
|
||||
def escape_mdx(text: str) -> str:
|
||||
"""Escape text for MDX compatibility."""
|
||||
"""Escape text for MDX compatibility.
|
||||
|
||||
Escapes stray ``{...}`` expressions and unknown ``<tag>`` patterns that
|
||||
would break Mintlify's MDX parser, while:
|
||||
- Preserving content inside inline/fenced code blocks.
|
||||
- Preserving valid Mintlify component tags (see ``VALID_MDX_TAGS``).
|
||||
- Wrapping indented code under ``Usage:``/``Example:`` docstring
|
||||
sections in fenced code blocks.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
|
||||
text = _wrap_docstring_code_examples(text)
|
||||
|
||||
# Use a per-call random sentinel so literal documentation text such as
|
||||
# ``__CODE_BLOCK_0__`` or ``__MDX_TAG_0__`` cannot be mistaken for one of
|
||||
# our internal placeholders during the restore phase below.
|
||||
token = uuid.uuid4().hex
|
||||
code_marker = f"\x00CB{token}"
|
||||
tag_marker = f"\x00MT{token}"
|
||||
|
||||
code_blocks = []
|
||||
def save_code_block(match):
|
||||
code_blocks.append(match.group(0))
|
||||
return f"__CODE_BLOCK_{len(code_blocks)-1}__"
|
||||
|
||||
return f"{code_marker}{len(code_blocks) - 1}\x00"
|
||||
|
||||
text = re.sub(r"```.*?```", save_code_block, text, flags=re.DOTALL)
|
||||
text = re.sub(r"`.*?`", save_code_block, text)
|
||||
|
||||
|
||||
text = text.replace('{', '{').replace('}', '}')
|
||||
|
||||
# Protect valid Mintlify/HTML component tags so they survive the blanket
|
||||
# angle-bracket escaping below.
|
||||
preserved_tags = []
|
||||
|
||||
def preserve_valid_tag(match):
|
||||
full = match.group(0)
|
||||
tag = match.group(1)
|
||||
if _is_valid_mdx_tag(tag):
|
||||
preserved_tags.append(full)
|
||||
return f"{tag_marker}{len(preserved_tags) - 1}\x00"
|
||||
return full
|
||||
|
||||
# Match opening tags <Tag ...>, closing tags </Tag>, and self-contained <tag>.
|
||||
text = re.sub(r"</?([A-Za-z][A-Za-z0-9]*)(?:\s[^<>]*?)?/?>", preserve_valid_tag, text)
|
||||
|
||||
# Escape all remaining angle brackets (unknown tags / stray brackets).
|
||||
text = text.replace('<', '<').replace('>', '>')
|
||||
|
||||
|
||||
for i, tag in enumerate(preserved_tags):
|
||||
text = text.replace(f"{tag_marker}{i}\x00", tag)
|
||||
|
||||
for i, block in enumerate(code_blocks):
|
||||
text = text.replace(f"__CODE_BLOCK_{i}__", block)
|
||||
|
||||
text = text.replace(f"{code_marker}{i}\x00", block)
|
||||
|
||||
return text
|
||||
|
||||
|
||||
@@ -160,18 +355,6 @@ def validate_mdx(content: str) -> List[str]:
|
||||
in_code_block = False
|
||||
in_frontmatter = False
|
||||
frontmatter_count = 0
|
||||
|
||||
VALID_MDX_TAGS = {
|
||||
'div', 'span', 'p', 'a', 'br', 'hr', 'img', 'ul', 'ol', 'li',
|
||||
'table', 'tr', 'td', 'th', 'thead', 'tbody', 'code', 'pre',
|
||||
'strong', 'em', 'b', 'i', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'Card', 'CardGroup', 'Note', 'Warning', 'Info', 'Tip', 'Check',
|
||||
'Danger', 'Accordion', 'AccordionGroup', 'Tab', 'Tabs', 'Step',
|
||||
'Steps', 'Frame', 'Icon', 'Badge', 'Tooltip', 'CodeGroup',
|
||||
'Expandable', 'ParamField', 'ResponseField', 'Columns', 'Column',
|
||||
'RequestExample', 'ResponseExample', 'Banner', 'Update', 'View',
|
||||
'Tree', 'Tile', 'Tiles', 'Panel', 'Color'
|
||||
}
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
stripped = line.strip()
|
||||
@@ -196,7 +379,9 @@ def validate_mdx(content: str) -> List[str]:
|
||||
angle_matches = re.findall(r'(?<!`)(<[a-zA-Z_][a-zA-Z0-9_]*>)(?!`)', line)
|
||||
for match in angle_matches:
|
||||
tag = match[1:-1]
|
||||
if tag not in VALID_MDX_TAGS and tag.lower() not in {t.lower() for t in VALID_MDX_TAGS if t.islower()}:
|
||||
# Mirror escape_mdx's allowlist exactly so the two stay in lockstep:
|
||||
# HTML tags are case-insensitive, JSX components are case-sensitive.
|
||||
if not _is_valid_mdx_tag(tag):
|
||||
errors.append(f"Line {i}: Unescaped JSX-like tag: {match}")
|
||||
|
||||
curly_matches = re.findall(r'(?<!`)(?<!=)\{([a-zA-Z_][a-zA-Z0-9_]*)\}(?!`)', line)
|
||||
@@ -210,10 +395,13 @@ def escape_for_table(text: str, is_type: bool = False) -> str:
|
||||
"""Escape text for use in markdown tables."""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
if is_type:
|
||||
|
||||
# Always simplify complex type signatures containing nested brackets
|
||||
# (e.g. ``Callable[[...], ...]``); these break MDX table rendering even
|
||||
# when the caller did not explicitly flag the value as a type.
|
||||
if is_type or "[[" in text:
|
||||
text = sanitize_type_for_mdx(text) or text
|
||||
|
||||
|
||||
text = text.replace('|', '\\|')
|
||||
# Use HTML entities for angle brackets to avoid tag confusion
|
||||
text = text.replace('<', '<').replace('>', '>')
|
||||
@@ -221,6 +409,48 @@ def escape_for_table(text: str, is_type: bool = False) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def validate_docs_json_structure(config: Dict[str, Any]) -> List[str]:
|
||||
"""Validate the structure of a docs.json navigation config.
|
||||
|
||||
Ensures the expected ``navigation.tabs`` -> ``SDK`` tab -> ``Reference``
|
||||
group hierarchy exists before it is modified.
|
||||
|
||||
Args:
|
||||
config: Parsed docs.json content.
|
||||
|
||||
Returns:
|
||||
A list of error strings; empty if the structure is valid.
|
||||
"""
|
||||
errors: List[str] = []
|
||||
|
||||
if not isinstance(config, dict):
|
||||
return ["docs.json root must be an object"]
|
||||
|
||||
navigation = config.get("navigation")
|
||||
if not isinstance(navigation, dict):
|
||||
return ["Missing or invalid 'navigation' object"]
|
||||
|
||||
tabs = navigation.get("tabs")
|
||||
if not isinstance(tabs, list):
|
||||
return ["Missing or invalid 'navigation.tabs' array"]
|
||||
|
||||
sdk_tab = next((t for t in tabs if isinstance(t, dict) and t.get("tab") == "SDK"), None)
|
||||
if sdk_tab is None:
|
||||
errors.append("Missing 'SDK' tab in navigation.tabs")
|
||||
return errors
|
||||
|
||||
groups = sdk_tab.get("groups")
|
||||
if not isinstance(groups, list):
|
||||
errors.append("Missing or invalid 'groups' in SDK tab")
|
||||
return errors
|
||||
|
||||
ref_group = next((g for g in groups if isinstance(g, dict) and g.get("group") == "Reference"), None)
|
||||
if ref_group is None:
|
||||
errors.append("Missing 'Reference' group in SDK tab")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def sanitize_description(text: str, max_length: int = 150) -> str:
|
||||
"""Sanitize description for YAML frontmatter. Truncates at word boundary."""
|
||||
if not text:
|
||||
@@ -1657,6 +1887,9 @@ ICON_MAP = {
|
||||
"workflows": "route",
|
||||
"telemetry": "chart-line",
|
||||
"search": "magnifying-glass",
|
||||
"hooks": "link",
|
||||
"mcp": "plug",
|
||||
"policy": "gavel",
|
||||
|
||||
# Modules & Integration
|
||||
"a2a": "network-wired",
|
||||
@@ -2223,11 +2456,12 @@ class TypeScriptDocParser:
|
||||
class MDXGenerator:
|
||||
"""Generate MDX documentation files."""
|
||||
|
||||
def __init__(self, output_dir: Path, package_name: str, config: Dict[str, Any], layout: LayoutType = LayoutType.LEGACY):
|
||||
def __init__(self, output_dir: Path, package_name: str, config: Dict[str, Any], layout: LayoutType = LayoutType.LEGACY, docs_root: Optional[Path] = None):
|
||||
self.output_dir = output_dir
|
||||
self.package_name = package_name
|
||||
self.config = config
|
||||
self.layout = layout
|
||||
self.docs_root = docs_root or output_dir
|
||||
self.generated_files = set() # Use set for faster lookups and cleanup
|
||||
# Index for "Used By" cross-references: {function_name: [(caller_name, caller_path), ...]}
|
||||
self.used_by_index: Dict[str, List[Tuple[str, str]]] = {}
|
||||
@@ -2345,8 +2579,7 @@ class MDXGenerator:
|
||||
return generated
|
||||
|
||||
def _track_file(self, path: Path):
|
||||
docs_root = Path("/Users/praison/PraisonAIDocs")
|
||||
rel_path = str(path.relative_to(docs_root))
|
||||
rel_path = str(path.relative_to(self.docs_root))
|
||||
nav_path = rel_path.replace('.mdx', '').replace('\\', '/')
|
||||
self.generated_files.add(nav_path)
|
||||
|
||||
@@ -2892,7 +3125,15 @@ class ReferenceDocsGenerator:
|
||||
layout: LayoutType = LayoutType.GRANULAR
|
||||
):
|
||||
self.docs_root = Path(docs_root)
|
||||
self.docs_json_path = self.docs_root / "docs.json"
|
||||
# Mintlify subpath hosting uses docs/docs.json (see PraisonAIDocs migrate script).
|
||||
docs_json_in_docs = self.docs_root / "docs" / "docs.json"
|
||||
docs_json_at_root = self.docs_root / "docs.json"
|
||||
if docs_json_in_docs.exists():
|
||||
self.docs_json_path = docs_json_in_docs
|
||||
elif docs_json_at_root.exists():
|
||||
self.docs_json_path = docs_json_at_root
|
||||
else:
|
||||
self.docs_json_path = docs_json_in_docs
|
||||
self.layout = layout
|
||||
|
||||
# Base source root - default to local development path if not provided
|
||||
@@ -2969,7 +3210,7 @@ class ReferenceDocsGenerator:
|
||||
workspace_parser = RustWorkspaceParser(config["source"])
|
||||
# Parse all crates in the workspace
|
||||
all_modules = workspace_parser.parse_all()
|
||||
generator = MDXGenerator(config["output"], package_name, config, layout=self.layout)
|
||||
generator = MDXGenerator(config["output"], package_name, config, layout=self.layout, docs_root=self.docs_root)
|
||||
generated = 0
|
||||
for crate_name, modules in all_modules.items():
|
||||
print(f" Crate: {crate_name} ({len(modules)} modules)")
|
||||
@@ -2995,7 +3236,7 @@ class ReferenceDocsGenerator:
|
||||
else:
|
||||
parser = PythonDocParser(config["source"], config["import_prefix"])
|
||||
|
||||
generator = MDXGenerator(config["output"], package_name, config, layout=self.layout)
|
||||
generator = MDXGenerator(config["output"], package_name, config, layout=self.layout, docs_root=self.docs_root)
|
||||
modules = parser.get_modules()
|
||||
print(f"Found {len(modules)} modules")
|
||||
|
||||
@@ -3039,14 +3280,13 @@ class ReferenceDocsGenerator:
|
||||
|
||||
def cleanup_orphaned_files(self, output_dir: Path, current_files: set):
|
||||
"""Delete MDX files that are no longer part of the generated set."""
|
||||
docs_root = Path("/Users/praison/PraisonAIDocs")
|
||||
for sub_dir in ["functions", "classes", "modules"]:
|
||||
target_dir = output_dir / sub_dir
|
||||
if not target_dir.exists():
|
||||
continue
|
||||
|
||||
for mdx_file in target_dir.glob("*.mdx"):
|
||||
rel_path = str(mdx_file.relative_to(docs_root))
|
||||
rel_path = str(mdx_file.relative_to(self.docs_root))
|
||||
nav_path = rel_path.replace('.mdx', '').replace('\\', '/')
|
||||
if nav_path not in current_files:
|
||||
print(f" Deleting orphaned file: {mdx_file.name}")
|
||||
@@ -3095,9 +3335,16 @@ class ReferenceDocsGenerator:
|
||||
|
||||
with open(self.docs_json_path, 'w') as f:
|
||||
json.dump(docs_config, f, indent=2)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self.layout == LayoutType.GRANULAR:
|
||||
page_count = sum(len(group.get('pages', [])) for group in package_group['pages'])
|
||||
else:
|
||||
page_count = len(package_group['pages'])
|
||||
print(f" Updated docs.json: {page_count} pages for {package_name}")
|
||||
|
||||
except Exception as exc:
|
||||
print(f" Error updating docs.json for {package_name}: {exc}")
|
||||
raise
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Tool-registry proxy connector for PraisonAI Agents.
|
||||
|
||||
Optional bring-your-own-account connector that lets an agent discover and call
|
||||
a large catalogue of third-party API endpoints through a single proxy token,
|
||||
with credentials injected server-side by the registry (never held by the agent).
|
||||
|
||||
Mirrors PraisonAI's deferred tool-search bridge:
|
||||
registry_search(query) -> capability search over the catalogue
|
||||
registry_describe(tool_id) -> params, price-per-call, example response
|
||||
registry_call(tool_id, ...) -> invoke via proxy (credential injected server-side)
|
||||
|
||||
The connector is disabled unless ``TOOL_PROXY_URL`` is configured. PraisonAI
|
||||
ships no registry code, no bundled binary and no default endpoint - the user
|
||||
supplies their own account token or self-hosted base URL.
|
||||
|
||||
Usage:
|
||||
from praisonai_tools.registry_proxy import (
|
||||
registry_search, registry_describe, registry_call,
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
instructions="SEO analyst",
|
||||
tools=[registry_search, registry_describe, registry_call],
|
||||
)
|
||||
|
||||
Environment Variables:
|
||||
TOOL_PROXY_URL: base URL of the tool registry (unset -> connector disabled)
|
||||
TOOL_PROXY_TOKEN: proxy token sent as the auth header
|
||||
"""
|
||||
|
||||
from .registry_proxy import (
|
||||
RegistryProxyTool,
|
||||
registry_search,
|
||||
registry_describe,
|
||||
registry_call,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"RegistryProxyTool",
|
||||
"registry_search",
|
||||
"registry_describe",
|
||||
"registry_call",
|
||||
]
|
||||
@@ -0,0 +1,533 @@
|
||||
"""Tool-registry proxy connector implementation.
|
||||
|
||||
Three agent-callable functions over a registry's plain-HTTP surface:
|
||||
|
||||
- ``registry_search(query)`` free capability search over the catalogue
|
||||
- ``registry_describe(tool_id)`` free read: params, price-per-call, example
|
||||
- ``registry_call(tool_id, ...)``paid invoke through the proxy; the registry
|
||||
injects the upstream vendor credential server-side.
|
||||
|
||||
Design notes:
|
||||
- ``httpx`` is lazy-imported so the connector adds zero import-time overhead and
|
||||
no hard dependency when unconfigured.
|
||||
- The connector is disabled unless ``TOOL_PROXY_URL`` is set.
|
||||
- Errors (auth / insufficient balance / upstream 4xx / timeout) are returned as
|
||||
``{"error": ...}`` tool results, never raised as exceptions.
|
||||
- ``registry_call`` supports optional per-call and per-session spend guards; the
|
||||
price is read from ``registry_describe`` and the call is denied + reported when
|
||||
a configured budget would be exceeded.
|
||||
"""
|
||||
|
||||
import os
|
||||
import math
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
from praisonai_tools.tools.base import BaseTool
|
||||
from praisonai_tools.tools.decorator import tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_INSTALL_HINT = "httpx not installed. Install with: pip install 'praisonai-tools[registry-proxy]'"
|
||||
|
||||
# Cumulative spend is tracked per (proxy_url, token) so that ``max_session_spend``
|
||||
# persists across the short-lived connector instances created by the decorated
|
||||
# functions, without mixing budgets across distinct accounts/endpoints.
|
||||
_SESSION_SPEND: Dict[tuple, float] = {}
|
||||
|
||||
# The registry enforces the endpoint's declared HTTP method on ``/call/{id}``.
|
||||
# Restricting to a known set keeps the wire behaviour predictable and returns a
|
||||
# clear tool-result error instead of forwarding a mislabelled/unexpected verb.
|
||||
_ALLOWED_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"})
|
||||
|
||||
|
||||
class RegistryProxyTool(BaseTool):
|
||||
"""Connector for a self-hostable tool registry exposing a plain-HTTP proxy."""
|
||||
|
||||
name = "registry_proxy"
|
||||
description = (
|
||||
"Discover and call third-party API endpoints through a tool registry "
|
||||
"proxy; credentials are injected server-side, never held by the agent."
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
proxy_url: Optional[str] = None,
|
||||
token: Optional[str] = None,
|
||||
timeout: float = 60.0,
|
||||
max_cost_per_call: Optional[float] = None,
|
||||
max_session_spend: Optional[float] = None,
|
||||
auth_header: Optional[str] = None,
|
||||
describe_path: Optional[str] = None,
|
||||
):
|
||||
url_from_env = proxy_url is None
|
||||
self.proxy_url = (proxy_url or os.getenv("TOOL_PROXY_URL") or "").rstrip("/")
|
||||
# Security: only fall back to the environment token when the URL is also
|
||||
# env-derived (trusted config). An agent-supplied ``proxy_url`` must never
|
||||
# receive the ambient ``TOOL_PROXY_TOKEN`` - otherwise a prompt-injected
|
||||
# agent could exfiltrate the token to an attacker-controlled endpoint.
|
||||
if token is not None:
|
||||
self.token = token
|
||||
elif url_from_env:
|
||||
self.token = os.getenv("TOOL_PROXY_TOKEN")
|
||||
else:
|
||||
self.token = None
|
||||
# Auth header is configurable for wire-compatibility with different
|
||||
# registry deployments. Default ``Authorization`` sends ``Bearer {token}``;
|
||||
# a custom header name (e.g. ``X-Treg-Token``) sends the raw token instead.
|
||||
self.auth_header = auth_header or os.getenv("TOOL_PROXY_AUTH_HEADER") or "Authorization"
|
||||
# The describe path template is configurable for wire-compatibility with
|
||||
# different registry deployments. It must contain ``{tool_id}``; the
|
||||
# default mirrors the live registry route (``/catalog/endpoints/{id}``).
|
||||
self.describe_path = (
|
||||
describe_path
|
||||
or os.getenv("TOOL_PROXY_DESCRIBE_PATH")
|
||||
or "/catalog/endpoints/{tool_id}"
|
||||
)
|
||||
self.timeout = timeout
|
||||
self.max_cost_per_call = max_cost_per_call
|
||||
self.max_session_spend = max_session_spend
|
||||
super().__init__()
|
||||
|
||||
@property
|
||||
def _spend_key(self) -> tuple:
|
||||
return (self.proxy_url, self.token)
|
||||
|
||||
@property
|
||||
def _session_spend(self) -> float:
|
||||
return _SESSION_SPEND.get(self._spend_key, 0.0)
|
||||
|
||||
@_session_spend.setter
|
||||
def _session_spend(self, value: float) -> None:
|
||||
_SESSION_SPEND[self._spend_key] = value
|
||||
|
||||
def run(self, action: str, **kwargs: Any) -> Dict[str, Any]:
|
||||
"""Dispatch to search/describe/call.
|
||||
|
||||
Args:
|
||||
action: One of ``"search"``, ``"describe"`` or ``"call"``.
|
||||
**kwargs: Arguments forwarded to the selected operation.
|
||||
"""
|
||||
if action == "search":
|
||||
return self.search(kwargs.get("query", ""))
|
||||
if action == "describe":
|
||||
return self.describe(kwargs.get("tool_id", ""))
|
||||
if action == "call":
|
||||
return self.call(
|
||||
kwargs.get("tool_id", ""),
|
||||
kwargs.get("params"),
|
||||
kwargs.get("method"),
|
||||
)
|
||||
return {"error": f"Unknown action '{action}'. Use search, describe or call."}
|
||||
|
||||
def _headers(self) -> Dict[str, str]:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.token:
|
||||
if self.auth_header.lower() == "authorization":
|
||||
headers[self.auth_header] = f"Bearer {self.token}"
|
||||
else:
|
||||
headers[self.auth_header] = self.token
|
||||
return headers
|
||||
|
||||
def _require_config(self) -> Optional[Dict[str, Any]]:
|
||||
if not self.proxy_url:
|
||||
return {
|
||||
"error": (
|
||||
"Tool registry proxy is not configured. Set TOOL_PROXY_URL "
|
||||
"(and optionally TOOL_PROXY_TOKEN) to enable the connector."
|
||||
)
|
||||
}
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _http_error(exc: Any) -> Dict[str, Any]:
|
||||
response = getattr(exc, "response", None)
|
||||
status = getattr(response, "status_code", None)
|
||||
text = getattr(response, "text", "") if response is not None else str(exc)
|
||||
if status == 401 or status == 403:
|
||||
return {"error": f"Authentication failed (HTTP {status}): {text}"}
|
||||
if status == 402:
|
||||
return {"error": f"Insufficient balance (HTTP {status}): {text}"}
|
||||
return {"error": f"HTTP {status}: {text}"}
|
||||
|
||||
def search(self, query: str) -> Dict[str, Any]:
|
||||
"""Search the registry catalogue by capability."""
|
||||
missing = self._require_config()
|
||||
if missing:
|
||||
return missing
|
||||
if not query:
|
||||
return {"error": "query is required"}
|
||||
try:
|
||||
import httpx
|
||||
except ImportError:
|
||||
return {"error": _INSTALL_HINT}
|
||||
try:
|
||||
with httpx.Client(timeout=self.timeout) as client:
|
||||
response = client.get(
|
||||
f"{self.proxy_url}/catalog/search",
|
||||
params={"q": query},
|
||||
headers=self._headers(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.TimeoutException:
|
||||
return {"error": f"Registry search timed out after {self.timeout} seconds"}
|
||||
except httpx.HTTPStatusError as exc:
|
||||
return self._http_error(exc)
|
||||
except Exception as exc: # noqa: BLE001 - surface as tool-result error
|
||||
logger.error("registry search error: %s", exc)
|
||||
return {"error": str(exc)}
|
||||
|
||||
def describe(self, tool_id: str) -> Dict[str, Any]:
|
||||
"""Describe one catalogue entry: params, price-per-call, example."""
|
||||
missing = self._require_config()
|
||||
if missing:
|
||||
return missing
|
||||
if not tool_id:
|
||||
return {"error": "tool_id is required"}
|
||||
try:
|
||||
import httpx
|
||||
except ImportError:
|
||||
return {"error": _INSTALL_HINT}
|
||||
if "{tool_id}" not in self.describe_path:
|
||||
return {
|
||||
"error": (
|
||||
f"Invalid describe path template '{self.describe_path}': "
|
||||
"it must contain '{tool_id}'."
|
||||
)
|
||||
}
|
||||
try:
|
||||
path = self.describe_path.format(tool_id=quote(tool_id, safe=""))
|
||||
except (KeyError, IndexError, ValueError) as exc:
|
||||
return {
|
||||
"error": (
|
||||
f"Invalid describe path template '{self.describe_path}': {exc}"
|
||||
)
|
||||
}
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
try:
|
||||
with httpx.Client(timeout=self.timeout) as client:
|
||||
response = client.get(
|
||||
f"{self.proxy_url}{path}",
|
||||
headers=self._headers(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.TimeoutException:
|
||||
return {"error": f"Registry describe timed out after {self.timeout} seconds"}
|
||||
except httpx.HTTPStatusError as exc:
|
||||
return self._http_error(exc)
|
||||
except Exception as exc: # noqa: BLE001 - surface as tool-result error
|
||||
logger.error("registry describe error: %s", exc)
|
||||
return {"error": str(exc)}
|
||||
|
||||
@staticmethod
|
||||
def _unwrap_envelope(data: Any) -> Any:
|
||||
"""Return the dict that carries the endpoint fields.
|
||||
|
||||
The live registry nests ``method``/``cost`` under an ``endpoint`` key
|
||||
(``{"endpoint": {...}, "provider": {}, ...}``); flatter registry
|
||||
implementations put them at the top level. Prefer ``data["endpoint"]``
|
||||
when it is a dict, else use ``data`` itself.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
endpoint = data.get("endpoint")
|
||||
if isinstance(endpoint, dict):
|
||||
return endpoint
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def _extract_method(cls, data: Any) -> Optional[str]:
|
||||
"""Read the HTTP method from a describe response, if present.
|
||||
|
||||
The live registry's endpoint doc carries ``method: "GET"`` (or POST/PUT)
|
||||
nested under ``endpoint``; the registry enforces the declared method on
|
||||
``/call/{id}``. Flat implementations expose it at the top level.
|
||||
"""
|
||||
data = cls._unwrap_envelope(data)
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
method = data.get("method")
|
||||
if isinstance(method, str) and method.strip():
|
||||
return method.strip().upper()
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _extract_price(cls, data: Any) -> Any:
|
||||
"""Read a price from a response, tolerating field-name/shape variation.
|
||||
|
||||
Unwraps the ``endpoint`` envelope, then tries ``price_per_call`` then
|
||||
``price`` then ``cost`` so the budget check and spend recording share the
|
||||
same tolerance against the live schema. ``cost`` may be a structured
|
||||
object (``{"value": 25, "currency": "credit", "usd": 0.0299, ...}``); in
|
||||
that case prefer ``usd`` since the guard budgets are in USD (``value`` is
|
||||
in provider credits). Flat numeric forms are returned unchanged.
|
||||
"""
|
||||
data = cls._unwrap_envelope(data)
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
for key in ("price_per_call", "price", "cost"):
|
||||
if key in data:
|
||||
value = data[key]
|
||||
if isinstance(value, dict):
|
||||
return value.get("usd")
|
||||
return value
|
||||
return None
|
||||
|
||||
def _check_budget(self, tool_id: str):
|
||||
"""Deny + report if a configured spend guard would be exceeded.
|
||||
|
||||
Returns ``(denial, price, described)``: ``denial`` is a ``{"error": ...}``
|
||||
dict when the call must be rejected (else ``None``); ``price`` is the
|
||||
validated per-call price when a guard applies (else ``None``);
|
||||
``described`` is the describe response fetched for the guard (else
|
||||
``None``) so the caller can reuse it (e.g. to derive the HTTP method)
|
||||
without a second network round-trip.
|
||||
"""
|
||||
if self.max_cost_per_call is None and self.max_session_spend is None:
|
||||
return None, None, None
|
||||
described = self.describe(tool_id)
|
||||
if isinstance(described, dict) and "error" in described:
|
||||
return described, None, None
|
||||
# Accept the same price field names tolerated by spend recording so the
|
||||
# budget check does not fail closed on a field-name mismatch against the
|
||||
# live registry schema (``price_per_call`` -> ``price`` -> ``cost``).
|
||||
price = self._extract_price(described)
|
||||
try:
|
||||
price = float(price)
|
||||
except (TypeError, ValueError):
|
||||
# Fail closed: a spend guard is configured but the price cannot be
|
||||
# validated, so we cannot guarantee the budget - deny the call.
|
||||
return {
|
||||
"error": (
|
||||
"Denied: price (price_per_call/price/cost) is missing or "
|
||||
"invalid, cannot enforce the configured spend guard."
|
||||
)
|
||||
}, None, described
|
||||
# Fail closed on non-finite (NaN/inf) or negative prices: NaN comparisons
|
||||
# are always false, so an unvalidated NaN would silently bypass both the
|
||||
# per-call and session guards and then corrupt ``_session_spend``.
|
||||
if not math.isfinite(price) or price < 0:
|
||||
return {
|
||||
"error": (
|
||||
"Denied: price (price_per_call/price/cost) is missing or "
|
||||
"invalid, cannot enforce the configured spend guard."
|
||||
)
|
||||
}, None, described
|
||||
if self.max_cost_per_call is not None and price > self.max_cost_per_call:
|
||||
return {
|
||||
"error": (
|
||||
f"Denied: price per call {price} exceeds max_cost_per_call "
|
||||
f"{self.max_cost_per_call}."
|
||||
)
|
||||
}, price, described
|
||||
if (
|
||||
self.max_session_spend is not None
|
||||
and self._session_spend + price > self.max_session_spend
|
||||
):
|
||||
return {
|
||||
"error": (
|
||||
f"Denied: session spend would reach "
|
||||
f"{self._session_spend + price}, exceeding max_session_spend "
|
||||
f"{self.max_session_spend}."
|
||||
)
|
||||
}, price, described
|
||||
return None, price, described
|
||||
|
||||
def call(
|
||||
self,
|
||||
tool_id: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
method: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Invoke an endpoint through the proxy (credential injected server-side).
|
||||
|
||||
The registry enforces each endpoint's declared HTTP method on
|
||||
``/call/{id}``. When ``method`` is not supplied it is derived from
|
||||
``describe`` (the endpoint doc carries ``method``); GET requests send
|
||||
``params`` as the query string, POST/PUT (and others) send them as a
|
||||
JSON body.
|
||||
"""
|
||||
missing = self._require_config()
|
||||
if missing:
|
||||
return missing
|
||||
if not tool_id:
|
||||
return {"error": "tool_id is required"}
|
||||
denied, quoted_price, described = self._check_budget(tool_id)
|
||||
if denied:
|
||||
return denied
|
||||
# Derive the HTTP method from the endpoint doc when not supplied. Reuse
|
||||
# the describe fetched by the spend guard when present to avoid a second
|
||||
# round-trip; otherwise fetch describe only when the method is unknown.
|
||||
if method is None:
|
||||
if described is None:
|
||||
described = self.describe(tool_id)
|
||||
if isinstance(described, dict) and "error" in described:
|
||||
return described
|
||||
method = self._extract_method(described)
|
||||
method = (method or "POST").strip().upper()
|
||||
if method not in _ALLOWED_METHODS:
|
||||
return {
|
||||
"error": (
|
||||
f"Unsupported HTTP method '{method}'. Allowed: "
|
||||
f"{', '.join(sorted(_ALLOWED_METHODS))}."
|
||||
)
|
||||
}
|
||||
try:
|
||||
import httpx
|
||||
except ImportError:
|
||||
return {"error": _INSTALL_HINT}
|
||||
url = f"{self.proxy_url}/call/{quote(tool_id, safe='')}"
|
||||
try:
|
||||
with httpx.Client(timeout=self.timeout) as client:
|
||||
if method == "GET":
|
||||
response = client.get(
|
||||
url, params=params or {}, headers=self._headers()
|
||||
)
|
||||
else:
|
||||
response = client.request(
|
||||
method, url, json=params or {}, headers=self._headers()
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
except httpx.TimeoutException:
|
||||
return {"error": f"Registry call timed out after {self.timeout} seconds"}
|
||||
except httpx.HTTPStatusError as exc:
|
||||
return self._http_error(exc)
|
||||
except Exception as exc: # noqa: BLE001 - surface as tool-result error
|
||||
logger.error("registry call error: %s", exc)
|
||||
return {"error": str(exc)}
|
||||
|
||||
# Record cumulative spend. Prefer the charge reported by the proxy; fall
|
||||
# back to the price quoted at budget-check time so the session budget
|
||||
# still accrues when the response omits cost fields.
|
||||
charged = self._extract_price(result)
|
||||
try:
|
||||
charged = float(charged)
|
||||
if not math.isfinite(charged) or charged < 0:
|
||||
raise ValueError("non-finite or negative charge")
|
||||
except (TypeError, ValueError):
|
||||
# The response omitted a usable charge; fall back to the finite,
|
||||
# non-negative price validated at budget-check time so a malformed
|
||||
# (NaN/inf/negative) response cannot corrupt cumulative spend.
|
||||
charged = quoted_price
|
||||
if charged is not None:
|
||||
self._session_spend += charged
|
||||
return result
|
||||
|
||||
|
||||
@tool
|
||||
def registry_search(
|
||||
query: str,
|
||||
proxy_url: Optional[str] = None,
|
||||
token: Optional[str] = None,
|
||||
auth_header: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search the connected tool registry by capability.
|
||||
|
||||
Free read. Returns catalogue matches for a natural-language capability query
|
||||
such as "backlinks for a domain" or "company enrichment by email".
|
||||
|
||||
Args:
|
||||
query: Capability to search for.
|
||||
proxy_url: Registry base URL (defaults to TOOL_PROXY_URL env var).
|
||||
token: Proxy token (defaults to TOOL_PROXY_TOKEN env var).
|
||||
auth_header: Auth header name (defaults to TOOL_PROXY_AUTH_HEADER env var,
|
||||
then ``Authorization``).
|
||||
|
||||
Returns:
|
||||
Dict with catalogue matches, or ``{"error": ...}`` on failure.
|
||||
"""
|
||||
return RegistryProxyTool(
|
||||
proxy_url=proxy_url, token=token, auth_header=auth_header
|
||||
).search(query)
|
||||
|
||||
|
||||
@tool
|
||||
def registry_describe(
|
||||
tool_id: str,
|
||||
proxy_url: Optional[str] = None,
|
||||
token: Optional[str] = None,
|
||||
auth_header: Optional[str] = None,
|
||||
describe_path: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Describe one registry catalogue entry.
|
||||
|
||||
Free read. Returns the endpoint's parameters, price-per-call and an example
|
||||
response so the model can decide whether and how to invoke it.
|
||||
|
||||
Args:
|
||||
tool_id: Catalogue entry identifier (from ``registry_search``).
|
||||
proxy_url: Registry base URL (defaults to TOOL_PROXY_URL env var).
|
||||
token: Proxy token (defaults to TOOL_PROXY_TOKEN env var).
|
||||
auth_header: Auth header name (defaults to TOOL_PROXY_AUTH_HEADER env var,
|
||||
then ``Authorization``).
|
||||
describe_path: Path template for the describe route (defaults to
|
||||
TOOL_PROXY_DESCRIBE_PATH env var, then ``/catalog/endpoints/{tool_id}``).
|
||||
Must contain ``{tool_id}``.
|
||||
|
||||
Returns:
|
||||
Dict describing the entry, or ``{"error": ...}`` on failure.
|
||||
"""
|
||||
return RegistryProxyTool(
|
||||
proxy_url=proxy_url, token=token, auth_header=auth_header,
|
||||
describe_path=describe_path,
|
||||
).describe(tool_id)
|
||||
|
||||
|
||||
@tool
|
||||
def registry_call(
|
||||
tool_id: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
proxy_url: Optional[str] = None,
|
||||
token: Optional[str] = None,
|
||||
max_cost_per_call: Optional[float] = None,
|
||||
max_session_spend: Optional[float] = None,
|
||||
auth_header: Optional[str] = None,
|
||||
method: Optional[str] = None,
|
||||
describe_path: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Invoke a registry endpoint through the proxy.
|
||||
|
||||
Paid call. The registry injects the upstream vendor credential server-side,
|
||||
so no vendor keys are ever held by the agent. Optional spend guards deny +
|
||||
report the call when a configured budget would be exceeded.
|
||||
|
||||
The registry enforces each endpoint's declared HTTP method. When ``method``
|
||||
is not supplied it is derived from ``registry_describe`` (the endpoint doc
|
||||
carries ``method``); GET params are sent as the query string, POST/PUT as a
|
||||
JSON body.
|
||||
|
||||
Note: passthrough-URL calls require the upstream to be registered on the
|
||||
registry first; loopback/private upstreams are refused by the registry's
|
||||
SSRF guard by design.
|
||||
|
||||
Args:
|
||||
tool_id: Catalogue entry identifier (from ``registry_search``).
|
||||
params: Parameters for the upstream endpoint.
|
||||
proxy_url: Registry base URL (defaults to TOOL_PROXY_URL env var).
|
||||
token: Proxy token (defaults to TOOL_PROXY_TOKEN env var).
|
||||
max_cost_per_call: Deny the call if its price exceeds this value.
|
||||
max_session_spend: Deny the call if it would push cumulative spend past this value.
|
||||
auth_header: Auth header name (defaults to TOOL_PROXY_AUTH_HEADER env var,
|
||||
then ``Authorization``). ``Authorization`` sends ``Bearer {token}``;
|
||||
any other name (e.g. ``X-Treg-Token``) sends the raw token.
|
||||
method: HTTP method for the endpoint (e.g. ``GET``/``POST``). When
|
||||
omitted it is derived from the endpoint's describe doc.
|
||||
describe_path: Path template for the describe route (defaults to
|
||||
TOOL_PROXY_DESCRIBE_PATH env var, then ``/catalog/endpoints/{tool_id}``).
|
||||
|
||||
Returns:
|
||||
Dict with the endpoint response, or ``{"error": ...}`` on failure/denial.
|
||||
"""
|
||||
return RegistryProxyTool(
|
||||
proxy_url=proxy_url,
|
||||
token=token,
|
||||
max_cost_per_call=max_cost_per_call,
|
||||
max_session_spend=max_session_spend,
|
||||
auth_header=auth_header,
|
||||
describe_path=describe_path,
|
||||
).call(tool_id, params, method)
|
||||
@@ -14,885 +14,64 @@ Ready-to-use tools:
|
||||
- WeatherTool: Get weather data
|
||||
- YouTubeTool: Search YouTube, get transcripts
|
||||
- TTSTool: Text-to-speech conversion
|
||||
|
||||
Tools are discovered automatically: adding a module under
|
||||
``praisonai_tools/tools/`` exposes its public top-level classes and functions
|
||||
without any manual registration. Imports remain lazy - a tool module (and its
|
||||
optional dependencies) is only imported the first time one of its symbols is
|
||||
accessed.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from praisonai_tools.tools.base import BaseTool, ToolResult, ToolValidationError, validate_tool
|
||||
from praisonai_tools.tools.decorator import tool, FunctionTool, is_tool, get_tool_schema
|
||||
from praisonai_tools.tools._discovery import build_manifest
|
||||
|
||||
# Lazy imports for tools to avoid loading dependencies until needed
|
||||
def __getattr__(name):
|
||||
"""Lazy load tool classes."""
|
||||
tool_map = {
|
||||
# Email
|
||||
"EmailTool": "email_tool",
|
||||
"send_email": "email_tool",
|
||||
"read_emails": "email_tool",
|
||||
"search_emails": "email_tool",
|
||||
# Slack
|
||||
"SlackTool": "slack_tool",
|
||||
"send_slack_message": "slack_tool",
|
||||
"get_slack_channels": "slack_tool",
|
||||
"get_slack_history": "slack_tool",
|
||||
# Discord
|
||||
"DiscordTool": "discord_tool",
|
||||
"send_discord_webhook": "discord_tool",
|
||||
"send_discord_message": "discord_tool",
|
||||
# GitHub
|
||||
"GitHubTool": "github_tool",
|
||||
"search_github_repos": "github_tool",
|
||||
"get_github_repo": "github_tool",
|
||||
"create_github_issue": "github_tool",
|
||||
# Image Generation
|
||||
"ImageTool": "image_tool",
|
||||
"generate_image": "image_tool",
|
||||
# Weather
|
||||
"WeatherTool": "weather_tool",
|
||||
"get_weather": "weather_tool",
|
||||
"get_forecast": "weather_tool",
|
||||
"get_air_quality": "weather_tool",
|
||||
# YouTube
|
||||
"YouTubeTool": "youtube_tool",
|
||||
"search_youtube": "youtube_tool",
|
||||
"get_youtube_video": "youtube_tool",
|
||||
"get_youtube_transcript": "youtube_tool",
|
||||
# Text-to-Speech
|
||||
"TTSTool": "tts_tool",
|
||||
"text_to_speech": "tts_tool",
|
||||
"list_tts_voices": "tts_tool",
|
||||
# Telegram
|
||||
"TelegramTool": "telegram_tool",
|
||||
"send_telegram_message": "telegram_tool",
|
||||
# Notion
|
||||
"NotionTool": "notion_tool",
|
||||
"search_notion": "notion_tool",
|
||||
"create_notion_page": "notion_tool",
|
||||
# PostgreSQL
|
||||
"PostgresTool": "postgres_tool",
|
||||
"query_postgres": "postgres_tool",
|
||||
"list_postgres_tables": "postgres_tool",
|
||||
# Reddit
|
||||
"RedditTool": "reddit_tool",
|
||||
"search_reddit": "reddit_tool",
|
||||
"get_reddit_hot": "reddit_tool",
|
||||
# Docker
|
||||
"DockerTool": "docker_tool",
|
||||
"list_docker_containers": "docker_tool",
|
||||
"run_docker_container": "docker_tool",
|
||||
# MySQL
|
||||
"MySQLTool": "mysql_tool",
|
||||
"query_mysql": "mysql_tool",
|
||||
# SQLite
|
||||
"SQLiteTool": "sqlite_tool",
|
||||
"query_sqlite": "sqlite_tool",
|
||||
# MongoDB
|
||||
"MongoDBTool": "mongodb_tool",
|
||||
"query_mongodb": "mongodb_tool",
|
||||
# Redis
|
||||
"RedisTool": "redis_tool",
|
||||
"redis_get": "redis_tool",
|
||||
"redis_set": "redis_tool",
|
||||
# DuckDuckGo
|
||||
"DuckDuckGoTool": "duckduckgo_tool",
|
||||
"duckduckgo_search": "duckduckgo_tool",
|
||||
# Tavily
|
||||
"TavilyTool": "tavily_tool",
|
||||
"tavily_search": "tavily_tool",
|
||||
"tavily_extract": "tavily_tool",
|
||||
# Wikipedia
|
||||
"WikipediaTool": "wikipedia_tool",
|
||||
"wikipedia_search": "wikipedia_tool",
|
||||
# ArXiv
|
||||
"ArxivTool": "arxiv_tool",
|
||||
"arxiv_search": "arxiv_tool",
|
||||
# Calculator
|
||||
"CalculatorTool": "calculator_tool",
|
||||
"calculate": "calculator_tool",
|
||||
# Shell
|
||||
"ShellTool": "shell_tool",
|
||||
"shell_execute": "shell_tool",
|
||||
# File
|
||||
"FileTool": "file_tool",
|
||||
"read_file": "file_tool",
|
||||
"write_file": "file_tool",
|
||||
# Firecrawl
|
||||
"FirecrawlTool": "firecrawl_tool",
|
||||
"firecrawl_scrape": "firecrawl_tool",
|
||||
# Serper
|
||||
"SerperTool": "serper_tool",
|
||||
"serper_search": "serper_tool",
|
||||
# Jina
|
||||
"JinaTool": "jina_tool",
|
||||
"jina_read": "jina_tool",
|
||||
# Google Calendar
|
||||
"GoogleCalendarTool": "google_calendar_tool",
|
||||
"list_calendar_events": "google_calendar_tool",
|
||||
# Google Sheets
|
||||
"GoogleSheetsTool": "google_sheets_tool",
|
||||
"read_google_sheet": "google_sheets_tool",
|
||||
# Jira
|
||||
"JiraTool": "jira_tool",
|
||||
"jira_search": "jira_tool",
|
||||
"jira_list_boards": "jira_tool",
|
||||
"jira_get_board": "jira_tool",
|
||||
"jira_get_board_issues": "jira_tool",
|
||||
"jira_get_backlog": "jira_tool",
|
||||
"jira_get_transitions": "jira_tool",
|
||||
"jira_move_issue": "jira_tool",
|
||||
"jira_create_task": "jira_tool",
|
||||
"jira_watch_issue": "jira_watch_tool",
|
||||
"jira_watch_project": "jira_watch_tool",
|
||||
"jira_get_issue_info": "jira_watch_tool",
|
||||
"jira_search_issues": "jira_watch_tool",
|
||||
"jira_tools": "jira_watch_tool",
|
||||
# Trello
|
||||
"TrelloTool": "trello_tool",
|
||||
"list_trello_boards": "trello_tool",
|
||||
# YFinance
|
||||
"YFinanceTool": "yfinance_tool",
|
||||
"get_stock_price": "yfinance_tool",
|
||||
# Pinecone
|
||||
"PineconeTool": "pinecone_tool",
|
||||
"pinecone_query": "pinecone_tool",
|
||||
# Qdrant
|
||||
"QdrantTool": "qdrant_tool",
|
||||
"qdrant_search": "qdrant_tool",
|
||||
# Chroma
|
||||
"ChromaTool": "chroma_tool",
|
||||
"chroma_query": "chroma_tool",
|
||||
# HackerNews
|
||||
"HackerNewsTool": "hackernews_tool",
|
||||
"get_hackernews_top": "hackernews_tool",
|
||||
# Twilio
|
||||
"TwilioTool": "twilio_tool",
|
||||
"send_sms": "twilio_tool",
|
||||
# Spotify
|
||||
"SpotifyTool": "spotify_tool",
|
||||
"spotify_search": "spotify_tool",
|
||||
# Linear
|
||||
"LinearTool": "linear_tool",
|
||||
"list_linear_issues": "linear_tool",
|
||||
# Crawl4AI
|
||||
"Crawl4AITool": "crawl4ai_tool",
|
||||
"crawl4ai_crawl": "crawl4ai_tool",
|
||||
# Exa
|
||||
"ExaTool": "exa_tool",
|
||||
"exa_search": "exa_tool",
|
||||
# DuckDB
|
||||
"DuckDBTool": "duckdb_tool",
|
||||
"duckdb_query": "duckdb_tool",
|
||||
# Neo4j
|
||||
"Neo4jTool": "neo4j_tool",
|
||||
"neo4j_query": "neo4j_tool",
|
||||
# Weaviate
|
||||
"WeaviateTool": "weaviate_tool",
|
||||
"weaviate_search": "weaviate_tool",
|
||||
# LanceDB
|
||||
"LanceDBTool": "lancedb_tool",
|
||||
"lancedb_search": "lancedb_tool",
|
||||
# Supabase
|
||||
"SupabaseTool": "supabase_tool",
|
||||
"supabase_select": "supabase_tool",
|
||||
# Milvus
|
||||
"MilvusTool": "milvus_tool",
|
||||
"milvus_search": "milvus_tool",
|
||||
# Google Maps
|
||||
"GoogleMapsTool": "google_maps_tool",
|
||||
"geocode_address": "google_maps_tool",
|
||||
# Zendesk
|
||||
"ZendeskTool": "zendesk_tool",
|
||||
"list_zendesk_tickets": "zendesk_tool",
|
||||
# Shopify
|
||||
"ShopifyTool": "shopify_tool",
|
||||
"list_shopify_products": "shopify_tool",
|
||||
# Brave Search
|
||||
"BraveSearchTool": "brave_search_tool",
|
||||
"brave_search": "brave_search_tool",
|
||||
# PubMed
|
||||
"PubMedTool": "pubmed_tool",
|
||||
"pubmed_search": "pubmed_tool",
|
||||
# SerpAPI
|
||||
"SerpAPITool": "serpapi_tool",
|
||||
"serpapi_search": "serpapi_tool",
|
||||
# SearxNG
|
||||
"SearxNGTool": "searxng_tool",
|
||||
"searxng_search": "searxng_tool",
|
||||
# LinkUp
|
||||
"LinkUpTool": "linkup_tool",
|
||||
"linkup_search": "linkup_tool",
|
||||
# Baidu Search
|
||||
"BaiduSearchTool": "baidu_search_tool",
|
||||
"baidu_search": "baidu_search_tool",
|
||||
# Valyu
|
||||
"ValyuTool": "valyu_tool",
|
||||
"valyu_search": "valyu_tool",
|
||||
# Gmail
|
||||
"GmailTool": "gmail_tool",
|
||||
"list_gmail_emails": "gmail_tool",
|
||||
"gmail_archive_email": "gmail_tool",
|
||||
"gmail_label_email": "gmail_tool",
|
||||
"gmail_mark_read": "gmail_tool",
|
||||
"gmail_draft_email": "gmail_tool",
|
||||
"gmail_trash_email": "gmail_tool",
|
||||
# Outlook
|
||||
"OutlookTool": "outlook_tool",
|
||||
"outlook_list_emails": "outlook_tool",
|
||||
"outlook_send_email": "outlook_tool",
|
||||
"outlook_search_emails": "outlook_tool",
|
||||
"outlook_archive_email": "outlook_tool",
|
||||
"outlook_draft_email": "outlook_tool",
|
||||
# WhatsApp
|
||||
"WhatsAppTool": "whatsapp_tool",
|
||||
"send_whatsapp_message": "whatsapp_tool",
|
||||
# Signal
|
||||
"SignalTool": "signal_tool",
|
||||
"send_signal_message": "signal_tool",
|
||||
"check_signal_connection": "signal_tool",
|
||||
# LINE
|
||||
"LineTool": "line_tool",
|
||||
"send_line_message": "line_tool",
|
||||
"broadcast_line_message": "line_tool",
|
||||
# iMessage
|
||||
"iMessageTool": "imessage_tool",
|
||||
"send_imessage": "imessage_tool",
|
||||
"check_imessage_availability": "imessage_tool",
|
||||
# Zoom
|
||||
"ZoomTool": "zoom_tool",
|
||||
"list_zoom_meetings": "zoom_tool",
|
||||
# Webex
|
||||
"WebexTool": "webex_tool",
|
||||
"list_webex_rooms": "webex_tool",
|
||||
# X (Twitter)
|
||||
"XTool": "x_tool",
|
||||
"post_to_x": "x_tool",
|
||||
# Newspaper
|
||||
"NewspaperTool": "newspaper_tool",
|
||||
"extract_article": "newspaper_tool",
|
||||
# Trafilatura
|
||||
"TrafilaturaTool": "trafilatura_tool",
|
||||
"trafilatura_extract": "trafilatura_tool",
|
||||
# Spider
|
||||
"SpiderTool": "spider_tool",
|
||||
"spider_crawl": "spider_tool",
|
||||
# BrowserBase
|
||||
"BrowserBaseTool": "browserbase_tool",
|
||||
"browserbase_scrape": "browserbase_tool",
|
||||
# BrightData
|
||||
"BrightDataTool": "brightdata_tool",
|
||||
"brightdata_scrape": "brightdata_tool",
|
||||
# Oxylabs
|
||||
"OxylabsTool": "oxylabs_tool",
|
||||
"oxylabs_scrape": "oxylabs_tool",
|
||||
# ScrapeGraph
|
||||
"ScrapeGraphTool": "scrapegraph_tool",
|
||||
"scrapegraph_scrape": "scrapegraph_tool",
|
||||
# AgentQL
|
||||
"AgentQLTool": "agentql_tool",
|
||||
"agentql_query": "agentql_tool",
|
||||
# DynamoDB
|
||||
"DynamoDBTool": "dynamodb_tool",
|
||||
"dynamodb_scan": "dynamodb_tool",
|
||||
# Firestore
|
||||
"FirestoreTool": "firestore_tool",
|
||||
"firestore_list": "firestore_tool",
|
||||
# GCS
|
||||
"GCSTool": "gcs_tool",
|
||||
"gcs_list_objects": "gcs_tool",
|
||||
# BigQuery
|
||||
"BigQueryTool": "bigquery_tool",
|
||||
"bigquery_query": "bigquery_tool",
|
||||
# SingleStore
|
||||
"SingleStoreTool": "singlestore_tool",
|
||||
"singlestore_query": "singlestore_tool",
|
||||
# SurrealDB
|
||||
"SurrealDBTool": "surrealdb_tool",
|
||||
"surrealdb_query": "surrealdb_tool",
|
||||
# CSV
|
||||
"CSVTool": "csv_tool",
|
||||
"read_csv": "csv_tool",
|
||||
# Pandas
|
||||
"PandasTool": "pandas_tool",
|
||||
"pandas_read_csv": "pandas_tool",
|
||||
# Redshift
|
||||
"RedshiftTool": "redshift_tool",
|
||||
"redshift_query": "redshift_tool",
|
||||
# ElevenLabs
|
||||
"ElevenLabsTool": "elevenlabs_tool",
|
||||
"elevenlabs_speak": "elevenlabs_tool",
|
||||
"elevenlabs_speak_to_file": "elevenlabs_tool",
|
||||
"elevenlabs_list_voices": "elevenlabs_tool",
|
||||
"elevenlabs_get_voice": "elevenlabs_tool",
|
||||
"elevenlabs_list_models": "elevenlabs_tool",
|
||||
"elevenlabs_get_history": "elevenlabs_tool",
|
||||
"elevenlabs_get_user_info": "elevenlabs_tool",
|
||||
# Replicate
|
||||
"ReplicateTool": "replicate_tool",
|
||||
"replicate_run": "replicate_tool",
|
||||
# Fal
|
||||
"FalTool": "fal_tool",
|
||||
"fal_run": "fal_tool",
|
||||
# Giphy
|
||||
"GiphyTool": "giphy_tool",
|
||||
"giphy_search": "giphy_tool",
|
||||
# Apify
|
||||
"ApifyTool": "apify_tool",
|
||||
"apify_run_actor": "apify_tool",
|
||||
# Airflow
|
||||
"AirflowTool": "airflow_tool",
|
||||
"list_airflow_dags": "airflow_tool",
|
||||
# AWS Lambda
|
||||
"AWSLambdaTool": "aws_lambda_tool",
|
||||
"invoke_lambda": "aws_lambda_tool",
|
||||
# AWS SES
|
||||
"AWSSESTool": "aws_ses_tool",
|
||||
"ses_send_email": "aws_ses_tool",
|
||||
# Resend
|
||||
"ResendTool": "resend_tool",
|
||||
"resend_send_email": "resend_tool",
|
||||
# ClickUp
|
||||
"ClickUpTool": "clickup_tool",
|
||||
"clickup_list_tasks": "clickup_tool",
|
||||
# Confluence
|
||||
"ConfluenceTool": "confluence_tool",
|
||||
"confluence_search": "confluence_tool",
|
||||
# Todoist
|
||||
"TodoistTool": "todoist_tool",
|
||||
"todoist_list_tasks": "todoist_tool",
|
||||
# Bitbucket
|
||||
"BitbucketTool": "bitbucket_tool",
|
||||
"bitbucket_list_repos": "bitbucket_tool",
|
||||
# OpenBB
|
||||
"OpenBBTool": "openbb_tool",
|
||||
"openbb_stock": "openbb_tool",
|
||||
# E2B
|
||||
"E2BTool": "e2b_tool",
|
||||
"e2b_run_code": "e2b_tool",
|
||||
# Sleep
|
||||
"SleepTool": "sleep_tool",
|
||||
"sleep_wait": "sleep_tool",
|
||||
# Python
|
||||
"PythonTool": "python_tool",
|
||||
"python_execute": "python_tool",
|
||||
# Capsule
|
||||
"CapsuleTool": "capsule_tool",
|
||||
"capsule_run_code": "capsule_tool",
|
||||
# Mem0
|
||||
"Mem0Tool": "mem0_tool",
|
||||
"mem0_add": "mem0_tool",
|
||||
# PGVector
|
||||
"PGVectorTool": "pgvector_tool",
|
||||
"pgvector_search": "pgvector_tool",
|
||||
# Cassandra
|
||||
"CassandraTool": "cassandra_tool",
|
||||
"cassandra_query": "cassandra_tool",
|
||||
# ClickHouse
|
||||
"ClickHouseTool": "clickhouse_tool",
|
||||
"clickhouse_query": "clickhouse_tool",
|
||||
# Upstash
|
||||
"UpstashTool": "upstash_tool",
|
||||
"upstash_get": "upstash_tool",
|
||||
# Couchbase
|
||||
"CouchbaseTool": "couchbase_tool",
|
||||
"couchbase_get": "couchbase_tool",
|
||||
# Google Drive
|
||||
"GoogleDriveTool": "google_drive_tool",
|
||||
"list_drive_files": "google_drive_tool",
|
||||
# LumaLabs
|
||||
"LumaLabsTool": "lumalabs_tool",
|
||||
"lumalabs_generate": "lumalabs_tool",
|
||||
# Langextract
|
||||
"LangExtractTool": "langextract_tool",
|
||||
"langextract_extract": "langextract_tool",
|
||||
"langextract_render_file": "langextract_tool",
|
||||
# HeyGen
|
||||
"HeyGenTool": "heygen_tool",
|
||||
"heygen_list_avatars": "heygen_tool",
|
||||
"heygen_list_avatar_groups": "heygen_tool",
|
||||
"heygen_list_group_avatars": "heygen_tool",
|
||||
"heygen_get_avatar_details": "heygen_tool",
|
||||
"heygen_list_voices": "heygen_tool",
|
||||
"heygen_list_voice_locales": "heygen_tool",
|
||||
"heygen_generate_video": "heygen_tool",
|
||||
"heygen_generate_video_with_audio": "heygen_tool",
|
||||
"heygen_upload_asset": "heygen_tool",
|
||||
"heygen_video_status": "heygen_tool",
|
||||
"heygen_wait_for_video": "heygen_tool",
|
||||
"heygen_list_videos": "heygen_tool",
|
||||
"heygen_delete_video": "heygen_tool",
|
||||
"heygen_list_assets": "heygen_tool",
|
||||
"heygen_delete_asset": "heygen_tool",
|
||||
"heygen_get_remaining_quota": "heygen_tool",
|
||||
"heygen_get_user_info": "heygen_tool",
|
||||
"heygen_list_templates": "heygen_tool",
|
||||
"heygen_generate_from_template": "heygen_tool",
|
||||
"heygen_translate_video": "heygen_tool",
|
||||
# Higgsfield
|
||||
"HiggsfieldTool": "higgsfield_tool",
|
||||
"higgsfield_generate": "higgsfield_tool",
|
||||
"higgsfield_status": "higgsfield_tool",
|
||||
"higgsfield_upload": "higgsfield_tool",
|
||||
# OpenCV
|
||||
"OpenCVTool": "opencv_tool",
|
||||
"opencv_resize": "opencv_tool",
|
||||
# CalCom
|
||||
"CalComTool": "calcom_tool",
|
||||
"calcom_list_bookings": "calcom_tool",
|
||||
# Cartesia
|
||||
"CartesiaTool": "cartesia_tool",
|
||||
"cartesia_speak": "cartesia_tool",
|
||||
# Brandfetch
|
||||
"BrandfetchTool": "brandfetch_tool",
|
||||
"brandfetch_get_brand": "brandfetch_tool",
|
||||
# Daytona
|
||||
"DaytonaTool": "daytona_tool",
|
||||
"daytona_list_workspaces": "daytona_tool",
|
||||
# Composio
|
||||
"ComposioTool": "composio_tool",
|
||||
"ComposioTools": "composio_tool",
|
||||
"composio_execute": "composio_tool",
|
||||
"composio_tools": "composio_tool",
|
||||
"composio_list_apps": "composio_tool",
|
||||
# JSON
|
||||
"JSONTool": "json_tool",
|
||||
"read_json": "json_tool",
|
||||
# Zep
|
||||
"ZepTool": "zep_tool",
|
||||
"zep_add_memory": "zep_tool",
|
||||
# WebBrowser
|
||||
"WebBrowserTool": "webbrowser_tool",
|
||||
"webbrowser_get_page": "webbrowser_tool",
|
||||
# WebTools
|
||||
"WebToolsTool": "webtools_tool",
|
||||
"webtools_fetch": "webtools_tool",
|
||||
# LocalFileSystem
|
||||
"LocalFileSystemTool": "local_filesystem_tool",
|
||||
"list_local_directory": "local_filesystem_tool",
|
||||
# Knowledge
|
||||
"KnowledgeTool": "knowledge_tool",
|
||||
"knowledge_search": "knowledge_tool",
|
||||
# Visualization
|
||||
"VisualizationTool": "visualization_tool",
|
||||
"create_bar_chart": "visualization_tool",
|
||||
"SQLiteVectorTool": "sqlite_vector_tool",
|
||||
"sqlite_vector_query": "sqlite_vector_tool",
|
||||
"sqlite_vector_add": "sqlite_vector_tool",
|
||||
# WordPress
|
||||
"WordPressTool": "wordpress_tool",
|
||||
"check_wp_duplicate": "wordpress_tool",
|
||||
"create_wp_post": "wordpress_tool",
|
||||
# Marketplace Tools
|
||||
"PinchworkTool": "pinchwork_tool",
|
||||
"pinchwork_delegate": "pinchwork_tool",
|
||||
"AgentIDTool": "agentid_tool",
|
||||
"verify_agent_identity": "agentid_tool",
|
||||
"JoyTrustTool": "joy_trust_tool",
|
||||
"check_trust_score": "joy_trust_tool",
|
||||
"verify_handoff_safety": "joy_trust_tool",
|
||||
"trust_verified_handoff": "joy_trust_tool",
|
||||
"is_trust_verification_enabled": "joy_trust_tool",
|
||||
"get_trust_config": "joy_trust_tool",
|
||||
"AgentFolioTool": "agentfolio_tool",
|
||||
"check_behavioral_trust": "agentfolio_tool",
|
||||
"verify_task_delegation_safety": "agentfolio_tool",
|
||||
# LinkedIn
|
||||
"LinkedInTool": "linkedin_tool",
|
||||
"linkedin_post_text": "linkedin_tool",
|
||||
"linkedin_post_image": "linkedin_tool",
|
||||
"linkedin_post_article": "linkedin_tool",
|
||||
"linkedin_get_profile": "linkedin_tool",
|
||||
"linkedin_delete_post": "linkedin_tool",
|
||||
# Facebook
|
||||
"FacebookTool": "facebook_tool",
|
||||
"facebook_post_text": "facebook_tool",
|
||||
"facebook_post_image": "facebook_tool",
|
||||
"facebook_post_link": "facebook_tool",
|
||||
"facebook_post_video": "facebook_tool",
|
||||
"facebook_get_page_info": "facebook_tool",
|
||||
"facebook_delete_post": "facebook_tool",
|
||||
# Instagram
|
||||
"InstagramTool": "instagram_tool",
|
||||
"instagram_post_image": "instagram_tool",
|
||||
"instagram_post_carousel": "instagram_tool",
|
||||
"instagram_post_reel": "instagram_tool",
|
||||
"instagram_get_media": "instagram_tool",
|
||||
"instagram_get_account_info": "instagram_tool",
|
||||
# TikTok
|
||||
"TikTokTool": "tiktok_tool",
|
||||
"tiktok_post_video": "tiktok_tool",
|
||||
"tiktok_post_photo": "tiktok_tool",
|
||||
"tiktok_get_creator_info": "tiktok_tool",
|
||||
"tiktok_get_post_status": "tiktok_tool",
|
||||
# Threads
|
||||
"ThreadsTool": "threads_tool",
|
||||
"threads_post_text": "threads_tool",
|
||||
"threads_post_image": "threads_tool",
|
||||
"threads_post_video": "threads_tool",
|
||||
"threads_get_threads": "threads_tool",
|
||||
"threads_get_profile": "threads_tool",
|
||||
# Pinterest
|
||||
"PinterestTool": "pinterest_tool",
|
||||
"pinterest_create_pin": "pinterest_tool",
|
||||
"pinterest_list_boards": "pinterest_tool",
|
||||
"pinterest_get_pin": "pinterest_tool",
|
||||
"pinterest_delete_pin": "pinterest_tool",
|
||||
"pinterest_get_account_info": "pinterest_tool",
|
||||
# Medium
|
||||
"MediumTool": "medium_tool",
|
||||
"medium_publish_post": "medium_tool",
|
||||
"medium_get_user": "medium_tool",
|
||||
"medium_list_publications": "medium_tool",
|
||||
"medium_publish_to_publication": "medium_tool",
|
||||
# n8n Tools
|
||||
"N8nWorkflowTool": "praisonai_tools.n8n.n8n_workflow",
|
||||
"n8n_workflow": "praisonai_tools.n8n.n8n_workflow",
|
||||
"n8n_list_workflows": "praisonai_tools.n8n.n8n_workflow",
|
||||
}
|
||||
|
||||
if name in tool_map:
|
||||
module_name = tool_map[name]
|
||||
from importlib import import_module
|
||||
|
||||
# Handle absolute module paths (e.g., praisonai_tools.n8n.n8n_workflow)
|
||||
if module_name.startswith("praisonai_tools."):
|
||||
module = import_module(module_name)
|
||||
else:
|
||||
# Handle relative module paths within tools package
|
||||
module = import_module(f".{module_name}", __package__)
|
||||
|
||||
return getattr(module, name)
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
__all__ = [
|
||||
# Base classes
|
||||
# Symbols exported eagerly above (base classes + decorator helpers).
|
||||
_BASE_EXPORTS = [
|
||||
"BaseTool",
|
||||
"ToolResult",
|
||||
"ToolValidationError",
|
||||
"validate_tool",
|
||||
# Decorator
|
||||
"tool",
|
||||
"FunctionTool",
|
||||
"is_tool",
|
||||
"get_tool_schema",
|
||||
# Email Tool
|
||||
"EmailTool",
|
||||
"send_email",
|
||||
"read_emails",
|
||||
"search_emails",
|
||||
# Slack Tool
|
||||
"SlackTool",
|
||||
"send_slack_message",
|
||||
"get_slack_channels",
|
||||
"get_slack_history",
|
||||
# Discord Tool
|
||||
"DiscordTool",
|
||||
"send_discord_webhook",
|
||||
"send_discord_message",
|
||||
# GitHub Tool
|
||||
"GitHubTool",
|
||||
"search_github_repos",
|
||||
"get_github_repo",
|
||||
"create_github_issue",
|
||||
# Image Tool
|
||||
"ImageTool",
|
||||
"generate_image",
|
||||
# Weather Tool
|
||||
"WeatherTool",
|
||||
"get_weather",
|
||||
"get_forecast",
|
||||
"get_air_quality",
|
||||
# YouTube Tool
|
||||
"YouTubeTool",
|
||||
"search_youtube",
|
||||
"get_youtube_video",
|
||||
"get_youtube_transcript",
|
||||
# TTS Tool
|
||||
"TTSTool",
|
||||
"text_to_speech",
|
||||
"list_tts_voices",
|
||||
# Telegram Tool
|
||||
"TelegramTool",
|
||||
"send_telegram_message",
|
||||
# Notion Tool
|
||||
"NotionTool",
|
||||
"search_notion",
|
||||
"create_notion_page",
|
||||
# PostgreSQL Tool
|
||||
"PostgresTool",
|
||||
"query_postgres",
|
||||
"list_postgres_tables",
|
||||
# Reddit Tool
|
||||
"RedditTool",
|
||||
"search_reddit",
|
||||
"get_reddit_hot",
|
||||
# Docker Tool
|
||||
"DockerTool",
|
||||
"list_docker_containers",
|
||||
"run_docker_container",
|
||||
# MySQL Tool
|
||||
"MySQLTool",
|
||||
"query_mysql",
|
||||
# SQLite Tool
|
||||
"SQLiteTool",
|
||||
"query_sqlite",
|
||||
# MongoDB Tool
|
||||
"MongoDBTool",
|
||||
"query_mongodb",
|
||||
# Redis Tool
|
||||
"RedisTool",
|
||||
"redis_get",
|
||||
"redis_set",
|
||||
# DuckDuckGo Tool
|
||||
"DuckDuckGoTool",
|
||||
"duckduckgo_search",
|
||||
# Tavily Tool
|
||||
"TavilyTool",
|
||||
"tavily_search",
|
||||
"tavily_extract",
|
||||
# Wikipedia Tool
|
||||
"WikipediaTool",
|
||||
"wikipedia_search",
|
||||
# ArXiv Tool
|
||||
"ArxivTool",
|
||||
"arxiv_search",
|
||||
# Calculator Tool
|
||||
"CalculatorTool",
|
||||
"calculate",
|
||||
# Shell Tool
|
||||
"ShellTool",
|
||||
"shell_execute",
|
||||
# File Tool
|
||||
"FileTool",
|
||||
"read_file",
|
||||
"write_file",
|
||||
# Firecrawl Tool
|
||||
"FirecrawlTool",
|
||||
"firecrawl_scrape",
|
||||
# Serper Tool
|
||||
"SerperTool",
|
||||
"serper_search",
|
||||
# Jina Tool
|
||||
"JinaTool",
|
||||
"jina_read",
|
||||
# Google Calendar Tool
|
||||
"GoogleCalendarTool",
|
||||
"list_calendar_events",
|
||||
# Google Sheets Tool
|
||||
"GoogleSheetsTool",
|
||||
"read_google_sheet",
|
||||
# Jira Tool
|
||||
"JiraTool",
|
||||
"jira_search",
|
||||
"jira_list_boards",
|
||||
"jira_get_board",
|
||||
"jira_get_board_issues",
|
||||
"jira_get_backlog",
|
||||
"jira_get_transitions",
|
||||
"jira_move_issue",
|
||||
"jira_create_task",
|
||||
"jira_watch_issue",
|
||||
"jira_watch_project",
|
||||
"jira_get_issue_info",
|
||||
"jira_search_issues",
|
||||
"jira_tools",
|
||||
# Trello Tool
|
||||
"TrelloTool",
|
||||
"list_trello_boards",
|
||||
# YFinance Tool
|
||||
"YFinanceTool",
|
||||
"get_stock_price",
|
||||
# Pinecone Tool
|
||||
"PineconeTool",
|
||||
"pinecone_query",
|
||||
# Qdrant Tool
|
||||
"QdrantTool",
|
||||
"qdrant_search",
|
||||
# Chroma Tool
|
||||
"ChromaTool",
|
||||
"chroma_query",
|
||||
# HackerNews Tool
|
||||
"HackerNewsTool",
|
||||
"get_hackernews_top",
|
||||
# Twilio Tool
|
||||
"TwilioTool",
|
||||
"send_sms",
|
||||
# Spotify Tool
|
||||
"SpotifyTool",
|
||||
"spotify_search",
|
||||
# Linear Tool
|
||||
"LinearTool",
|
||||
"list_linear_issues",
|
||||
# Crawl4AI Tool
|
||||
"Crawl4AITool",
|
||||
"crawl4ai_crawl",
|
||||
# Exa Tool
|
||||
"ExaTool",
|
||||
"exa_search",
|
||||
# DuckDB Tool
|
||||
"DuckDBTool",
|
||||
"duckdb_query",
|
||||
# Neo4j Tool
|
||||
"Neo4jTool",
|
||||
"neo4j_query",
|
||||
# Weaviate Tool
|
||||
"WeaviateTool",
|
||||
"weaviate_search",
|
||||
# LanceDB Tool
|
||||
"LanceDBTool",
|
||||
"lancedb_search",
|
||||
# Supabase Tool
|
||||
"SupabaseTool",
|
||||
"supabase_select",
|
||||
# Milvus Tool
|
||||
"MilvusTool",
|
||||
"milvus_search",
|
||||
# Google Maps Tool
|
||||
"GoogleMapsTool",
|
||||
"geocode_address",
|
||||
# Zendesk Tool
|
||||
"ZendeskTool",
|
||||
"list_zendesk_tickets",
|
||||
# Shopify Tool
|
||||
"ShopifyTool",
|
||||
"list_shopify_products",
|
||||
# SQLite Vector Store (zero deps)
|
||||
"SQLiteVectorTool",
|
||||
"sqlite_vector_query",
|
||||
"sqlite_vector_add",
|
||||
# WordPress Tool (requires praisonaiwp)
|
||||
"WordPressTool",
|
||||
"check_wp_duplicate",
|
||||
"create_wp_post",
|
||||
# HeyGen Tool
|
||||
"HeyGenTool",
|
||||
"heygen_list_avatars",
|
||||
"heygen_list_avatar_groups",
|
||||
"heygen_list_group_avatars",
|
||||
"heygen_get_avatar_details",
|
||||
"heygen_list_voices",
|
||||
"heygen_list_voice_locales",
|
||||
"heygen_generate_video",
|
||||
"heygen_generate_video_with_audio",
|
||||
"heygen_upload_asset",
|
||||
"heygen_video_status",
|
||||
"heygen_wait_for_video",
|
||||
"heygen_list_videos",
|
||||
"heygen_delete_video",
|
||||
"heygen_list_assets",
|
||||
"heygen_delete_asset",
|
||||
"heygen_get_remaining_quota",
|
||||
"heygen_get_user_info",
|
||||
"heygen_list_templates",
|
||||
"heygen_generate_from_template",
|
||||
"heygen_translate_video",
|
||||
# ElevenLabs Tool
|
||||
"ElevenLabsTool",
|
||||
"elevenlabs_speak",
|
||||
"elevenlabs_speak_to_file",
|
||||
"elevenlabs_list_voices",
|
||||
"elevenlabs_get_voice",
|
||||
"elevenlabs_list_models",
|
||||
"elevenlabs_get_history",
|
||||
"elevenlabs_get_user_info",
|
||||
# Higgsfield Tool
|
||||
"HiggsfieldTool",
|
||||
"higgsfield_generate",
|
||||
"higgsfield_status",
|
||||
"higgsfield_upload",
|
||||
# Signal Tool
|
||||
"SignalTool",
|
||||
"send_signal_message",
|
||||
"check_signal_connection",
|
||||
# LINE Tool
|
||||
"LineTool",
|
||||
"send_line_message",
|
||||
"broadcast_line_message",
|
||||
# iMessage Tool
|
||||
"iMessageTool",
|
||||
"send_imessage",
|
||||
"check_imessage_availability",
|
||||
# Gmail Tool (extended)
|
||||
"GmailTool",
|
||||
"list_gmail_emails",
|
||||
"gmail_archive_email",
|
||||
"gmail_label_email",
|
||||
"gmail_mark_read",
|
||||
"gmail_draft_email",
|
||||
"gmail_trash_email",
|
||||
# Outlook Tool
|
||||
"OutlookTool",
|
||||
"outlook_list_emails",
|
||||
"outlook_send_email",
|
||||
"outlook_search_emails",
|
||||
"outlook_archive_email",
|
||||
"outlook_draft_email",
|
||||
# Marketplace Tools
|
||||
"PinchworkTool",
|
||||
"pinchwork_delegate",
|
||||
"AgentIDTool",
|
||||
"verify_agent_identity",
|
||||
"JoyTrustTool",
|
||||
"check_trust_score",
|
||||
"verify_handoff_safety",
|
||||
"trust_verified_handoff",
|
||||
"is_trust_verification_enabled",
|
||||
"get_trust_config",
|
||||
"AgentFolioTool",
|
||||
"check_behavioral_trust",
|
||||
"verify_task_delegation_safety",
|
||||
# LinkedIn Tool
|
||||
"LinkedInTool",
|
||||
"linkedin_post_text",
|
||||
"linkedin_post_image",
|
||||
"linkedin_post_article",
|
||||
"linkedin_get_profile",
|
||||
"linkedin_delete_post",
|
||||
# Facebook Tool
|
||||
"FacebookTool",
|
||||
"facebook_post_text",
|
||||
"facebook_post_image",
|
||||
"facebook_post_link",
|
||||
"facebook_post_video",
|
||||
"facebook_get_page_info",
|
||||
"facebook_delete_post",
|
||||
# Instagram Tool
|
||||
"InstagramTool",
|
||||
"instagram_post_image",
|
||||
"instagram_post_carousel",
|
||||
"instagram_post_reel",
|
||||
"instagram_get_media",
|
||||
"instagram_get_account_info",
|
||||
# TikTok Tool
|
||||
"TikTokTool",
|
||||
"tiktok_post_video",
|
||||
"tiktok_post_photo",
|
||||
"tiktok_get_creator_info",
|
||||
"tiktok_get_post_status",
|
||||
# Threads Tool
|
||||
"ThreadsTool",
|
||||
"threads_post_text",
|
||||
"threads_post_image",
|
||||
"threads_post_video",
|
||||
"threads_get_threads",
|
||||
"threads_get_profile",
|
||||
# Pinterest Tool
|
||||
"PinterestTool",
|
||||
"pinterest_create_pin",
|
||||
"pinterest_list_boards",
|
||||
"pinterest_get_pin",
|
||||
"pinterest_delete_pin",
|
||||
"pinterest_get_account_info",
|
||||
# Medium Tool
|
||||
"MediumTool",
|
||||
"medium_publish_post",
|
||||
"medium_get_user",
|
||||
"medium_list_publications",
|
||||
"medium_publish_to_publication",
|
||||
# n8n Tool
|
||||
"N8nWorkflowTool",
|
||||
"n8n_workflow",
|
||||
"n8n_list_workflows",
|
||||
# Langextract Tool
|
||||
"LangExtractTool",
|
||||
"langextract_extract",
|
||||
"langextract_render_file",
|
||||
]
|
||||
|
||||
# Build the discovery manifest once at import time. This only reads/parses
|
||||
# source files (via AST) - it does NOT import any tool module, so lazy loading
|
||||
# of heavy optional dependencies is preserved.
|
||||
_TOOL_MANIFEST, _COLLISIONS = build_manifest(os.path.dirname(__file__))
|
||||
|
||||
# Backward-compatible alias. The catalogue/discovery contract in
|
||||
# ``praisonai_tools.catalogue`` enumerates shipped tools via ``_TOOL_MAP``
|
||||
# (name -> module). It is now derived from the auto-discovered manifest so the
|
||||
# two can never drift apart.
|
||||
_TOOL_MAP = _TOOL_MANIFEST
|
||||
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
"""Lazily import a discovered tool symbol on first access."""
|
||||
module_name = _TOOL_MANIFEST.get(name)
|
||||
if module_name is not None:
|
||||
from importlib import import_module
|
||||
|
||||
if module_name.startswith("praisonai_tools."):
|
||||
module = import_module(module_name)
|
||||
else:
|
||||
module = import_module(f".{module_name}", __package__)
|
||||
return getattr(module, name)
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
def __dir__():
|
||||
return sorted(set(_BASE_EXPORTS) | set(_TOOL_MANIFEST))
|
||||
|
||||
|
||||
# ``__all__`` is derived from the same manifest as ``__getattr__`` so the two
|
||||
# can never drift apart.
|
||||
__all__ = sorted(set(_BASE_EXPORTS) | set(_TOOL_MANIFEST))
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Automatic tool discovery for praisonai_tools.tools.
|
||||
|
||||
Builds a ``name -> module`` manifest by scanning the source of every tool
|
||||
module in this package *without importing it*. Public top-level classes and
|
||||
functions (names not starting with ``_``) become part of the public surface.
|
||||
|
||||
This preserves the per-symbol lazy import behaviour of the old hand-maintained
|
||||
``tool_map`` while eliminating the three-place bookkeeping (the map plus two
|
||||
``__all__`` blocks) that previously had to stay in lock-step. Adding a tool is
|
||||
now just adding one module under ``praisonai_tools/tools/``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cache file name written alongside the tools package. Bumping ``_CACHE_VERSION``
|
||||
# invalidates all previously written caches after a discovery-format change.
|
||||
_CACHE_VERSION = 1
|
||||
_CACHE_FILENAME = f"._discovery_cache_v{_CACHE_VERSION}.json"
|
||||
|
||||
# Modules in *this* package directory that are infrastructure, not tools.
|
||||
# Their public symbols are exported explicitly by ``tools/__init__.py`` and
|
||||
# must not be treated as discovered tools.
|
||||
_SKIP_MODULES = {"__init__", "base", "decorator", "_discovery"}
|
||||
|
||||
# Tools that physically live outside ``praisonai_tools/tools/`` but have always
|
||||
# been importable from it. Maps public symbol -> absolute module path.
|
||||
_EXTERNAL_SYMBOLS: Dict[str, str] = {
|
||||
"N8nWorkflowTool": "praisonai_tools.n8n.n8n_workflow",
|
||||
"n8n_workflow": "praisonai_tools.n8n.n8n_workflow",
|
||||
"n8n_list_workflows": "praisonai_tools.n8n.n8n_workflow",
|
||||
}
|
||||
|
||||
|
||||
def _extract_all(tree: ast.Module) -> List[str] | None:
|
||||
"""Return the names listed in a module-level ``__all__``, if declared.
|
||||
|
||||
Only literal string entries are honoured. Returns ``None`` when the module
|
||||
does not declare ``__all__`` at the top level.
|
||||
"""
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign):
|
||||
targets = node.targets
|
||||
elif isinstance(node, ast.AnnAssign) and node.value is not None:
|
||||
targets = [node.target]
|
||||
else:
|
||||
continue
|
||||
for target in targets:
|
||||
if isinstance(target, ast.Name) and target.id == "__all__":
|
||||
if isinstance(node.value, (ast.List, ast.Tuple, ast.Set)):
|
||||
names: List[str] = []
|
||||
for elt in node.value.elts:
|
||||
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
|
||||
names.append(elt.value)
|
||||
return names
|
||||
return None
|
||||
|
||||
|
||||
def _iter_public_symbols(source: str, module_name: str = "<unknown>") -> List[str]:
|
||||
"""Return the public top-level symbol names exported by ``source``.
|
||||
|
||||
Uses AST parsing so the module body is never executed (its heavy/optional
|
||||
dependencies are not imported at discovery time).
|
||||
|
||||
Resolution order:
|
||||
1. If the module declares ``__all__``, honour it verbatim (author intent).
|
||||
This lets a module opt its module-level re-export aliases into the public
|
||||
surface (e.g. ``read_yaml = _yaml_tools.read_yaml``) or, conversely,
|
||||
narrow what is exported.
|
||||
2. Otherwise, collect public top-level classes and functions. Bare
|
||||
assignment aliases are intentionally *not* auto-exported without an
|
||||
explicit ``__all__``: several ``*_tools.py`` modules bind the same
|
||||
helper names (``read_csv``/``read_excel``/…) that would otherwise
|
||||
collide across modules and were never part of the historical public API.
|
||||
"""
|
||||
try:
|
||||
tree = ast.parse(source)
|
||||
except SyntaxError as exc: # pragma: no cover - defensive
|
||||
logger.warning(
|
||||
"Tool discovery: could not parse %r (%s); its tools will not be "
|
||||
"discoverable. Fix the syntax error to restore them.",
|
||||
module_name, exc,
|
||||
)
|
||||
return []
|
||||
|
||||
explicit = _extract_all(tree)
|
||||
if explicit is not None:
|
||||
return [name for name in explicit if not name.startswith("_")]
|
||||
|
||||
symbols: List[str] = []
|
||||
for node in tree.body:
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||||
if not node.name.startswith("_"):
|
||||
symbols.append(node.name)
|
||||
return symbols
|
||||
|
||||
|
||||
def _fingerprint(package_dir: str) -> str:
|
||||
"""Cheap directory fingerprint used to validate the discovery cache.
|
||||
|
||||
Reflects the set of ``.py`` files and their size/mtime, so any edit,
|
||||
addition, or removal of a tool module invalidates the cache (keeping
|
||||
discovery fully automatic) without re-parsing on every import.
|
||||
"""
|
||||
parts: List[str] = [f"v{_CACHE_VERSION}"]
|
||||
for filename in sorted(os.listdir(package_dir)):
|
||||
if not filename.endswith(".py"):
|
||||
continue
|
||||
try:
|
||||
st = os.stat(os.path.join(package_dir, filename))
|
||||
except OSError: # pragma: no cover - defensive
|
||||
continue
|
||||
parts.append(f"{filename}:{st.st_size}:{st.st_mtime_ns}")
|
||||
return "|".join(parts)
|
||||
|
||||
|
||||
def build_manifest(package_dir: str) -> Tuple[Dict[str, str], Dict[str, List[str]]]:
|
||||
"""Return the discovery manifest for ``package_dir``, using a disk cache.
|
||||
|
||||
The manifest is expensive to compute (it AST-parses every tool module), so
|
||||
the result is memoised in a small JSON file next to the package and reused
|
||||
while the directory fingerprint is unchanged. The cache is best-effort:
|
||||
any read/write/validation failure falls back to a live scan, so behaviour
|
||||
is always correct even on read-only installs.
|
||||
"""
|
||||
cache_path = os.path.join(package_dir, _CACHE_FILENAME)
|
||||
fingerprint = _fingerprint(package_dir)
|
||||
|
||||
try:
|
||||
with open(cache_path, "r", encoding="utf-8") as fh:
|
||||
cached = json.load(fh)
|
||||
if cached.get("fingerprint") == fingerprint:
|
||||
return cached["manifest"], cached["collisions"]
|
||||
except (OSError, ValueError, KeyError):
|
||||
pass # No/invalid cache -> scan below.
|
||||
|
||||
manifest, collisions = _scan_manifest(package_dir)
|
||||
|
||||
try:
|
||||
tmp_path = f"{cache_path}.{os.getpid()}.tmp"
|
||||
with open(tmp_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(
|
||||
{
|
||||
"fingerprint": fingerprint,
|
||||
"manifest": manifest,
|
||||
"collisions": collisions,
|
||||
},
|
||||
fh,
|
||||
)
|
||||
os.replace(tmp_path, cache_path)
|
||||
except OSError: # pragma: no cover - read-only install, cache disabled.
|
||||
pass
|
||||
|
||||
return manifest, collisions
|
||||
|
||||
|
||||
def _scan_manifest(package_dir: str) -> Tuple[Dict[str, str], Dict[str, List[str]]]:
|
||||
"""AST-scan ``package_dir`` and build the discovery manifest.
|
||||
|
||||
Returns a tuple ``(manifest, collisions)`` where ``manifest`` maps each
|
||||
public symbol name to the module it should be imported from, and
|
||||
``collisions`` maps any symbol defined in more than one module to the list
|
||||
of modules that define it (first one wins in the manifest).
|
||||
"""
|
||||
manifest: Dict[str, str] = {}
|
||||
collisions: Dict[str, List[str]] = {}
|
||||
|
||||
for filename in sorted(os.listdir(package_dir)):
|
||||
if not filename.endswith(".py"):
|
||||
continue
|
||||
module_name = filename[:-3]
|
||||
# Skip infrastructure modules and any private (underscore-prefixed)
|
||||
# module so internal/experimental code does not leak into the public
|
||||
# surface by convention.
|
||||
if module_name in _SKIP_MODULES or module_name.startswith("_"):
|
||||
continue
|
||||
|
||||
path = os.path.join(package_dir, filename)
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
source = fh.read()
|
||||
except OSError: # pragma: no cover - defensive
|
||||
continue
|
||||
|
||||
for symbol in _iter_public_symbols(source, module_name):
|
||||
if symbol in manifest and manifest[symbol] != module_name:
|
||||
collisions.setdefault(symbol, [manifest[symbol]])
|
||||
collisions[symbol].append(module_name)
|
||||
logger.warning(
|
||||
"Tool discovery: symbol %r defined in both %r and %r; "
|
||||
"keeping %r. Rename one to avoid shadowing.",
|
||||
symbol, manifest[symbol], module_name, manifest[symbol],
|
||||
)
|
||||
continue
|
||||
manifest[symbol] = module_name
|
||||
|
||||
# External tools that live in sibling packages but are re-exported here.
|
||||
for symbol, module in _EXTERNAL_SYMBOLS.items():
|
||||
manifest.setdefault(symbol, module)
|
||||
|
||||
return manifest, collisions
|
||||
@@ -261,7 +261,17 @@ def check_behavioral_trust(
|
||||
min_trust_score: float = 50.0,
|
||||
organization_filter: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Check an agent's behavioral trust score across organizations using SATP protocol."""
|
||||
"""Check an agent's behavioral trust score across organizations using SATP protocol.
|
||||
|
||||
Args:
|
||||
agent_name: Name/identifier of the agent to check.
|
||||
task_class: Type of task for scoped trust (e.g., "code_review").
|
||||
min_trust_score: Minimum trust score threshold (0-100, default: 50.0).
|
||||
organization_filter: Optional filter for specific organization history.
|
||||
|
||||
Returns:
|
||||
Dictionary with behavioral trust data.
|
||||
"""
|
||||
return AgentFolioTool().check_behavioral_trust(
|
||||
agent_name=agent_name,
|
||||
task_class=task_class,
|
||||
@@ -276,7 +286,17 @@ def verify_task_delegation_safety(
|
||||
task_description: str,
|
||||
required_trust_level: float = 70.0
|
||||
) -> Dict[str, Any]:
|
||||
"""Comprehensive safety check before delegating tasks using all trust layers."""
|
||||
"""Comprehensive safety check before delegating tasks using all trust layers.
|
||||
|
||||
Args:
|
||||
agent_name: Name/identifier of the agent to check.
|
||||
task_class: Type of task for scoped trust (e.g., "code_review").
|
||||
task_description: Natural-language description of the task to delegate.
|
||||
required_trust_level: Minimum trust level required (0-100, default: 70.0).
|
||||
|
||||
Returns:
|
||||
Dictionary with safety assessment and recommendations.
|
||||
"""
|
||||
return AgentFolioTool().verify_delegation_safety(
|
||||
agent_name=agent_name,
|
||||
task_class=task_class,
|
||||
|
||||
@@ -130,5 +130,15 @@ class AgentIDTool(BaseTool):
|
||||
|
||||
|
||||
def verify_agent_identity(agent_url: str) -> Dict[str, Any]:
|
||||
"""Verify an external agent's identity using AgentID certificates."""
|
||||
"""Verify an external agent's identity using AgentID certificates.
|
||||
|
||||
Args:
|
||||
agent_url: URL of the agent to verify.
|
||||
|
||||
Returns:
|
||||
Dictionary with verification result. Always includes ``verified``,
|
||||
``trust_score``, ``certificate``, ``agent_url`` and ``error``.
|
||||
Successful verifications also include a ``timestamp`` field; failures
|
||||
populate ``error`` with a human-readable message.
|
||||
"""
|
||||
return AgentIDTool().verify(agent_url=agent_url)
|
||||
@@ -17,8 +17,51 @@ Usage:
|
||||
return [...]
|
||||
"""
|
||||
|
||||
import logging as _logging
|
||||
|
||||
|
||||
class _SuppressRegistryNoneWarning(_logging.Filter):
|
||||
"""Drop the benign "Registry is None for tool …" WARNING.
|
||||
|
||||
``praisonaiagents.tools.decorator`` logs a WARNING for every ``@tool``
|
||||
decorated function when no global tool registry exists yet. On a bare
|
||||
``import`` (library use, scripts, pytest collection) that is the normal
|
||||
state, not an anomaly - registration happens lazily once an agent/registry
|
||||
comes up. The message fires once per decorated function, so the noise scales
|
||||
with the tool catalogue and is the first thing every consumer sees. We drop
|
||||
only that specific record and leave all other logging untouched.
|
||||
"""
|
||||
|
||||
def filter(self, record: _logging.LogRecord) -> bool:
|
||||
return not record.getMessage().startswith("Registry is None for tool")
|
||||
|
||||
|
||||
def _silence_registry_none_warning() -> None:
|
||||
"""Install the filter so bare imports stay quiet at default log levels.
|
||||
|
||||
The upstream code emits the record via ``logging.warning`` (the root
|
||||
logger), and ``praisonaiagents`` configures a handler on the root logger, so
|
||||
the record surfaces there. We attach the filter to the root logger and its
|
||||
handlers - it only drops the single "Registry is None for tool …" record and
|
||||
passes everything else through, so user logging is unaffected. The proper fix
|
||||
(defer registration / downgrade to debug) belongs in praisonaiagents core.
|
||||
"""
|
||||
filt = _SuppressRegistryNoneWarning()
|
||||
root = _logging.getLogger()
|
||||
root.addFilter(filt)
|
||||
for handler in root.handlers:
|
||||
handler.addFilter(filt)
|
||||
|
||||
|
||||
# ``praisonaiagents`` configures root logging on import; do it first so its
|
||||
# handlers exist, then attach the filter before any module-level ``@tool``
|
||||
# decoration runs.
|
||||
import praisonaiagents # noqa: E402,F401 (import for logging side effects only)
|
||||
|
||||
_silence_registry_none_warning()
|
||||
|
||||
# Import from praisonaiagents - single source of truth
|
||||
from praisonaiagents.tools.decorator import (
|
||||
from praisonaiagents.tools.decorator import ( # noqa: E402
|
||||
tool,
|
||||
FunctionTool,
|
||||
is_tool,
|
||||
|
||||
@@ -25,7 +25,7 @@ class FirecrawlTool(BaseTool):
|
||||
"""Tool for web scraping using Firecrawl."""
|
||||
|
||||
name = "firecrawl"
|
||||
description = "Scrape and crawl websites using Firecrawl."
|
||||
description = "Scrape, crawl, and search the web using Firecrawl."
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None):
|
||||
self.api_key = api_key or os.getenv("FIRECRAWL_API_KEY")
|
||||
@@ -38,10 +38,12 @@ class FirecrawlTool(BaseTool):
|
||||
if not self.api_key:
|
||||
raise ValueError("FIRECRAWL_API_KEY is required")
|
||||
try:
|
||||
from firecrawl import FirecrawlApp
|
||||
from firecrawl import Firecrawl
|
||||
except ImportError:
|
||||
raise ImportError("firecrawl-py not installed. Install with: pip install firecrawl-py")
|
||||
self._client = FirecrawlApp(api_key=self.api_key)
|
||||
raise ImportError(
|
||||
"firecrawl-py>=4 not installed. Install with: pip install 'firecrawl-py>=4'"
|
||||
)
|
||||
self._client = Firecrawl(api_key=self.api_key)
|
||||
return self._client
|
||||
|
||||
def run(
|
||||
@@ -56,6 +58,8 @@ class FirecrawlTool(BaseTool):
|
||||
return self.scrape(url=url, **kwargs)
|
||||
elif action == "crawl":
|
||||
return self.crawl(url=url, **kwargs)
|
||||
elif action == "search":
|
||||
return self.search(query=kwargs.get("query") or url, limit=kwargs.get("limit", 5))
|
||||
else:
|
||||
return {"error": f"Unknown action: {action}"}
|
||||
|
||||
@@ -68,16 +72,28 @@ class FirecrawlTool(BaseTool):
|
||||
return {"error": "FIRECRAWL_API_KEY not configured"}
|
||||
|
||||
try:
|
||||
params = {}
|
||||
# firecrawl-py v2 (>=4): kwargs instead of a params dict; returns a
|
||||
# typed Document, not a dict.
|
||||
kwargs: Dict[str, Any] = {}
|
||||
if formats:
|
||||
params["formats"] = formats
|
||||
|
||||
result = self.client.scrape_url(url, params=params if params else None)
|
||||
|
||||
kwargs["formats"] = formats
|
||||
|
||||
result = self.client.scrape(url, **kwargs)
|
||||
|
||||
# v2 returns a Document object; metadata is a typed DocumentMetadata.
|
||||
# Coerce to a plain dict to keep this tool's return shape stable.
|
||||
metadata = getattr(result, "metadata", None)
|
||||
if metadata is not None and hasattr(metadata, "model_dump"):
|
||||
metadata = metadata.model_dump(exclude_none=True)
|
||||
elif metadata is None:
|
||||
metadata = {}
|
||||
|
||||
markdown = getattr(result, "markdown", None) or ""
|
||||
|
||||
return {
|
||||
"url": url,
|
||||
"markdown": result.get("markdown", "")[:5000],
|
||||
"metadata": result.get("metadata", {}),
|
||||
"markdown": markdown[:5000],
|
||||
"metadata": metadata,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Firecrawl scrape error: {e}")
|
||||
@@ -92,25 +108,77 @@ class FirecrawlTool(BaseTool):
|
||||
return [{"error": "FIRECRAWL_API_KEY not configured"}]
|
||||
|
||||
try:
|
||||
result = self.client.crawl_url(
|
||||
# `limit` may arrive as a string (e.g. "10") or None when invoked by
|
||||
# an LLM/agent; coerce to int so the SDK call and `data[:limit]` slice
|
||||
# below don't raise TypeError.
|
||||
try:
|
||||
limit = int(limit) if limit is not None else 10
|
||||
except (ValueError, TypeError):
|
||||
limit = 10
|
||||
|
||||
# firecrawl-py v2 (>=4): kwargs instead of a params dict; returns a
|
||||
# typed CrawlJob whose `.data` is a list of Document objects.
|
||||
result = self.client.crawl(
|
||||
url,
|
||||
params={"limit": limit},
|
||||
limit=limit,
|
||||
poll_interval=5,
|
||||
)
|
||||
|
||||
|
||||
data = getattr(result, "data", None) or []
|
||||
|
||||
pages = []
|
||||
for page in result.get("data", [])[:limit]:
|
||||
for page in data[:limit]:
|
||||
metadata = getattr(page, "metadata", None)
|
||||
pages.append({
|
||||
"url": page.get("metadata", {}).get("sourceURL"),
|
||||
"title": page.get("metadata", {}).get("title"),
|
||||
"markdown": page.get("markdown", "")[:2000],
|
||||
"url": getattr(metadata, "source_url", None),
|
||||
"title": getattr(metadata, "title", None),
|
||||
"markdown": (getattr(page, "markdown", None) or "")[:2000],
|
||||
})
|
||||
return pages
|
||||
except Exception as e:
|
||||
logger.error(f"Firecrawl crawl error: {e}")
|
||||
return [{"error": str(e)}]
|
||||
|
||||
def search(self, query: str, limit: int = 5) -> List[Dict[str, Any]]:
|
||||
"""Search the web and return results."""
|
||||
if not query:
|
||||
return [{"error": "query is required"}]
|
||||
|
||||
if not self.api_key:
|
||||
return [{"error": "FIRECRAWL_API_KEY not configured"}]
|
||||
|
||||
try:
|
||||
# `limit` may arrive as a string or None from an LLM/agent; coerce to
|
||||
# int so the SDK call and `web[:limit]` slice below don't raise.
|
||||
try:
|
||||
limit = int(limit) if limit is not None else 5
|
||||
except (ValueError, TypeError):
|
||||
limit = 5
|
||||
|
||||
# firecrawl-py v2 returns a typed SearchData object whose results are
|
||||
# grouped by source; read the web results and coerce to plain dicts.
|
||||
result = self.client.search(query, limit=limit)
|
||||
|
||||
web = getattr(result, "web", None) or []
|
||||
results = []
|
||||
for item in web[:limit]:
|
||||
results.append({
|
||||
"url": getattr(item, "url", None),
|
||||
"title": getattr(item, "title", None),
|
||||
"description": getattr(item, "description", None),
|
||||
"markdown": (getattr(item, "markdown", None) or "")[:2000],
|
||||
})
|
||||
return results
|
||||
except Exception as e:
|
||||
logger.error(f"Firecrawl search error: {e}")
|
||||
return [{"error": str(e)}]
|
||||
|
||||
|
||||
def firecrawl_scrape(url: str) -> Dict[str, Any]:
|
||||
"""Scrape URL with Firecrawl."""
|
||||
return FirecrawlTool().scrape(url=url)
|
||||
|
||||
|
||||
def firecrawl_search(query: str, limit: int = 5) -> List[Dict[str, Any]]:
|
||||
"""Search the web with Firecrawl."""
|
||||
return FirecrawlTool().search(query=query, limit=limit)
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Fireflies.ai Tool for PraisonAI Agents.
|
||||
|
||||
Curated GraphQL operations over the Fireflies.ai meeting-intelligence API:
|
||||
list recent meetings, fetch a transcript, and keyword search.
|
||||
|
||||
Design (Option B from the issue): each agent-callable method maps to a small,
|
||||
pre-approved GraphQL document rather than letting the LLM craft arbitrary
|
||||
queries. This keeps the surface predictable and robust.
|
||||
|
||||
Usage:
|
||||
from praisonai_tools import FirefliesTool
|
||||
|
||||
fireflies = FirefliesTool()
|
||||
meetings = fireflies.list_recent_meetings(limit=5)
|
||||
transcript = fireflies.get_transcript(meeting_id="abc123")
|
||||
|
||||
Environment Variables:
|
||||
FIREFLIES_API_KEY: Fireflies API key (sent as ``Authorization: Bearer <key>``)
|
||||
FIREFLIES_API_URL: Optional endpoint override (defaults to the public API)
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from praisonai_tools.tools.base import BaseTool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_API_URL = "https://api.fireflies.ai/graphql"
|
||||
|
||||
|
||||
class FirefliesTool(BaseTool):
|
||||
"""Tool for querying Fireflies.ai meeting intelligence via curated GraphQL."""
|
||||
|
||||
name = "fireflies"
|
||||
description = (
|
||||
"Query Fireflies.ai meeting intelligence: list recent meetings, "
|
||||
"fetch transcripts, and search by keyword."
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: Optional[str] = None,
|
||||
api_url: Optional[str] = None,
|
||||
):
|
||||
self.api_key = api_key or os.getenv("FIREFLIES_API_KEY")
|
||||
self.api_url = api_url or os.getenv("FIREFLIES_API_URL") or DEFAULT_API_URL
|
||||
super().__init__()
|
||||
|
||||
def _graphql(self, query: str, variables: Dict = None) -> Dict:
|
||||
"""Execute a curated GraphQL document, returning normalized results.
|
||||
|
||||
Missing credentials are reported distinctly from HTTP auth failures so
|
||||
callers can tell a misconfiguration (no key) apart from a rejected key
|
||||
(401/403).
|
||||
"""
|
||||
if not self.api_key:
|
||||
return {"error": "FIREFLIES_API_KEY required"}
|
||||
|
||||
# Never transmit the API key over a plaintext endpoint.
|
||||
if not self.api_url.lower().startswith("https://"):
|
||||
return {"error": "FIREFLIES_API_URL must use HTTPS"}
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
return {"error": "requests not installed"}
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
self.api_url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={"query": query, "variables": variables or {}},
|
||||
timeout=30,
|
||||
)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
if resp.status_code in (401, 403):
|
||||
return {
|
||||
"error": f"Fireflies authentication failed (HTTP {resp.status_code})",
|
||||
"status_code": resp.status_code,
|
||||
}
|
||||
if resp.status_code == 429:
|
||||
retry_after = resp.headers.get("Retry-After") or self._graphql_retry_after(resp)
|
||||
error = {"error": "Fireflies rate limit exceeded", "status_code": 429}
|
||||
if retry_after:
|
||||
error["retry_after"] = retry_after
|
||||
return error
|
||||
|
||||
try:
|
||||
payload = resp.json()
|
||||
except Exception:
|
||||
return {
|
||||
"error": f"Fireflies returned non-JSON response (HTTP {resp.status_code})",
|
||||
"status_code": resp.status_code,
|
||||
}
|
||||
|
||||
if payload.get("errors"):
|
||||
messages = [e.get("message", "unknown error") for e in payload["errors"]]
|
||||
return {"error": "; ".join(messages)}
|
||||
# Surface any remaining HTTP failure instead of letting an error body be
|
||||
# normalized into an empty/missing result by the operation handlers.
|
||||
if resp.status_code >= 400:
|
||||
return {
|
||||
"error": f"Fireflies request failed (HTTP {resp.status_code})",
|
||||
"status_code": resp.status_code,
|
||||
}
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _graphql_retry_after(resp) -> Optional[str]:
|
||||
"""Extract retryAfter from a GraphQL 429 body when the header is absent."""
|
||||
try:
|
||||
body = resp.json()
|
||||
except Exception:
|
||||
return None
|
||||
for err in body.get("errors") or []:
|
||||
metadata = ((err.get("extensions") or {}).get("metadata")) or {}
|
||||
if metadata.get("retryAfter"):
|
||||
return str(metadata["retryAfter"])
|
||||
return None
|
||||
|
||||
def run(
|
||||
self,
|
||||
action: str = "list_recent_meetings",
|
||||
meeting_id: Optional[str] = None,
|
||||
keywords: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> Union[str, Dict[str, Any], List[Dict[str, Any]]]:
|
||||
action = action.lower().replace("-", "_")
|
||||
|
||||
if action == "list_recent_meetings":
|
||||
return self.list_recent_meetings(**kwargs)
|
||||
elif action == "get_transcript":
|
||||
return self.get_transcript(meeting_id=meeting_id)
|
||||
elif action == "search":
|
||||
return self.search(keywords=keywords, **kwargs)
|
||||
else:
|
||||
return {"error": f"Unknown action: {action}"}
|
||||
|
||||
def list_recent_meetings(
|
||||
self, limit: int = 10, after: Optional[str] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""List recent meetings/transcripts.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of meetings to return.
|
||||
after: Cursor (skip offset) for pagination. Explicit paging avoids
|
||||
unbounded scans of the account history.
|
||||
"""
|
||||
query = """
|
||||
query($limit: Int, $skip: Int) {
|
||||
transcripts(limit: $limit, skip: $skip) {
|
||||
id
|
||||
title
|
||||
date
|
||||
duration
|
||||
organizer_email
|
||||
}
|
||||
}
|
||||
"""
|
||||
limit = max(1, min(limit, 50)) # Fireflies caps page size at 50.
|
||||
variables: Dict[str, Any] = {"limit": limit}
|
||||
if after is not None:
|
||||
try:
|
||||
variables["skip"] = int(after)
|
||||
except (TypeError, ValueError):
|
||||
return [{"error": "after must be an integer cursor"}]
|
||||
|
||||
result = self._graphql(query, variables)
|
||||
if "error" in result:
|
||||
return [result]
|
||||
|
||||
transcripts = result.get("data", {}).get("transcripts") or []
|
||||
return [
|
||||
{
|
||||
"id": t.get("id"),
|
||||
"title": t.get("title"),
|
||||
"date": t.get("date"),
|
||||
"duration": t.get("duration"),
|
||||
"organizer_email": t.get("organizer_email"),
|
||||
}
|
||||
for t in transcripts
|
||||
]
|
||||
|
||||
def get_transcript(self, meeting_id: str) -> Dict[str, Any]:
|
||||
"""Fetch a single meeting transcript with summary and sentences."""
|
||||
if not meeting_id:
|
||||
return {"error": "meeting_id required"}
|
||||
|
||||
query = """
|
||||
query($id: String!) {
|
||||
transcript(id: $id) {
|
||||
id
|
||||
title
|
||||
date
|
||||
duration
|
||||
organizer_email
|
||||
summary { overview }
|
||||
sentences {
|
||||
text
|
||||
speaker_name
|
||||
start_time
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = self._graphql(query, {"id": meeting_id})
|
||||
if "error" in result:
|
||||
return result
|
||||
|
||||
transcript = result.get("data", {}).get("transcript")
|
||||
if not transcript:
|
||||
return {"error": "transcript not found"}
|
||||
|
||||
summary = transcript.get("summary") or {}
|
||||
sentences = transcript.get("sentences") or []
|
||||
return {
|
||||
"id": transcript.get("id"),
|
||||
"title": transcript.get("title"),
|
||||
"date": transcript.get("date"),
|
||||
"duration": transcript.get("duration"),
|
||||
"organizer_email": transcript.get("organizer_email"),
|
||||
"summary": summary.get("overview"),
|
||||
"sentences": sentences,
|
||||
"sentence_count": len(sentences),
|
||||
}
|
||||
|
||||
def search(self, keywords: str, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""Search meetings whose transcripts match the given keyword phrase."""
|
||||
if not keywords:
|
||||
return [{"error": "keywords required"}]
|
||||
|
||||
limit = max(1, min(limit, 50)) # Fireflies caps page size at 50.
|
||||
query = """
|
||||
query($keyword: String, $limit: Int) {
|
||||
transcripts(keyword: $keyword, limit: $limit) {
|
||||
id
|
||||
title
|
||||
date
|
||||
organizer_email
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = self._graphql(query, {"keyword": keywords, "limit": limit})
|
||||
if "error" in result:
|
||||
return [result]
|
||||
|
||||
transcripts = result.get("data", {}).get("transcripts") or []
|
||||
return [
|
||||
{
|
||||
"id": t.get("id"),
|
||||
"title": t.get("title"),
|
||||
"date": t.get("date"),
|
||||
"organizer_email": t.get("organizer_email"),
|
||||
}
|
||||
for t in transcripts
|
||||
]
|
||||
|
||||
|
||||
def list_fireflies_meetings(limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""List recent Fireflies meetings."""
|
||||
return FirefliesTool().list_recent_meetings(limit=limit)
|
||||
@@ -0,0 +1,569 @@
|
||||
"""Inkog Security Analysis Tool for PraisonAI Agents.
|
||||
|
||||
Security analysis for AI agent code using Inkog.
|
||||
Detects token bombing, prompt injection, missing oversight, and compliance gaps.
|
||||
|
||||
Usage:
|
||||
from praisonai_tools import InkogTool
|
||||
|
||||
inkog = InkogTool()
|
||||
results = inkog.scan_directory("./my-agent")
|
||||
|
||||
# Or use standalone functions
|
||||
from praisonai_tools.tools.inkog_tool import scan_agent_code
|
||||
results = scan_agent_code("./my-agent")
|
||||
|
||||
Features:
|
||||
- Static analysis of AI agent code
|
||||
- Token bombing detection
|
||||
- Prompt injection vulnerability scanning
|
||||
- Recursive tool calling detection
|
||||
- EU AI Act / NIST / OWASP compliance mapping
|
||||
- Support for 21+ frameworks (LangChain, CrewAI, AutoGen, etc.)
|
||||
- Multiple output formats (text, JSON, HTML, SARIF)
|
||||
|
||||
Environment Variables:
|
||||
INKOG_API_KEY: API key from app.inkog.io (required)
|
||||
INKOG_SERVER_URL: Custom server URL (optional)
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
from typing import Any, Dict, Optional, Union
|
||||
|
||||
from praisonai_tools.tools.base import BaseTool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _check_inkog_available(api_key: Optional[str] = None) -> tuple[bool, Optional[str]]:
|
||||
"""Check if inkog CLI is available and properly configured.
|
||||
|
||||
Args:
|
||||
api_key: Optional API key to validate. Falls back to the
|
||||
INKOG_API_KEY environment variable when not provided.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_available, error_message)
|
||||
"""
|
||||
# Check if inkog CLI is installed
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["inkog", "-version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False, "inkog CLI is not properly installed. Install with: brew tap inkog-io/inkog && brew install inkog"
|
||||
except FileNotFoundError:
|
||||
return False, "inkog CLI is not installed. Install with: brew tap inkog-io/inkog && brew install inkog"
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "inkog CLI is not responding. Please check installation."
|
||||
except Exception as e:
|
||||
return False, f"Error checking inkog installation: {e}"
|
||||
|
||||
# Check for API key (instance-provided key takes precedence over env var)
|
||||
api_key = api_key or os.environ.get("INKOG_API_KEY")
|
||||
if not api_key:
|
||||
return False, "INKOG_API_KEY environment variable is required. Get your free key at https://app.inkog.io"
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
class InkogTool(BaseTool):
|
||||
"""Comprehensive security analysis tool for AI agent code using Inkog.
|
||||
|
||||
Inkog provides static analysis specifically designed for AI agents, detecting:
|
||||
- Token bombing attacks (loops where LLM controls termination)
|
||||
- Prompt injection vulnerabilities
|
||||
- Recursive tool calling without cycle detection
|
||||
- Missing human oversight for destructive operations
|
||||
- Cross-tenant data leakage
|
||||
- MCP tool poisoning
|
||||
|
||||
Supports 21+ frameworks including LangChain, CrewAI, AutoGen, OpenAI Agents,
|
||||
and provides compliance mapping to EU AI Act, NIST AI RMF, and OWASP LLM Top 10.
|
||||
|
||||
Requires:
|
||||
- inkog CLI: brew tap inkog-io/inkog && brew install inkog
|
||||
- INKOG_API_KEY environment variable (free at app.inkog.io)
|
||||
"""
|
||||
|
||||
name = "inkog_security"
|
||||
description = "Analyze AI agent code for security vulnerabilities using Inkog static analysis."
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None, server_url: Optional[str] = None):
|
||||
"""Initialize InkogTool.
|
||||
|
||||
Args:
|
||||
api_key: Optional API key. If not provided, uses INKOG_API_KEY env var.
|
||||
server_url: Optional server URL. If not provided, uses INKOG_SERVER_URL env var.
|
||||
"""
|
||||
super().__init__()
|
||||
self._api_key = api_key
|
||||
self._server_url = server_url
|
||||
|
||||
def _get_api_key(self) -> Optional[str]:
|
||||
"""Get API key from instance or environment."""
|
||||
return self._api_key or os.environ.get("INKOG_API_KEY")
|
||||
|
||||
def _get_server_url(self) -> Optional[str]:
|
||||
"""Get server URL from instance or environment."""
|
||||
return self._server_url or os.environ.get("INKOG_SERVER_URL")
|
||||
|
||||
def run(
|
||||
self,
|
||||
action: str = "scan",
|
||||
path: str = ".",
|
||||
output_format: str = "json",
|
||||
policy: str = "balanced",
|
||||
severity: str = "low",
|
||||
deep: bool = False,
|
||||
**kwargs
|
||||
) -> Union[str, Dict[str, Any]]:
|
||||
"""Execute inkog security analysis.
|
||||
|
||||
Args:
|
||||
action: Action to perform ("scan", "skill-scan", "mcp-scan")
|
||||
path: Path to scan (directory or file)
|
||||
output_format: Output format ("json", "text", "html", "sarif")
|
||||
policy: Security policy ("low-noise", "balanced", "comprehensive")
|
||||
severity: Minimum severity ("critical", "high", "medium", "low")
|
||||
deep: Enable deep scan with AI orchestrator analysis
|
||||
**kwargs: Additional arguments
|
||||
|
||||
Returns:
|
||||
Scan results as dict (JSON) or formatted string (text/html)
|
||||
"""
|
||||
action = action.lower().replace("-", "_")
|
||||
|
||||
if action == "scan":
|
||||
return self.scan_directory(
|
||||
path=path,
|
||||
output_format=output_format,
|
||||
policy=policy,
|
||||
severity=severity,
|
||||
deep=deep
|
||||
)
|
||||
elif action == "skill_scan":
|
||||
return self.skill_scan(
|
||||
path=path,
|
||||
output_format=output_format,
|
||||
deep=deep,
|
||||
repo=kwargs.get("repo")
|
||||
)
|
||||
elif action == "mcp_scan":
|
||||
return self.mcp_scan(
|
||||
server_name=kwargs.get("server_name", path),
|
||||
output_format=output_format,
|
||||
deep=deep,
|
||||
repo=kwargs.get("repo")
|
||||
)
|
||||
else:
|
||||
return {"error": f"Unknown action: {action}"}
|
||||
|
||||
def scan_directory(
|
||||
self,
|
||||
path: str = ".",
|
||||
output_format: str = "json",
|
||||
policy: str = "balanced",
|
||||
severity: str = "low",
|
||||
deep: bool = False,
|
||||
verbose: bool = False
|
||||
) -> Union[Dict[str, Any], str]:
|
||||
"""Scan a directory or file for AI agent security vulnerabilities.
|
||||
|
||||
Args:
|
||||
path: Path to scan (directory or file)
|
||||
output_format: Output format ("json", "text", "html", "sarif")
|
||||
policy: Security policy ("low-noise", "balanced", "comprehensive")
|
||||
severity: Minimum severity level ("critical", "high", "medium", "low")
|
||||
deep: Enable deep scan with AI orchestrator analysis
|
||||
verbose: Enable verbose output
|
||||
|
||||
Returns:
|
||||
Scan results as dict (JSON format) or string (other formats)
|
||||
"""
|
||||
is_available, error = _check_inkog_available(self._get_api_key())
|
||||
if not is_available:
|
||||
logger.error(error)
|
||||
return {"error": error}
|
||||
|
||||
try:
|
||||
# Build command
|
||||
cmd = ["inkog"]
|
||||
|
||||
if deep:
|
||||
cmd.append("-deep")
|
||||
|
||||
cmd.extend([
|
||||
"-output", output_format,
|
||||
"-policy", policy,
|
||||
"-severity", severity,
|
||||
])
|
||||
|
||||
if verbose:
|
||||
cmd.append("-verbose")
|
||||
|
||||
# Add server URL if specified
|
||||
server_url = self._get_server_url()
|
||||
if server_url:
|
||||
cmd.extend(["-server", server_url])
|
||||
|
||||
# Add path
|
||||
cmd.append(path)
|
||||
|
||||
# Set environment
|
||||
env = os.environ.copy()
|
||||
api_key = self._get_api_key()
|
||||
if api_key:
|
||||
env["INKOG_API_KEY"] = api_key
|
||||
|
||||
# Execute scan
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=300 # 5 minutes timeout
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
error_msg = f"Inkog scan failed: {result.stderr or result.stdout}"
|
||||
logger.error(error_msg)
|
||||
return {"error": error_msg}
|
||||
|
||||
# Parse output based on format
|
||||
if output_format == "json":
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except json.JSONDecodeError as e:
|
||||
return {"error": f"Failed to parse JSON output: {e}", "raw_output": result.stdout}
|
||||
else:
|
||||
return result.stdout
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
error_msg = "Inkog scan timed out after 5 minutes"
|
||||
logger.error(error_msg)
|
||||
return {"error": error_msg}
|
||||
except Exception as e:
|
||||
error_msg = f"Inkog scan error: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return {"error": error_msg}
|
||||
|
||||
def skill_scan(
|
||||
self,
|
||||
path: str = ".",
|
||||
output_format: str = "json",
|
||||
deep: bool = False,
|
||||
repo: Optional[str] = None
|
||||
) -> Union[Dict[str, Any], str]:
|
||||
"""Scan SKILL.md packages and agent tools for security vulnerabilities.
|
||||
|
||||
Args:
|
||||
path: Path to scan or server name
|
||||
output_format: Output format ("json", "text", "html", "sarif")
|
||||
deep: Enable deep scan with AI orchestrator analysis
|
||||
repo: Repository URL to scan
|
||||
|
||||
Returns:
|
||||
Scan results as dict (JSON format) or string (other formats)
|
||||
"""
|
||||
is_available, error = _check_inkog_available(self._get_api_key())
|
||||
if not is_available:
|
||||
logger.error(error)
|
||||
return {"error": error}
|
||||
|
||||
try:
|
||||
# Build command
|
||||
cmd = ["inkog", "skill-scan"]
|
||||
|
||||
if deep:
|
||||
cmd.append("-deep")
|
||||
|
||||
cmd.extend(["-output", output_format])
|
||||
|
||||
if repo:
|
||||
cmd.extend(["-repo", repo])
|
||||
else:
|
||||
cmd.append(path)
|
||||
|
||||
# Set environment
|
||||
env = os.environ.copy()
|
||||
api_key = self._get_api_key()
|
||||
if api_key:
|
||||
env["INKOG_API_KEY"] = api_key
|
||||
|
||||
# Execute scan
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=300
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
error_msg = f"Inkog skill scan failed: {result.stderr or result.stdout}"
|
||||
logger.error(error_msg)
|
||||
return {"error": error_msg}
|
||||
|
||||
# Parse output based on format
|
||||
if output_format == "json":
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except json.JSONDecodeError as e:
|
||||
return {"error": f"Failed to parse JSON output: {e}", "raw_output": result.stdout}
|
||||
else:
|
||||
return result.stdout
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
error_msg = "Inkog skill scan timed out after 5 minutes"
|
||||
logger.error(error_msg)
|
||||
return {"error": error_msg}
|
||||
except Exception as e:
|
||||
error_msg = f"Inkog skill scan error: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return {"error": error_msg}
|
||||
|
||||
def mcp_scan(
|
||||
self,
|
||||
server_name: str,
|
||||
output_format: str = "json",
|
||||
deep: bool = False,
|
||||
repo: Optional[str] = None
|
||||
) -> Union[Dict[str, Any], str]:
|
||||
"""Scan MCP servers for security vulnerabilities.
|
||||
|
||||
Args:
|
||||
server_name: MCP server name to scan
|
||||
output_format: Output format ("json", "text", "html", "sarif")
|
||||
deep: Enable deep scan with AI orchestrator analysis
|
||||
repo: Repository URL for the MCP server source code
|
||||
|
||||
Returns:
|
||||
Scan results as dict (JSON format) or string (other formats)
|
||||
"""
|
||||
is_available, error = _check_inkog_available(self._get_api_key())
|
||||
if not is_available:
|
||||
logger.error(error)
|
||||
return {"error": error}
|
||||
|
||||
try:
|
||||
# Build command
|
||||
cmd = ["inkog", "mcp-scan"]
|
||||
|
||||
if deep:
|
||||
cmd.append("-deep")
|
||||
|
||||
cmd.extend(["-output", output_format])
|
||||
|
||||
if repo:
|
||||
cmd.extend(["-repo", repo])
|
||||
|
||||
cmd.append(server_name)
|
||||
|
||||
# Set environment
|
||||
env = os.environ.copy()
|
||||
api_key = self._get_api_key()
|
||||
if api_key:
|
||||
env["INKOG_API_KEY"] = api_key
|
||||
|
||||
# Execute scan
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=300
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
error_msg = f"Inkog MCP scan failed: {result.stderr or result.stdout}"
|
||||
logger.error(error_msg)
|
||||
return {"error": error_msg}
|
||||
|
||||
# Parse output based on format
|
||||
if output_format == "json":
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except json.JSONDecodeError as e:
|
||||
return {"error": f"Failed to parse JSON output: {e}", "raw_output": result.stdout}
|
||||
else:
|
||||
return result.stdout
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
error_msg = "Inkog MCP scan timed out after 5 minutes"
|
||||
logger.error(error_msg)
|
||||
return {"error": error_msg}
|
||||
except Exception as e:
|
||||
error_msg = f"Inkog MCP scan error: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return {"error": error_msg}
|
||||
|
||||
def analyze_findings(self, scan_results: Union[Dict[str, Any], str]) -> str:
|
||||
"""Analyze scan results and provide a summary.
|
||||
|
||||
Args:
|
||||
scan_results: Results from a JSON format scan. Non-dict inputs
|
||||
(e.g. text/HTML output) are returned as-is.
|
||||
|
||||
Returns:
|
||||
Human-readable analysis summary
|
||||
"""
|
||||
try:
|
||||
if not isinstance(scan_results, dict):
|
||||
return str(scan_results)
|
||||
|
||||
if "error" in scan_results:
|
||||
return f"Scan failed: {scan_results['error']}"
|
||||
|
||||
summary = scan_results.get("summary", {})
|
||||
total_findings = summary.get("total_findings", 0)
|
||||
|
||||
if total_findings == 0:
|
||||
return "✅ No security vulnerabilities found in the agent code."
|
||||
|
||||
# Build severity breakdown
|
||||
critical = summary.get("critical", 0)
|
||||
high = summary.get("high", 0)
|
||||
medium = summary.get("medium", 0)
|
||||
low = summary.get("low", 0)
|
||||
|
||||
analysis = "🔍 Security Analysis Results:\n"
|
||||
analysis += f"Total Findings: {total_findings}\n\n"
|
||||
|
||||
if critical > 0:
|
||||
analysis += f"🔴 CRITICAL: {critical} findings - Immediate attention required\n"
|
||||
if high > 0:
|
||||
analysis += f"🟠 HIGH: {high} findings - Should be addressed soon\n"
|
||||
if medium > 0:
|
||||
analysis += f"🟡 MEDIUM: {medium} findings - Review recommended\n"
|
||||
if low > 0:
|
||||
analysis += f"⚪ LOW: {low} findings - Minor issues\n"
|
||||
|
||||
# Add key finding types if available
|
||||
findings = scan_results.get("server_findings", [])
|
||||
if findings:
|
||||
patterns = set()
|
||||
for finding in findings[:5]: # Show top 5 patterns
|
||||
pattern = finding.get("pattern", "Unknown")
|
||||
patterns.add(pattern)
|
||||
|
||||
if patterns:
|
||||
analysis += "\nKey Vulnerability Types Detected:\n"
|
||||
for pattern in sorted(patterns):
|
||||
analysis += f"• {pattern}\n"
|
||||
|
||||
# Risk recommendations
|
||||
if critical > 0 or high > 0:
|
||||
analysis += "\n⚠️ Recommendation: Address critical and high severity findings before deployment."
|
||||
|
||||
return analysis
|
||||
|
||||
except Exception as e:
|
||||
return f"Error analyzing scan results: {e}"
|
||||
|
||||
|
||||
# Standalone functions for direct import and use
|
||||
def scan_agent_code(
|
||||
path: str = ".",
|
||||
output_format: str = "json",
|
||||
policy: str = "balanced",
|
||||
severity: str = "low",
|
||||
deep: bool = False
|
||||
) -> Union[Dict[str, Any], str]:
|
||||
"""Scan AI agent code for security vulnerabilities using Inkog.
|
||||
|
||||
Args:
|
||||
path: Path to scan (directory or file)
|
||||
output_format: Output format ("json", "text", "html", "sarif")
|
||||
policy: Security policy ("low-noise", "balanced", "comprehensive")
|
||||
severity: Minimum severity level ("critical", "high", "medium", "low")
|
||||
deep: Enable deep scan with AI orchestrator analysis
|
||||
|
||||
Returns:
|
||||
Scan results as dict (JSON format) or string (other formats)
|
||||
"""
|
||||
tool = InkogTool()
|
||||
return tool.scan_directory(
|
||||
path=path,
|
||||
output_format=output_format,
|
||||
policy=policy,
|
||||
severity=severity,
|
||||
deep=deep
|
||||
)
|
||||
|
||||
|
||||
def scan_skill_package(
|
||||
path: str = ".",
|
||||
deep: bool = False,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Scan SKILL.md packages for security vulnerabilities.
|
||||
|
||||
Args:
|
||||
path: Path to scan
|
||||
deep: Enable deep scan with AI orchestrator analysis
|
||||
repo: Repository URL to scan
|
||||
|
||||
Returns:
|
||||
Scan results as dict
|
||||
"""
|
||||
tool = InkogTool()
|
||||
return tool.skill_scan(path=path, deep=deep, repo=repo)
|
||||
|
||||
|
||||
def scan_mcp_server(
|
||||
server_name: str,
|
||||
deep: bool = False,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Scan MCP server for security vulnerabilities.
|
||||
|
||||
Args:
|
||||
server_name: MCP server name to scan
|
||||
deep: Enable deep scan with AI orchestrator analysis
|
||||
repo: Repository URL for the MCP server
|
||||
|
||||
Returns:
|
||||
Scan results as dict
|
||||
"""
|
||||
tool = InkogTool()
|
||||
return tool.mcp_scan(server_name=server_name, deep=deep, repo=repo)
|
||||
|
||||
|
||||
# Example usage and testing
|
||||
if __name__ == "__main__":
|
||||
print("\n" + "="*60)
|
||||
print("Inkog Security Analysis Tool Demonstration")
|
||||
print("="*60 + "\n")
|
||||
|
||||
# Check if inkog is available
|
||||
is_available, error = _check_inkog_available()
|
||||
if not is_available:
|
||||
print(f"❌ Error: {error}")
|
||||
print("\nTo use Inkog tools:")
|
||||
print("1. Install CLI: brew tap inkog-io/inkog && brew install inkog")
|
||||
print("2. Get API key: https://app.inkog.io")
|
||||
print("3. Set environment: export INKOG_API_KEY=your_key_here")
|
||||
else:
|
||||
print("✅ Inkog is available and configured!")
|
||||
|
||||
# Example usage with current directory
|
||||
try:
|
||||
print("\nScanning current directory for security vulnerabilities...")
|
||||
result = scan_agent_code(".", output_format="json", policy="balanced")
|
||||
|
||||
if isinstance(result, dict) and "error" not in result:
|
||||
tool = InkogTool()
|
||||
analysis = tool.analyze_findings(result)
|
||||
print(analysis)
|
||||
else:
|
||||
print("Scan completed:", result)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Demo error: {e}")
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Keenable Search Tool for PraisonAI Agents.
|
||||
|
||||
Keyless (or keyed) web search using Keenable.
|
||||
|
||||
Keenable works with no API key and no extra install: it calls the keyless
|
||||
public endpoint with the Python standard library (``urllib``). Setting
|
||||
``KEENABLE_API_KEY`` switches to the authenticated endpoint for higher limits.
|
||||
|
||||
Usage:
|
||||
from praisonai_tools import KeenableSearchTool
|
||||
|
||||
keenable = KeenableSearchTool()
|
||||
results = keenable.search("AI news 2024")
|
||||
|
||||
Environment Variables:
|
||||
KEENABLE_API_KEY: Optional. When set, the authenticated endpoint is used.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from urllib.request import HTTPRedirectHandler
|
||||
|
||||
from praisonai_tools.tools.base import BaseTool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KEENABLE_SEARCH_URL = "https://api.keenable.ai/v1/search"
|
||||
KEENABLE_PUBLIC_SEARCH_URL = "https://api.keenable.ai/v1/search/public"
|
||||
KEENABLE_APP_TITLE = "PraisonAI"
|
||||
KEENABLE_SEARCH_TIMEOUT_SECONDS = 30
|
||||
KEENABLE_MAX_RESPONSE_BYTES = 1_000_000
|
||||
KEENABLE_MAX_RESULTS = 50
|
||||
KEENABLE_MAX_TITLE_CHARS = 256
|
||||
KEENABLE_MAX_URL_CHARS = 2048
|
||||
KEENABLE_MAX_SNIPPET_CHARS = 1200
|
||||
|
||||
|
||||
def _truncate_text(value: Any, limit: int) -> str:
|
||||
"""Trim whitespace and cap ``value`` to ``limit`` characters."""
|
||||
text = str(value or "").strip()
|
||||
if len(text) > limit:
|
||||
return text[:limit - 1] + "\u2026"
|
||||
return text
|
||||
|
||||
|
||||
class _NoRedirect(HTTPRedirectHandler):
|
||||
"""Redirect handler that refuses to follow any redirect.
|
||||
|
||||
Keenable never redirects. Following a 3xx would resend ``X-API-Key`` to
|
||||
whatever host it points at, so the redirect is dropped (urllib then raises
|
||||
the 3xx as an error, surfaced to the caller) and no second request is sent.
|
||||
"""
|
||||
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
"""Return ``None`` so urllib does not issue a follow-up request."""
|
||||
return None
|
||||
|
||||
|
||||
class KeenableSearchTool(BaseTool):
|
||||
"""Tool for keyless (or keyed) web search using Keenable."""
|
||||
|
||||
name = "keenable_search"
|
||||
description = "Search the web using Keenable (keyless; an API key raises limits)."
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None):
|
||||
"""Create the tool, resolving the API key from the argument or env.
|
||||
|
||||
Args:
|
||||
api_key: Optional Keenable API key. Falls back to the
|
||||
``KEENABLE_API_KEY`` environment variable; keyless if unset.
|
||||
"""
|
||||
self.api_key = (api_key or os.getenv("KEENABLE_API_KEY") or "").strip()
|
||||
super().__init__()
|
||||
|
||||
def run(
|
||||
self,
|
||||
action: str = "search",
|
||||
query: Optional[str] = None,
|
||||
max_results: int = 5,
|
||||
**kwargs,
|
||||
) -> Union[str, Dict[str, Any], List[Dict[str, Any]]]:
|
||||
"""Dispatch a Keenable action; only ``search`` is supported."""
|
||||
action = action.lower().replace("-", "_")
|
||||
if action == "search":
|
||||
return self.search(query=query, max_results=max_results)
|
||||
return {"error": f"Unknown action: {action}"}
|
||||
|
||||
def search(self, query: str, max_results: int = 5) -> List[Dict[str, Any]]:
|
||||
"""Search the web with Keenable.
|
||||
|
||||
Args:
|
||||
query: Search query string.
|
||||
max_results: Maximum results to return (capped at 50).
|
||||
|
||||
Returns:
|
||||
List of dicts with ``title``, ``url``, ``snippet`` and ``provider``,
|
||||
or a single-item list with an ``error`` key on failure.
|
||||
"""
|
||||
if not query:
|
||||
return [{"error": "query is required"}]
|
||||
|
||||
result_limit = max(0, min(int(max_results), KEENABLE_MAX_RESULTS))
|
||||
if result_limit == 0:
|
||||
return []
|
||||
|
||||
from urllib.error import URLError
|
||||
from urllib.request import Request, build_opener
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "praisonai-tools",
|
||||
# Required on keyless calls; names the app, carries no user identifier.
|
||||
"X-Keenable-Title": KEENABLE_APP_TITLE,
|
||||
}
|
||||
if self.api_key:
|
||||
headers["X-API-Key"] = self.api_key
|
||||
body = json.dumps({
|
||||
"query": query,
|
||||
"max_results": result_limit,
|
||||
"snippet_max_length": KEENABLE_MAX_SNIPPET_CHARS,
|
||||
}).encode("utf-8")
|
||||
request = Request(
|
||||
KEENABLE_SEARCH_URL if self.api_key else KEENABLE_PUBLIC_SEARCH_URL,
|
||||
data=body,
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
opener = build_opener(_NoRedirect)
|
||||
with opener.open(request, timeout=KEENABLE_SEARCH_TIMEOUT_SECONDS) as response:
|
||||
raw = response.read(KEENABLE_MAX_RESPONSE_BYTES + 1)
|
||||
except URLError as exc:
|
||||
logger.error(f"Keenable search error: {exc}")
|
||||
return [{"error": str(exc)}]
|
||||
except Exception as exc:
|
||||
logger.error(f"Keenable search error: {exc}")
|
||||
return [{"error": str(exc)}]
|
||||
|
||||
if len(raw) > KEENABLE_MAX_RESPONSE_BYTES:
|
||||
return [{"error": "Keenable returned an oversized response"}]
|
||||
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except ValueError as exc:
|
||||
return [{"error": f"Keenable returned invalid JSON: {exc}"}]
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get("results"), list):
|
||||
return [{"error": "Keenable returned an invalid response shape"}]
|
||||
|
||||
results = []
|
||||
for result in payload["results"]:
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
url = result.get("url")
|
||||
if not isinstance(url, str) or not url or len(url) > KEENABLE_MAX_URL_CHARS:
|
||||
continue
|
||||
# The page text is in `snippet`; `description` is usually empty.
|
||||
snippet = " ".join(str(result.get("snippet") or result.get("description") or "").split())
|
||||
results.append({
|
||||
"title": _truncate_text(result.get("title", ""), KEENABLE_MAX_TITLE_CHARS),
|
||||
"url": url,
|
||||
"snippet": _truncate_text(snippet, KEENABLE_MAX_SNIPPET_CHARS),
|
||||
"provider": "keenable",
|
||||
})
|
||||
if len(results) >= result_limit:
|
||||
break
|
||||
return results
|
||||
|
||||
|
||||
def keenable_search(query: str, max_results: int = 5) -> List[Dict[str, Any]]:
|
||||
"""Search the web with Keenable (keyless; KEENABLE_API_KEY raises limits)."""
|
||||
return KeenableSearchTool().search(query=query, max_results=max_results)
|
||||
@@ -0,0 +1,700 @@
|
||||
"""Meeting Agent Tools for PraisonAI Agents.
|
||||
|
||||
Eight agent-callable ``@tool`` functions covering the Phase 1 meeting-agent
|
||||
pipeline: transcription, meetings CRUD, summarisation, action-item extraction,
|
||||
and RAG indexing/search.
|
||||
|
||||
Tools
|
||||
-----
|
||||
1. ``transcribe_file`` - Whisper transcription with segment timestamps.
|
||||
2. ``save_meeting`` - Persist a meeting record (returns ``meeting_id``).
|
||||
3. ``get_meeting`` - Fetch a full meeting record.
|
||||
4. ``list_meetings`` - List meetings (limit/offset).
|
||||
5. ``summarize_transcript`` - Structured summary (map-reduce for long input).
|
||||
6. ``extract_action_items`` - Structured action items.
|
||||
7. ``index_meeting`` - Chunk/embed/upsert into a vector store (idempotent).
|
||||
8. ``search_meetings`` - Ranked cross-meeting semantic search.
|
||||
|
||||
Dependency hygiene
|
||||
-------------------
|
||||
``openai`` and ``chromadb`` are **lazy-imported inside functions**, never at
|
||||
module import time. They are optional extras::
|
||||
|
||||
pip install praisonai-tools[meeting]
|
||||
|
||||
Meetings are persisted in a small SQLite database (Python stdlib) under the app
|
||||
data dir. This is a thin, swappable persistence layer: when the canonical
|
||||
PAI-MEET-CORE schema is available it can back these same CRUD wrappers.
|
||||
|
||||
All tool outputs use deterministic serialisation (stable field order / sorted
|
||||
keys) so results are reproducible.
|
||||
|
||||
Environment Variables
|
||||
---------------------
|
||||
OPENAI_API_KEY Required for transcription/summaries/embeddings.
|
||||
OPENAI_BASE_URL Optional custom OpenAI-compatible endpoint.
|
||||
PRAISONAI_MEETINGS_DB Optional path to the SQLite meetings database.
|
||||
PRAISONAI_MEETINGS_DIR Optional app data dir (db + vector store live here).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import uuid
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from praisonai_tools.tools.decorator import tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Chunking / embedding defaults (issue spec).
|
||||
_CHUNK_SIZE_TOKENS = 500
|
||||
_CHUNK_OVERLAP_TOKENS = 50
|
||||
_SUMMARY_CHUNK_TOKENS = 6000
|
||||
_SUMMARY_OVERLAP_TOKENS = 200
|
||||
_EMBEDDING_MODEL = "text-embedding-3-small"
|
||||
_CHAT_MODEL = "gpt-4o-mini"
|
||||
_WHISPER_MODEL = "whisper-1"
|
||||
_COLLECTION = "meetings"
|
||||
|
||||
# Rough token->char ratio used only for deterministic, dependency-free chunking.
|
||||
_CHARS_PER_TOKEN = 4
|
||||
|
||||
|
||||
# ── Paths / persistence ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def _app_dir() -> Path:
|
||||
"""Return the app data directory (created if missing)."""
|
||||
env = os.getenv("PRAISONAI_MEETINGS_DIR")
|
||||
base = Path(env) if env else Path.home() / ".praisonai" / "meetings"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
return base
|
||||
|
||||
|
||||
def _db_path() -> str:
|
||||
env = os.getenv("PRAISONAI_MEETINGS_DB")
|
||||
if env:
|
||||
Path(env).parent.mkdir(parents=True, exist_ok=True)
|
||||
return env
|
||||
return str(_app_dir() / "meetings.db")
|
||||
|
||||
|
||||
def _connect() -> sqlite3.Connection:
|
||||
"""Open the meetings DB, creating the schema on first use."""
|
||||
conn = sqlite3.connect(_db_path())
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS meetings (
|
||||
meeting_id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
source TEXT,
|
||||
metadata TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
return conn
|
||||
|
||||
|
||||
def _row_to_meeting(row: sqlite3.Row) -> Dict[str, Any]:
|
||||
"""Convert a DB row to a deterministic meeting dict (stable key order)."""
|
||||
try:
|
||||
metadata = json.loads(row["metadata"]) if row["metadata"] else {}
|
||||
except (ValueError, TypeError):
|
||||
metadata = {}
|
||||
return {
|
||||
"created_at": row["created_at"],
|
||||
"meeting_id": row["meeting_id"],
|
||||
"metadata": metadata,
|
||||
"source": row["source"],
|
||||
"title": row["title"],
|
||||
}
|
||||
|
||||
|
||||
# ── Lazy clients ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_openai_client():
|
||||
"""Lazily construct an OpenAI client. Raises clear errors on misconfig."""
|
||||
api_key = os.getenv("OPENAI_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"OPENAI_API_KEY is required. Set it in the environment before "
|
||||
"calling meeting transcription/summarisation/embedding tools."
|
||||
)
|
||||
try:
|
||||
from openai import OpenAI
|
||||
except ImportError as exc: # pragma: no cover - trivial
|
||||
raise ImportError(
|
||||
"openai package required. Install with: pip install praisonai-tools[meeting]"
|
||||
) from exc
|
||||
base_url = os.getenv("OPENAI_BASE_URL")
|
||||
if base_url:
|
||||
return OpenAI(api_key=api_key, base_url=base_url)
|
||||
return OpenAI(api_key=api_key)
|
||||
|
||||
|
||||
def _get_vector_collection():
|
||||
"""Lazily open (or create) the persistent Chroma collection."""
|
||||
try:
|
||||
import chromadb
|
||||
except ImportError as exc: # pragma: no cover - trivial
|
||||
raise ImportError(
|
||||
"chromadb package required. Install with: pip install praisonai-tools[meeting]"
|
||||
) from exc
|
||||
client = chromadb.PersistentClient(path=str(_app_dir() / "chroma"))
|
||||
return client.get_or_create_collection(_COLLECTION)
|
||||
|
||||
|
||||
def _embed(texts: List[str]) -> List[List[float]]:
|
||||
"""Embed a list of texts with the configured embedding model."""
|
||||
client = _get_openai_client()
|
||||
resp = client.embeddings.create(model=_EMBEDDING_MODEL, input=texts)
|
||||
return [item.embedding for item in resp.data]
|
||||
|
||||
|
||||
# ── Chunking helpers ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _chunk_spans(text: str, size_tokens: int, overlap_tokens: int) -> List[tuple]:
|
||||
"""Split text into overlapping windows, returning (chunk, char_start, char_end).
|
||||
|
||||
Uses a deterministic character-window approximation so no tokenizer
|
||||
dependency is needed. Character offsets refer to the pre-strip window and
|
||||
let callers map each chunk back to source positions (e.g. segment times).
|
||||
"""
|
||||
if not text:
|
||||
return []
|
||||
size = max(1, size_tokens * _CHARS_PER_TOKEN)
|
||||
overlap = max(0, min(overlap_tokens * _CHARS_PER_TOKEN, size - 1))
|
||||
step = size - overlap
|
||||
spans: List[tuple] = []
|
||||
start = 0
|
||||
length = len(text)
|
||||
while start < length:
|
||||
end = min(start + size, length)
|
||||
chunk = text[start:end].strip()
|
||||
if chunk:
|
||||
spans.append((chunk, start, end))
|
||||
start += step
|
||||
return spans
|
||||
|
||||
|
||||
def _chunk_text(text: str, size_tokens: int, overlap_tokens: int) -> List[str]:
|
||||
"""Split text into overlapping chunks by approximate token size.
|
||||
|
||||
Returns a list of non-empty chunk strings (see :func:`_chunk_spans`).
|
||||
"""
|
||||
return [chunk for chunk, _s, _e in _chunk_spans(text, size_tokens, overlap_tokens)]
|
||||
|
||||
|
||||
def _segment_char_offsets(segments: List[Dict[str, Any]]) -> List[tuple]:
|
||||
"""Return (char_start, char_end, seg_start, seg_end) for concatenated segments.
|
||||
|
||||
The transcript indexed here is assumed to be the segment texts joined by a
|
||||
single space, matching how ``transcribe_file`` emits ``segments``.
|
||||
"""
|
||||
offsets: List[tuple] = []
|
||||
pos = 0
|
||||
for i, seg in enumerate(segments):
|
||||
seg_text = str(seg.get("text", "")).strip()
|
||||
if i > 0:
|
||||
pos += 1 # the joining space
|
||||
char_start = pos
|
||||
pos += len(seg_text)
|
||||
offsets.append(
|
||||
(char_start, pos, float(seg.get("start", 0.0)), float(seg.get("end", 0.0)))
|
||||
)
|
||||
return offsets
|
||||
|
||||
|
||||
def _timestamps_for_span(
|
||||
char_start: int, char_end: int, seg_offsets: List[tuple]
|
||||
) -> tuple:
|
||||
"""Map a chunk's char span to the (start, end) time of the segments it covers."""
|
||||
if not seg_offsets:
|
||||
return 0.0, 0.0
|
||||
start_time: Optional[float] = None
|
||||
end_time: Optional[float] = None
|
||||
for cs, ce, s_start, s_end in seg_offsets:
|
||||
if ce > char_start and cs < char_end: # overlap
|
||||
if start_time is None:
|
||||
start_time = s_start
|
||||
end_time = s_end
|
||||
if start_time is None:
|
||||
return 0.0, 0.0
|
||||
return start_time, end_time if end_time is not None else start_time
|
||||
|
||||
|
||||
# ── LLM JSON helpers ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _chat_json(system: str, user: str) -> Dict[str, Any]:
|
||||
"""Call the chat model requesting a JSON object and parse it."""
|
||||
client = _get_openai_client()
|
||||
resp = client.chat.completions.create(
|
||||
model=_CHAT_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
response_format={"type": "json_object"},
|
||||
temperature=0,
|
||||
)
|
||||
content = resp.choices[0].message.content or "{}"
|
||||
try:
|
||||
return json.loads(content)
|
||||
except (ValueError, TypeError):
|
||||
return {}
|
||||
|
||||
|
||||
# ── 1. transcribe_file ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@tool
|
||||
def transcribe_file(media_path: str) -> Dict[str, Any]:
|
||||
"""Transcribe an audio/video file to text with segment timestamps.
|
||||
|
||||
Uses OpenAI Whisper (``whisper-1``) via the audio transcriptions API and
|
||||
returns word/segment level timing.
|
||||
|
||||
Args:
|
||||
media_path: Path to the audio or video file to transcribe.
|
||||
|
||||
Returns:
|
||||
Dict with ``text``, ``segments`` (start/end/text), ``duration_seconds``
|
||||
and ``language``. On failure returns ``{"error": "..."}`` with a clear
|
||||
message (missing file, API 403 key permissions, timeout, empty audio).
|
||||
"""
|
||||
path = Path(media_path)
|
||||
if not media_path or not path.exists():
|
||||
return {"error": f"Media file not found: {media_path}"}
|
||||
|
||||
try:
|
||||
client = _get_openai_client()
|
||||
except (ValueError, ImportError) as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
def _do_transcribe():
|
||||
with open(path, "rb") as audio_file:
|
||||
return client.audio.transcriptions.create(
|
||||
model=_WHISPER_MODEL,
|
||||
file=audio_file,
|
||||
response_format="verbose_json",
|
||||
timestamp_granularities=["segment"],
|
||||
)
|
||||
|
||||
try:
|
||||
response = _do_transcribe()
|
||||
except Exception as exc: # noqa: BLE001 - map to clear messages
|
||||
message = str(exc)
|
||||
status = getattr(exc, "status_code", None)
|
||||
exc_name = type(exc).__name__.lower()
|
||||
if status == 403 or "403" in message or "permission" in message.lower():
|
||||
return {"error": "Transcription failed: API key lacks permissions (403)."}
|
||||
if "timeout" in exc_name or "timeout" in message.lower():
|
||||
# Retry once, then fail with a clear message.
|
||||
try:
|
||||
response = _do_transcribe()
|
||||
except Exception: # noqa: BLE001
|
||||
return {"error": "Transcription failed: request timed out after retry."}
|
||||
else:
|
||||
return {"error": f"Transcription failed: {message}"}
|
||||
|
||||
text = (getattr(response, "text", "") or "").strip()
|
||||
segments_raw = getattr(response, "segments", None) or []
|
||||
segments = [
|
||||
{
|
||||
"end": float(getattr(seg, "end", 0.0)),
|
||||
"start": float(getattr(seg, "start", 0.0)),
|
||||
"text": (getattr(seg, "text", "") or "").strip(),
|
||||
}
|
||||
for seg in segments_raw
|
||||
]
|
||||
|
||||
if not text and not segments:
|
||||
return {"error": "No speech detected in recording"}
|
||||
|
||||
return {
|
||||
"duration_seconds": float(getattr(response, "duration", 0.0) or 0.0),
|
||||
"language": getattr(response, "language", "unknown") or "unknown",
|
||||
"segments": segments,
|
||||
"text": text,
|
||||
}
|
||||
|
||||
|
||||
# ── 2-4. Meetings CRUD ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@tool
|
||||
def save_meeting(
|
||||
title: str,
|
||||
source: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Persist a meeting record and return its generated ``meeting_id``.
|
||||
|
||||
Thin wrapper over the meetings persistence layer (SQLite by default).
|
||||
|
||||
Args:
|
||||
title: Human-readable meeting title.
|
||||
source: Optional origin of the meeting (e.g. file path, URL).
|
||||
metadata: Optional JSON-serialisable metadata dict.
|
||||
|
||||
Returns:
|
||||
Dict ``{"meeting_id": "<uuid>"}`` or ``{"error": "..."}``.
|
||||
"""
|
||||
if not title:
|
||||
return {"error": "title is required"}
|
||||
meeting_id = str(uuid.uuid4())
|
||||
from datetime import datetime, timezone
|
||||
|
||||
created_at = datetime.now(timezone.utc).isoformat()
|
||||
try:
|
||||
with closing(_connect()) as conn, conn:
|
||||
conn.execute(
|
||||
"INSERT INTO meetings (meeting_id, title, source, metadata, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
meeting_id,
|
||||
title,
|
||||
source,
|
||||
json.dumps(metadata or {}, sort_keys=True),
|
||||
created_at,
|
||||
),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("save_meeting error: %s", exc)
|
||||
return {"error": str(exc)}
|
||||
return {"meeting_id": meeting_id}
|
||||
|
||||
|
||||
@tool
|
||||
def get_meeting(meeting_id: str) -> Dict[str, Any]:
|
||||
"""Fetch a full meeting record by id.
|
||||
|
||||
Args:
|
||||
meeting_id: The meeting identifier returned by ``save_meeting``.
|
||||
|
||||
Returns:
|
||||
The meeting record dict, or ``{"error": "..."}`` if not found.
|
||||
"""
|
||||
if not meeting_id:
|
||||
return {"error": "meeting_id is required"}
|
||||
try:
|
||||
with closing(_connect()) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM meetings WHERE meeting_id = ?", (meeting_id,)
|
||||
).fetchone()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("get_meeting error: %s", exc)
|
||||
return {"error": str(exc)}
|
||||
if row is None:
|
||||
return {"error": f"Meeting not found: {meeting_id}"}
|
||||
return _row_to_meeting(row)
|
||||
|
||||
|
||||
@tool
|
||||
def list_meetings(limit: int = 20, offset: int = 0) -> List[Dict[str, Any]]:
|
||||
"""List stored meetings, most recent first.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of meetings to return (default 20).
|
||||
offset: Number of meetings to skip for pagination (default 0).
|
||||
|
||||
Returns:
|
||||
A list of meeting record dicts (possibly empty), or a single-element
|
||||
list ``[{"error": "..."}]`` on failure.
|
||||
"""
|
||||
try:
|
||||
limit = max(0, int(limit))
|
||||
offset = max(0, int(offset))
|
||||
with closing(_connect()) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM meetings ORDER BY created_at DESC, meeting_id DESC "
|
||||
"LIMIT ? OFFSET ?",
|
||||
(limit, offset),
|
||||
).fetchall()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("list_meetings error: %s", exc)
|
||||
return [{"error": str(exc)}]
|
||||
return [_row_to_meeting(r) for r in rows]
|
||||
|
||||
|
||||
# ── 5. summarize_transcript ─────────────────────────────────────────
|
||||
|
||||
|
||||
def _summarize_chunk(transcript: str) -> Dict[str, Any]:
|
||||
system = (
|
||||
"You are a meeting summariser. Return a JSON object with keys: "
|
||||
"'summary' (2-3 paragraph string), 'decisions' (array of strings), "
|
||||
"'topics' (array of strings), and 'key_quotes' (array of objects with "
|
||||
"'speaker' and 'quote'). Base output only on the transcript."
|
||||
)
|
||||
return _chat_json(system, transcript)
|
||||
|
||||
|
||||
def _merge_summaries(parts: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
merged_summary = "\n\n".join(
|
||||
str(p.get("summary", "")).strip() for p in parts if p.get("summary")
|
||||
)
|
||||
decisions: List[str] = []
|
||||
topics: List[str] = []
|
||||
quotes: List[Dict[str, Any]] = []
|
||||
for p in parts:
|
||||
for d in p.get("decisions", []) or []:
|
||||
if d not in decisions:
|
||||
decisions.append(d)
|
||||
for t in p.get("topics", []) or []:
|
||||
if t not in topics:
|
||||
topics.append(t)
|
||||
for q in p.get("key_quotes", []) or []:
|
||||
quotes.append(
|
||||
{"quote": q.get("quote", ""), "speaker": q.get("speaker")}
|
||||
)
|
||||
return {
|
||||
"decisions": decisions,
|
||||
"key_quotes": quotes,
|
||||
"summary": merged_summary,
|
||||
"topics": topics,
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def summarize_transcript(transcript: str) -> Dict[str, Any]:
|
||||
"""Summarise a meeting transcript into structured JSON.
|
||||
|
||||
For long transcripts a map-reduce strategy is used: the transcript is split
|
||||
into overlapping chunks, each is summarised, and the partial summaries are
|
||||
merged.
|
||||
|
||||
Args:
|
||||
transcript: The full plain-text transcript.
|
||||
|
||||
Returns:
|
||||
Dict with ``summary`` (str), ``decisions`` (list), ``topics`` (list) and
|
||||
``key_quotes`` (list of ``{speaker, quote}``). ``{"error": "..."}`` on
|
||||
failure.
|
||||
"""
|
||||
if not transcript or not transcript.strip():
|
||||
return {"error": "transcript is required"}
|
||||
try:
|
||||
chunks = _chunk_text(
|
||||
transcript, _SUMMARY_CHUNK_TOKENS, _SUMMARY_OVERLAP_TOKENS
|
||||
)
|
||||
if len(chunks) <= 1:
|
||||
result = _summarize_chunk(transcript)
|
||||
else:
|
||||
partials = [_summarize_chunk(c) for c in chunks]
|
||||
result = _merge_summaries(partials)
|
||||
except (ValueError, ImportError) as exc:
|
||||
return {"error": str(exc)}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("summarize_transcript error: %s", exc)
|
||||
return {"error": str(exc)}
|
||||
|
||||
return {
|
||||
"decisions": result.get("decisions", []) or [],
|
||||
"key_quotes": result.get("key_quotes", []) or [],
|
||||
"summary": result.get("summary", "") or "",
|
||||
"topics": result.get("topics", []) or [],
|
||||
}
|
||||
|
||||
|
||||
# ── 6. extract_action_items ─────────────────────────────────────────
|
||||
|
||||
|
||||
@tool
|
||||
def extract_action_items(transcript: str) -> Dict[str, Any]:
|
||||
"""Extract action items from a transcript as structured JSON.
|
||||
|
||||
Args:
|
||||
transcript: The full plain-text transcript.
|
||||
|
||||
Returns:
|
||||
Dict ``{"action_items": [{description, owner, due_date, priority}, ...]}``
|
||||
or ``{"error": "..."}`` on failure.
|
||||
"""
|
||||
if not transcript or not transcript.strip():
|
||||
return {"error": "transcript is required"}
|
||||
system = (
|
||||
"You extract action items from meeting transcripts. Return a JSON object "
|
||||
"with a single key 'action_items': an array of objects each with keys "
|
||||
"'description' (string), 'owner' (string or null), 'due_date' (ISO date "
|
||||
"string or null), and 'priority' (one of 'low', 'medium', 'high' or null). "
|
||||
"Only include real, actionable items."
|
||||
)
|
||||
try:
|
||||
result = _chat_json(system, transcript)
|
||||
except (ValueError, ImportError) as exc:
|
||||
return {"error": str(exc)}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("extract_action_items error: %s", exc)
|
||||
return {"error": str(exc)}
|
||||
|
||||
items = result.get("action_items", []) or []
|
||||
normalised = [
|
||||
{
|
||||
"description": it.get("description", ""),
|
||||
"due_date": it.get("due_date"),
|
||||
"owner": it.get("owner"),
|
||||
"priority": it.get("priority"),
|
||||
}
|
||||
for it in items
|
||||
if isinstance(it, dict)
|
||||
]
|
||||
return {"action_items": normalised}
|
||||
|
||||
|
||||
# ── 7. index_meeting ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@tool
|
||||
def index_meeting(
|
||||
meeting_id: str,
|
||||
transcript: Optional[str] = None,
|
||||
segments: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Chunk, embed and upsert a meeting transcript into the vector store.
|
||||
|
||||
Idempotent: existing chunks for ``meeting_id`` are replaced only *after* the
|
||||
new embeddings are computed, so a failed re-index never leaves the meeting
|
||||
unsearchable.
|
||||
|
||||
Args:
|
||||
meeting_id: The meeting identifier.
|
||||
transcript: Optional transcript text. If omitted, the transcript is read
|
||||
from the stored meeting metadata (``metadata['transcript']``).
|
||||
segments: Optional ``transcribe_file`` segments (``start``/``end``/``text``).
|
||||
When provided, each indexed chunk is tagged with the real
|
||||
``timestamp_start``/``timestamp_end`` of the segments it covers.
|
||||
Falls back to stored ``metadata['segments']`` when omitted.
|
||||
|
||||
Returns:
|
||||
Dict ``{"chunks_indexed": N}`` or ``{"error": "..."}``.
|
||||
"""
|
||||
if not meeting_id:
|
||||
return {"error": "meeting_id is required"}
|
||||
|
||||
meeting = get_meeting(meeting_id)
|
||||
if "error" in meeting:
|
||||
return meeting
|
||||
|
||||
text = transcript
|
||||
title = meeting.get("title", "")
|
||||
meta = meeting.get("metadata") or {}
|
||||
if not text:
|
||||
text = meta.get("transcript")
|
||||
if not text or not str(text).strip():
|
||||
return {"error": "No transcript available to index"}
|
||||
|
||||
if segments is None:
|
||||
segments = meta.get("segments")
|
||||
seg_offsets = _segment_char_offsets(segments) if segments else []
|
||||
|
||||
try:
|
||||
collection = _get_vector_collection()
|
||||
|
||||
spans = _chunk_spans(text, _CHUNK_SIZE_TOKENS, _CHUNK_OVERLAP_TOKENS)
|
||||
if not spans:
|
||||
return {"chunks_indexed": 0}
|
||||
|
||||
chunks = [c for c, _s, _e in spans]
|
||||
# Embed first; only replace existing chunks once embedding succeeds so a
|
||||
# failure never destroys the previously searchable version.
|
||||
embeddings = _embed(chunks)
|
||||
|
||||
ids = [f"{meeting_id}:{i}" for i in range(len(chunks))]
|
||||
metadatas = []
|
||||
for i, (_chunk, cs, ce) in enumerate(spans):
|
||||
ts_start, ts_end = _timestamps_for_span(cs, ce, seg_offsets)
|
||||
metadatas.append(
|
||||
{
|
||||
"chunk_index": i,
|
||||
"meeting_id": meeting_id,
|
||||
"timestamp_end": ts_end,
|
||||
"timestamp_start": ts_start,
|
||||
"title": title,
|
||||
}
|
||||
)
|
||||
|
||||
# Delete existing chunks for this meeting so re-index is idempotent.
|
||||
collection.delete(where={"meeting_id": meeting_id})
|
||||
collection.add(
|
||||
ids=ids,
|
||||
documents=chunks,
|
||||
embeddings=embeddings,
|
||||
metadatas=metadatas,
|
||||
)
|
||||
except (ValueError, ImportError) as exc:
|
||||
return {"error": str(exc)}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("index_meeting error: %s", exc)
|
||||
return {"error": str(exc)}
|
||||
|
||||
return {"chunks_indexed": len(chunks)}
|
||||
|
||||
|
||||
# ── 8. search_meetings ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@tool
|
||||
def search_meetings(query: str, limit: int = 5) -> List[Dict[str, Any]]:
|
||||
"""Semantic search across indexed meetings, returning ranked snippets.
|
||||
|
||||
Args:
|
||||
query: The natural-language search query.
|
||||
limit: Maximum number of results to return (default 5).
|
||||
|
||||
Returns:
|
||||
A list of ``{meeting_id, title, snippet, score, timestamp_start,
|
||||
timestamp_end}`` dicts ranked by relevance (best first), or
|
||||
``[{"error": "..."}]`` on failure.
|
||||
"""
|
||||
if not query or not query.strip():
|
||||
return [{"error": "query is required"}]
|
||||
try:
|
||||
limit = max(1, int(limit))
|
||||
collection = _get_vector_collection()
|
||||
query_embedding = _embed([query])[0]
|
||||
results = collection.query(
|
||||
query_embeddings=[query_embedding], n_results=limit
|
||||
)
|
||||
except (ValueError, ImportError) as exc:
|
||||
return [{"error": str(exc)}]
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("search_meetings error: %s", exc)
|
||||
return [{"error": str(exc)}]
|
||||
|
||||
documents = (results.get("documents") or [[]])[0]
|
||||
metadatas = (results.get("metadatas") or [[]])[0]
|
||||
distances = (results.get("distances") or [[]])[0]
|
||||
|
||||
items: List[Dict[str, Any]] = []
|
||||
for i, doc in enumerate(documents):
|
||||
meta = metadatas[i] if i < len(metadatas) else {}
|
||||
distance = distances[i] if i < len(distances) else None
|
||||
# Chroma's default space is squared-L2 (unbounded above). Map to a
|
||||
# bounded, monotonically decreasing relevance score in (0, 1].
|
||||
score = (
|
||||
round(1.0 / (1.0 + float(distance)), 6) if distance is not None else None
|
||||
)
|
||||
snippet = (doc or "")[:280]
|
||||
items.append(
|
||||
{
|
||||
"meeting_id": meta.get("meeting_id"),
|
||||
"score": score,
|
||||
"snippet": snippet,
|
||||
"timestamp_end": meta.get("timestamp_end", 0.0),
|
||||
"timestamp_start": meta.get("timestamp_start", 0.0),
|
||||
"title": meta.get("title"),
|
||||
}
|
||||
)
|
||||
return items
|
||||
@@ -0,0 +1,253 @@
|
||||
"""NEXUS Prediction Market Tool for PraisonAI
|
||||
|
||||
Provides access to live Kalshi/Polymarket prediction market data and arbitrage opportunities
|
||||
through the NEXUS API (https://nexus-agent-xa12.onrender.com).
|
||||
|
||||
Features:
|
||||
- Free market data queries
|
||||
- Paid arbitrage opportunity detection
|
||||
- Real-time prediction market insights
|
||||
|
||||
API Documentation:
|
||||
- Free endpoint: /kalshi?market={market_name}
|
||||
- Paid endpoint: /arb/check?markets={market1,market2}
|
||||
- Validation: https://agentic.market/validate?url=https://nexus-agent-xa12.onrender.com/arb/check
|
||||
"""
|
||||
|
||||
from typing import Optional, List, Dict, Any
|
||||
import requests
|
||||
import json
|
||||
from praisonai_tools.tools.base import BaseTool
|
||||
|
||||
|
||||
class NexusPredictionMarketTool(BaseTool):
|
||||
"""Tool for accessing NEXUS prediction market data and arbitrage opportunities."""
|
||||
|
||||
name: str = "nexus_prediction_market"
|
||||
description: str = """Access live Kalshi/Polymarket prediction market data and arbitrage opportunities.
|
||||
|
||||
Features:
|
||||
- Query free market data for specific markets (Fed, BTC, etc.)
|
||||
- Check arbitrage opportunities between prediction markets (requires payment)
|
||||
- Get real-time market insights and pricing data
|
||||
|
||||
Use cases:
|
||||
- Market research and analysis
|
||||
- Arbitrage opportunity identification
|
||||
- Prediction market trend monitoring
|
||||
"""
|
||||
|
||||
def __init__(self, source_name: Optional[str] = None, api_key: Optional[str] = None):
|
||||
"""Initialize the NEXUS Prediction Market Tool.
|
||||
|
||||
Args:
|
||||
source_name: Optional source identifier for tracking (sent as X-NEXUS-Source header)
|
||||
api_key: Optional API key for paid features (if implemented)
|
||||
"""
|
||||
super().__init__()
|
||||
self.base_url = "https://nexus-agent-xa12.onrender.com"
|
||||
self.source_name = source_name or "praisonai-agent"
|
||||
self.api_key = api_key
|
||||
self.headers = {
|
||||
"X-NEXUS-Source": self.source_name,
|
||||
"User-Agent": "PraisonAI-NEXUS-Tool/1.0",
|
||||
"Accept": "application/json"
|
||||
}
|
||||
if self.api_key:
|
||||
self.headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
|
||||
def get_market_data(self, market: str) -> Dict[str, Any]:
|
||||
"""Get free market data for a specific prediction market.
|
||||
|
||||
Args:
|
||||
market: Market identifier (e.g., 'Fed', 'BTC', 'Election')
|
||||
|
||||
Returns:
|
||||
Dict containing market data including prices, volumes, and trends
|
||||
"""
|
||||
try:
|
||||
url = f"{self.base_url}/kalshi"
|
||||
params = {"market": market}
|
||||
|
||||
response = requests.get(url, params=params, headers=self.headers, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"market": market,
|
||||
"data": response.json(),
|
||||
"source": "nexus-free-api"
|
||||
}
|
||||
|
||||
except requests.RequestException as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Failed to fetch market data: {str(e)}",
|
||||
"market": market
|
||||
}
|
||||
except json.JSONDecodeError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Invalid JSON response: {str(e)}",
|
||||
"market": market
|
||||
}
|
||||
|
||||
def check_arbitrage_opportunities(self, markets: List[str]) -> Dict[str, Any]:
|
||||
"""Check for arbitrage opportunities between prediction markets (paid feature).
|
||||
|
||||
Args:
|
||||
markets: List of market identifiers to analyze for arbitrage
|
||||
|
||||
Returns:
|
||||
Dict containing arbitrage opportunities and analysis
|
||||
|
||||
Note:
|
||||
This is a paid feature requiring $0.02 USDC on eip155:8453
|
||||
"""
|
||||
try:
|
||||
url = f"{self.base_url}/arb/check"
|
||||
markets_param = ",".join(markets)
|
||||
params = {"markets": markets_param}
|
||||
|
||||
response = requests.get(url, params=params, headers=self.headers, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"markets": markets,
|
||||
"arbitrage_data": response.json(),
|
||||
"source": "nexus-paid-api",
|
||||
"note": "This is a paid feature ($0.02 USDC on eip155:8453)"
|
||||
}
|
||||
|
||||
except requests.RequestException as e:
|
||||
if e.response and e.response.status_code == 402:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Payment required for arbitrage analysis. Cost: $0.02 USDC on eip155:8453",
|
||||
"markets": markets,
|
||||
"payment_info": "This feature requires payment on eip155:8453 blockchain"
|
||||
}
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Failed to check arbitrage opportunities: {str(e)}",
|
||||
"markets": markets
|
||||
}
|
||||
except json.JSONDecodeError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Invalid JSON response: {str(e)}",
|
||||
"markets": markets
|
||||
}
|
||||
|
||||
def get_agent_info(self) -> Dict[str, Any]:
|
||||
"""Get NEXUS agent information and capabilities.
|
||||
|
||||
Returns:
|
||||
Dict containing agent metadata and API information
|
||||
"""
|
||||
try:
|
||||
url = f"{self.base_url}/.well-known/agent.json"
|
||||
|
||||
response = requests.get(url, headers=self.headers, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"agent_info": response.json(),
|
||||
"source": "nexus-agent-metadata"
|
||||
}
|
||||
|
||||
except requests.RequestException as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Failed to fetch agent info: {str(e)}"
|
||||
}
|
||||
except json.JSONDecodeError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Invalid JSON response: {str(e)}"
|
||||
}
|
||||
|
||||
def run(self,
|
||||
action: str,
|
||||
market: Optional[str] = None,
|
||||
markets: Optional[List[str]] = None) -> str:
|
||||
"""Execute NEXUS prediction market operations.
|
||||
|
||||
Args:
|
||||
action: Action to perform ('get_market_data', 'check_arbitrage', 'get_agent_info')
|
||||
market: Single market identifier (for get_market_data)
|
||||
markets: List of markets (for check_arbitrage)
|
||||
|
||||
Returns:
|
||||
JSON string with operation results
|
||||
"""
|
||||
try:
|
||||
if action == "get_market_data":
|
||||
if not market:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": "Market parameter required for get_market_data action"
|
||||
})
|
||||
result = self.get_market_data(market)
|
||||
|
||||
elif action == "check_arbitrage":
|
||||
if not markets or not isinstance(markets, list):
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": "Markets parameter (list) required for check_arbitrage action"
|
||||
})
|
||||
result = self.check_arbitrage_opportunities(markets)
|
||||
|
||||
elif action == "get_agent_info":
|
||||
result = self.get_agent_info()
|
||||
|
||||
else:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": f"Unknown action: {action}. Available actions: get_market_data, check_arbitrage, get_agent_info"
|
||||
})
|
||||
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": f"Unexpected error: {str(e)}"
|
||||
})
|
||||
|
||||
|
||||
# For backward compatibility and ease of use
|
||||
def nexus_prediction_market_tool(
|
||||
action: str,
|
||||
market: Optional[str] = None,
|
||||
markets: Optional[List[str]] = None,
|
||||
source_name: Optional[str] = None
|
||||
) -> str:
|
||||
"""Function-based interface for NEXUS prediction market operations.
|
||||
|
||||
Args:
|
||||
action: Action to perform ('get_market_data', 'check_arbitrage', 'get_agent_info')
|
||||
market: Single market identifier (for get_market_data)
|
||||
markets: List of markets (for check_arbitrage)
|
||||
source_name: Optional source identifier for tracking
|
||||
|
||||
Returns:
|
||||
JSON string with operation results
|
||||
|
||||
Examples:
|
||||
# Get Fed market data
|
||||
result = nexus_prediction_market_tool("get_market_data", market="Fed")
|
||||
|
||||
# Check arbitrage between Fed and BTC markets
|
||||
result = nexus_prediction_market_tool("check_arbitrage", markets=["Fed", "BTC"])
|
||||
|
||||
# Get agent info
|
||||
result = nexus_prediction_market_tool("get_agent_info")
|
||||
"""
|
||||
tool = NexusPredictionMarketTool(source_name=source_name)
|
||||
return tool.run(action=action, market=market, markets=markets)
|
||||
|
||||
|
||||
__all__ = ["NexusPredictionMarketTool", "nexus_prediction_market_tool"]
|
||||
@@ -97,5 +97,18 @@ class PinchworkTool(BaseTool):
|
||||
|
||||
|
||||
def pinchwork_delegate(task: str, skills_required: Optional[List[str]] = None, budget: float = 0.0) -> str:
|
||||
"""Delegate a task to the Pinchwork agent marketplace."""
|
||||
"""Delegate a task to the Pinchwork agent marketplace.
|
||||
|
||||
Args:
|
||||
task: Description of the task to delegate.
|
||||
skills_required: List of required skills for the agent (optional).
|
||||
budget: Maximum budget for the task (default: 0.0).
|
||||
|
||||
Returns:
|
||||
On success, the result string from the marketplace agent that
|
||||
completed the task. On failure, an ``"Error: ..."`` message string
|
||||
describing the problem (missing task input, the ``httpx`` dependency
|
||||
being unavailable, a connection or HTTP status error, or an
|
||||
unexpected exception).
|
||||
"""
|
||||
return PinchworkTool().delegate(task=task, skills_required=skills_required, budget=budget)
|
||||
@@ -17,6 +17,17 @@ Environment Variables:
|
||||
REDDIT_CLIENT_ID: Reddit API client ID
|
||||
REDDIT_CLIENT_SECRET: Reddit API client secret
|
||||
REDDIT_USER_AGENT: User agent string (optional)
|
||||
REDDIT_USERNAME: Reddit username (required for write actions)
|
||||
REDDIT_PASSWORD: Reddit password (required for write actions)
|
||||
REDDIT_ALLOW_WRITE: Set to "1" to enable write actions (post/comment)
|
||||
|
||||
Write actions (submit_post, post_comment) are disabled by default to reduce
|
||||
spam/abuse surface. They are only permitted when ``REDDIT_ALLOW_WRITE=1`` (or
|
||||
``allow_write=True``) and Reddit account credentials are configured. You are
|
||||
responsible for complying with Reddit's API terms when enabling writes.
|
||||
|
||||
Reddit rate-limits misconfigured clients aggressively. Use a descriptive
|
||||
User-Agent referencing your app id, e.g. ``script:my-app:v1.0 (by /u/you)``.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -32,13 +43,20 @@ class RedditTool(BaseTool):
|
||||
"""Tool for interacting with Reddit."""
|
||||
|
||||
name = "reddit"
|
||||
description = "Search Reddit, get posts from subreddits, and read comments."
|
||||
description = (
|
||||
"Search Reddit, get posts from subreddits, and read comments. "
|
||||
"Can also comment on posts and submit new posts when write actions "
|
||||
"are enabled (REDDIT_ALLOW_WRITE=1 with account credentials)."
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client_id: Optional[str] = None,
|
||||
client_secret: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
allow_write: Optional[bool] = None,
|
||||
):
|
||||
"""Initialize RedditTool.
|
||||
|
||||
@@ -46,13 +64,30 @@ class RedditTool(BaseTool):
|
||||
client_id: Reddit API client ID
|
||||
client_secret: Reddit API client secret
|
||||
user_agent: User agent string
|
||||
username: Reddit username (required for write actions)
|
||||
password: Reddit password (required for write actions)
|
||||
allow_write: Enable write actions. Defaults to the
|
||||
``REDDIT_ALLOW_WRITE`` env var ("1"/"true" enables).
|
||||
"""
|
||||
self.client_id = client_id or os.getenv("REDDIT_CLIENT_ID")
|
||||
self.client_secret = client_secret or os.getenv("REDDIT_CLIENT_SECRET")
|
||||
self.user_agent = user_agent or os.getenv("REDDIT_USER_AGENT", "PraisonAI/1.0")
|
||||
self.user_agent = user_agent or os.getenv("REDDIT_USER_AGENT") or self._default_user_agent()
|
||||
self.username = username or os.getenv("REDDIT_USERNAME")
|
||||
self.password = password or os.getenv("REDDIT_PASSWORD")
|
||||
if allow_write is None:
|
||||
allow_write = os.getenv("REDDIT_ALLOW_WRITE", "").lower() in ("1", "true", "yes")
|
||||
self.allow_write = allow_write
|
||||
self._reddit = None
|
||||
super().__init__()
|
||||
|
||||
def _default_user_agent(self) -> str:
|
||||
"""Build a compliant User-Agent referencing the app id.
|
||||
|
||||
Reddit recommends ``<platform>:<app id>:<version> (by /u/<user>)``.
|
||||
"""
|
||||
app_id = self.client_id or "unknown"
|
||||
return f"script:praisonai-{app_id}:1.0 (by PraisonAI)"
|
||||
|
||||
@property
|
||||
def reddit(self):
|
||||
"""Lazy-load Reddit client."""
|
||||
@@ -65,13 +100,31 @@ class RedditTool(BaseTool):
|
||||
if not self.client_id or not self.client_secret:
|
||||
raise ValueError("REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET required")
|
||||
|
||||
self._reddit = praw.Reddit(
|
||||
client_id=self.client_id,
|
||||
client_secret=self.client_secret,
|
||||
user_agent=self.user_agent,
|
||||
)
|
||||
kwargs = {
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"user_agent": self.user_agent,
|
||||
}
|
||||
if self.username and self.password:
|
||||
kwargs["username"] = self.username
|
||||
kwargs["password"] = self.password
|
||||
|
||||
self._reddit = praw.Reddit(**kwargs)
|
||||
return self._reddit
|
||||
|
||||
def _check_write_allowed(self) -> Optional[Dict[str, Any]]:
|
||||
"""Return an error dict if write actions are not permitted, else None."""
|
||||
if not self.allow_write:
|
||||
return {
|
||||
"error": "Write actions disabled. Set REDDIT_ALLOW_WRITE=1 to enable "
|
||||
"posting/commenting.",
|
||||
}
|
||||
if not self.username or not self.password:
|
||||
return {
|
||||
"error": "REDDIT_USERNAME and REDDIT_PASSWORD required for write actions.",
|
||||
}
|
||||
return None
|
||||
|
||||
def run(
|
||||
self,
|
||||
action: str = "search",
|
||||
@@ -98,6 +151,15 @@ class RedditTool(BaseTool):
|
||||
return self.get_comments(post_id=post_id, limit=limit)
|
||||
elif action == "subreddit_info":
|
||||
return self.get_subreddit_info(subreddit=subreddit)
|
||||
elif action == "post_comment":
|
||||
return self.post_comment(post_id=post_id, body=kwargs.get("body"))
|
||||
elif action == "submit_post":
|
||||
return self.submit_post(
|
||||
subreddit=subreddit,
|
||||
title=kwargs.get("title"),
|
||||
body=kwargs.get("body"),
|
||||
url=kwargs.get("url"),
|
||||
)
|
||||
else:
|
||||
return {"error": f"Unknown action: {action}"}
|
||||
|
||||
@@ -284,6 +346,83 @@ class RedditTool(BaseTool):
|
||||
logger.error(f"Reddit get_subreddit_info error: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
def post_comment(self, post_id: str, body: str) -> Dict[str, Any]:
|
||||
"""Reply with a comment to a post (write action).
|
||||
|
||||
Gated behind ``REDDIT_ALLOW_WRITE=1`` and account credentials.
|
||||
|
||||
Args:
|
||||
post_id: Reddit post ID to comment on
|
||||
body: Comment text
|
||||
|
||||
Returns:
|
||||
Result with the new comment id, or an error dict
|
||||
"""
|
||||
denied = self._check_write_allowed()
|
||||
if denied:
|
||||
return denied
|
||||
if not post_id:
|
||||
return {"error": "post_id is required"}
|
||||
if not body:
|
||||
return {"error": "body is required"}
|
||||
|
||||
try:
|
||||
submission = self.reddit.submission(id=post_id)
|
||||
comment = submission.reply(body)
|
||||
return {
|
||||
"success": True,
|
||||
"id": comment.id,
|
||||
"permalink": f"https://reddit.com{comment.permalink}",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Reddit post_comment error: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
def submit_post(
|
||||
self,
|
||||
subreddit: str,
|
||||
title: str,
|
||||
body: Optional[str] = None,
|
||||
url: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Submit a new post to a subreddit (write action).
|
||||
|
||||
Gated behind ``REDDIT_ALLOW_WRITE=1`` and account credentials.
|
||||
|
||||
Args:
|
||||
subreddit: Target subreddit name
|
||||
title: Post title
|
||||
body: Self-post text (mutually exclusive with url)
|
||||
url: Link URL (mutually exclusive with body)
|
||||
|
||||
Returns:
|
||||
Result with the new post id, or an error dict
|
||||
"""
|
||||
denied = self._check_write_allowed()
|
||||
if denied:
|
||||
return denied
|
||||
if not subreddit:
|
||||
return {"error": "subreddit is required"}
|
||||
if not title:
|
||||
return {"error": "title is required"}
|
||||
if url and body:
|
||||
return {"error": "provide either body or url, not both"}
|
||||
|
||||
try:
|
||||
sub = self.reddit.subreddit(subreddit)
|
||||
if url:
|
||||
submission = sub.submit(title, url=url)
|
||||
else:
|
||||
submission = sub.submit(title, selftext=body or "")
|
||||
return {
|
||||
"success": True,
|
||||
"id": submission.id,
|
||||
"permalink": f"https://reddit.com{submission.permalink}",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Reddit submit_post error: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
def _format_post(self, post, include_body: bool = False) -> Dict[str, Any]:
|
||||
"""Format a Reddit post."""
|
||||
result = {
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
"""Stripe Tool for PraisonAI Agents.
|
||||
|
||||
Narrow, audited Stripe operations — retrieve customers/payment intents,
|
||||
list payment links, and create payment links with required correlation
|
||||
metadata and optional idempotency keys.
|
||||
|
||||
Design constraint: prefer narrow, audited operations over a "god tool"
|
||||
that dumps the entire Stripe surface. One verb per action.
|
||||
|
||||
Usage:
|
||||
from praisonai_tools import StripeTool
|
||||
|
||||
stripe = StripeTool() # reads STRIPE_SECRET_KEY (or STRIPE_API_KEY)
|
||||
customer = stripe.run(action="get_customer", customer_id="cus_123")
|
||||
link = stripe.run(
|
||||
action="create_payment_link",
|
||||
price_id="price_123",
|
||||
quantity=1,
|
||||
correlation_id="order-42",
|
||||
)
|
||||
|
||||
Environment Variables:
|
||||
STRIPE_SECRET_KEY: Stripe secret key. Restricted keys are recommended.
|
||||
Accepts test (``sk_test_``/``rk_test_``) or live
|
||||
(``sk_live_``/``rk_live_``) keys. STRIPE_API_KEY is also accepted
|
||||
as a fallback.
|
||||
|
||||
Security notes:
|
||||
* Secrets are read from the environment only and never logged.
|
||||
* Responses include a ``mode`` banner (``test``/``live``) derived from
|
||||
the key prefix so live operations are visible to callers.
|
||||
* Structured logs record only the operation, mode, idempotency key, and
|
||||
the Stripe object id — never full request/response payloads.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from praisonai_tools.tools.base import BaseTool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
API_BASE = "https://api.stripe.com/v1"
|
||||
|
||||
_TEST_PREFIXES = ("sk_test_", "rk_test_")
|
||||
_LIVE_PREFIXES = ("sk_live_", "rk_live_")
|
||||
|
||||
|
||||
class StripeTool(BaseTool):
|
||||
"""Tool for narrow, audited Stripe operations."""
|
||||
|
||||
name = "stripe"
|
||||
description = (
|
||||
"Retrieve Stripe customers and payment intents, list payment links, "
|
||||
"and create payment links with required correlation metadata."
|
||||
)
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None):
|
||||
self.api_key = (
|
||||
api_key
|
||||
or os.getenv("STRIPE_SECRET_KEY")
|
||||
or os.getenv("STRIPE_API_KEY")
|
||||
)
|
||||
super().__init__()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _mode(self) -> str:
|
||||
"""Return ``test``, ``live``, or ``unknown`` from the key prefix."""
|
||||
key = self.api_key or ""
|
||||
if key.startswith(_LIVE_PREFIXES):
|
||||
return "live"
|
||||
if key.startswith(_TEST_PREFIXES):
|
||||
return "test"
|
||||
return "unknown"
|
||||
|
||||
def _require_key(self) -> Optional[Dict[str, str]]:
|
||||
if not self.api_key:
|
||||
return {"error": "STRIPE_SECRET_KEY (or STRIPE_API_KEY) is required"}
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _import_requests():
|
||||
try:
|
||||
import requests
|
||||
return requests
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
data: Optional[Dict[str, Any]] = None,
|
||||
idempotency_key: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Make a request to the Stripe REST API.
|
||||
|
||||
Centralises key validation, the optional ``requests`` import, the
|
||||
HTTP call, auditable logging, and error handling.
|
||||
"""
|
||||
err = self._require_key()
|
||||
if err:
|
||||
return err
|
||||
|
||||
requests = self._import_requests()
|
||||
if requests is None:
|
||||
return {"error": "requests package is not installed"}
|
||||
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||
if idempotency_key:
|
||||
headers["Idempotency-Key"] = idempotency_key
|
||||
|
||||
try:
|
||||
resp = requests.request(
|
||||
method,
|
||||
f"{API_BASE}/{endpoint}",
|
||||
headers=headers,
|
||||
data=data,
|
||||
timeout=30,
|
||||
)
|
||||
body = resp.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error("Stripe request error: %s", e)
|
||||
return {"error": f"API request failed: {e}"}
|
||||
except ValueError as e:
|
||||
logger.error("Stripe JSON decode error: %s", e)
|
||||
return {"error": "Failed to decode API response"}
|
||||
|
||||
if isinstance(body, dict) and "error" in body:
|
||||
stripe_err = body["error"]
|
||||
message = stripe_err.get("message", "Stripe API error")
|
||||
logger.error(
|
||||
"Stripe API error: op=%s mode=%s type=%s",
|
||||
endpoint, self._mode(), stripe_err.get("type"),
|
||||
)
|
||||
return {"error": message}
|
||||
|
||||
# Defensive: a non-2xx status without Stripe's usual ``error`` object
|
||||
# must not be treated as a successful payload.
|
||||
if resp.status_code >= 400:
|
||||
logger.error(
|
||||
"Stripe HTTP error: op=%s mode=%s status=%s",
|
||||
endpoint, self._mode(), resp.status_code,
|
||||
)
|
||||
return {"error": f"Stripe API returned HTTP {resp.status_code}"}
|
||||
|
||||
obj_id = body.get("id") if isinstance(body, dict) else None
|
||||
logger.info(
|
||||
"Stripe op=%s mode=%s idempotency_key=%s object_id=%s",
|
||||
endpoint, self._mode(), idempotency_key, obj_id,
|
||||
)
|
||||
return body
|
||||
|
||||
def _banner(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Attach a live/test mode banner to a successful response."""
|
||||
if isinstance(payload, dict) and "error" not in payload:
|
||||
payload = {"mode": self._mode(), **payload}
|
||||
return payload
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dispatcher
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def run(
|
||||
self,
|
||||
action: str = "get_customer",
|
||||
customer_id: Optional[str] = None,
|
||||
payment_intent_id: Optional[str] = None,
|
||||
price_id: Optional[str] = None,
|
||||
quantity: int = 1,
|
||||
correlation_id: Optional[str] = None,
|
||||
idempotency_key: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
**kwargs,
|
||||
) -> Union[Dict[str, Any], List[Dict[str, Any]]]:
|
||||
"""Dispatch to the appropriate Stripe action.
|
||||
|
||||
Actions:
|
||||
get_customer — Retrieve a customer by id (read).
|
||||
get_payment_intent — Retrieve a payment intent by id (read).
|
||||
list_payment_links — List payment links (read).
|
||||
create_payment_link — Create a payment link (write, guarded).
|
||||
"""
|
||||
action = action.lower().replace("-", "_")
|
||||
|
||||
if action == "get_customer":
|
||||
return self.get_customer(customer_id=customer_id)
|
||||
elif action == "get_payment_intent":
|
||||
return self.get_payment_intent(payment_intent_id=payment_intent_id)
|
||||
elif action == "list_payment_links":
|
||||
return self.list_payment_links(limit=limit)
|
||||
elif action == "create_payment_link":
|
||||
return self.create_payment_link(
|
||||
price_id=price_id,
|
||||
quantity=quantity,
|
||||
correlation_id=correlation_id,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
else:
|
||||
return {"error": f"Unknown action: {action}"}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Phase 1 — Read-mostly safety
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_customer(self, customer_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Retrieve a customer by id.
|
||||
|
||||
Args:
|
||||
customer_id: The Stripe customer id (``cus_...``).
|
||||
"""
|
||||
if not customer_id:
|
||||
return {"error": "customer_id is required"}
|
||||
|
||||
result = self._request("get", f"customers/{customer_id}")
|
||||
if "error" in result:
|
||||
return result
|
||||
|
||||
return self._banner({
|
||||
"id": result.get("id"),
|
||||
"email": result.get("email"),
|
||||
"name": result.get("name"),
|
||||
"created": result.get("created"),
|
||||
"currency": result.get("currency"),
|
||||
"delinquent": result.get("delinquent"),
|
||||
})
|
||||
|
||||
def get_payment_intent(
|
||||
self, payment_intent_id: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Retrieve a payment intent by id.
|
||||
|
||||
Args:
|
||||
payment_intent_id: The Stripe payment intent id (``pi_...``).
|
||||
"""
|
||||
if not payment_intent_id:
|
||||
return {"error": "payment_intent_id is required"}
|
||||
|
||||
result = self._request("get", f"payment_intents/{payment_intent_id}")
|
||||
if "error" in result:
|
||||
return result
|
||||
|
||||
return self._banner({
|
||||
"id": result.get("id"),
|
||||
"amount": result.get("amount"),
|
||||
"currency": result.get("currency"),
|
||||
"status": result.get("status"),
|
||||
"customer": result.get("customer"),
|
||||
"created": result.get("created"),
|
||||
})
|
||||
|
||||
def list_payment_links(self, limit: int = 10) -> Union[Dict[str, Any], List[Dict[str, Any]]]:
|
||||
"""List payment links.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of payment links to return (1–100).
|
||||
"""
|
||||
limit = max(1, min(int(limit), 100))
|
||||
result = self._request("get", f"payment_links?limit={limit}")
|
||||
if "error" in result:
|
||||
return result
|
||||
|
||||
links = [
|
||||
{
|
||||
"id": link.get("id"),
|
||||
"url": link.get("url"),
|
||||
"active": link.get("active"),
|
||||
"metadata": link.get("metadata", {}),
|
||||
}
|
||||
for link in result.get("data", [])
|
||||
]
|
||||
return self._banner({"payment_links": links})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Phase 2 — Mutations with guardrails
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def create_payment_link(
|
||||
self,
|
||||
price_id: Optional[str] = None,
|
||||
quantity: int = 1,
|
||||
correlation_id: Optional[str] = None,
|
||||
idempotency_key: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a payment link.
|
||||
|
||||
A ``correlation_id`` is required and stored as payment-link metadata
|
||||
so every created link is traceable back to the request that made it.
|
||||
An optional ``idempotency_key`` is surfaced to make retries safe.
|
||||
|
||||
Args:
|
||||
price_id: An existing Stripe Price id (``price_...``).
|
||||
quantity: Quantity for the line item (>= 1).
|
||||
correlation_id: Required correlation id stored as metadata.
|
||||
idempotency_key: Optional Stripe ``Idempotency-Key`` header value.
|
||||
"""
|
||||
if not price_id:
|
||||
return {"error": "price_id is required"}
|
||||
if not correlation_id:
|
||||
return {"error": "correlation_id is required (stored as metadata)"}
|
||||
try:
|
||||
quantity = int(quantity)
|
||||
except (TypeError, ValueError):
|
||||
return {"error": "quantity must be an integer"}
|
||||
if quantity < 1:
|
||||
return {"error": "quantity must be >= 1"}
|
||||
|
||||
data = {
|
||||
"line_items[0][price]": price_id,
|
||||
"line_items[0][quantity]": quantity,
|
||||
"metadata[correlation_id]": correlation_id,
|
||||
}
|
||||
result = self._request(
|
||||
"post",
|
||||
"payment_links",
|
||||
data=data,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
if "error" in result:
|
||||
return result
|
||||
|
||||
return self._banner({
|
||||
"id": result.get("id"),
|
||||
"url": result.get("url"),
|
||||
"active": result.get("active"),
|
||||
"metadata": result.get("metadata", {}),
|
||||
})
|
||||
|
||||
|
||||
def get_stripe_customer(
|
||||
customer_id: str, api_key: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Retrieve a Stripe customer by id."""
|
||||
return StripeTool(api_key=api_key).get_customer(customer_id=customer_id)
|
||||
@@ -0,0 +1,259 @@
|
||||
"""SwarmScore integration tool for PraisonAI.
|
||||
|
||||
SwarmScore is a portable trust rating system for AI agents based on verified execution history.
|
||||
This tool allows agents to:
|
||||
- Load their SwarmScore rating and certificate
|
||||
- Verify SwarmScore freshness
|
||||
- Access discovery manifest for agent-to-agent lookup
|
||||
|
||||
More information: https://swarmsync.ai/docs/protocol-specs/swarmscore
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
from urllib.parse import urlparse, quote
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
requests = None
|
||||
|
||||
from .base import BaseTool, ToolResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SwarmScoreTool(BaseTool):
|
||||
"""Tool for integrating with SwarmScore trust ratings."""
|
||||
|
||||
name = "swarmscore"
|
||||
description = "Load and verify SwarmScore trust ratings for AI agents"
|
||||
|
||||
def __init__(self, api_base_url: str = "https://api.swarmsync.ai/v1/swarmscore/"):
|
||||
"""Initialize SwarmScore tool.
|
||||
|
||||
Args:
|
||||
api_base_url: Base URL for SwarmScore API
|
||||
"""
|
||||
if requests is None:
|
||||
raise ImportError("requests is required for SwarmScoreTool. Install with: pip install requests")
|
||||
|
||||
super().__init__()
|
||||
self.api_base_url = api_base_url.rstrip('/')
|
||||
|
||||
def load_swarmscore(self, slug: str) -> ToolResult:
|
||||
"""Load SwarmScore data by agent slug.
|
||||
|
||||
Args:
|
||||
slug: Agent slug identifier
|
||||
|
||||
Returns:
|
||||
ToolResult containing SwarmScore data including passport, certificate, and verification payload
|
||||
"""
|
||||
try:
|
||||
url = f"{self.api_base_url}/load-by-slug/{quote(slug, safe='')}"
|
||||
response = requests.get(url, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
return ToolResult(
|
||||
output=data,
|
||||
success=True,
|
||||
metadata={"message": f"Successfully loaded SwarmScore for agent '{slug}'"}
|
||||
)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Invalid JSON response for SwarmScore {slug}: {e}")
|
||||
return ToolResult(
|
||||
output=None,
|
||||
success=False,
|
||||
error="Invalid response format from SwarmScore API"
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to load SwarmScore for {slug}: {e}")
|
||||
return ToolResult(
|
||||
output=None,
|
||||
success=False,
|
||||
error=f"Failed to load SwarmScore: {str(e)}"
|
||||
)
|
||||
|
||||
def verify_swarmscore(self, verify_payload: Dict[str, Any]) -> ToolResult:
|
||||
"""Verify SwarmScore freshness using verification payload.
|
||||
|
||||
Args:
|
||||
verify_payload: Verification payload from load_swarmscore response
|
||||
|
||||
Returns:
|
||||
ToolResult containing verification status
|
||||
"""
|
||||
try:
|
||||
url = f"{self.api_base_url}/verify"
|
||||
response = requests.post(
|
||||
url,
|
||||
json=verify_payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=30
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
return ToolResult(
|
||||
output=data,
|
||||
success=True,
|
||||
metadata={"message": "SwarmScore verification completed"}
|
||||
)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Invalid JSON response for SwarmScore verification: {e}")
|
||||
return ToolResult(
|
||||
output=None,
|
||||
success=False,
|
||||
error="Invalid response format from verification API"
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to verify SwarmScore: {e}")
|
||||
return ToolResult(
|
||||
output=None,
|
||||
success=False,
|
||||
error=f"Failed to verify SwarmScore: {str(e)}"
|
||||
)
|
||||
|
||||
def get_discovery_manifest(self) -> ToolResult:
|
||||
"""Get machine-readable agent discovery manifest.
|
||||
|
||||
Returns:
|
||||
ToolResult containing discovery manifest data
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(self.api_base_url)
|
||||
base = f"{parsed.scheme}://{parsed.netloc}"
|
||||
url = f"{base}/.well-known/agent-card.json"
|
||||
response = requests.get(url, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
return ToolResult(
|
||||
output=data,
|
||||
success=True,
|
||||
metadata={"message": "Retrieved SwarmScore discovery manifest"}
|
||||
)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Invalid JSON in discovery manifest: {e}")
|
||||
return ToolResult(
|
||||
output=None,
|
||||
success=False,
|
||||
error="Invalid discovery manifest format"
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to get discovery manifest: {e}")
|
||||
return ToolResult(
|
||||
output=None,
|
||||
success=False,
|
||||
error=f"Failed to get discovery manifest: {str(e)}"
|
||||
)
|
||||
|
||||
def run(self, action: str = "load", **kwargs) -> ToolResult:
|
||||
"""Execute SwarmScore action.
|
||||
|
||||
Args:
|
||||
action: Action to perform ('load', 'verify', or 'discover')
|
||||
**kwargs: Action-specific parameters
|
||||
|
||||
Returns:
|
||||
ToolResult with action results
|
||||
"""
|
||||
if action == "load":
|
||||
slug = kwargs.get("slug")
|
||||
if not slug:
|
||||
return ToolResult(
|
||||
output=None,
|
||||
success=False,
|
||||
error="Missing required parameter 'slug' for load action"
|
||||
)
|
||||
return self.load_swarmscore(slug)
|
||||
|
||||
elif action == "verify":
|
||||
verify_payload = kwargs.get("verify_payload")
|
||||
if not verify_payload:
|
||||
return ToolResult(
|
||||
output=None,
|
||||
success=False,
|
||||
error="Missing required parameter 'verify_payload' for verify action"
|
||||
)
|
||||
return self.verify_swarmscore(verify_payload)
|
||||
|
||||
elif action == "discover":
|
||||
return self.get_discovery_manifest()
|
||||
|
||||
else:
|
||||
return ToolResult(
|
||||
output=None,
|
||||
success=False,
|
||||
error=f"Unknown action '{action}'. Supported actions: load, verify, discover"
|
||||
)
|
||||
|
||||
|
||||
# Standalone functions for easy agent integration
|
||||
def load_swarmscore_by_slug(slug: str) -> Dict[str, Any]:
|
||||
"""Load SwarmScore data by agent slug.
|
||||
|
||||
Args:
|
||||
slug: Agent slug identifier
|
||||
|
||||
Returns:
|
||||
Dictionary containing SwarmScore data
|
||||
|
||||
Raises:
|
||||
Exception: If loading fails
|
||||
"""
|
||||
tool = SwarmScoreTool()
|
||||
result = tool.load_swarmscore(slug)
|
||||
|
||||
if not result.success:
|
||||
raise Exception(result.error)
|
||||
|
||||
return result.output
|
||||
|
||||
|
||||
def verify_swarmscore_freshness(verify_payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Verify SwarmScore freshness.
|
||||
|
||||
Args:
|
||||
verify_payload: Verification payload from load response
|
||||
|
||||
Returns:
|
||||
Dictionary containing verification results
|
||||
|
||||
Raises:
|
||||
Exception: If verification fails
|
||||
"""
|
||||
tool = SwarmScoreTool()
|
||||
result = tool.verify_swarmscore(verify_payload)
|
||||
|
||||
if not result.success:
|
||||
raise Exception(result.error)
|
||||
|
||||
return result.output
|
||||
|
||||
|
||||
def get_agent_discovery_manifest() -> Dict[str, Any]:
|
||||
"""Get machine-readable agent discovery manifest.
|
||||
|
||||
Returns:
|
||||
Dictionary containing discovery manifest
|
||||
|
||||
Raises:
|
||||
Exception: If retrieval fails
|
||||
"""
|
||||
tool = SwarmScoreTool()
|
||||
result = tool.get_discovery_manifest()
|
||||
|
||||
if not result.success:
|
||||
raise Exception(result.error)
|
||||
|
||||
return result.output
|
||||
@@ -0,0 +1,484 @@
|
||||
"""You.com search and content tools for PraisonAI Agents.
|
||||
|
||||
Migrated from ``praisonaiagents.tools.youdotcom_tools`` (MervinPraison/PraisonAI#6168).
|
||||
|
||||
You.com provides AI-powered web search, news search, content extraction, and image search.
|
||||
This module requires the YDC_API_KEY environment variable to be set.
|
||||
|
||||
Usage:
|
||||
from praisonai_tools.tools.youdotcom_tool import ydc_search, ydc_news, ydc_contents
|
||||
|
||||
# Search the web (unified web + news)
|
||||
results = ydc_search("AI news 2024")
|
||||
|
||||
# Get live news
|
||||
news = ydc_news("technology trends")
|
||||
|
||||
# Extract content from URLs
|
||||
content = ydc_contents(["https://example.com"])
|
||||
|
||||
# Or use the class directly
|
||||
from praisonai_tools.tools.youdotcom_tool import YouTools
|
||||
you = YouTools()
|
||||
results = you.search("AI news")
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any, Optional, Union
|
||||
import logging
|
||||
import os
|
||||
from importlib import util
|
||||
|
||||
|
||||
class YouTools:
|
||||
"""Comprehensive tools for web search and content using You.com API.
|
||||
|
||||
You.com provides AI-powered search capabilities optimized for LLM applications.
|
||||
|
||||
Features:
|
||||
- Unified web and news search with LLM-ready snippets
|
||||
- Live news search from authoritative sources
|
||||
- Content extraction from URLs (HTML/Markdown)
|
||||
- Image search
|
||||
- Advanced search operators support
|
||||
- Geographic and language targeting
|
||||
- Freshness controls
|
||||
|
||||
Requires:
|
||||
- youdotcom package: pip install youdotcom
|
||||
- YDC_API_KEY environment variable
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None):
|
||||
"""Initialize YouTools.
|
||||
|
||||
Args:
|
||||
api_key: Optional API key. If not provided, uses YDC_API_KEY env var.
|
||||
"""
|
||||
self._api_key = api_key
|
||||
self._client = None
|
||||
|
||||
def _get_api_key(self) -> Optional[str]:
|
||||
"""Get API key from instance or environment."""
|
||||
return self._api_key or os.environ.get("YDC_API_KEY")
|
||||
|
||||
def _get_client(self):
|
||||
"""Get or create You.com client."""
|
||||
if self._client is None:
|
||||
if util.find_spec("youdotcom") is None:
|
||||
return None, "youdotcom package is not installed. Please install it using: pip install youdotcom"
|
||||
api_key = self._get_api_key()
|
||||
if not api_key:
|
||||
return None, "YDC_API_KEY environment variable is not set. Please set it to use You.com tools."
|
||||
|
||||
from youdotcom import You
|
||||
self._client = You(api_key_auth=api_key)
|
||||
return self._client, None
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
count: int = 10,
|
||||
freshness: Optional[str] = None,
|
||||
country: Optional[str] = None,
|
||||
language: Optional[str] = None,
|
||||
offset: int = 0,
|
||||
safesearch: str = "moderate",
|
||||
livecrawl: Optional[str] = None,
|
||||
livecrawl_formats: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search the web using You.com's unified search API.
|
||||
|
||||
Returns both web and news results in a single call with LLM-ready snippets.
|
||||
|
||||
Args:
|
||||
query: Search query (supports operators like site:, filetype:, +term, -term)
|
||||
count: Max results per section (default 10, max 100)
|
||||
freshness: Filter by recency - "day", "week", "month", "year" or "YYYY-MM-DDtoYYYY-MM-DD"
|
||||
country: ISO country code (e.g., "US", "GB", "FR")
|
||||
language: BCP 47 language code (e.g., "EN", "JP", "DE")
|
||||
offset: Pagination offset (0-9), results = offset * count
|
||||
safesearch: Content filter - "off", "moderate", "strict"
|
||||
livecrawl: Fetch full content - "web", "news", or "all"
|
||||
livecrawl_formats: Content format - "html" or "markdown"
|
||||
|
||||
Returns:
|
||||
Dict containing:
|
||||
- results: {web: [...], news: [...]}
|
||||
- metadata: {query, search_uuid, latency}
|
||||
|
||||
Search Operators:
|
||||
- site:domain.com - Search within specific domain
|
||||
- filetype:pdf - Filter by file type
|
||||
- +term - Must include term
|
||||
- -term - Exclude term
|
||||
- AND, OR, NOT - Boolean operators
|
||||
"""
|
||||
client, error = self._get_client()
|
||||
if error:
|
||||
logging.error(error)
|
||||
return {"error": error}
|
||||
|
||||
try:
|
||||
kwargs = {"query": query, "count": count, "offset": offset}
|
||||
|
||||
if freshness:
|
||||
kwargs["freshness"] = freshness
|
||||
if country:
|
||||
kwargs["country"] = country
|
||||
if language:
|
||||
kwargs["language"] = language
|
||||
if safesearch:
|
||||
kwargs["safesearch"] = safesearch
|
||||
if livecrawl:
|
||||
kwargs["livecrawl"] = livecrawl
|
||||
if livecrawl_formats:
|
||||
kwargs["livecrawl_formats"] = livecrawl_formats
|
||||
|
||||
response = client.search.unified(**kwargs)
|
||||
|
||||
# Convert response to dict if it's a model object
|
||||
if hasattr(response, 'model_dump'):
|
||||
return response.model_dump()
|
||||
elif hasattr(response, 'to_dict'):
|
||||
return response.to_dict()
|
||||
elif hasattr(response, '__dict__'):
|
||||
return self._convert_response(response)
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"You.com search error: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
return {"error": error_msg}
|
||||
|
||||
def _convert_response(self, response) -> Dict[str, Any]:
|
||||
"""Convert SDK response object to dictionary."""
|
||||
result = {}
|
||||
|
||||
if hasattr(response, 'results'):
|
||||
results = response.results
|
||||
result['results'] = {}
|
||||
if hasattr(results, 'web') and results.web:
|
||||
result['results']['web'] = [
|
||||
{
|
||||
'url': getattr(r, 'url', ''),
|
||||
'title': getattr(r, 'title', ''),
|
||||
'description': getattr(r, 'description', ''),
|
||||
'snippets': getattr(r, 'snippets', []),
|
||||
'thumbnail_url': getattr(r, 'thumbnail_url', None),
|
||||
'page_age': getattr(r, 'page_age', None),
|
||||
'favicon_url': getattr(r, 'favicon_url', None),
|
||||
'authors': getattr(r, 'authors', []),
|
||||
}
|
||||
for r in results.web
|
||||
]
|
||||
if hasattr(results, 'news') and results.news:
|
||||
result['results']['news'] = [
|
||||
{
|
||||
'url': getattr(r, 'url', ''),
|
||||
'title': getattr(r, 'title', ''),
|
||||
'description': getattr(r, 'description', ''),
|
||||
'thumbnail_url': getattr(r, 'thumbnail_url', None),
|
||||
'page_age': getattr(r, 'page_age', None),
|
||||
}
|
||||
for r in results.news
|
||||
]
|
||||
|
||||
if hasattr(response, 'metadata'):
|
||||
meta = response.metadata
|
||||
result['metadata'] = {
|
||||
'query': getattr(meta, 'query', ''),
|
||||
'search_uuid': getattr(meta, 'search_uuid', ''),
|
||||
'latency': getattr(meta, 'latency', 0),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
def get_contents(
|
||||
self,
|
||||
urls: Union[str, List[str]],
|
||||
format: str = "markdown",
|
||||
) -> Dict[str, Any]:
|
||||
"""Fetch content from URLs using You.com's content extraction API.
|
||||
|
||||
Args:
|
||||
urls: Single URL or list of URLs to fetch content from
|
||||
format: Content format - "html" or "markdown" (default: markdown)
|
||||
|
||||
Returns:
|
||||
Dict containing array of results with:
|
||||
- url: The fetched URL
|
||||
- title: Page title
|
||||
- html: HTML content (if format="html")
|
||||
- markdown: Markdown content (if format="markdown")
|
||||
- metadata: {site_name, favicon_url}
|
||||
"""
|
||||
client, error = self._get_client()
|
||||
if error:
|
||||
logging.error(error)
|
||||
return {"error": error}
|
||||
|
||||
try:
|
||||
if isinstance(urls, str):
|
||||
urls = [urls]
|
||||
|
||||
# Use the contents endpoint
|
||||
response = client.search.contents(urls=urls, format=format)
|
||||
|
||||
# Convert response
|
||||
if hasattr(response, 'model_dump'):
|
||||
return {"results": response.model_dump()}
|
||||
elif isinstance(response, list):
|
||||
return {"results": [
|
||||
{
|
||||
'url': getattr(r, 'url', ''),
|
||||
'title': getattr(r, 'title', ''),
|
||||
'html': getattr(r, 'html', None),
|
||||
'markdown': getattr(r, 'markdown', None),
|
||||
'metadata': {
|
||||
'site_name': getattr(getattr(r, 'metadata', None), 'site_name', None) if hasattr(r, 'metadata') else None,
|
||||
'favicon_url': getattr(getattr(r, 'metadata', None), 'favicon_url', None) if hasattr(r, 'metadata') else None,
|
||||
}
|
||||
}
|
||||
for r in response
|
||||
]}
|
||||
return {"results": response}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"You.com contents error: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
return {"error": error_msg}
|
||||
|
||||
def live_news(
|
||||
self,
|
||||
query: str,
|
||||
count: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search for live news using You.com's news API.
|
||||
|
||||
Note: This API may require early access. Contact [email protected] for access.
|
||||
|
||||
Args:
|
||||
query: News search query
|
||||
count: Maximum number of news results to return
|
||||
|
||||
Returns:
|
||||
Dict containing:
|
||||
- news: {query, results: [...], metadata}
|
||||
- Each result has: title, description, url, page_age, source_name, thumbnail
|
||||
"""
|
||||
client, error = self._get_client()
|
||||
if error:
|
||||
logging.error(error)
|
||||
return {"error": error}
|
||||
|
||||
try:
|
||||
kwargs = {"q": query}
|
||||
if count:
|
||||
kwargs["count"] = count
|
||||
|
||||
# Try to access live news endpoint
|
||||
if hasattr(client, 'livenews'):
|
||||
response = client.livenews.get(**kwargs)
|
||||
else:
|
||||
# Fallback: use unified search with news focus
|
||||
return self.search(query=query, count=count or 10)
|
||||
|
||||
if hasattr(response, 'model_dump'):
|
||||
return response.model_dump()
|
||||
elif hasattr(response, 'news'):
|
||||
news = response.news
|
||||
return {
|
||||
'news': {
|
||||
'query': getattr(getattr(news, 'query', None), 'original', query) if hasattr(news, 'query') else query,
|
||||
'results': [
|
||||
{
|
||||
'title': getattr(r, 'title', ''),
|
||||
'description': getattr(r, 'description', ''),
|
||||
'url': getattr(r, 'url', ''),
|
||||
'page_age': getattr(r, 'page_age', None),
|
||||
'age': getattr(r, 'age', None),
|
||||
'source_name': getattr(r, 'source_name', None),
|
||||
'thumbnail': getattr(getattr(r, 'thumbnail', None), 'src', None) if hasattr(r, 'thumbnail') else None,
|
||||
}
|
||||
for r in getattr(news, 'results', [])
|
||||
],
|
||||
'metadata': {
|
||||
'request_uuid': getattr(getattr(news, 'metadata', None), 'request_uuid', None) if hasattr(news, 'metadata') else None,
|
||||
}
|
||||
}
|
||||
}
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"You.com live news error: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
return {"error": error_msg}
|
||||
|
||||
def images(
|
||||
self,
|
||||
query: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search for images using You.com's image search API.
|
||||
|
||||
Note: This API may require early access. Contact [email protected] for access.
|
||||
|
||||
Args:
|
||||
query: Image search query
|
||||
|
||||
Returns:
|
||||
Dict containing:
|
||||
- images: {results: [...]}
|
||||
- metadata: {query, search_uuid}
|
||||
- Each result has: title, page_url, image_url
|
||||
"""
|
||||
client, error = self._get_client()
|
||||
if error:
|
||||
logging.error(error)
|
||||
return {"error": error}
|
||||
|
||||
try:
|
||||
if hasattr(client, 'images'):
|
||||
response = client.images.get(q=query)
|
||||
else:
|
||||
return {"error": "Image search not available in this SDK version"}
|
||||
|
||||
if hasattr(response, 'model_dump'):
|
||||
return response.model_dump()
|
||||
elif hasattr(response, 'images'):
|
||||
images = response.images
|
||||
return {
|
||||
'images': {
|
||||
'results': [
|
||||
{
|
||||
'title': getattr(r, 'title', ''),
|
||||
'page_url': getattr(r, 'page_url', ''),
|
||||
'image_url': getattr(r, 'image_url', ''),
|
||||
}
|
||||
for r in getattr(images, 'results', [])
|
||||
]
|
||||
},
|
||||
'metadata': {
|
||||
'query': getattr(getattr(response, 'metadata', None), 'query', query) if hasattr(response, 'metadata') else query,
|
||||
'search_uuid': getattr(getattr(response, 'metadata', None), 'search_uuid', None) if hasattr(response, 'metadata') else None,
|
||||
}
|
||||
}
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"You.com images error: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
return {"error": error_msg}
|
||||
|
||||
def __enter__(self):
|
||||
"""Context manager entry."""
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Context manager exit."""
|
||||
if self._client is not None:
|
||||
# Close client if it has a close method
|
||||
if hasattr(self._client, 'close'):
|
||||
self._client.close()
|
||||
elif hasattr(self._client, '__exit__'):
|
||||
self._client.__exit__(exc_type, exc_val, exc_tb)
|
||||
return False
|
||||
|
||||
|
||||
# Standalone functions for direct import
|
||||
def ydc_search(
|
||||
query: str,
|
||||
count: int = 10,
|
||||
freshness: Optional[str] = None,
|
||||
country: Optional[str] = None,
|
||||
language: Optional[str] = None,
|
||||
offset: int = 0,
|
||||
safesearch: str = "moderate",
|
||||
livecrawl: Optional[str] = None,
|
||||
livecrawl_formats: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search the web using You.com's unified search API.
|
||||
|
||||
This is a convenience function that creates a YouTools instance.
|
||||
For repeated calls, consider using YouTools class directly.
|
||||
|
||||
Args:
|
||||
query: Search query (supports operators like site:, filetype:, +term, -term)
|
||||
count: Max results per section (default 10, max 100)
|
||||
freshness: Filter by recency - "day", "week", "month", "year"
|
||||
country: ISO country code (e.g., "US", "GB")
|
||||
language: BCP 47 language code (e.g., "EN", "JP")
|
||||
offset: Pagination offset (0-9)
|
||||
safesearch: Content filter - "off", "moderate", "strict"
|
||||
livecrawl: Fetch full content - "web", "news", or "all"
|
||||
livecrawl_formats: Content format - "html" or "markdown"
|
||||
|
||||
Returns:
|
||||
Dict containing search results with web and news sections
|
||||
"""
|
||||
tools = YouTools()
|
||||
return tools.search(
|
||||
query=query,
|
||||
count=count,
|
||||
freshness=freshness,
|
||||
country=country,
|
||||
language=language,
|
||||
offset=offset,
|
||||
safesearch=safesearch,
|
||||
livecrawl=livecrawl,
|
||||
livecrawl_formats=livecrawl_formats,
|
||||
)
|
||||
|
||||
|
||||
def ydc_contents(
|
||||
urls: Union[str, List[str]],
|
||||
format: str = "markdown",
|
||||
) -> Dict[str, Any]:
|
||||
"""Fetch content from URLs using You.com's content extraction API.
|
||||
|
||||
Args:
|
||||
urls: Single URL or list of URLs to fetch content from
|
||||
format: Content format - "html" or "markdown"
|
||||
|
||||
Returns:
|
||||
Dict containing extracted content for each URL
|
||||
"""
|
||||
tools = YouTools()
|
||||
return tools.get_contents(urls=urls, format=format)
|
||||
|
||||
|
||||
def ydc_news(
|
||||
query: str,
|
||||
count: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search for live news using You.com's news API.
|
||||
|
||||
Args:
|
||||
query: News search query
|
||||
count: Maximum number of results
|
||||
|
||||
Returns:
|
||||
Dict containing news results
|
||||
"""
|
||||
tools = YouTools()
|
||||
return tools.live_news(query=query, count=count)
|
||||
|
||||
|
||||
def ydc_images(
|
||||
query: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search for images using You.com's image search API.
|
||||
|
||||
Args:
|
||||
query: Image search query
|
||||
|
||||
Returns:
|
||||
Dict containing image results
|
||||
"""
|
||||
tools = YouTools()
|
||||
return tools.images(query=query)
|
||||
|
||||
|
||||
# Alias for simple usage
|
||||
ydc = ydc_search
|
||||
|
||||
__all__ = ["YouTools", "ydc_search", "ydc_contents", "ydc_news", "ydc_images", "ydc"]
|
||||
@@ -35,6 +35,11 @@ def motion_graphics_team(
|
||||
- Researcher: Optional web search for content research
|
||||
- CodeExplorer: Optional git repository exploration
|
||||
- Animator: HTML/GSAP composition authoring and rendering
|
||||
|
||||
The team runs with ``process="hierarchical"``: the ``manager_llm``
|
||||
orchestrates the worker agents at runtime rather than exposing a static
|
||||
``leader`` reference. The coordinator is the semantic leader and is always
|
||||
the first agent in ``team.agents``.
|
||||
|
||||
Args:
|
||||
research: Include research specialist
|
||||
@@ -46,6 +51,12 @@ def motion_graphics_team(
|
||||
|
||||
Returns:
|
||||
AgentTeam configured for motion graphics creation
|
||||
|
||||
Note:
|
||||
The team uses ``process="hierarchical"`` where the ``manager_llm``
|
||||
orchestrates the worker agents at runtime. The coordinator is the
|
||||
semantic leader and is always the first agent (``team.agents[0]``);
|
||||
there is no static ``team.leader`` reference in this mode.
|
||||
"""
|
||||
if AgentTeam is None:
|
||||
raise ImportError(
|
||||
|
||||
+32
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-tools"
|
||||
version = "0.2.30"
|
||||
version = "0.2.46"
|
||||
description = "Extended tools for PraisonAI Agents"
|
||||
authors = [
|
||||
{name = "Mervin Praison"}
|
||||
@@ -27,11 +27,22 @@ dependencies = [
|
||||
[project.scripts]
|
||||
praisonai-tools = "praisonai_tools.cli:main"
|
||||
|
||||
# Discovery contract for third-party tool packages. They register agent-callable
|
||||
# tools under the ``praisonai.tools`` entry-point group so that
|
||||
# ``pip install some-praisonai-tool`` makes them discoverable via
|
||||
# ``praisonai_tools.list_tools()`` without editing core, e.g.:
|
||||
#
|
||||
# [project.entry-points."praisonai.tools"]
|
||||
# my_tool = "my_pkg:MyTool"
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"httpx>=0.27.0",
|
||||
"playwright>=1.40",
|
||||
"imageio-ffmpeg>=0.5",
|
||||
]
|
||||
wordpress = [
|
||||
"praisonaiwp[ai]>=0.1.0",
|
||||
@@ -54,9 +65,15 @@ agentfolio = [
|
||||
n8n = [
|
||||
"httpx>=0.27.0",
|
||||
]
|
||||
registry-proxy = [
|
||||
"httpx>=0.24.0",
|
||||
]
|
||||
langextract = [
|
||||
"langextract>=0.1.0",
|
||||
]
|
||||
swarmscore = [
|
||||
"requests>=2.28.0",
|
||||
]
|
||||
video-motion = [
|
||||
"playwright>=1.40",
|
||||
"imageio-ffmpeg>=0.5",
|
||||
@@ -64,6 +81,13 @@ video-motion = [
|
||||
composio = [
|
||||
"composio>=0.5.0",
|
||||
]
|
||||
meeting = [
|
||||
"openai>=1.12.0",
|
||||
"chromadb>=0.4.0",
|
||||
]
|
||||
stripe = [
|
||||
"requests>=2.28.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://docs.praison.ai"
|
||||
@@ -80,4 +104,10 @@ packages = ["praisonai_tools"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_mode = "auto"
|
||||
|
||||
[tool.ruff.lint]
|
||||
# Pin the rule set to the historical default (Pyflakes + a subset of
|
||||
# pycodestyle) so an unpinned ruff upgrade does not spuriously fail CI by
|
||||
# enabling thousands of new default rules across the pre-existing codebase.
|
||||
select = ["E4", "E7", "E9", "F"]
|
||||
Whitespace-only changes.
@@ -0,0 +1,55 @@
|
||||
"""Compatibility shim for the reference docs generator.
|
||||
|
||||
The canonical implementation lives in
|
||||
``praisonai_tools/docs_generator/generator.py``. This module re-exports the MDX
|
||||
escaping / validation helpers so that test suites and tooling which import from
|
||||
``scripts/generate_reference_docs.py`` resolve the same, single source of truth.
|
||||
|
||||
The generator module only depends on the Python standard library, so it is
|
||||
loaded directly by file path. This avoids importing the top-level
|
||||
``praisonai_tools`` package (which eagerly pulls in optional heavy
|
||||
dependencies such as ``praisonaiagents``) and keeps this shim usable in a
|
||||
lightweight docs/CI environment.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
_GENERATOR_PATH = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "praisonai_tools"
|
||||
/ "docs_generator"
|
||||
/ "generator.py"
|
||||
)
|
||||
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"praisonai_tools_docs_generator", _GENERATOR_PATH
|
||||
)
|
||||
_generator = importlib.util.module_from_spec(_spec)
|
||||
# Register before executing so dataclasses can resolve the module namespace.
|
||||
import sys as _sys # noqa: E402
|
||||
|
||||
_sys.modules[_spec.name] = _generator
|
||||
_spec.loader.exec_module(_generator)
|
||||
|
||||
ICON_MAP = _generator.ICON_MAP
|
||||
VALID_MDX_TAGS = _generator.VALID_MDX_TAGS
|
||||
escape_for_table = _generator.escape_for_table
|
||||
escape_mdx = _generator.escape_mdx
|
||||
sanitize_description = _generator.sanitize_description
|
||||
sanitize_type_for_mdx = _generator.sanitize_type_for_mdx
|
||||
validate_docs_json_structure = _generator.validate_docs_json_structure
|
||||
validate_mdx = _generator.validate_mdx
|
||||
|
||||
__all__ = [
|
||||
"ICON_MAP",
|
||||
"VALID_MDX_TAGS",
|
||||
"escape_for_table",
|
||||
"escape_mdx",
|
||||
"sanitize_description",
|
||||
"sanitize_type_for_mdx",
|
||||
"validate_docs_json_structure",
|
||||
"validate_mdx",
|
||||
]
|
||||
@@ -164,8 +164,11 @@ class TestMotionGraphicsTeam:
|
||||
custom_param="test"
|
||||
)
|
||||
|
||||
# Check that custom parameters are passed through
|
||||
assert team.kwargs == {"custom_param": "test"}
|
||||
# Hierarchical teams forward process + manager_llm alongside
|
||||
# any user-supplied kwargs, so assert individual entries.
|
||||
assert team.kwargs["custom_param"] == "test"
|
||||
assert team.kwargs["process"] == "hierarchical"
|
||||
assert team.kwargs["manager_llm"] == "gpt-4"
|
||||
|
||||
# Check that workspace is set
|
||||
assert team._motion_graphics_workspace == Path(tmpdir)
|
||||
@@ -209,15 +212,20 @@ class TestMotionGraphicsTeam:
|
||||
@patch('praisonai_tools.video.motion_graphics.team.search_web', MockSearch())
|
||||
@patch('praisonai_tools.video.motion_graphics.team.create_motion_graphics_agent')
|
||||
def test_coordinator_is_leader(self, mock_create_agent):
|
||||
"""Test that coordinator is set as team leader."""
|
||||
"""Test that coordinator is the semantic leader (first agent).
|
||||
|
||||
In hierarchical process mode the manager LLM orchestrates workers,
|
||||
so the coordinator is the first agent rather than a static
|
||||
``team.leader`` reference.
|
||||
"""
|
||||
mock_animator = MockAgent(name="animator")
|
||||
mock_create_agent.return_value = mock_animator
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
team = motion_graphics_team(workspace=tmpdir)
|
||||
|
||||
assert team.leader is not None
|
||||
assert team.leader.name == "coordinator"
|
||||
coordinator = team.agents[0]
|
||||
assert coordinator.name == "coordinator"
|
||||
|
||||
@patch('praisonai_tools.video.motion_graphics.team.AgentTeam', MockAgentTeam)
|
||||
@patch('praisonai_tools.video.motion_graphics.team.Agent', MockAgent)
|
||||
@@ -278,7 +286,7 @@ class TestMotionGraphicsTeam:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
team = motion_graphics_team(workspace=tmpdir)
|
||||
|
||||
coordinator = team.leader
|
||||
coordinator = team.agents[0]
|
||||
instructions = coordinator.instructions.lower()
|
||||
|
||||
assert "coordinator" in instructions
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Tests for the tool discovery / catalogue contract."""
|
||||
|
||||
|
||||
def test_list_tools_importable_from_top_level():
|
||||
from praisonai_tools import list_tools, ToolCatalogueEntry, get_tool_names
|
||||
|
||||
assert callable(list_tools)
|
||||
assert callable(get_tool_names)
|
||||
assert ToolCatalogueEntry is not None
|
||||
|
||||
|
||||
def test_list_tools_returns_entries():
|
||||
from praisonai_tools import list_tools
|
||||
from praisonai_tools.catalogue import ToolCatalogueEntry
|
||||
|
||||
entries = list_tools()
|
||||
assert isinstance(entries, list)
|
||||
assert len(entries) > 100 # ships a large catalogue
|
||||
assert all(isinstance(e, ToolCatalogueEntry) for e in entries)
|
||||
|
||||
|
||||
def test_entries_only_expose_tool_classes():
|
||||
from praisonai_tools import list_tools
|
||||
|
||||
for entry in list_tools():
|
||||
assert entry.name.endswith("Tool"), entry.name
|
||||
|
||||
|
||||
def test_entries_are_sorted_and_unique():
|
||||
from praisonai_tools import list_tools
|
||||
|
||||
names = [e.name for e in list_tools()]
|
||||
assert names == sorted(names)
|
||||
assert len(names) == len(set(names))
|
||||
|
||||
|
||||
def test_catalogue_derived_from_tool_map_source_of_truth():
|
||||
from praisonai_tools import list_tools
|
||||
from praisonai_tools.tools import _TOOL_MAP
|
||||
|
||||
expected = {name for name in _TOOL_MAP if name.endswith("Tool")}
|
||||
actual = {e.name for e in list_tools()}
|
||||
# Every class in the map must be catalogued (third-party EPs may add more).
|
||||
assert expected <= actual
|
||||
|
||||
|
||||
def test_previously_missing_optional_tool_is_now_discoverable():
|
||||
# QdrantTool was a valid tool absent from the wrapper's hand-maintained
|
||||
# allowlist; it must now be discoverable via the catalogue.
|
||||
from praisonai_tools import get_tool_names
|
||||
|
||||
names = get_tool_names()
|
||||
assert "QdrantTool" in names
|
||||
|
||||
|
||||
def test_extras_are_populated_for_optional_tools():
|
||||
from praisonai_tools import list_tools
|
||||
|
||||
by_name = {e.name for e in list_tools()}
|
||||
entries = {e.name: e for e in list_tools()}
|
||||
|
||||
assert "SwarmScoreTool" in by_name
|
||||
assert "swarmscore" in entries["SwarmScoreTool"].extras
|
||||
|
||||
|
||||
def test_entries_have_summary_and_module():
|
||||
from praisonai_tools import list_tools
|
||||
|
||||
for entry in list_tools():
|
||||
assert entry.module
|
||||
assert entry.summary
|
||||
|
||||
|
||||
def test_list_tools_does_not_require_optional_deps():
|
||||
# Building the catalogue must not import any tool module / optional dep.
|
||||
from praisonai_tools import list_tools
|
||||
|
||||
entries = list_tools()
|
||||
assert entries # smoke: no ImportError raised
|
||||
@@ -0,0 +1,109 @@
|
||||
"""CI guard: the catalogue must never advertise a phantom tool.
|
||||
|
||||
A "phantom" is a tool name surfaced by ``get_tool_names()`` / ``list_tools()``
|
||||
whose class cannot be resolved because no implementation file (or class) exists.
|
||||
Because the catalogue is now source-derived (see ``praisonai_tools.catalogue``),
|
||||
phantoms should be impossible by construction; this test enforces that during
|
||||
any future refactor.
|
||||
|
||||
Missing *optional dependencies* are explicitly tolerated: a tool whose file and
|
||||
class exist but which needs an un-installed extra will raise ``ImportError`` /
|
||||
``ModuleNotFoundError`` for the extra, not an ``AttributeError``. Only the
|
||||
latter (name resolves to nothing) counts as a phantom.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
|
||||
import praisonai_tools
|
||||
from praisonai_tools import get_tool_names, list_tools
|
||||
from praisonai_tools.catalogue import _scan_source_tree
|
||||
|
||||
|
||||
def _is_missing_optional_dependency(exc: BaseException, tool_module: str) -> bool:
|
||||
"""True if the import failure is due to a missing *optional* dependency.
|
||||
|
||||
A missing optional dependency raises ``ModuleNotFoundError`` for a module
|
||||
that is *not* the tool's own module. If the tool's own file were missing the
|
||||
name would simply not resolve (``AttributeError``) rather than importing and
|
||||
then failing on a third-party import.
|
||||
"""
|
||||
if not isinstance(exc, ModuleNotFoundError):
|
||||
return False
|
||||
missing = getattr(exc, "name", "") or ""
|
||||
# ``tool_module`` may be relative (flat ``*_tool.py`` under
|
||||
# ``praisonai_tools.tools``) or already absolute (nested subpackages such as
|
||||
# ``praisonai_tools.n8n.n8n_workflow``). Resolve to the tool's own dotted
|
||||
# module either way so a *missing first-party* module is treated as a
|
||||
# phantom, not an optional dependency.
|
||||
own = (
|
||||
tool_module
|
||||
if tool_module.startswith("praisonai_tools.")
|
||||
else f"praisonai_tools.tools.{tool_module}"
|
||||
)
|
||||
return missing != own and not own.startswith(f"{missing}.")
|
||||
|
||||
|
||||
def test_every_catalogue_name_resolves_or_needs_optional_dep():
|
||||
entries = {e.name: e for e in list_tools()}
|
||||
phantoms = []
|
||||
for name in sorted(get_tool_names()):
|
||||
module = entries.get(name).module if name in entries else ""
|
||||
try:
|
||||
getattr(praisonai_tools, name)
|
||||
except AttributeError as exc:
|
||||
phantoms.append((name, module, repr(exc)))
|
||||
except Exception as exc: # ImportError / ModuleNotFoundError, etc.
|
||||
if not _is_missing_optional_dependency(exc, module):
|
||||
phantoms.append((name, module, repr(exc)))
|
||||
assert not phantoms, f"catalogue advertises unresolvable (phantom) tools: {phantoms}"
|
||||
|
||||
|
||||
def test_no_phantom_modules_in_scan():
|
||||
"""Every source-derived tool maps to a file that actually exists on disk."""
|
||||
import os
|
||||
|
||||
tools_dir = os.path.join(os.path.dirname(praisonai_tools.__file__), "tools")
|
||||
for name, (module, _summary) in _scan_source_tree().items():
|
||||
path = os.path.join(tools_dir, f"{module}.py")
|
||||
assert os.path.isfile(path), f"{name} maps to missing file {module}.py"
|
||||
|
||||
|
||||
def test_dropping_a_tool_file_is_auto_discovered():
|
||||
"""Files on disk defining a *Tool class are catalogued without _TOOL_MAP edits.
|
||||
|
||||
Guards the core promise of the issue: adding a tool is a single-file change.
|
||||
``ClaudeMemoryTool``/``CrowPayTool``/``NightmarketTool`` exist on disk but
|
||||
were historically absent from ``_TOOL_MAP``; they must still be discoverable.
|
||||
"""
|
||||
names = get_tool_names()
|
||||
for expected in ("ClaudeMemoryTool", "CrowPayTool", "NightmarketTool"):
|
||||
# Only assert for the ones whose files are present in this checkout.
|
||||
derived = _scan_source_tree()
|
||||
if expected in derived:
|
||||
assert expected in names, f"{expected} exists on disk but is not catalogued"
|
||||
|
||||
|
||||
def test_summaries_come_from_real_description():
|
||||
entries = {e.name: e for e in list_tools()}
|
||||
# CalculatorTool declares a literal description; the summary must match it,
|
||||
# not the name-synthesised fallback ("Calculator integration tool").
|
||||
calc = entries.get("CalculatorTool")
|
||||
assert calc is not None
|
||||
assert calc.summary == "Perform mathematical calculations."
|
||||
assert "integration tool" not in calc.summary
|
||||
|
||||
|
||||
def test_annotated_description_summaries_are_extracted():
|
||||
"""Tools declaring ``description: str = "..."`` must surface the real text.
|
||||
|
||||
``CapsuleTool`` and ``NexusPredictionMarketTool`` use annotated assignments;
|
||||
their catalogue summaries must come from the literal, not the name-based
|
||||
fallback.
|
||||
"""
|
||||
entries = {e.name: e for e in list_tools()}
|
||||
for name in ("CapsuleTool", "NexusPredictionMarketTool"):
|
||||
entry = entries.get(name)
|
||||
if entry is None:
|
||||
continue
|
||||
assert entry.summary
|
||||
assert "integration tool" not in entry.summary
|
||||
@@ -0,0 +1,305 @@
|
||||
"""Tests for Inkog Security Analysis Tool."""
|
||||
|
||||
from unittest.mock import patch, MagicMock
|
||||
from praisonai_tools.tools.inkog_tool import InkogTool, _check_inkog_available, scan_agent_code
|
||||
|
||||
|
||||
class TestInkogTool:
|
||||
"""Test suite for InkogTool."""
|
||||
|
||||
def test_init(self):
|
||||
"""Test InkogTool initialization."""
|
||||
# Test default initialization
|
||||
tool = InkogTool()
|
||||
assert tool.name == "inkog_security"
|
||||
assert "security" in tool.description.lower()
|
||||
|
||||
# Test with custom parameters
|
||||
tool = InkogTool(api_key="test_key", server_url="http://test.com")
|
||||
assert tool._api_key == "test_key"
|
||||
assert tool._server_url == "http://test.com"
|
||||
|
||||
@patch.dict("os.environ", {"INKOG_API_KEY": "test_env_key"})
|
||||
def test_get_api_key(self):
|
||||
"""Test API key retrieval."""
|
||||
tool = InkogTool()
|
||||
assert tool._get_api_key() == "test_env_key"
|
||||
|
||||
# Instance key takes precedence
|
||||
tool = InkogTool(api_key="instance_key")
|
||||
assert tool._get_api_key() == "instance_key"
|
||||
|
||||
@patch.dict("os.environ", {"INKOG_SERVER_URL": "http://env.com"})
|
||||
def test_get_server_url(self):
|
||||
"""Test server URL retrieval."""
|
||||
tool = InkogTool()
|
||||
assert tool._get_server_url() == "http://env.com"
|
||||
|
||||
# Instance URL takes precedence
|
||||
tool = InkogTool(server_url="http://instance.com")
|
||||
assert tool._get_server_url() == "http://instance.com"
|
||||
|
||||
def test_analyze_findings_empty(self):
|
||||
"""Test analyze_findings with empty results."""
|
||||
tool = InkogTool()
|
||||
|
||||
# Test with error
|
||||
error_results = {"error": "test error"}
|
||||
analysis = tool.analyze_findings(error_results)
|
||||
assert "Scan failed" in analysis
|
||||
assert "test error" in analysis
|
||||
|
||||
# Test with no findings
|
||||
empty_results = {"summary": {"total_findings": 0}}
|
||||
analysis = tool.analyze_findings(empty_results)
|
||||
assert "No security vulnerabilities found" in analysis
|
||||
|
||||
def test_analyze_findings_with_data(self):
|
||||
"""Test analyze_findings with security findings."""
|
||||
tool = InkogTool()
|
||||
|
||||
results = {
|
||||
"summary": {
|
||||
"total_findings": 5,
|
||||
"critical": 2,
|
||||
"high": 2,
|
||||
"medium": 1,
|
||||
"low": 0
|
||||
},
|
||||
"server_findings": [
|
||||
{"pattern": "Token Bombing"},
|
||||
{"pattern": "Prompt Injection"},
|
||||
{"pattern": "Recursive Tool Calling"}
|
||||
]
|
||||
}
|
||||
|
||||
analysis = tool.analyze_findings(results)
|
||||
assert "Total Findings: 5" in analysis
|
||||
assert "CRITICAL: 2 findings" in analysis
|
||||
assert "HIGH: 2 findings" in analysis
|
||||
assert "MEDIUM: 1 findings" in analysis
|
||||
assert "Token Bombing" in analysis
|
||||
assert "Immediate attention required" in analysis
|
||||
|
||||
def test_analyze_findings_string_input(self):
|
||||
"""String (text/HTML) scan output is returned as-is, even when it
|
||||
contains the word 'error' (regression: no TypeError)."""
|
||||
tool = InkogTool()
|
||||
|
||||
text_output = "Finding: potential error in tool loop termination."
|
||||
analysis = tool.analyze_findings(text_output)
|
||||
assert analysis == text_output
|
||||
|
||||
|
||||
class TestInkogAvailability:
|
||||
"""Test inkog availability checking."""
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_check_inkog_available_success(self, mock_run):
|
||||
"""Test successful inkog availability check."""
|
||||
# Mock successful version check
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
with patch.dict("os.environ", {"INKOG_API_KEY": "test_key"}):
|
||||
is_available, error = _check_inkog_available()
|
||||
assert is_available is True
|
||||
assert error is None
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_check_inkog_not_installed(self, mock_run):
|
||||
"""Test inkog not installed."""
|
||||
mock_run.side_effect = FileNotFoundError()
|
||||
|
||||
is_available, error = _check_inkog_available()
|
||||
assert is_available is False
|
||||
assert "not installed" in error.lower()
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_check_inkog_no_api_key(self, mock_run):
|
||||
"""Test inkog available but no API key."""
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
is_available, error = _check_inkog_available()
|
||||
assert is_available is False
|
||||
assert "INKOG_API_KEY" in error
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_check_inkog_available_with_instance_api_key(self, mock_run):
|
||||
"""Instance-provided api_key satisfies the availability check."""
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
is_available, error = _check_inkog_available(api_key="instance_key")
|
||||
assert is_available is True
|
||||
assert error is None
|
||||
|
||||
|
||||
class TestInkogScanOperations:
|
||||
"""Test inkog scan operations."""
|
||||
|
||||
@patch("praisonai_tools.tools.inkog_tool._check_inkog_available")
|
||||
@patch("subprocess.run")
|
||||
@patch.dict("os.environ", {"INKOG_API_KEY": "test_key"})
|
||||
def test_scan_directory_json_success(self, mock_run, mock_check):
|
||||
"""Test successful directory scan with JSON output."""
|
||||
mock_check.return_value = (True, None)
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout='{"summary": {"total_findings": 0}}'
|
||||
)
|
||||
|
||||
tool = InkogTool()
|
||||
result = tool.scan_directory(path="./test", output_format="json")
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert "summary" in result
|
||||
assert result["summary"]["total_findings"] == 0
|
||||
|
||||
@patch("praisonai_tools.tools.inkog_tool._check_inkog_available")
|
||||
@patch("subprocess.run")
|
||||
@patch.dict("os.environ", {"INKOG_API_KEY": "test_key"})
|
||||
def test_scan_directory_text_success(self, mock_run, mock_check):
|
||||
"""Test successful directory scan with text output."""
|
||||
mock_check.return_value = (True, None)
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout="No vulnerabilities found."
|
||||
)
|
||||
|
||||
tool = InkogTool()
|
||||
result = tool.scan_directory(path="./test", output_format="text")
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "vulnerabilities" in result.lower()
|
||||
|
||||
@patch("praisonai_tools.tools.inkog_tool._check_inkog_available")
|
||||
def test_scan_directory_not_available(self, mock_check):
|
||||
"""Test scan when inkog is not available."""
|
||||
mock_check.return_value = (False, "inkog not installed")
|
||||
|
||||
tool = InkogTool()
|
||||
result = tool.scan_directory()
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert "error" in result
|
||||
assert "not installed" in result["error"]
|
||||
|
||||
@patch("praisonai_tools.tools.inkog_tool._check_inkog_available")
|
||||
@patch("subprocess.run")
|
||||
def test_scan_directory_command_failure(self, mock_run, mock_check):
|
||||
"""Test scan when inkog command fails."""
|
||||
mock_check.return_value = (True, None)
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=1,
|
||||
stderr="Command failed",
|
||||
stdout=""
|
||||
)
|
||||
|
||||
tool = InkogTool()
|
||||
result = tool.scan_directory()
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert "error" in result
|
||||
assert "failed" in result["error"].lower()
|
||||
|
||||
@patch("praisonai_tools.tools.inkog_tool._check_inkog_available")
|
||||
@patch("subprocess.run")
|
||||
@patch.dict("os.environ", {"INKOG_API_KEY": "test_key"})
|
||||
def test_skill_scan_success(self, mock_run, mock_check):
|
||||
"""Test successful skill package scan."""
|
||||
mock_check.return_value = (True, None)
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout='{"scan_type": "skill", "findings": []}'
|
||||
)
|
||||
|
||||
tool = InkogTool()
|
||||
result = tool.skill_scan(path="./skill", deep=True)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert "scan_type" in result or "findings" in result
|
||||
|
||||
@patch("praisonai_tools.tools.inkog_tool._check_inkog_available")
|
||||
@patch("subprocess.run")
|
||||
@patch.dict("os.environ", {"INKOG_API_KEY": "test_key"})
|
||||
def test_mcp_scan_success(self, mock_run, mock_check):
|
||||
"""Test successful MCP server scan."""
|
||||
mock_check.return_value = (True, None)
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout='{"mcp_server": "github", "vulnerabilities": []}'
|
||||
)
|
||||
|
||||
tool = InkogTool()
|
||||
result = tool.mcp_scan("github", deep=True)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
# Should have some scan results structure
|
||||
|
||||
def test_run_method_routing(self):
|
||||
"""Test the run method routes to correct functions."""
|
||||
tool = InkogTool()
|
||||
|
||||
with patch.object(tool, 'scan_directory') as mock_scan:
|
||||
mock_scan.return_value = {"test": "result"}
|
||||
tool.run(action="scan", path="./test")
|
||||
mock_scan.assert_called_once_with(
|
||||
path="./test",
|
||||
output_format="json",
|
||||
policy="balanced",
|
||||
severity="low",
|
||||
deep=False
|
||||
)
|
||||
|
||||
with patch.object(tool, 'skill_scan') as mock_skill:
|
||||
mock_skill.return_value = {"test": "result"}
|
||||
tool.run(action="skill-scan", path="./test")
|
||||
mock_skill.assert_called_once_with(
|
||||
path="./test",
|
||||
output_format="json",
|
||||
deep=False,
|
||||
repo=None
|
||||
)
|
||||
|
||||
|
||||
class TestStandaloneFunctions:
|
||||
"""Test standalone function interfaces."""
|
||||
|
||||
@patch("praisonai_tools.tools.inkog_tool.InkogTool")
|
||||
def test_scan_agent_code(self, mock_tool_class):
|
||||
"""Test scan_agent_code standalone function."""
|
||||
mock_tool = MagicMock()
|
||||
mock_tool.scan_directory.return_value = {"result": "test"}
|
||||
mock_tool_class.return_value = mock_tool
|
||||
|
||||
scan_agent_code("./test", deep=True)
|
||||
|
||||
mock_tool_class.assert_called_once()
|
||||
mock_tool.scan_directory.assert_called_once_with(
|
||||
path="./test",
|
||||
output_format="json",
|
||||
policy="balanced",
|
||||
severity="low",
|
||||
deep=True
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run basic smoke test
|
||||
print("Running Inkog Tool smoke tests...")
|
||||
|
||||
# Test basic initialization
|
||||
tool = InkogTool()
|
||||
print(f"✓ Tool name: {tool.name}")
|
||||
print(f"✓ Tool description contains 'security': {'security' in tool.description.lower()}")
|
||||
|
||||
# Test availability check (will likely fail without actual installation)
|
||||
is_available, error = _check_inkog_available()
|
||||
if is_available:
|
||||
print("✓ Inkog is available and configured")
|
||||
else:
|
||||
print(f"⚠ Inkog not available: {error}")
|
||||
print("This is expected if inkog CLI is not installed")
|
||||
|
||||
print("Smoke tests completed!")
|
||||
@@ -1,15 +1,15 @@
|
||||
"""Tests for LangExtract Tool."""
|
||||
|
||||
import tempfile
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from praisonai_tools.tools.langextract_tool import (
|
||||
LangExtractTool,
|
||||
_create_annotated_document,
|
||||
_get_langextract,
|
||||
langextract_extract,
|
||||
langextract_render_file,
|
||||
_get_langextract,
|
||||
_create_annotated_document
|
||||
)
|
||||
|
||||
|
||||
@@ -96,19 +96,19 @@ class TestLangExtractTool:
|
||||
mock_lx.io.save_annotated_documents = Mock()
|
||||
mock_lx.visualize = Mock(return_value="<html>mock visualization</html>")
|
||||
|
||||
with patch('webbrowser.open') as mock_open:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
output_path = os.path.join(temp_dir, "test.html")
|
||||
|
||||
result = self.tool.extract(
|
||||
text="test text",
|
||||
auto_open=True,
|
||||
output_path=output_path
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert "auto_opened" in result
|
||||
mock_open.assert_called_once()
|
||||
with patch('webbrowser.open') as mock_open, \
|
||||
tempfile.TemporaryDirectory() as temp_dir:
|
||||
output_path = os.path.join(temp_dir, "test.html")
|
||||
|
||||
result = self.tool.extract(
|
||||
text="test text",
|
||||
auto_open=True,
|
||||
output_path=output_path
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert "auto_opened" in result
|
||||
mock_open.assert_called_once()
|
||||
|
||||
def test_render_file_missing_path(self):
|
||||
"""Test render_file with missing file_path."""
|
||||
@@ -144,13 +144,17 @@ class TestLangExtractTool:
|
||||
temp_file_path = temp_file.name
|
||||
|
||||
try:
|
||||
result = self.tool.render_file(
|
||||
file_path=temp_file_path,
|
||||
extractions=["important terms"]
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["text_length"] > 0
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
output_path = os.path.join(temp_dir, "render.html")
|
||||
|
||||
result = self.tool.render_file(
|
||||
file_path=temp_file_path,
|
||||
extractions=["important terms"],
|
||||
output_path=output_path
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["text_length"] > 0
|
||||
|
||||
finally:
|
||||
os.unlink(temp_file_path)
|
||||
@@ -161,10 +165,10 @@ class TestHelperFunctions:
|
||||
|
||||
def test_get_langextract_not_installed(self):
|
||||
"""Test _get_langextract when module not installed."""
|
||||
with patch.dict('sys.modules', {'langextract': None}):
|
||||
with patch('builtins.__import__', side_effect=ImportError):
|
||||
result = _get_langextract()
|
||||
assert result is None
|
||||
with patch.dict('sys.modules', {'langextract': None}), \
|
||||
patch('builtins.__import__', side_effect=ImportError):
|
||||
result = _get_langextract()
|
||||
assert result is None
|
||||
|
||||
@patch('praisonai_tools.tools.langextract_tool._get_langextract')
|
||||
def test_create_annotated_document(self, mock_get_lx):
|
||||
@@ -265,19 +269,32 @@ class TestIntegration:
|
||||
|
||||
key_terms = ["John Doe", "OpenAI Corporation", "January 1, 2024", "Net 30 days"]
|
||||
|
||||
result = langextract_extract(
|
||||
text=contract_text,
|
||||
extractions=key_terms,
|
||||
document_id="contract-001",
|
||||
auto_open=False
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["document_id"] == "contract-001"
|
||||
assert result["extractions_count"] == len(key_terms)
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
output_path = os.path.join(temp_dir, "contract-001.html")
|
||||
|
||||
result = langextract_extract(
|
||||
text=contract_text,
|
||||
extractions=key_terms,
|
||||
document_id="contract-001",
|
||||
output_path=output_path,
|
||||
auto_open=False
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["document_id"] == "contract-001"
|
||||
assert result["extractions_count"] == len(key_terms)
|
||||
|
||||
def test_agent_usage_pattern(self):
|
||||
@patch('praisonai_tools.tools.langextract_tool._get_langextract')
|
||||
@patch('praisonai_tools.tools.langextract_tool._create_annotated_document')
|
||||
def test_agent_usage_pattern(self, mock_create_doc, mock_get_lx):
|
||||
"""Test typical agent usage pattern."""
|
||||
# Mock langextract so the test runs without the optional dependency
|
||||
mock_lx = Mock()
|
||||
mock_get_lx.return_value = mock_lx
|
||||
mock_create_doc.return_value = Mock()
|
||||
mock_lx.io.save_annotated_documents = Mock()
|
||||
mock_lx.visualize = Mock(return_value="<html>mock visualization</html>")
|
||||
|
||||
# This would be how an agent uses the tool
|
||||
from praisonai_tools import langextract_extract
|
||||
|
||||
@@ -285,13 +302,17 @@ class TestIntegration:
|
||||
analysis_text = "The quarterly report shows revenue of $1.2M and profit of $300K."
|
||||
findings = ["$1.2M", "$300K", "quarterly report"]
|
||||
|
||||
result = langextract_extract(
|
||||
text=analysis_text,
|
||||
extractions=findings,
|
||||
document_id="financial-analysis",
|
||||
auto_open=False,
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
output_path = os.path.join(temp_dir, "financial-analysis.html")
|
||||
|
||||
result = langextract_extract(
|
||||
text=analysis_text,
|
||||
extractions=findings,
|
||||
document_id="financial-analysis",
|
||||
output_path=output_path,
|
||||
auto_open=False,
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["document_id"] == "financial-analysis"
|
||||
assert result["extractions_count"] == len(findings)
|
||||
assert result["success"] is True
|
||||
assert result["document_id"] == "financial-analysis"
|
||||
assert result["extractions_count"] == len(findings)
|
||||
@@ -162,7 +162,7 @@ def test_trust_config_functions():
|
||||
config = get_trust_config()
|
||||
assert config.enabled is False
|
||||
assert config.provider == "joy"
|
||||
assert config.min_score == 3.0
|
||||
assert config.min_score == 1.5
|
||||
|
||||
# Test with environment variables
|
||||
old_provider = os.environ.get('PRAISONAI_TRUST_PROVIDER')
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Tests for NEXUS Prediction Market Tool"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
import requests
|
||||
from unittest.mock import Mock, patch
|
||||
from praisonai_tools.tools.nexus_prediction_market_tool import (
|
||||
NexusPredictionMarketTool,
|
||||
nexus_prediction_market_tool
|
||||
)
|
||||
|
||||
|
||||
class TestNexusPredictionMarketTool:
|
||||
"""Test cases for NEXUS Prediction Market Tool"""
|
||||
|
||||
def test_tool_initialization(self):
|
||||
"""Test tool initialization with default and custom parameters."""
|
||||
# Default initialization
|
||||
tool = NexusPredictionMarketTool()
|
||||
assert tool.name == "nexus_prediction_market"
|
||||
assert tool.source_name == "praisonai-agent"
|
||||
assert "X-NEXUS-Source" in tool.headers
|
||||
assert tool.base_url == "https://nexus-agent-xa12.onrender.com"
|
||||
|
||||
# Custom initialization
|
||||
custom_tool = NexusPredictionMarketTool(
|
||||
source_name="test-bot",
|
||||
api_key="test-key"
|
||||
)
|
||||
assert custom_tool.source_name == "test-bot"
|
||||
assert custom_tool.headers["X-NEXUS-Source"] == "test-bot"
|
||||
assert "Authorization" in custom_tool.headers
|
||||
|
||||
@patch('praisonai_tools.tools.nexus_prediction_market_tool.requests.get')
|
||||
def test_get_market_data_success(self, mock_get):
|
||||
"""Test successful market data retrieval."""
|
||||
# Mock successful response
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"market": "Fed",
|
||||
"price": 0.75,
|
||||
"volume": 1000000,
|
||||
"trend": "bullish"
|
||||
}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
tool = NexusPredictionMarketTool()
|
||||
result = tool.get_market_data("Fed")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["market"] == "Fed"
|
||||
assert "data" in result
|
||||
assert result["source"] == "nexus-free-api"
|
||||
|
||||
# Verify request was made correctly
|
||||
mock_get.assert_called_once()
|
||||
args, kwargs = mock_get.call_args
|
||||
assert "kalshi" in args[0]
|
||||
assert kwargs["params"]["market"] == "Fed"
|
||||
assert "X-NEXUS-Source" in kwargs["headers"]
|
||||
|
||||
@patch('praisonai_tools.tools.nexus_prediction_market_tool.requests.get')
|
||||
def test_get_market_data_failure(self, mock_get):
|
||||
"""Test market data retrieval failure."""
|
||||
# Mock failed response
|
||||
mock_get.side_effect = requests.exceptions.ConnectionError("Network error")
|
||||
|
||||
tool = NexusPredictionMarketTool()
|
||||
result = tool.get_market_data("Fed")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "error" in result
|
||||
assert result["market"] == "Fed"
|
||||
|
||||
@patch('praisonai_tools.tools.nexus_prediction_market_tool.requests.get')
|
||||
def test_check_arbitrage_opportunities_success(self, mock_get):
|
||||
"""Test successful arbitrage checking."""
|
||||
# Mock successful response
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"opportunities": [
|
||||
{
|
||||
"markets": ["Fed", "BTC"],
|
||||
"profit_potential": 0.05,
|
||||
"risk_level": "medium"
|
||||
}
|
||||
]
|
||||
}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
tool = NexusPredictionMarketTool()
|
||||
result = tool.check_arbitrage_opportunities(["Fed", "BTC"])
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["markets"] == ["Fed", "BTC"]
|
||||
assert "arbitrage_data" in result
|
||||
assert result["source"] == "nexus-paid-api"
|
||||
|
||||
# Verify request was made correctly
|
||||
mock_get.assert_called_once()
|
||||
args, kwargs = mock_get.call_args
|
||||
assert "arb/check" in args[0]
|
||||
assert kwargs["params"]["markets"] == "Fed,BTC"
|
||||
|
||||
@patch('praisonai_tools.tools.nexus_prediction_market_tool.requests.get')
|
||||
def test_check_arbitrage_payment_required(self, mock_get):
|
||||
"""Test arbitrage checking when payment is required."""
|
||||
# Mock payment required response
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 402
|
||||
http_error = requests.exceptions.HTTPError("Payment Required")
|
||||
http_error.response = mock_response
|
||||
mock_get.side_effect = http_error
|
||||
|
||||
tool = NexusPredictionMarketTool()
|
||||
result = tool.check_arbitrage_opportunities(["Fed", "BTC"])
|
||||
|
||||
assert result["success"] is False
|
||||
assert "payment required" in result["error"].lower()
|
||||
assert result["markets"] == ["Fed", "BTC"]
|
||||
|
||||
@patch('praisonai_tools.tools.nexus_prediction_market_tool.requests.get')
|
||||
def test_get_agent_info_success(self, mock_get):
|
||||
"""Test successful agent info retrieval."""
|
||||
# Mock successful response
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"name": "NEXUS",
|
||||
"description": "Prediction market data API",
|
||||
"version": "1.0"
|
||||
}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
tool = NexusPredictionMarketTool()
|
||||
result = tool.get_agent_info()
|
||||
|
||||
assert result["success"] is True
|
||||
assert "agent_info" in result
|
||||
assert result["source"] == "nexus-agent-metadata"
|
||||
|
||||
# Verify request was made correctly
|
||||
mock_get.assert_called_once()
|
||||
args, kwargs = mock_get.call_args
|
||||
assert ".well-known/agent.json" in args[0]
|
||||
|
||||
def test_run_method_get_market_data(self):
|
||||
"""Test run method with get_market_data action."""
|
||||
tool = NexusPredictionMarketTool()
|
||||
|
||||
with patch.object(tool, 'get_market_data') as mock_method:
|
||||
mock_method.return_value = {"success": True, "test": "data"}
|
||||
|
||||
result = tool.run("get_market_data", market="Fed")
|
||||
result_data = json.loads(result)
|
||||
|
||||
assert result_data["success"] is True
|
||||
mock_method.assert_called_once_with("Fed")
|
||||
|
||||
def test_run_method_check_arbitrage(self):
|
||||
"""Test run method with check_arbitrage action."""
|
||||
tool = NexusPredictionMarketTool()
|
||||
|
||||
with patch.object(tool, 'check_arbitrage_opportunities') as mock_method:
|
||||
mock_method.return_value = {"success": True, "test": "data"}
|
||||
|
||||
result = tool.run("check_arbitrage", markets=["Fed", "BTC"])
|
||||
result_data = json.loads(result)
|
||||
|
||||
assert result_data["success"] is True
|
||||
mock_method.assert_called_once_with(["Fed", "BTC"])
|
||||
|
||||
def test_run_method_get_agent_info(self):
|
||||
"""Test run method with get_agent_info action."""
|
||||
tool = NexusPredictionMarketTool()
|
||||
|
||||
with patch.object(tool, 'get_agent_info') as mock_method:
|
||||
mock_method.return_value = {"success": True, "test": "data"}
|
||||
|
||||
result = tool.run("get_agent_info")
|
||||
result_data = json.loads(result)
|
||||
|
||||
assert result_data["success"] is True
|
||||
mock_method.assert_called_once()
|
||||
|
||||
def test_run_method_invalid_action(self):
|
||||
"""Test run method with invalid action."""
|
||||
tool = NexusPredictionMarketTool()
|
||||
|
||||
result = tool.run("invalid_action")
|
||||
result_data = json.loads(result)
|
||||
|
||||
assert result_data["success"] is False
|
||||
assert "unknown action" in result_data["error"].lower()
|
||||
|
||||
def test_run_method_missing_parameters(self):
|
||||
"""Test run method with missing required parameters."""
|
||||
tool = NexusPredictionMarketTool()
|
||||
|
||||
# Missing market parameter
|
||||
result = tool.run("get_market_data")
|
||||
result_data = json.loads(result)
|
||||
assert result_data["success"] is False
|
||||
assert "market parameter required" in result_data["error"].lower()
|
||||
|
||||
# Missing markets parameter
|
||||
result = tool.run("check_arbitrage")
|
||||
result_data = json.loads(result)
|
||||
assert result_data["success"] is False
|
||||
assert "markets parameter" in result_data["error"].lower()
|
||||
|
||||
|
||||
class TestNexusPredictionMarketFunction:
|
||||
"""Test cases for the function-based interface"""
|
||||
|
||||
def test_function_interface(self):
|
||||
"""Test the function-based interface."""
|
||||
with patch('praisonai_tools.tools.nexus_prediction_market_tool.NexusPredictionMarketTool') as mock_class:
|
||||
mock_instance = Mock()
|
||||
mock_instance.run.return_value = '{"success": true}'
|
||||
mock_class.return_value = mock_instance
|
||||
|
||||
result = nexus_prediction_market_tool(
|
||||
action="get_market_data",
|
||||
market="Fed",
|
||||
source_name="test-source"
|
||||
)
|
||||
|
||||
assert result == '{"success": true}'
|
||||
mock_class.assert_called_once_with(source_name="test-source")
|
||||
mock_instance.run.assert_called_once_with(
|
||||
action="get_market_data",
|
||||
market="Fed",
|
||||
markets=None
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__])
|
||||
@@ -0,0 +1,901 @@
|
||||
"""Unit tests for the tool-registry proxy connector."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_session_spend():
|
||||
"""Isolate cumulative-spend state between tests."""
|
||||
from praisonai_tools.registry_proxy import registry_proxy as mod
|
||||
|
||||
mod._SESSION_SPEND.clear()
|
||||
yield
|
||||
mod._SESSION_SPEND.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_httpx():
|
||||
"""Mock httpx module with proper exception classes."""
|
||||
mock_module = MagicMock()
|
||||
mock_module.TimeoutException = type("TimeoutException", (Exception,), {})
|
||||
|
||||
class MockHTTPStatusError(Exception):
|
||||
def __init__(self, message, request=None, response=None):
|
||||
super().__init__(message)
|
||||
self.request = request
|
||||
self.response = response
|
||||
|
||||
mock_module.HTTPStatusError = MockHTTPStatusError
|
||||
with patch.dict("sys.modules", {"httpx": mock_module}):
|
||||
yield mock_module
|
||||
|
||||
|
||||
class TestConfiguration:
|
||||
def test_disabled_when_unconfigured(self):
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
tool = RegistryProxyTool()
|
||||
result = tool.search("backlinks for a domain")
|
||||
assert "not configured" in result["error"]
|
||||
|
||||
def test_reads_env_vars(self):
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
with patch.dict(os.environ, {
|
||||
"TOOL_PROXY_URL": "https://registry.example.com/",
|
||||
"TOOL_PROXY_TOKEN": "secret-token",
|
||||
}):
|
||||
tool = RegistryProxyTool()
|
||||
assert tool.proxy_url == "https://registry.example.com"
|
||||
assert tool.token == "secret-token"
|
||||
assert tool._headers()["Authorization"] == "Bearer secret-token"
|
||||
|
||||
def test_explicit_args_override_env(self):
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
tool = RegistryProxyTool(proxy_url="https://self.host/", token="tok")
|
||||
assert tool.proxy_url == "https://self.host"
|
||||
assert tool.token == "tok"
|
||||
|
||||
def test_env_token_not_leaked_to_supplied_url(self):
|
||||
"""Security: an agent-supplied proxy_url must not receive the env token."""
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
with patch.dict(os.environ, {
|
||||
"TOOL_PROXY_URL": "https://trusted.example.com",
|
||||
"TOOL_PROXY_TOKEN": "secret-token",
|
||||
}):
|
||||
tool = RegistryProxyTool(proxy_url="https://attacker.example.com")
|
||||
assert tool.proxy_url == "https://attacker.example.com"
|
||||
assert tool.token is None
|
||||
assert "Authorization" not in tool._headers()
|
||||
|
||||
def test_env_token_used_for_env_url(self):
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
with patch.dict(os.environ, {
|
||||
"TOOL_PROXY_URL": "https://trusted.example.com",
|
||||
"TOOL_PROXY_TOKEN": "secret-token",
|
||||
}):
|
||||
tool = RegistryProxyTool()
|
||||
assert tool._headers()["Authorization"] == "Bearer secret-token"
|
||||
|
||||
|
||||
class TestAuthHeader:
|
||||
def test_auth_header_configurable(self):
|
||||
"""Default sends Bearer; a custom header name sends the raw token."""
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
default = RegistryProxyTool(proxy_url="https://r.example.com", token="tok")
|
||||
assert default._headers()["Authorization"] == "Bearer tok"
|
||||
assert "X-Treg-Token" not in default._headers()
|
||||
|
||||
custom = RegistryProxyTool(
|
||||
proxy_url="https://r.example.com", token="tok", auth_header="X-Treg-Token"
|
||||
)
|
||||
headers = custom._headers()
|
||||
assert headers["X-Treg-Token"] == "tok"
|
||||
assert "Authorization" not in headers
|
||||
|
||||
def test_auth_header_from_env(self):
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
with patch.dict(os.environ, {
|
||||
"TOOL_PROXY_URL": "https://trusted.example.com",
|
||||
"TOOL_PROXY_TOKEN": "secret-token",
|
||||
"TOOL_PROXY_AUTH_HEADER": "X-Treg-Token",
|
||||
}):
|
||||
tool = RegistryProxyTool()
|
||||
headers = tool._headers()
|
||||
assert headers["X-Treg-Token"] == "secret-token"
|
||||
assert "Authorization" not in headers
|
||||
|
||||
|
||||
class TestSearch:
|
||||
def test_search_success(self, mock_httpx):
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
response = Mock()
|
||||
response.json.return_value = {"matches": [{"id": "seo.backlinks"}]}
|
||||
response.raise_for_status.return_value = None
|
||||
client = Mock()
|
||||
client.get.return_value = response
|
||||
mock_httpx.Client.return_value.__enter__.return_value = client
|
||||
|
||||
tool = RegistryProxyTool(proxy_url="https://r.example.com", token="t")
|
||||
result = tool.search("backlinks for a domain")
|
||||
|
||||
assert result["matches"][0]["id"] == "seo.backlinks"
|
||||
client.get.assert_called_once_with(
|
||||
"https://r.example.com/catalog/search",
|
||||
params={"q": "backlinks for a domain"},
|
||||
headers={"Content-Type": "application/json", "Authorization": "Bearer t"},
|
||||
)
|
||||
|
||||
def test_search_requires_query(self):
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
tool = RegistryProxyTool(proxy_url="https://r.example.com")
|
||||
assert tool.search("")["error"] == "query is required"
|
||||
|
||||
|
||||
class TestDescribe:
|
||||
def test_describe_success(self, mock_httpx):
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
response = Mock()
|
||||
response.json.return_value = {"id": "seo.backlinks", "price_per_call": 0.01}
|
||||
response.raise_for_status.return_value = None
|
||||
client = Mock()
|
||||
client.get.return_value = response
|
||||
mock_httpx.Client.return_value.__enter__.return_value = client
|
||||
|
||||
tool = RegistryProxyTool(proxy_url="https://r.example.com")
|
||||
result = tool.describe("seo.backlinks")
|
||||
|
||||
assert result["price_per_call"] == 0.01
|
||||
client.get.assert_called_once_with(
|
||||
"https://r.example.com/catalog/endpoints/seo.backlinks",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
def test_describe_uses_endpoints_path(self, mock_httpx):
|
||||
"""The real registry route is /catalog/endpoints/{id}, not /catalog/{id}."""
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
response = Mock()
|
||||
response.json.return_value = {"id": "diffbot.people.enrich", "method": "GET"}
|
||||
response.raise_for_status.return_value = None
|
||||
client = Mock()
|
||||
client.get.return_value = response
|
||||
mock_httpx.Client.return_value.__enter__.return_value = client
|
||||
|
||||
tool = RegistryProxyTool(proxy_url="https://r.example.com", token="t")
|
||||
tool.describe("diffbot.people.enrich")
|
||||
|
||||
client.get.assert_called_once_with(
|
||||
"https://r.example.com/catalog/endpoints/diffbot.people.enrich",
|
||||
headers={"Content-Type": "application/json", "Authorization": "Bearer t"},
|
||||
)
|
||||
|
||||
def test_describe_path_configurable(self, mock_httpx):
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
response = Mock()
|
||||
response.json.return_value = {"id": "seo.backlinks"}
|
||||
response.raise_for_status.return_value = None
|
||||
client = Mock()
|
||||
client.get.return_value = response
|
||||
mock_httpx.Client.return_value.__enter__.return_value = client
|
||||
|
||||
tool = RegistryProxyTool(
|
||||
proxy_url="https://r.example.com",
|
||||
describe_path="/catalog/{tool_id}",
|
||||
)
|
||||
tool.describe("seo.backlinks")
|
||||
|
||||
client.get.assert_called_once_with(
|
||||
"https://r.example.com/catalog/seo.backlinks",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
def test_describe_path_from_env(self, mock_httpx):
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
response = Mock()
|
||||
response.json.return_value = {"id": "seo.backlinks"}
|
||||
response.raise_for_status.return_value = None
|
||||
client = Mock()
|
||||
client.get.return_value = response
|
||||
mock_httpx.Client.return_value.__enter__.return_value = client
|
||||
|
||||
with patch.dict(os.environ, {
|
||||
"TOOL_PROXY_URL": "https://r.example.com",
|
||||
"TOOL_PROXY_DESCRIBE_PATH": "/v2/endpoints/{tool_id}",
|
||||
}):
|
||||
tool = RegistryProxyTool()
|
||||
tool.describe("seo.backlinks")
|
||||
|
||||
client.get.assert_called_once_with(
|
||||
"https://r.example.com/v2/endpoints/seo.backlinks",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
def test_describe_path_missing_placeholder_errors(self, mock_httpx):
|
||||
"""A template lacking {tool_id} must return an error, not silently misroute."""
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
client = Mock()
|
||||
mock_httpx.Client.return_value.__enter__.return_value = client
|
||||
|
||||
tool = RegistryProxyTool(
|
||||
proxy_url="https://r.example.com",
|
||||
describe_path="/catalog/static",
|
||||
)
|
||||
result = tool.describe("seo.backlinks")
|
||||
|
||||
assert "must contain '{tool_id}'" in result["error"]
|
||||
client.get.assert_not_called()
|
||||
|
||||
def test_describe_path_malformed_template_errors(self, mock_httpx):
|
||||
"""A malformed template must return an error instead of raising."""
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
client = Mock()
|
||||
mock_httpx.Client.return_value.__enter__.return_value = client
|
||||
|
||||
tool = RegistryProxyTool(
|
||||
proxy_url="https://r.example.com",
|
||||
describe_path="/catalog/{tool_id}/{unknown}",
|
||||
)
|
||||
result = tool.describe("seo.backlinks")
|
||||
|
||||
assert "Invalid describe path template" in result["error"]
|
||||
client.get.assert_not_called()
|
||||
|
||||
def test_describe_encodes_tool_id(self, mock_httpx):
|
||||
"""A tool_id with path-changing characters is URL-encoded."""
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
response = Mock()
|
||||
response.json.return_value = {"id": "x"}
|
||||
response.raise_for_status.return_value = None
|
||||
client = Mock()
|
||||
client.get.return_value = response
|
||||
mock_httpx.Client.return_value.__enter__.return_value = client
|
||||
|
||||
tool = RegistryProxyTool(proxy_url="https://r.example.com")
|
||||
tool.describe("../admin?x=1")
|
||||
|
||||
client.get.assert_called_once_with(
|
||||
"https://r.example.com/catalog/endpoints/..%2Fadmin%3Fx%3D1",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
|
||||
class TestCall:
|
||||
def test_call_success(self, mock_httpx):
|
||||
"""Explicit method=POST skips describe and posts a JSON body."""
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
response = Mock()
|
||||
response.json.return_value = {"data": {"backlinks": 42}}
|
||||
response.raise_for_status.return_value = None
|
||||
client = Mock()
|
||||
client.request.return_value = response
|
||||
mock_httpx.Client.return_value.__enter__.return_value = client
|
||||
|
||||
tool = RegistryProxyTool(proxy_url="https://r.example.com", token="t")
|
||||
result = tool.call("seo.backlinks", {"domain": "example.com"}, method="POST")
|
||||
|
||||
assert result["data"]["backlinks"] == 42
|
||||
client.request.assert_called_once_with(
|
||||
"POST",
|
||||
"https://r.example.com/call/seo.backlinks",
|
||||
json={"domain": "example.com"},
|
||||
headers={"Content-Type": "application/json", "Authorization": "Bearer t"},
|
||||
)
|
||||
client.get.assert_not_called()
|
||||
|
||||
def test_call_requires_tool_id(self):
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
tool = RegistryProxyTool(proxy_url="https://r.example.com")
|
||||
assert tool.call("")["error"] == "tool_id is required"
|
||||
|
||||
def test_call_normalizes_explicit_method_whitespace(self, mock_httpx):
|
||||
"""An explicit method with whitespace/casing is normalized (GET -> query)."""
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
response = Mock()
|
||||
response.json.return_value = {"data": {"ok": True}}
|
||||
response.raise_for_status.return_value = None
|
||||
client = Mock()
|
||||
client.get.return_value = response
|
||||
mock_httpx.Client.return_value.__enter__.return_value = client
|
||||
|
||||
tool = RegistryProxyTool(proxy_url="https://r.example.com")
|
||||
result = tool.call("t.id", {"q": "x"}, method=" get ")
|
||||
|
||||
assert result["data"]["ok"] is True
|
||||
client.get.assert_called_once_with(
|
||||
"https://r.example.com/call/t.id",
|
||||
params={"q": "x"},
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
client.request.assert_not_called()
|
||||
|
||||
def test_call_rejects_unsupported_method(self, mock_httpx):
|
||||
"""An unsupported/mislabelled method is rejected before any request."""
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
client = Mock()
|
||||
mock_httpx.Client.return_value.__enter__.return_value = client
|
||||
|
||||
tool = RegistryProxyTool(proxy_url="https://r.example.com")
|
||||
result = tool.call("t.id", {}, method="TRACE")
|
||||
|
||||
assert "Unsupported HTTP method 'TRACE'" in result["error"]
|
||||
client.get.assert_not_called()
|
||||
client.request.assert_not_called()
|
||||
|
||||
def test_call_encodes_tool_id_in_url(self, mock_httpx):
|
||||
"""A tool_id with path-changing characters is URL-encoded on /call."""
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
response = Mock()
|
||||
response.json.return_value = {"data": "ok"}
|
||||
response.raise_for_status.return_value = None
|
||||
client = Mock()
|
||||
client.request.return_value = response
|
||||
mock_httpx.Client.return_value.__enter__.return_value = client
|
||||
|
||||
tool = RegistryProxyTool(proxy_url="https://r.example.com")
|
||||
tool.call("../admin", {}, method="POST")
|
||||
|
||||
client.request.assert_called_once_with(
|
||||
"POST",
|
||||
"https://r.example.com/call/..%2Fadmin",
|
||||
json={},
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
def test_call_derives_post_method_from_describe(self, mock_httpx):
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
describe_resp = Mock()
|
||||
describe_resp.json.return_value = {"id": "seo.backlinks", "method": "POST"}
|
||||
describe_resp.raise_for_status.return_value = None
|
||||
call_resp = Mock()
|
||||
call_resp.json.return_value = {"data": {"backlinks": 42}}
|
||||
call_resp.raise_for_status.return_value = None
|
||||
client = Mock()
|
||||
client.get.return_value = describe_resp
|
||||
client.request.return_value = call_resp
|
||||
mock_httpx.Client.return_value.__enter__.return_value = client
|
||||
|
||||
tool = RegistryProxyTool(proxy_url="https://r.example.com", token="t")
|
||||
result = tool.call("seo.backlinks", {"domain": "example.com"})
|
||||
|
||||
assert result["data"]["backlinks"] == 42
|
||||
client.request.assert_called_once_with(
|
||||
"POST",
|
||||
"https://r.example.com/call/seo.backlinks",
|
||||
json={"domain": "example.com"},
|
||||
headers={"Content-Type": "application/json", "Authorization": "Bearer t"},
|
||||
)
|
||||
|
||||
def test_call_uses_declared_method(self, mock_httpx):
|
||||
"""describe says GET -> GET with query params (no JSON body)."""
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
describe_resp = Mock()
|
||||
describe_resp.json.return_value = {
|
||||
"id": "diffbot.people.enrich", "method": "GET"
|
||||
}
|
||||
describe_resp.raise_for_status.return_value = None
|
||||
call_resp = Mock()
|
||||
call_resp.json.return_value = {"data": {"name": "Jane"}}
|
||||
call_resp.raise_for_status.return_value = None
|
||||
client = Mock()
|
||||
client.get.side_effect = [describe_resp, call_resp]
|
||||
mock_httpx.Client.return_value.__enter__.return_value = client
|
||||
|
||||
tool = RegistryProxyTool(proxy_url="https://r.example.com", token="t")
|
||||
result = tool.call("diffbot.people.enrich", {"query": "type=email"})
|
||||
|
||||
assert result["data"]["name"] == "Jane"
|
||||
client.request.assert_not_called()
|
||||
# First get = describe, second get = the GET call with query params.
|
||||
assert client.get.call_count == 2
|
||||
call_args = client.get.call_args_list[1]
|
||||
assert call_args.args[0] == "https://r.example.com/call/diffbot.people.enrich"
|
||||
assert call_args.kwargs["params"] == {"query": "type=email"}
|
||||
|
||||
def test_call_explicit_get_skips_describe(self, mock_httpx):
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
call_resp = Mock()
|
||||
call_resp.json.return_value = {"data": "ok"}
|
||||
call_resp.raise_for_status.return_value = None
|
||||
client = Mock()
|
||||
client.get.return_value = call_resp
|
||||
mock_httpx.Client.return_value.__enter__.return_value = client
|
||||
|
||||
tool = RegistryProxyTool(proxy_url="https://r.example.com", token="t")
|
||||
result = tool.call("diffbot.people.enrich", {"q": "x"}, method="GET")
|
||||
|
||||
assert result["data"] == "ok"
|
||||
client.get.assert_called_once_with(
|
||||
"https://r.example.com/call/diffbot.people.enrich",
|
||||
params={"q": "x"},
|
||||
headers={"Content-Type": "application/json", "Authorization": "Bearer t"},
|
||||
)
|
||||
|
||||
def test_call_defaults_to_post_when_method_absent(self, mock_httpx):
|
||||
"""describe omits method -> default to POST with JSON body."""
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
describe_resp = Mock()
|
||||
describe_resp.json.return_value = {"id": "seo.backlinks"}
|
||||
describe_resp.raise_for_status.return_value = None
|
||||
call_resp = Mock()
|
||||
call_resp.json.return_value = {"data": "ok"}
|
||||
call_resp.raise_for_status.return_value = None
|
||||
client = Mock()
|
||||
client.get.return_value = describe_resp
|
||||
client.request.return_value = call_resp
|
||||
mock_httpx.Client.return_value.__enter__.return_value = client
|
||||
|
||||
tool = RegistryProxyTool(proxy_url="https://r.example.com")
|
||||
result = tool.call("seo.backlinks", {"domain": "x"})
|
||||
|
||||
assert result["data"] == "ok"
|
||||
client.request.assert_called_once_with(
|
||||
"POST",
|
||||
"https://r.example.com/call/seo.backlinks",
|
||||
json={"domain": "x"},
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
def test_guarded_call_works_on_get_endpoint(self, mock_httpx):
|
||||
"""Regression: spend guard + GET endpoint end-to-end (describe fetched once)."""
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
describe_resp = Mock()
|
||||
describe_resp.json.return_value = {
|
||||
"id": "diffbot.people.enrich", "method": "GET", "price_per_call": 0.05
|
||||
}
|
||||
describe_resp.raise_for_status.return_value = None
|
||||
call_resp = Mock()
|
||||
call_resp.json.return_value = {"data": "ok", "price_per_call": 0.05}
|
||||
call_resp.raise_for_status.return_value = None
|
||||
client = Mock()
|
||||
client.get.side_effect = [describe_resp, call_resp]
|
||||
mock_httpx.Client.return_value.__enter__.return_value = client
|
||||
|
||||
tool = RegistryProxyTool(
|
||||
proxy_url="https://r.example.com", max_session_spend=1.0
|
||||
)
|
||||
result = tool.call("diffbot.people.enrich", {"query": "type=email"})
|
||||
|
||||
assert result["data"] == "ok"
|
||||
assert tool._session_spend == pytest.approx(0.05)
|
||||
client.request.assert_not_called()
|
||||
# describe reused from the guard: exactly one describe + one GET call.
|
||||
assert client.get.call_count == 2
|
||||
assert client.get.call_args_list[1].kwargs["params"] == {"query": "type=email"}
|
||||
|
||||
|
||||
# The live registry (v0.11.0) nests the endpoint fields under an ``endpoint``
|
||||
# key and expresses cost as a structured object; fixtures mirror that shape so
|
||||
# unit tests exercise the real wire schema, not a flattened approximation.
|
||||
_LIVE_ENDPOINT_DOC = {
|
||||
"endpoint": {
|
||||
"id": "diffbot.people.enrich",
|
||||
"method": "GET",
|
||||
"cost": {
|
||||
"type": "per_call",
|
||||
"value": 25,
|
||||
"currency": "credit",
|
||||
"per": 1,
|
||||
"usd": 0.0299,
|
||||
},
|
||||
},
|
||||
"provider": {},
|
||||
"siblings": [],
|
||||
"call_template": "…",
|
||||
"example_response": {},
|
||||
"hints": [],
|
||||
}
|
||||
|
||||
|
||||
class TestExtractors:
|
||||
def test_extract_method_nested_endpoint_envelope(self):
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
assert RegistryProxyTool._extract_method(
|
||||
{"endpoint": {"method": "GET"}}
|
||||
) == "GET"
|
||||
# Flat form still works.
|
||||
assert RegistryProxyTool._extract_method({"method": "post"}) == "POST"
|
||||
# Absent -> None (default POST applies downstream).
|
||||
assert RegistryProxyTool._extract_method({"endpoint": {}}) is None
|
||||
assert RegistryProxyTool._extract_method({}) is None
|
||||
|
||||
def test_extract_price_structured_cost_usd(self):
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
# Nested endpoint.cost.usd is preferred (guard budgets are USD).
|
||||
assert RegistryProxyTool._extract_price(_LIVE_ENDPOINT_DOC) == 0.0299
|
||||
assert RegistryProxyTool._extract_price(
|
||||
{"cost": {"usd": 0.0299, "value": 25}}
|
||||
) == 0.0299
|
||||
# Flat numeric forms unchanged.
|
||||
assert RegistryProxyTool._extract_price({"price_per_call": 0.05}) == 0.05
|
||||
assert RegistryProxyTool._extract_price({"price": 0.02}) == 0.02
|
||||
assert RegistryProxyTool._extract_price({"cost": 0.03}) == 0.03
|
||||
# Nested flat number under the envelope.
|
||||
assert RegistryProxyTool._extract_price(
|
||||
{"endpoint": {"price_per_call": 0.07}}
|
||||
) == 0.07
|
||||
# Non-numeric/absent -> None (fail-closed downstream).
|
||||
assert RegistryProxyTool._extract_price({"cost": {"value": 25}}) is None
|
||||
assert RegistryProxyTool._extract_price({}) is None
|
||||
|
||||
|
||||
class TestLiveEnvelope:
|
||||
def test_call_auto_derives_get_from_live_shape(self, mock_httpx):
|
||||
"""The live nested envelope must yield an auto-derived GET (no method=)."""
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
describe_resp = Mock()
|
||||
describe_resp.json.return_value = _LIVE_ENDPOINT_DOC
|
||||
describe_resp.raise_for_status.return_value = None
|
||||
call_resp = Mock()
|
||||
call_resp.json.return_value = {"data": {"name": "Jane"}}
|
||||
call_resp.raise_for_status.return_value = None
|
||||
client = Mock()
|
||||
client.get.side_effect = [describe_resp, call_resp]
|
||||
mock_httpx.Client.return_value.__enter__.return_value = client
|
||||
|
||||
tool = RegistryProxyTool(proxy_url="https://r.example.com", token="t")
|
||||
result = tool.call("diffbot.people.enrich", {"query": "type=email"})
|
||||
|
||||
assert result["data"]["name"] == "Jane"
|
||||
client.request.assert_not_called()
|
||||
assert client.get.call_count == 2
|
||||
call_args = client.get.call_args_list[1]
|
||||
assert call_args.args[0] == "https://r.example.com/call/diffbot.people.enrich"
|
||||
assert call_args.kwargs["params"] == {"query": "type=email"}
|
||||
|
||||
def test_guard_quotes_real_price_from_live_shape(self, mock_httpx):
|
||||
"""Guard must quote the structured cost's usd, not fail-closed."""
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
describe_resp = Mock()
|
||||
describe_resp.json.return_value = _LIVE_ENDPOINT_DOC
|
||||
describe_resp.raise_for_status.return_value = None
|
||||
client = Mock()
|
||||
client.get.return_value = describe_resp
|
||||
mock_httpx.Client.return_value.__enter__.return_value = client
|
||||
|
||||
tool = RegistryProxyTool(
|
||||
proxy_url="https://r.example.com", max_cost_per_call=0.01
|
||||
)
|
||||
result = tool.call("diffbot.people.enrich", {"query": "type=email"})
|
||||
|
||||
assert "exceeds max_cost_per_call" in result["error"]
|
||||
assert "0.0299" in result["error"]
|
||||
assert "cannot enforce" not in result["error"]
|
||||
client.request.assert_not_called()
|
||||
|
||||
|
||||
class TestErrorTaxonomy:
|
||||
def test_auth_error(self, mock_httpx):
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
resp = Mock()
|
||||
resp.status_code = 401
|
||||
resp.text = "Unauthorized"
|
||||
client = Mock()
|
||||
client.get.side_effect = mock_httpx.HTTPStatusError("401", response=resp)
|
||||
mock_httpx.Client.return_value.__enter__.return_value = client
|
||||
|
||||
tool = RegistryProxyTool(proxy_url="https://r.example.com", token="bad")
|
||||
result = tool.search("x")
|
||||
assert "Authentication failed" in result["error"]
|
||||
|
||||
def test_insufficient_balance_error(self, mock_httpx):
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
resp = Mock()
|
||||
resp.status_code = 402
|
||||
resp.text = "Payment Required"
|
||||
client = Mock()
|
||||
client.request.side_effect = mock_httpx.HTTPStatusError("402", response=resp)
|
||||
mock_httpx.Client.return_value.__enter__.return_value = client
|
||||
|
||||
tool = RegistryProxyTool(proxy_url="https://r.example.com")
|
||||
result = tool.call("seo.backlinks", {}, method="POST")
|
||||
assert "Insufficient balance" in result["error"]
|
||||
|
||||
def test_upstream_4xx_passthrough(self, mock_httpx):
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
resp = Mock()
|
||||
resp.status_code = 404
|
||||
resp.text = "Not Found"
|
||||
client = Mock()
|
||||
client.request.side_effect = mock_httpx.HTTPStatusError("404", response=resp)
|
||||
mock_httpx.Client.return_value.__enter__.return_value = client
|
||||
|
||||
tool = RegistryProxyTool(proxy_url="https://r.example.com")
|
||||
result = tool.call("seo.backlinks", {}, method="POST")
|
||||
assert "HTTP 404" in result["error"]
|
||||
|
||||
def test_timeout_error(self, mock_httpx):
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
client = Mock()
|
||||
client.get.side_effect = mock_httpx.TimeoutException("timed out")
|
||||
mock_httpx.Client.return_value.__enter__.return_value = client
|
||||
|
||||
tool = RegistryProxyTool(proxy_url="https://r.example.com", timeout=5.0)
|
||||
result = tool.search("x")
|
||||
assert "timed out after 5.0" in result["error"]
|
||||
|
||||
def test_missing_httpx(self):
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
tool = RegistryProxyTool(proxy_url="https://r.example.com")
|
||||
with patch.dict("sys.modules", {"httpx": None}):
|
||||
with patch("builtins.__import__", side_effect=ImportError("no httpx")):
|
||||
result = tool.search("x")
|
||||
assert "httpx not installed" in result["error"]
|
||||
assert "praisonai-tools[registry-proxy]" in result["error"]
|
||||
|
||||
|
||||
class TestSpendGuards:
|
||||
def test_max_cost_per_call_denies(self, mock_httpx):
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
describe_resp = Mock()
|
||||
describe_resp.json.return_value = {"id": "seo.backlinks", "price_per_call": 0.50}
|
||||
describe_resp.raise_for_status.return_value = None
|
||||
client = Mock()
|
||||
client.get.return_value = describe_resp
|
||||
mock_httpx.Client.return_value.__enter__.return_value = client
|
||||
|
||||
tool = RegistryProxyTool(proxy_url="https://r.example.com", max_cost_per_call=0.10)
|
||||
result = tool.call("seo.backlinks", {})
|
||||
assert "exceeds max_cost_per_call" in result["error"]
|
||||
client.request.assert_not_called()
|
||||
|
||||
def test_max_session_spend_denies(self, mock_httpx):
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
describe_resp = Mock()
|
||||
describe_resp.json.return_value = {"price_per_call": 0.60}
|
||||
describe_resp.raise_for_status.return_value = None
|
||||
client = Mock()
|
||||
client.get.return_value = describe_resp
|
||||
mock_httpx.Client.return_value.__enter__.return_value = client
|
||||
|
||||
tool = RegistryProxyTool(proxy_url="https://r.example.com", max_session_spend=1.0)
|
||||
tool._session_spend = 0.50
|
||||
result = tool.call("seo.backlinks", {})
|
||||
assert "max_session_spend" in result["error"]
|
||||
client.request.assert_not_called()
|
||||
|
||||
def test_within_budget_allows_and_tracks_spend(self, mock_httpx):
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
describe_resp = Mock()
|
||||
describe_resp.json.return_value = {"price_per_call": 0.05}
|
||||
describe_resp.raise_for_status.return_value = None
|
||||
call_resp = Mock()
|
||||
call_resp.json.return_value = {"data": "ok", "price_per_call": 0.05}
|
||||
call_resp.raise_for_status.return_value = None
|
||||
client = Mock()
|
||||
client.get.return_value = describe_resp
|
||||
client.request.return_value = call_resp
|
||||
mock_httpx.Client.return_value.__enter__.return_value = client
|
||||
|
||||
tool = RegistryProxyTool(proxy_url="https://r.example.com", max_session_spend=1.0)
|
||||
result = tool.call("seo.backlinks", {})
|
||||
assert result["data"] == "ok"
|
||||
assert tool._session_spend == pytest.approx(0.05)
|
||||
|
||||
@pytest.mark.parametrize("field", ["price_per_call", "price", "cost"])
|
||||
def test_budget_check_accepts_price_and_cost_fields(self, mock_httpx, field):
|
||||
"""Budget check tolerates price_per_call/price/cost, matching recording."""
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
describe_resp = Mock()
|
||||
describe_resp.json.return_value = {"id": "seo.backlinks", field: 0.05}
|
||||
describe_resp.raise_for_status.return_value = None
|
||||
call_resp = Mock()
|
||||
call_resp.json.return_value = {"data": "ok", field: 0.05}
|
||||
call_resp.raise_for_status.return_value = None
|
||||
client = Mock()
|
||||
client.get.return_value = describe_resp
|
||||
client.request.return_value = call_resp
|
||||
mock_httpx.Client.return_value.__enter__.return_value = client
|
||||
|
||||
tool = RegistryProxyTool(proxy_url="https://r.example.com", max_session_spend=1.0)
|
||||
result = tool.call("seo.backlinks", {})
|
||||
assert result["data"] == "ok"
|
||||
assert tool._session_spend == pytest.approx(0.05)
|
||||
|
||||
def test_missing_price_fails_closed(self, mock_httpx):
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
describe_resp = Mock()
|
||||
describe_resp.json.return_value = {"id": "seo.backlinks"}
|
||||
describe_resp.raise_for_status.return_value = None
|
||||
client = Mock()
|
||||
client.get.return_value = describe_resp
|
||||
mock_httpx.Client.return_value.__enter__.return_value = client
|
||||
|
||||
tool = RegistryProxyTool(proxy_url="https://r.example.com", max_cost_per_call=0.10)
|
||||
result = tool.call("seo.backlinks", {})
|
||||
assert "cannot enforce" in result["error"]
|
||||
client.request.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize("bad_price", ["NaN", "Infinity", "-Infinity", -0.01])
|
||||
def test_non_finite_or_negative_price_fails_closed(self, mock_httpx, bad_price):
|
||||
"""NaN/inf/negative prices must be rejected, not silently bypass the guard."""
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
describe_resp = Mock()
|
||||
describe_resp.json.return_value = {"price_per_call": bad_price}
|
||||
describe_resp.raise_for_status.return_value = None
|
||||
client = Mock()
|
||||
client.get.return_value = describe_resp
|
||||
mock_httpx.Client.return_value.__enter__.return_value = client
|
||||
|
||||
tool = RegistryProxyTool(proxy_url="https://r.example.com", max_cost_per_call=0.10)
|
||||
result = tool.call("seo.backlinks", {})
|
||||
assert "cannot enforce" in result["error"]
|
||||
client.request.assert_not_called()
|
||||
|
||||
def test_non_finite_response_charge_does_not_corrupt_spend(self, mock_httpx):
|
||||
"""A NaN/inf charge in the call response must not poison cumulative spend."""
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
describe_resp = Mock()
|
||||
describe_resp.json.return_value = {"price_per_call": 0.20}
|
||||
describe_resp.raise_for_status.return_value = None
|
||||
call_resp = Mock()
|
||||
call_resp.json.return_value = {"data": "ok", "price_per_call": "NaN"}
|
||||
call_resp.raise_for_status.return_value = None
|
||||
client = Mock()
|
||||
client.get.return_value = describe_resp
|
||||
client.request.return_value = call_resp
|
||||
mock_httpx.Client.return_value.__enter__.return_value = client
|
||||
|
||||
tool = RegistryProxyTool(proxy_url="https://r.example.com", max_session_spend=1.0)
|
||||
result = tool.call("seo.backlinks", {})
|
||||
assert result["data"] == "ok"
|
||||
assert tool._session_spend == 0.20
|
||||
|
||||
def test_session_spend_persists_across_registry_call(self, mock_httpx):
|
||||
"""max_session_spend must accumulate across separate registry_call calls."""
|
||||
from praisonai_tools.registry_proxy import registry_call
|
||||
|
||||
describe_resp = Mock()
|
||||
describe_resp.json.return_value = {"price_per_call": 0.60}
|
||||
describe_resp.raise_for_status.return_value = None
|
||||
call_resp = Mock()
|
||||
call_resp.json.return_value = {"data": "ok", "price_per_call": 0.60}
|
||||
call_resp.raise_for_status.return_value = None
|
||||
client = Mock()
|
||||
client.get.return_value = describe_resp
|
||||
client.request.return_value = call_resp
|
||||
mock_httpx.Client.return_value.__enter__.return_value = client
|
||||
|
||||
first = registry_call(
|
||||
"seo.backlinks", {},
|
||||
proxy_url="https://r.example.com", token="t",
|
||||
max_session_spend=1.0,
|
||||
)
|
||||
assert first["data"] == "ok"
|
||||
|
||||
second = registry_call(
|
||||
"seo.backlinks", {},
|
||||
proxy_url="https://r.example.com", token="t",
|
||||
max_session_spend=1.0,
|
||||
)
|
||||
assert "max_session_spend" in second["error"]
|
||||
|
||||
|
||||
class TestDecoratedFunctions:
|
||||
@patch("praisonai_tools.registry_proxy.registry_proxy.RegistryProxyTool")
|
||||
def test_registry_search_function(self, mock_cls):
|
||||
from praisonai_tools.registry_proxy import registry_search
|
||||
|
||||
instance = Mock()
|
||||
instance.search.return_value = {"matches": []}
|
||||
mock_cls.return_value = instance
|
||||
|
||||
registry_search("q", proxy_url="https://r", token="t")
|
||||
mock_cls.assert_called_once_with(
|
||||
proxy_url="https://r", token="t", auth_header=None
|
||||
)
|
||||
instance.search.assert_called_once_with("q")
|
||||
|
||||
@patch("praisonai_tools.registry_proxy.registry_proxy.RegistryProxyTool")
|
||||
def test_registry_call_function_passes_guards(self, mock_cls):
|
||||
from praisonai_tools.registry_proxy import registry_call
|
||||
|
||||
instance = Mock()
|
||||
instance.call.return_value = {"data": "ok"}
|
||||
mock_cls.return_value = instance
|
||||
|
||||
registry_call(
|
||||
"seo.backlinks",
|
||||
{"domain": "x"},
|
||||
proxy_url="https://r",
|
||||
token="t",
|
||||
max_cost_per_call=0.1,
|
||||
max_session_spend=1.0,
|
||||
)
|
||||
mock_cls.assert_called_once_with(
|
||||
proxy_url="https://r",
|
||||
token="t",
|
||||
max_cost_per_call=0.1,
|
||||
max_session_spend=1.0,
|
||||
auth_header=None,
|
||||
describe_path=None,
|
||||
)
|
||||
instance.call.assert_called_once_with("seo.backlinks", {"domain": "x"}, None)
|
||||
|
||||
@patch("praisonai_tools.registry_proxy.registry_proxy.RegistryProxyTool")
|
||||
def test_registry_call_function_passes_method(self, mock_cls):
|
||||
from praisonai_tools.registry_proxy import registry_call
|
||||
|
||||
instance = Mock()
|
||||
instance.call.return_value = {"data": "ok"}
|
||||
mock_cls.return_value = instance
|
||||
|
||||
registry_call(
|
||||
"diffbot.people.enrich",
|
||||
{"query": "type=email"},
|
||||
proxy_url="https://r",
|
||||
token="t",
|
||||
method="GET",
|
||||
)
|
||||
instance.call.assert_called_once_with(
|
||||
"diffbot.people.enrich", {"query": "type=email"}, "GET"
|
||||
)
|
||||
|
||||
|
||||
def test_smoke_import():
|
||||
from praisonai_tools.registry_proxy import (
|
||||
RegistryProxyTool,
|
||||
registry_search,
|
||||
registry_describe,
|
||||
registry_call,
|
||||
)
|
||||
|
||||
assert callable(registry_search)
|
||||
assert callable(registry_describe)
|
||||
assert callable(registry_call)
|
||||
assert RegistryProxyTool().name == "registry_proxy"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_live_smoke():
|
||||
"""Gated live smoke test; skipped unless TOOL_PROXY_URL is configured."""
|
||||
proxy_url = os.getenv("TOOL_PROXY_URL")
|
||||
if not proxy_url:
|
||||
pytest.skip("TOOL_PROXY_URL not set")
|
||||
from praisonai_tools.registry_proxy import registry_search
|
||||
|
||||
result = registry_search("backlinks for a domain")
|
||||
assert isinstance(result, dict)
|
||||
assert "error" not in result
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Tier 0: pin that registry-installed skills (./.claude/skills/) are discoverable.
|
||||
|
||||
Registries that expose ``skill install <name>`` install into ``./.claude/skills/``.
|
||||
PraisonAI's skill discovery already scans that directory, so registry-installed
|
||||
skills are discoverable with zero connector code. This test pins that contract.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
|
||||
|
||||
def test_claude_skills_dir_is_scanned(tmp_path, monkeypatch):
|
||||
discovery = pytest.importorskip("praisonaiagents.skills.discovery")
|
||||
|
||||
skills_dir = tmp_path / ".claude" / "skills" / "my-registry-skill"
|
||||
skills_dir.mkdir(parents=True)
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
dirs = discovery.get_default_skill_dirs()
|
||||
|
||||
claude_skills = tmp_path / ".claude" / "skills"
|
||||
assert any(os.path.samefile(str(d), str(claude_skills)) for d in dirs), (
|
||||
"PraisonAI must scan ./.claude/skills/ so registry-installed skills are "
|
||||
"discoverable with zero connector code."
|
||||
)
|
||||
@@ -0,0 +1,451 @@
|
||||
"""Tests for SwarmScore tool integration."""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
import requests as real_requests
|
||||
from urllib.parse import urlparse
|
||||
from unittest.mock import Mock, patch
|
||||
from praisonai_tools.tools.swarmscore_tool import (
|
||||
SwarmScoreTool,
|
||||
load_swarmscore_by_slug,
|
||||
verify_swarmscore_freshness,
|
||||
get_agent_discovery_manifest
|
||||
)
|
||||
from praisonai_tools.tools.base import ToolResult
|
||||
|
||||
|
||||
class TestSwarmScoreTool:
|
||||
"""Test cases for SwarmScoreTool class."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures."""
|
||||
self.tool = SwarmScoreTool()
|
||||
self.sample_score_data = {
|
||||
"score": 85,
|
||||
"tier": "gold",
|
||||
"jobs_completed": 150,
|
||||
"success_rate": 94.5,
|
||||
"verify_payload": {
|
||||
"signature": "test_signature",
|
||||
"timestamp": "2024-01-15T10:30:00Z",
|
||||
"agent_id": "test-agent-123"
|
||||
}
|
||||
}
|
||||
|
||||
@patch('praisonai_tools.tools.swarmscore_tool.requests')
|
||||
def test_load_swarmscore_success(self, mock_requests):
|
||||
"""Test successful SwarmScore loading."""
|
||||
# Setup mock response
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = self.sample_score_data
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_requests.get.return_value = mock_response
|
||||
|
||||
# Execute
|
||||
result = self.tool.load_swarmscore("test-agent")
|
||||
|
||||
# Verify
|
||||
assert result.success is True
|
||||
assert result.output == self.sample_score_data
|
||||
assert "test-agent" in result.metadata["message"]
|
||||
mock_requests.get.assert_called_once()
|
||||
|
||||
@patch('praisonai_tools.tools.swarmscore_tool.requests')
|
||||
def test_load_swarmscore_slug_url_encoded(self, mock_requests):
|
||||
"""Slugs with special characters must be URL-encoded to avoid malformed paths."""
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = self.sample_score_data
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_requests.get.return_value = mock_response
|
||||
|
||||
result = self.tool.load_swarmscore("acme/agent one")
|
||||
|
||||
assert result.success is True
|
||||
called_url = mock_requests.get.call_args[0][0]
|
||||
assert called_url.endswith("/load-by-slug/acme%2Fagent%20one")
|
||||
|
||||
@patch('praisonai_tools.tools.swarmscore_tool.requests')
|
||||
def test_load_swarmscore_network_error(self, mock_requests):
|
||||
"""Test SwarmScore loading with network error."""
|
||||
# Setup mock to raise exception
|
||||
mock_requests.exceptions = real_requests.exceptions
|
||||
mock_requests.get.side_effect = real_requests.exceptions.RequestException("Network error")
|
||||
|
||||
# Execute
|
||||
result = self.tool.load_swarmscore("test-agent")
|
||||
|
||||
# Verify
|
||||
assert result.success is False
|
||||
assert "Network error" in result.error
|
||||
|
||||
@patch('praisonai_tools.tools.swarmscore_tool.requests')
|
||||
def test_load_swarmscore_invalid_json(self, mock_requests):
|
||||
"""Test SwarmScore loading with invalid JSON response."""
|
||||
# Setup mock response with invalid JSON
|
||||
mock_response = Mock()
|
||||
mock_requests.exceptions = real_requests.exceptions
|
||||
mock_response.json.side_effect = json.JSONDecodeError("Invalid JSON", "", 0)
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_requests.get.return_value = mock_response
|
||||
|
||||
# Execute
|
||||
result = self.tool.load_swarmscore("test-agent")
|
||||
|
||||
# Verify
|
||||
assert result.success is False
|
||||
assert "Invalid response format" in result.error
|
||||
|
||||
@patch('praisonai_tools.tools.swarmscore_tool.requests')
|
||||
def test_verify_swarmscore_success(self, mock_requests):
|
||||
"""Test successful SwarmScore verification."""
|
||||
# Setup mock response
|
||||
verify_data = {"verified": True, "timestamp": "2024-01-15T10:30:00Z"}
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = verify_data
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_requests.post.return_value = mock_response
|
||||
|
||||
# Execute
|
||||
verify_payload = self.sample_score_data["verify_payload"]
|
||||
result = self.tool.verify_swarmscore(verify_payload)
|
||||
|
||||
# Verify
|
||||
assert result.success is True
|
||||
assert result.output == verify_data
|
||||
mock_requests.post.assert_called_once()
|
||||
|
||||
@patch('praisonai_tools.tools.swarmscore_tool.requests')
|
||||
def test_verify_swarmscore_error(self, mock_requests):
|
||||
"""Test SwarmScore verification with error."""
|
||||
# Setup mock to raise exception
|
||||
mock_requests.exceptions = real_requests.exceptions
|
||||
mock_requests.post.side_effect = real_requests.exceptions.RequestException("Verification failed")
|
||||
|
||||
# Execute
|
||||
result = self.tool.verify_swarmscore({"test": "payload"})
|
||||
|
||||
# Verify
|
||||
assert result.success is False
|
||||
assert "Verification failed" in result.error
|
||||
|
||||
@patch('praisonai_tools.tools.swarmscore_tool.requests')
|
||||
def test_get_discovery_manifest_success(self, mock_requests):
|
||||
"""Test successful discovery manifest retrieval."""
|
||||
# Setup mock response
|
||||
manifest_data = {
|
||||
"agents": ["agent1", "agent2"],
|
||||
"capabilities": ["trading", "analysis"],
|
||||
"version": "1.0"
|
||||
}
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = manifest_data
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_requests.get.return_value = mock_response
|
||||
|
||||
# Execute
|
||||
result = self.tool.get_discovery_manifest()
|
||||
|
||||
# Verify
|
||||
assert result.success is True
|
||||
assert result.output == manifest_data
|
||||
mock_requests.get.assert_called_once()
|
||||
|
||||
@patch('praisonai_tools.tools.swarmscore_tool.requests')
|
||||
def test_get_discovery_manifest_url_derivation(self, mock_requests):
|
||||
"""Discovery manifest URL must resolve to the domain root regardless of version prefix."""
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {"agents": []}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_requests.get.return_value = mock_response
|
||||
|
||||
for base_url in [
|
||||
"https://api.swarmsync.ai/v1/swarmscore/",
|
||||
"https://api.swarmsync.ai/v2/swarmscore/",
|
||||
"https://staging.swarmsync.ai/api/swarmscore/",
|
||||
"https://api.swarmsync.ai/swarmscore",
|
||||
]:
|
||||
mock_requests.get.reset_mock()
|
||||
tool = SwarmScoreTool(api_base_url=base_url)
|
||||
result = tool.get_discovery_manifest()
|
||||
|
||||
assert result.success is True
|
||||
called_url = mock_requests.get.call_args[0][0]
|
||||
expected_root = f"{urlparse(base_url).scheme}://{urlparse(base_url).netloc}"
|
||||
assert called_url == f"{expected_root}/.well-known/agent-card.json"
|
||||
|
||||
@patch('praisonai_tools.tools.swarmscore_tool.requests')
|
||||
def test_get_discovery_manifest_error(self, mock_requests):
|
||||
"""Test discovery manifest retrieval with error."""
|
||||
# Setup mock to raise exception
|
||||
mock_requests.exceptions = real_requests.exceptions
|
||||
mock_requests.get.side_effect = real_requests.exceptions.RequestException("Manifest error")
|
||||
|
||||
# Execute
|
||||
result = self.tool.get_discovery_manifest()
|
||||
|
||||
# Verify
|
||||
assert result.success is False
|
||||
assert "Manifest error" in result.error
|
||||
|
||||
def test_run_load_action(self):
|
||||
"""Test run method with load action."""
|
||||
with patch.object(self.tool, 'load_swarmscore') as mock_load:
|
||||
mock_load.return_value = ToolResult(output=self.sample_score_data, success=True)
|
||||
|
||||
result = self.tool.run("load", slug="test-agent")
|
||||
|
||||
assert result.success is True
|
||||
mock_load.assert_called_once_with("test-agent")
|
||||
|
||||
def test_run_verify_action(self):
|
||||
"""Test run method with verify action."""
|
||||
with patch.object(self.tool, 'verify_swarmscore') as mock_verify:
|
||||
mock_verify.return_value = ToolResult(output={"verified": True}, success=True)
|
||||
|
||||
verify_payload = {"test": "payload"}
|
||||
result = self.tool.run("verify", verify_payload=verify_payload)
|
||||
|
||||
assert result.success is True
|
||||
mock_verify.assert_called_once_with(verify_payload)
|
||||
|
||||
def test_run_discover_action(self):
|
||||
"""Test run method with discover action."""
|
||||
with patch.object(self.tool, 'get_discovery_manifest') as mock_discover:
|
||||
mock_discover.return_value = ToolResult(output={"agents": []}, success=True)
|
||||
|
||||
result = self.tool.run("discover")
|
||||
|
||||
assert result.success is True
|
||||
mock_discover.assert_called_once()
|
||||
|
||||
def test_run_default_action(self):
|
||||
"""Bare run() defaults to 'load' and degrades gracefully without a slug."""
|
||||
result = self.tool.run()
|
||||
|
||||
assert result.success is False
|
||||
assert "Missing required parameter 'slug'" in result.error
|
||||
|
||||
def test_run_invalid_action(self):
|
||||
"""Test run method with invalid action."""
|
||||
result = self.tool.run("invalid_action")
|
||||
|
||||
assert result.success is False
|
||||
assert "Unknown action" in result.error
|
||||
|
||||
def test_run_missing_parameters(self):
|
||||
"""Test run method with missing required parameters."""
|
||||
# Test load without slug
|
||||
result = self.tool.run("load")
|
||||
assert result.success is False
|
||||
assert "Missing required parameter 'slug'" in result.error
|
||||
|
||||
# Test verify without verify_payload
|
||||
result = self.tool.run("verify")
|
||||
assert result.success is False
|
||||
assert "Missing required parameter 'verify_payload'" in result.error
|
||||
|
||||
|
||||
class TestStandaloneFunctions:
|
||||
"""Test cases for standalone utility functions."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures."""
|
||||
self.sample_data = {
|
||||
"score": 75,
|
||||
"tier": "silver",
|
||||
"verify_payload": {"test": "payload"}
|
||||
}
|
||||
|
||||
@patch('praisonai_tools.tools.swarmscore_tool.SwarmScoreTool')
|
||||
def test_load_swarmscore_by_slug_success(self, mock_tool_class):
|
||||
"""Test successful standalone load function."""
|
||||
# Setup mock
|
||||
mock_tool = Mock()
|
||||
mock_tool.load_swarmscore.return_value = ToolResult(
|
||||
output=self.sample_data,
|
||||
success=True
|
||||
)
|
||||
mock_tool_class.return_value = mock_tool
|
||||
|
||||
# Execute
|
||||
result = load_swarmscore_by_slug("test-agent")
|
||||
|
||||
# Verify
|
||||
assert result == self.sample_data
|
||||
mock_tool.load_swarmscore.assert_called_once_with("test-agent")
|
||||
|
||||
@patch('praisonai_tools.tools.swarmscore_tool.SwarmScoreTool')
|
||||
def test_load_swarmscore_by_slug_failure(self, mock_tool_class):
|
||||
"""Test standalone load function with failure."""
|
||||
# Setup mock
|
||||
mock_tool = Mock()
|
||||
mock_tool.load_swarmscore.return_value = ToolResult(
|
||||
output=None,
|
||||
success=False,
|
||||
error="Load failed"
|
||||
)
|
||||
mock_tool_class.return_value = mock_tool
|
||||
|
||||
# Execute and verify exception
|
||||
with pytest.raises(Exception, match="Load failed"):
|
||||
load_swarmscore_by_slug("test-agent")
|
||||
|
||||
@patch('praisonai_tools.tools.swarmscore_tool.SwarmScoreTool')
|
||||
def test_verify_swarmscore_freshness_success(self, mock_tool_class):
|
||||
"""Test successful standalone verify function."""
|
||||
# Setup mock
|
||||
verify_data = {"verified": True}
|
||||
mock_tool = Mock()
|
||||
mock_tool.verify_swarmscore.return_value = ToolResult(
|
||||
output=verify_data,
|
||||
success=True
|
||||
)
|
||||
mock_tool_class.return_value = mock_tool
|
||||
|
||||
# Execute
|
||||
payload = {"test": "payload"}
|
||||
result = verify_swarmscore_freshness(payload)
|
||||
|
||||
# Verify
|
||||
assert result == verify_data
|
||||
mock_tool.verify_swarmscore.assert_called_once_with(payload)
|
||||
|
||||
@patch('praisonai_tools.tools.swarmscore_tool.SwarmScoreTool')
|
||||
def test_verify_swarmscore_freshness_failure(self, mock_tool_class):
|
||||
"""Test standalone verify function with failure."""
|
||||
# Setup mock
|
||||
mock_tool = Mock()
|
||||
mock_tool.verify_swarmscore.return_value = ToolResult(
|
||||
output=None,
|
||||
success=False,
|
||||
error="Verification failed"
|
||||
)
|
||||
mock_tool_class.return_value = mock_tool
|
||||
|
||||
# Execute and verify exception
|
||||
with pytest.raises(Exception, match="Verification failed"):
|
||||
verify_swarmscore_freshness({"test": "payload"})
|
||||
|
||||
@patch('praisonai_tools.tools.swarmscore_tool.SwarmScoreTool')
|
||||
def test_get_agent_discovery_manifest_success(self, mock_tool_class):
|
||||
"""Test successful standalone discovery function."""
|
||||
# Setup mock
|
||||
manifest_data = {"agents": ["test1", "test2"]}
|
||||
mock_tool = Mock()
|
||||
mock_tool.get_discovery_manifest.return_value = ToolResult(
|
||||
output=manifest_data,
|
||||
success=True
|
||||
)
|
||||
mock_tool_class.return_value = mock_tool
|
||||
|
||||
# Execute
|
||||
result = get_agent_discovery_manifest()
|
||||
|
||||
# Verify
|
||||
assert result == manifest_data
|
||||
mock_tool.get_discovery_manifest.assert_called_once()
|
||||
|
||||
@patch('praisonai_tools.tools.swarmscore_tool.SwarmScoreTool')
|
||||
def test_get_agent_discovery_manifest_failure(self, mock_tool_class):
|
||||
"""Test standalone discovery function with failure."""
|
||||
# Setup mock
|
||||
mock_tool = Mock()
|
||||
mock_tool.get_discovery_manifest.return_value = ToolResult(
|
||||
output=None,
|
||||
success=False,
|
||||
error="Discovery failed"
|
||||
)
|
||||
mock_tool_class.return_value = mock_tool
|
||||
|
||||
# Execute and verify exception
|
||||
with pytest.raises(Exception, match="Discovery failed"):
|
||||
get_agent_discovery_manifest()
|
||||
|
||||
|
||||
class TestToolInitialization:
|
||||
"""Test tool initialization and dependencies."""
|
||||
|
||||
def test_tool_initialization_default(self):
|
||||
"""Test tool initialization with default parameters."""
|
||||
tool = SwarmScoreTool()
|
||||
assert "api.swarmsync.ai" in tool.api_base_url
|
||||
assert not tool.api_base_url.endswith('/')
|
||||
|
||||
def test_tool_initialization_custom_url(self):
|
||||
"""Test tool initialization with custom URL."""
|
||||
custom_url = "https://custom.api.com/v1/swarmscore/"
|
||||
tool = SwarmScoreTool(api_base_url=custom_url)
|
||||
assert tool.api_base_url == "https://custom.api.com/v1/swarmscore"
|
||||
|
||||
@patch('praisonai_tools.tools.swarmscore_tool.requests', None)
|
||||
def test_tool_initialization_missing_requests(self):
|
||||
"""Test tool initialization without requests library."""
|
||||
with pytest.raises(ImportError, match="requests is required"):
|
||||
SwarmScoreTool()
|
||||
|
||||
|
||||
class TestIntegrationScenarios:
|
||||
"""Test realistic integration scenarios."""
|
||||
|
||||
@patch('praisonai_tools.tools.swarmscore_tool.requests')
|
||||
def test_full_workflow_success(self, mock_requests):
|
||||
"""Test complete workflow: load -> verify -> discover."""
|
||||
tool = SwarmScoreTool()
|
||||
|
||||
# Setup mock responses
|
||||
load_data = {
|
||||
"score": 90,
|
||||
"tier": "platinum",
|
||||
"verify_payload": {"signature": "test123"}
|
||||
}
|
||||
verify_data = {"verified": True}
|
||||
manifest_data = {"agents": ["agent1"]}
|
||||
|
||||
mock_responses = [
|
||||
Mock(json=lambda: load_data, raise_for_status=lambda: None),
|
||||
Mock(json=lambda: verify_data, raise_for_status=lambda: None),
|
||||
Mock(json=lambda: manifest_data, raise_for_status=lambda: None)
|
||||
]
|
||||
|
||||
# Configure mock to return different responses for different calls
|
||||
mock_requests.exceptions = real_requests.exceptions
|
||||
mock_requests.get.side_effect = [mock_responses[0], mock_responses[2]]
|
||||
mock_requests.post.return_value = mock_responses[1]
|
||||
|
||||
# Execute workflow
|
||||
load_result = tool.load_swarmscore("test-agent")
|
||||
assert load_result.success is True
|
||||
assert load_result.output["score"] == 90
|
||||
|
||||
verify_result = tool.verify_swarmscore(load_data["verify_payload"])
|
||||
assert verify_result.success is True
|
||||
assert verify_result.output["verified"] is True
|
||||
|
||||
manifest_result = tool.get_discovery_manifest()
|
||||
assert manifest_result.success is True
|
||||
assert "agent1" in manifest_result.output["agents"]
|
||||
|
||||
@patch('praisonai_tools.tools.swarmscore_tool.requests')
|
||||
def test_error_recovery(self, mock_requests):
|
||||
"""Test graceful error handling and recovery."""
|
||||
tool = SwarmScoreTool()
|
||||
|
||||
# First call fails, second succeeds
|
||||
mock_requests.exceptions = real_requests.exceptions
|
||||
mock_requests.get.side_effect = [
|
||||
real_requests.exceptions.RequestException("Network timeout"),
|
||||
Mock(
|
||||
json=lambda: {"score": 80},
|
||||
raise_for_status=lambda: None
|
||||
)
|
||||
]
|
||||
|
||||
# First attempt fails
|
||||
result1 = tool.load_swarmscore("test-agent")
|
||||
assert result1.success is False
|
||||
assert "Network timeout" in result1.error
|
||||
|
||||
# Second attempt succeeds
|
||||
result2 = tool.load_swarmscore("test-agent")
|
||||
assert result2.success is True
|
||||
assert result2.output["score"] == 80
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Regression tests for automatic tool discovery.
|
||||
|
||||
These guard the "shipped but not importable" gap: every public tool symbol that
|
||||
ships under ``praisonai_tools/tools/`` must be reachable through both public
|
||||
import surfaces, and adding a new tool module must require no other edits.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import importlib
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
import praisonai_tools
|
||||
from praisonai_tools import tools as tools_pkg
|
||||
from praisonai_tools.tools._discovery import (
|
||||
build_manifest,
|
||||
_iter_public_symbols,
|
||||
_SKIP_MODULES,
|
||||
)
|
||||
|
||||
TOOLS_DIR = os.path.dirname(tools_pkg.__file__)
|
||||
|
||||
|
||||
def _public_symbols(path):
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
source = fh.read()
|
||||
return _iter_public_symbols(source, os.path.basename(path)[:-3])
|
||||
|
||||
|
||||
def _tool_modules():
|
||||
for filename in sorted(os.listdir(TOOLS_DIR)):
|
||||
module_name = filename[:-3]
|
||||
if (
|
||||
filename.endswith(".py")
|
||||
and module_name not in _SKIP_MODULES
|
||||
and not module_name.startswith("_")
|
||||
):
|
||||
yield module_name
|
||||
|
||||
|
||||
def test_no_discovery_collisions():
|
||||
_, collisions = build_manifest(TOOLS_DIR)
|
||||
assert collisions == {}, f"Colliding tool symbols across modules: {collisions}"
|
||||
|
||||
|
||||
def test_every_shipped_module_parses():
|
||||
"""Every shipped tool module must parse.
|
||||
|
||||
Discovery deliberately swallows a per-module ``SyntaxError`` (logging a
|
||||
warning) so one broken module cannot break the whole package import. The
|
||||
downside is that a broken module silently drops out of the manifest and its
|
||||
tools resolve as ``AttributeError`` rather than surfacing the real parse
|
||||
error. This test closes that gap loudly at CI time: it parses each module
|
||||
directly (not via the swallowing discovery helper) so a genuinely broken
|
||||
shipped module fails here with the pointing ``SyntaxError``.
|
||||
"""
|
||||
for module in _tool_modules():
|
||||
path = os.path.join(TOOLS_DIR, module + ".py")
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
source = fh.read()
|
||||
try:
|
||||
ast.parse(source)
|
||||
except SyntaxError as exc: # pragma: no cover - fails only if a module breaks
|
||||
pytest.fail(f"tools/{module}.py has a syntax error: {exc}")
|
||||
|
||||
|
||||
def test_manifest_covers_every_shipped_symbol():
|
||||
manifest, _ = build_manifest(TOOLS_DIR)
|
||||
for module in _tool_modules():
|
||||
path = os.path.join(TOOLS_DIR, module + ".py")
|
||||
for symbol in _public_symbols(path):
|
||||
assert symbol in manifest, (
|
||||
f"{symbol!r} defined in tools/{module}.py is not discoverable"
|
||||
)
|
||||
|
||||
|
||||
def test_all_matches_manifest():
|
||||
manifest, _ = build_manifest(TOOLS_DIR)
|
||||
base = {
|
||||
"BaseTool", "ToolResult", "ToolValidationError", "validate_tool",
|
||||
"tool", "FunctionTool", "is_tool", "get_tool_schema",
|
||||
}
|
||||
expected = base | set(manifest)
|
||||
assert set(tools_pkg.__all__) == expected
|
||||
# The top-level package mirrors the tools package surface and additionally
|
||||
# exposes the catalogue/discovery helpers (which are not tool symbols).
|
||||
catalogue_exports = {"ToolCatalogueEntry", "list_tools", "get_tool_names"}
|
||||
assert set(praisonai_tools.__all__) == set(tools_pkg.__all__) | catalogue_exports
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", [
|
||||
"EmailTool", "SlackTool", "GitHubTool", "WeatherTool", "YouTubeTool",
|
||||
"send_email", "get_weather", "search_youtube",
|
||||
# Symbol from a module that declares an explicit __all__ (author intent).
|
||||
"NexusPredictionMarketTool",
|
||||
# External symbol living outside tools/ (exercises the absolute-path
|
||||
# import branch in __getattr__ and the _EXTERNAL_SYMBOLS mapping).
|
||||
"N8nWorkflowTool",
|
||||
])
|
||||
def test_known_symbols_resolve_both_surfaces(name):
|
||||
assert getattr(tools_pkg, name) is getattr(praisonai_tools, name)
|
||||
|
||||
|
||||
def test_explicit_all_is_respected():
|
||||
"""A module declaring __all__ contributes exactly those public names."""
|
||||
manifest, _ = build_manifest(TOOLS_DIR)
|
||||
# nexus_prediction_market_tool.py declares __all__ with these two names.
|
||||
assert manifest.get("NexusPredictionMarketTool") == "nexus_prediction_market_tool"
|
||||
assert manifest.get("nexus_prediction_market_tool") == "nexus_prediction_market_tool"
|
||||
|
||||
|
||||
def test_lazy_import_not_eager(monkeypatch):
|
||||
"""Importing the package must not eagerly import a heavy tool module."""
|
||||
import sys
|
||||
|
||||
# A representative heavy/optional tool module should not be imported until
|
||||
# one of its symbols is accessed.
|
||||
target = "praisonai_tools.tools.docker_tool"
|
||||
sys.modules.pop(target, None)
|
||||
importlib.reload(tools_pkg)
|
||||
assert target not in sys.modules
|
||||
# Accessing a symbol triggers the lazy import.
|
||||
_ = tools_pkg.DockerTool
|
||||
assert target in sys.modules
|
||||
|
||||
|
||||
def test_drop_in_new_tool(tmp_path, monkeypatch):
|
||||
"""A new module dropped into tools/ is importable with no other edits."""
|
||||
if not os.access(TOOLS_DIR, os.W_OK):
|
||||
pytest.skip("TOOLS_DIR is not writable (read-only install)")
|
||||
echo_path = os.path.join(TOOLS_DIR, "echo_discovery_probe.py")
|
||||
source = (
|
||||
"from praisonai_tools.tools.decorator import tool\n\n\n"
|
||||
"@tool\n"
|
||||
"def echo_discovery_probe(text: str) -> str:\n"
|
||||
" '''Echo the given text.'''\n"
|
||||
" return text\n"
|
||||
)
|
||||
with open(echo_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(source)
|
||||
try:
|
||||
importlib.reload(tools_pkg)
|
||||
fn = tools_pkg.echo_discovery_probe
|
||||
assert fn("hi") == "hi"
|
||||
assert "echo_discovery_probe" in tools_pkg.__all__
|
||||
finally:
|
||||
os.remove(echo_path)
|
||||
importlib.reload(tools_pkg)
|
||||
|
||||
|
||||
def test_manifest_cache_roundtrip(tmp_path):
|
||||
"""The disk cache is written, reused, and invalidated on directory change."""
|
||||
from praisonai_tools.tools import _discovery
|
||||
|
||||
pkg = tmp_path / "toolsdir"
|
||||
pkg.mkdir()
|
||||
(pkg / "sample_tool.py").write_text("class SampleTool:\n pass\n")
|
||||
|
||||
manifest, _ = _discovery.build_manifest(str(pkg))
|
||||
assert manifest.get("SampleTool") == "sample_tool"
|
||||
cache_file = pkg / _discovery._CACHE_FILENAME
|
||||
assert cache_file.exists(), "cache should be written after first scan"
|
||||
|
||||
# Second call must serve from cache (fingerprint unchanged) and match.
|
||||
manifest2, _ = _discovery.build_manifest(str(pkg))
|
||||
assert manifest2 == manifest
|
||||
|
||||
# Adding a module changes the fingerprint -> cache is rebuilt automatically.
|
||||
(pkg / "second_tool.py").write_text("def second_tool():\n return 1\n")
|
||||
manifest3, _ = _discovery.build_manifest(str(pkg))
|
||||
assert "second_tool" in manifest3
|
||||
|
||||
|
||||
def test_manifest_cache_corrupt_falls_back(tmp_path):
|
||||
"""A corrupt cache file must not break discovery."""
|
||||
from praisonai_tools.tools import _discovery
|
||||
|
||||
pkg = tmp_path / "toolsdir"
|
||||
pkg.mkdir()
|
||||
(pkg / "sample_tool.py").write_text("class SampleTool:\n pass\n")
|
||||
(pkg / _discovery._CACHE_FILENAME).write_text("{ not valid json")
|
||||
|
||||
manifest, _ = _discovery.build_manifest(str(pkg))
|
||||
assert manifest.get("SampleTool") == "sample_tool"
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Unit tests for FirefliesTool."""
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from praisonai_tools.tools.fireflies_tool import (
|
||||
FirefliesTool,
|
||||
list_fireflies_meetings,
|
||||
DEFAULT_API_URL,
|
||||
)
|
||||
|
||||
|
||||
def _mock_response(payload, status_code=200, headers=None):
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = payload
|
||||
resp.status_code = status_code
|
||||
resp.headers = headers or {}
|
||||
return resp
|
||||
|
||||
|
||||
# ── Configuration ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestConfiguration:
|
||||
def test_api_key_from_arg(self):
|
||||
tool = FirefliesTool(api_key="ff_xyz")
|
||||
assert tool.api_key == "ff_xyz"
|
||||
|
||||
def test_api_key_from_env(self):
|
||||
with patch.dict(os.environ, {"FIREFLIES_API_KEY": "envkey"}, clear=True):
|
||||
tool = FirefliesTool()
|
||||
assert tool.api_key == "envkey"
|
||||
|
||||
def test_default_api_url(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
assert tool.api_url == DEFAULT_API_URL
|
||||
|
||||
def test_api_url_override_from_env(self):
|
||||
with patch.dict(
|
||||
os.environ, {"FIREFLIES_API_URL": "https://proxy.local/graphql"}, clear=True
|
||||
):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
assert tool.api_url == "https://proxy.local/graphql"
|
||||
|
||||
|
||||
# ── _graphql wiring ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGraphQL:
|
||||
def test_missing_key_returns_distinct_error(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
tool = FirefliesTool()
|
||||
assert tool._graphql("query { x }") == {"error": "FIREFLIES_API_KEY required"}
|
||||
|
||||
def test_sends_bearer_header(self):
|
||||
tool = FirefliesTool(api_key="ff_xyz")
|
||||
with patch("requests.post") as post:
|
||||
post.return_value = _mock_response({"data": {"ok": True}})
|
||||
tool._graphql("query { x }", {"v": 1})
|
||||
kwargs = post.call_args.kwargs
|
||||
assert kwargs["headers"]["Authorization"] == "Bearer ff_xyz"
|
||||
assert kwargs["json"] == {"query": "query { x }", "variables": {"v": 1}}
|
||||
|
||||
def test_maps_401_to_auth_error(self):
|
||||
tool = FirefliesTool(api_key="bad")
|
||||
with patch("requests.post", return_value=_mock_response({}, status_code=401)):
|
||||
result = tool._graphql("query { x }")
|
||||
assert result["status_code"] == 401
|
||||
assert "authentication failed" in result["error"]
|
||||
|
||||
def test_maps_403_to_auth_error(self):
|
||||
tool = FirefliesTool(api_key="bad")
|
||||
with patch("requests.post", return_value=_mock_response({}, status_code=403)):
|
||||
result = tool._graphql("query { x }")
|
||||
assert result["status_code"] == 403
|
||||
|
||||
def test_rate_limit_surfaces_retry_after(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
resp = _mock_response({}, status_code=429, headers={"Retry-After": "12"})
|
||||
with patch("requests.post", return_value=resp):
|
||||
result = tool._graphql("query { x }")
|
||||
assert result["status_code"] == 429
|
||||
assert result["retry_after"] == "12"
|
||||
|
||||
def test_graphql_errors_are_flattened(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
payload = {"errors": [{"message": "boom"}, {"message": "bad"}]}
|
||||
with patch("requests.post", return_value=_mock_response(payload)):
|
||||
assert tool._graphql("query { x }") == {"error": "boom; bad"}
|
||||
|
||||
def test_handles_request_exception(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
with patch("requests.post", side_effect=RuntimeError("net")):
|
||||
assert tool._graphql("q") == {"error": "net"}
|
||||
|
||||
def test_rejects_non_https_url(self):
|
||||
tool = FirefliesTool(api_key="x", api_url="http://insecure.local/graphql")
|
||||
with patch("requests.post") as post:
|
||||
result = tool._graphql("query { x }")
|
||||
post.assert_not_called()
|
||||
assert result == {"error": "FIREFLIES_API_URL must use HTTPS"}
|
||||
|
||||
def test_rate_limit_from_graphql_payload(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
payload = {
|
||||
"errors": [{"extensions": {"metadata": {"retryAfter": 30}}}]
|
||||
}
|
||||
resp = _mock_response(payload, status_code=429)
|
||||
with patch("requests.post", return_value=resp):
|
||||
result = tool._graphql("query { x }")
|
||||
assert result["status_code"] == 429
|
||||
assert result["retry_after"] == "30"
|
||||
|
||||
def test_generic_http_error_surfaces_status(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
resp = _mock_response({"data": {"transcripts": None}}, status_code=500)
|
||||
with patch("requests.post", return_value=resp):
|
||||
result = tool._graphql("query { x }")
|
||||
assert result["status_code"] == 500
|
||||
assert "HTTP 500" in result["error"]
|
||||
|
||||
|
||||
# ── list_recent_meetings ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestListRecentMeetings:
|
||||
def test_returns_normalised_meetings(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
payload = {
|
||||
"data": {
|
||||
"transcripts": [
|
||||
{
|
||||
"id": "m1",
|
||||
"title": "Standup",
|
||||
"date": "2026-01-01",
|
||||
"duration": 15,
|
||||
"organizer_email": "[email protected]",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
with patch("requests.post", return_value=_mock_response(payload)):
|
||||
meetings = tool.list_recent_meetings(limit=5)
|
||||
assert len(meetings) == 1
|
||||
assert meetings[0]["title"] == "Standup"
|
||||
assert meetings[0]["organizer_email"] == "[email protected]"
|
||||
|
||||
def test_after_cursor_maps_to_skip(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
with patch(
|
||||
"requests.post", return_value=_mock_response({"data": {"transcripts": []}})
|
||||
) as post:
|
||||
tool.list_recent_meetings(limit=5, after="10")
|
||||
sent = post.call_args.kwargs["json"]["variables"]
|
||||
assert sent == {"limit": 5, "skip": 10}
|
||||
|
||||
def test_invalid_after_cursor(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
assert tool.list_recent_meetings(after="notint") == [
|
||||
{"error": "after must be an integer cursor"}
|
||||
]
|
||||
|
||||
def test_propagates_error(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
with patch("requests.post", side_effect=RuntimeError("net")):
|
||||
assert tool.list_recent_meetings() == [{"error": "net"}]
|
||||
|
||||
def test_limit_clamped_to_50(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
with patch(
|
||||
"requests.post", return_value=_mock_response({"data": {"transcripts": []}})
|
||||
) as post:
|
||||
tool.list_recent_meetings(limit=500)
|
||||
assert post.call_args.kwargs["json"]["variables"]["limit"] == 50
|
||||
|
||||
|
||||
# ── get_transcript ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetTranscript:
|
||||
def test_requires_meeting_id(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
assert tool.get_transcript(meeting_id="") == {"error": "meeting_id required"}
|
||||
|
||||
def test_returns_normalised_transcript(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
payload = {
|
||||
"data": {
|
||||
"transcript": {
|
||||
"id": "m1",
|
||||
"title": "Standup",
|
||||
"date": "2026-01-01",
|
||||
"duration": 15,
|
||||
"organizer_email": "[email protected]",
|
||||
"summary": {"overview": "We talked."},
|
||||
"sentences": [
|
||||
{"text": "Hi", "speaker_name": "A", "start_time": 0.0}
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
with patch("requests.post", return_value=_mock_response(payload)):
|
||||
transcript = tool.get_transcript(meeting_id="m1")
|
||||
assert transcript["summary"] == "We talked."
|
||||
assert transcript["sentence_count"] == 1
|
||||
|
||||
def test_not_found(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
with patch(
|
||||
"requests.post",
|
||||
return_value=_mock_response({"data": {"transcript": None}}),
|
||||
):
|
||||
assert tool.get_transcript(meeting_id="missing") == {
|
||||
"error": "transcript not found"
|
||||
}
|
||||
|
||||
|
||||
# ── search ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSearch:
|
||||
def test_requires_keywords(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
assert tool.search(keywords="") == [{"error": "keywords required"}]
|
||||
|
||||
def test_returns_results(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
payload = {
|
||||
"data": {
|
||||
"transcripts": [
|
||||
{
|
||||
"id": "m1",
|
||||
"title": "Roadmap",
|
||||
"date": "2026-01-01",
|
||||
"organizer_email": "[email protected]",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
with patch("requests.post", return_value=_mock_response(payload)) as post:
|
||||
results = tool.search(keywords="roadmap", limit=3)
|
||||
sent = post.call_args.kwargs["json"]["variables"]
|
||||
assert sent == {"keyword": "roadmap", "limit": 3}
|
||||
assert results[0]["title"] == "Roadmap"
|
||||
|
||||
|
||||
# ── run() dispatcher ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRunDispatcher:
|
||||
def test_unknown_action(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
assert tool.run(action="bogus") == {"error": "Unknown action: bogus"}
|
||||
|
||||
def test_routes_get_transcript(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
with patch.object(tool, "get_transcript", return_value={"ok": True}) as m:
|
||||
out = tool.run(action="get-transcript", meeting_id="m1")
|
||||
m.assert_called_once_with(meeting_id="m1")
|
||||
assert out == {"ok": True}
|
||||
|
||||
def test_routes_search(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
with patch.object(tool, "search", return_value=[]) as m:
|
||||
tool.run(action="search", keywords="hi", limit=5)
|
||||
m.assert_called_once_with(keywords="hi", limit=5)
|
||||
|
||||
|
||||
# ── Module-level helper ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestListFirefliesMeetingsHelper:
|
||||
def test_delegates_to_tool(self):
|
||||
with patch.object(
|
||||
FirefliesTool, "list_recent_meetings", return_value=["x"]
|
||||
) as m:
|
||||
assert list_fireflies_meetings(limit=5) == ["x"]
|
||||
m.assert_called_once_with(limit=5)
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Unit tests for KeenableSearchTool."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from praisonai_tools.tools.keenable_search_tool import (
|
||||
KEENABLE_MAX_RESULTS,
|
||||
KEENABLE_MAX_SNIPPET_CHARS,
|
||||
KEENABLE_MAX_TITLE_CHARS,
|
||||
KEENABLE_MAX_URL_CHARS,
|
||||
KEENABLE_PUBLIC_SEARCH_URL,
|
||||
KEENABLE_SEARCH_URL,
|
||||
KeenableSearchTool,
|
||||
_NoRedirect,
|
||||
keenable_search,
|
||||
)
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
"""Minimal stand-in for an ``http.client.HTTPResponse`` context manager."""
|
||||
|
||||
def __init__(self, raw: bytes):
|
||||
self._raw = raw
|
||||
|
||||
def read(self, amt=None):
|
||||
"""Return at most ``amt`` bytes, honouring the caller's read cap."""
|
||||
if amt is None:
|
||||
return self._raw
|
||||
return self._raw[:amt]
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
|
||||
def _payload(results):
|
||||
return json.dumps({"results": results}).encode("utf-8")
|
||||
|
||||
|
||||
# ── Import + schema (CI smoke) ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestImportAndSchema:
|
||||
def test_import_via_package(self):
|
||||
from praisonai_tools import KeenableSearchTool as PkgTool
|
||||
|
||||
assert PkgTool is KeenableSearchTool
|
||||
|
||||
def test_schema_generation(self):
|
||||
tool = KeenableSearchTool()
|
||||
schema = tool.get_schema()
|
||||
assert schema["function"]["name"] == "keenable_search"
|
||||
assert "description" in schema["function"]
|
||||
assert "action" in schema["function"]["parameters"]["properties"]
|
||||
|
||||
|
||||
# ── Endpoint selection ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEndpointSelection:
|
||||
def test_public_endpoint_without_key(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
tool = KeenableSearchTool()
|
||||
assert tool.api_key == ""
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_open(request, timeout=None):
|
||||
captured["url"] = request.full_url
|
||||
captured["headers"] = dict(request.header_items())
|
||||
return _FakeResponse(_payload([]))
|
||||
|
||||
opener = MagicMock()
|
||||
opener.open.side_effect = fake_open
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
tool.search("python", max_results=3)
|
||||
|
||||
assert captured["url"] == KEENABLE_PUBLIC_SEARCH_URL
|
||||
header_names = {k.lower() for k in captured["headers"]}
|
||||
assert "x-api-key" not in header_names
|
||||
assert "X-keenable-title" in captured["headers"] or "X-Keenable-Title" in captured["headers"]
|
||||
|
||||
def test_authenticated_endpoint_with_key(self):
|
||||
tool = KeenableSearchTool(api_key="secret-key")
|
||||
captured = {}
|
||||
|
||||
def fake_open(request, timeout=None):
|
||||
captured["url"] = request.full_url
|
||||
captured["headers"] = dict(request.header_items())
|
||||
return _FakeResponse(_payload([]))
|
||||
|
||||
opener = MagicMock()
|
||||
opener.open.side_effect = fake_open
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
tool.search("python", max_results=3)
|
||||
|
||||
assert captured["url"] == KEENABLE_SEARCH_URL
|
||||
# urllib title-cases header keys.
|
||||
assert captured["headers"].get("X-api-key") == "secret-key"
|
||||
|
||||
|
||||
# ── Result mapping + bounds ─────────────────────────────────────────
|
||||
|
||||
|
||||
class TestResultMapping:
|
||||
def _run(self, tool, results):
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(_payload(results))
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
return tool.search("q", max_results=10)
|
||||
|
||||
def test_maps_fields(self):
|
||||
out = self._run(
|
||||
KeenableSearchTool(),
|
||||
[{"title": "T", "url": "https://example.com", "snippet": "hello world"}],
|
||||
)
|
||||
assert out == [{
|
||||
"title": "T",
|
||||
"url": "https://example.com",
|
||||
"snippet": "hello world",
|
||||
"provider": "keenable",
|
||||
}]
|
||||
|
||||
def test_skips_entries_without_url(self):
|
||||
out = self._run(
|
||||
KeenableSearchTool(),
|
||||
[{"title": "no url"}, {"title": "ok", "url": "https://a.com"}],
|
||||
)
|
||||
assert len(out) == 1
|
||||
assert out[0]["url"] == "https://a.com"
|
||||
|
||||
def test_respects_max_results(self):
|
||||
tool = KeenableSearchTool()
|
||||
many = [{"url": f"https://a.com/{i}"} for i in range(10)]
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(_payload(many))
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = tool.search("q", max_results=3)
|
||||
assert len(out) == 3
|
||||
|
||||
def test_zero_max_results_short_circuits(self):
|
||||
out = KeenableSearchTool().search("q", max_results=0)
|
||||
assert out == []
|
||||
|
||||
def test_empty_query_errors(self):
|
||||
out = KeenableSearchTool().search("", max_results=5)
|
||||
assert out == [{"error": "query is required"}]
|
||||
|
||||
|
||||
# ── Error handling ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
def test_invalid_shape_errors(self):
|
||||
tool = KeenableSearchTool()
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(b'{"not_results": 1}')
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = tool.search("q")
|
||||
assert out and "error" in out[0]
|
||||
|
||||
def test_oversized_response_errors(self):
|
||||
tool = KeenableSearchTool()
|
||||
oversized = b"x" * (1_000_001 + 1)
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(oversized)
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = tool.search("q")
|
||||
assert out == [{"error": "Keenable returned an oversized response"}]
|
||||
|
||||
def test_http_error_is_captured(self):
|
||||
tool = KeenableSearchTool()
|
||||
opener = MagicMock()
|
||||
opener.open.side_effect = RuntimeError("boom")
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = tool.search("q")
|
||||
assert out == [{"error": "boom"}]
|
||||
|
||||
|
||||
# ── Protective bounds + redirect refusal ────────────────────────────
|
||||
|
||||
|
||||
class TestProtections:
|
||||
def _capture_body(self, tool, requested):
|
||||
"""Run a search and return the decoded JSON request body sent."""
|
||||
captured = {}
|
||||
|
||||
def fake_open(request, timeout=None):
|
||||
captured["body"] = json.loads(request.data.decode("utf-8"))
|
||||
return _FakeResponse(_payload([]))
|
||||
|
||||
opener = MagicMock()
|
||||
opener.open.side_effect = fake_open
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
tool.search("q", max_results=requested)
|
||||
return captured["body"]
|
||||
|
||||
def test_caps_requested_results_at_limit(self):
|
||||
"""max_results above the hard cap is clamped before the request."""
|
||||
body = self._capture_body(KeenableSearchTool(), requested=1000)
|
||||
assert body["max_results"] == KEENABLE_MAX_RESULTS
|
||||
|
||||
def test_truncates_oversized_title(self):
|
||||
"""Titles longer than the cap are truncated with an ellipsis."""
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(
|
||||
_payload([{"title": "T" * 5000, "url": "https://a.com"}])
|
||||
)
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = KeenableSearchTool().search("q")
|
||||
assert len(out[0]["title"]) == KEENABLE_MAX_TITLE_CHARS
|
||||
assert out[0]["title"].endswith("…")
|
||||
|
||||
def test_truncates_oversized_snippet(self):
|
||||
"""Snippets longer than the cap are truncated with an ellipsis."""
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(
|
||||
_payload([{"snippet": "s " * 5000, "url": "https://a.com"}])
|
||||
)
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = KeenableSearchTool().search("q")
|
||||
assert len(out[0]["snippet"]) == KEENABLE_MAX_SNIPPET_CHARS
|
||||
assert out[0]["snippet"].endswith("…")
|
||||
|
||||
def test_skips_oversized_url(self):
|
||||
"""Results whose URL exceeds the length cap are dropped."""
|
||||
long_url = "https://a.com/" + "x" * KEENABLE_MAX_URL_CHARS
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(
|
||||
_payload([{"url": long_url}, {"url": "https://ok.com"}])
|
||||
)
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = KeenableSearchTool().search("q")
|
||||
assert out == [{
|
||||
"title": "",
|
||||
"url": "https://ok.com",
|
||||
"snippet": "",
|
||||
"provider": "keenable",
|
||||
}]
|
||||
|
||||
def test_read_is_capped_at_limit(self):
|
||||
"""The body read is bounded, so an oversized stream still errors out."""
|
||||
tool = KeenableSearchTool()
|
||||
# Far larger than the cap; _FakeResponse.read honours the byte count,
|
||||
# so this verifies the read limit rather than a pre-sized buffer.
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(b"x" * 5_000_000)
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = tool.search("q")
|
||||
assert out == [{"error": "Keenable returned an oversized response"}]
|
||||
|
||||
def test_refuses_redirects(self):
|
||||
"""The redirect handler drops 3xx responses so no follow-up is sent."""
|
||||
handler = _NoRedirect()
|
||||
# Returning None is urllib's signal to NOT build/send a second request.
|
||||
assert handler.redirect_request(
|
||||
MagicMock(), MagicMock(), 302, "Found", {}, "https://evil.example"
|
||||
) is None
|
||||
|
||||
|
||||
# ── Module-level convenience function ───────────────────────────────
|
||||
|
||||
|
||||
class TestConvenienceFunction:
|
||||
def test_keenable_search_function(self):
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(_payload([{"url": "https://a.com"}]))
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = keenable_search("q", max_results=2)
|
||||
assert out[0]["provider"] == "keenable"
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Unit tests for meeting_tools.
|
||||
|
||||
All external clients (OpenAI, Chroma) are mocked so no network or heavy
|
||||
dependency is required. The SQLite persistence is redirected to a temp dir.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonai_tools.tools import meeting_tools as mt
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolated_app_dir(tmp_path, monkeypatch):
|
||||
"""Point the meetings DB and vector store at a temp dir per test."""
|
||||
monkeypatch.setenv("PRAISONAI_MEETINGS_DIR", str(tmp_path))
|
||||
monkeypatch.setenv("PRAISONAI_MEETINGS_DB", str(tmp_path / "meetings.db"))
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
|
||||
yield
|
||||
|
||||
|
||||
# ── transcribe_file ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTranscribeFile:
|
||||
def test_missing_file(self):
|
||||
result = mt.transcribe_file.__wrapped__("/no/such/file.mp3")
|
||||
assert "error" in result
|
||||
assert "not found" in result["error"].lower()
|
||||
|
||||
def _fake_response(self):
|
||||
return SimpleNamespace(
|
||||
text="Good morning everyone",
|
||||
duration=12.5,
|
||||
language="en",
|
||||
segments=[
|
||||
SimpleNamespace(start=0.0, end=4.2, text="Good morning everyone"),
|
||||
],
|
||||
)
|
||||
|
||||
def test_success(self, tmp_path):
|
||||
audio = tmp_path / "a.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
client = MagicMock()
|
||||
client.audio.transcriptions.create.return_value = self._fake_response()
|
||||
with patch.object(mt, "_get_openai_client", return_value=client):
|
||||
result = mt.transcribe_file.__wrapped__(str(audio))
|
||||
assert result["text"] == "Good morning everyone"
|
||||
assert result["language"] == "en"
|
||||
assert result["duration_seconds"] == 12.5
|
||||
assert result["segments"][0]["start"] == 0.0
|
||||
assert result["segments"][0]["end"] == 4.2
|
||||
|
||||
def test_403_error_mapping(self, tmp_path):
|
||||
audio = tmp_path / "a.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
exc = Exception("403 forbidden")
|
||||
setattr(exc, "status_code", 403)
|
||||
client = MagicMock()
|
||||
client.audio.transcriptions.create.side_effect = exc
|
||||
with patch.object(mt, "_get_openai_client", return_value=client):
|
||||
result = mt.transcribe_file.__wrapped__(str(audio))
|
||||
assert "403" in result["error"] or "permission" in result["error"].lower()
|
||||
|
||||
def test_timeout_retries_once_then_fails(self, tmp_path):
|
||||
audio = tmp_path / "a.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
|
||||
class TimeoutError_(Exception):
|
||||
pass
|
||||
|
||||
client = MagicMock()
|
||||
client.audio.transcriptions.create.side_effect = TimeoutError_("timeout")
|
||||
with patch.object(mt, "_get_openai_client", return_value=client):
|
||||
result = mt.transcribe_file.__wrapped__(str(audio))
|
||||
assert "timed out" in result["error"].lower()
|
||||
assert client.audio.transcriptions.create.call_count == 2
|
||||
|
||||
def test_empty_audio(self, tmp_path):
|
||||
audio = tmp_path / "a.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
client = MagicMock()
|
||||
client.audio.transcriptions.create.return_value = SimpleNamespace(
|
||||
text="", duration=0.0, language="en", segments=[]
|
||||
)
|
||||
with patch.object(mt, "_get_openai_client", return_value=client):
|
||||
result = mt.transcribe_file.__wrapped__(str(audio))
|
||||
assert result["error"] == "No speech detected in recording"
|
||||
|
||||
|
||||
# ── Meetings CRUD ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMeetingsCRUD:
|
||||
def test_save_and_get(self):
|
||||
saved = mt.save_meeting.__wrapped__(
|
||||
title="Weekly standup", source="file.mp3", metadata={"team": "eng"}
|
||||
)
|
||||
mid = saved["meeting_id"]
|
||||
assert mid
|
||||
record = mt.get_meeting.__wrapped__(mid)
|
||||
assert record["title"] == "Weekly standup"
|
||||
assert record["source"] == "file.mp3"
|
||||
assert record["metadata"] == {"team": "eng"}
|
||||
|
||||
def test_save_requires_title(self):
|
||||
assert "error" in mt.save_meeting.__wrapped__(title="")
|
||||
|
||||
def test_get_not_found(self):
|
||||
assert "error" in mt.get_meeting.__wrapped__("nope")
|
||||
|
||||
def test_list(self):
|
||||
mt.save_meeting.__wrapped__(title="A")
|
||||
mt.save_meeting.__wrapped__(title="B")
|
||||
meetings = mt.list_meetings.__wrapped__(limit=10, offset=0)
|
||||
assert len(meetings) == 2
|
||||
titles = {m["title"] for m in meetings}
|
||||
assert titles == {"A", "B"}
|
||||
|
||||
def test_list_pagination(self):
|
||||
for i in range(3):
|
||||
mt.save_meeting.__wrapped__(title=f"M{i}")
|
||||
page = mt.list_meetings.__wrapped__(limit=1, offset=1)
|
||||
assert len(page) == 1
|
||||
|
||||
|
||||
# ── summarize_transcript ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSummarize:
|
||||
def test_requires_transcript(self):
|
||||
assert "error" in mt.summarize_transcript.__wrapped__("")
|
||||
|
||||
def test_single_chunk(self):
|
||||
payload = {
|
||||
"summary": "It was a good meeting.",
|
||||
"decisions": ["Ship on Friday"],
|
||||
"topics": ["release"],
|
||||
"key_quotes": [{"speaker": "Alex", "quote": "Ship it"}],
|
||||
}
|
||||
with patch.object(mt, "_chat_json", return_value=payload):
|
||||
result = mt.summarize_transcript.__wrapped__("short transcript")
|
||||
assert result["summary"] == "It was a good meeting."
|
||||
assert result["decisions"] == ["Ship on Friday"]
|
||||
assert result["topics"] == ["release"]
|
||||
assert result["key_quotes"][0]["quote"] == "Ship it"
|
||||
|
||||
def test_map_reduce_merges_chunks(self):
|
||||
long_text = "word " * 8000 # forces multiple summary chunks
|
||||
calls = []
|
||||
|
||||
def fake_chat(system, user):
|
||||
idx = len(calls)
|
||||
calls.append(user)
|
||||
return {
|
||||
"summary": f"para{idx}",
|
||||
"decisions": [f"d{idx}"],
|
||||
"topics": ["t"],
|
||||
"key_quotes": [{"speaker": None, "quote": f"q{idx}"}],
|
||||
}
|
||||
|
||||
with patch.object(mt, "_chat_json", side_effect=fake_chat):
|
||||
result = mt.summarize_transcript.__wrapped__(long_text)
|
||||
|
||||
assert len(calls) > 1
|
||||
assert "para0" in result["summary"]
|
||||
assert "t" in result["topics"] # deduped
|
||||
assert result["topics"].count("t") == 1
|
||||
assert len(result["decisions"]) == len(calls)
|
||||
|
||||
|
||||
# ── extract_action_items ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestActionItems:
|
||||
def test_requires_transcript(self):
|
||||
assert "error" in mt.extract_action_items.__wrapped__("")
|
||||
|
||||
def test_returns_valid_schema(self):
|
||||
payload = {
|
||||
"action_items": [
|
||||
{
|
||||
"description": "Fix API bug",
|
||||
"owner": "Alex",
|
||||
"due_date": "2026-09-05",
|
||||
"priority": "high",
|
||||
}
|
||||
]
|
||||
}
|
||||
with patch.object(mt, "_chat_json", return_value=payload):
|
||||
result = mt.extract_action_items.__wrapped__("transcript")
|
||||
item = result["action_items"][0]
|
||||
assert set(item.keys()) == {"description", "owner", "due_date", "priority"}
|
||||
assert item["owner"] == "Alex"
|
||||
|
||||
def test_handles_empty(self):
|
||||
with patch.object(mt, "_chat_json", return_value={}):
|
||||
result = mt.extract_action_items.__wrapped__("transcript")
|
||||
assert result["action_items"] == []
|
||||
|
||||
|
||||
# ── Fake in-memory vector collection ────────────────────────────────
|
||||
|
||||
|
||||
class _FakeCollection:
|
||||
"""Minimal Chroma-like collection using cosine-ish distance on tags."""
|
||||
|
||||
def __init__(self):
|
||||
self.store = {} # id -> (document, metadata, embedding)
|
||||
|
||||
def delete(self, where=None):
|
||||
if not where:
|
||||
self.store.clear()
|
||||
return
|
||||
mid = where.get("meeting_id")
|
||||
for k in [k for k, v in self.store.items() if v[1].get("meeting_id") == mid]:
|
||||
del self.store[k]
|
||||
|
||||
def add(self, ids, documents, embeddings, metadatas):
|
||||
for i, _id in enumerate(ids):
|
||||
self.store[_id] = (documents[i], metadatas[i], embeddings[i])
|
||||
|
||||
def query(self, query_embeddings, n_results):
|
||||
q = query_embeddings[0]
|
||||
|
||||
def dist(emb):
|
||||
return sum((a - b) ** 2 for a, b in zip(q, emb))
|
||||
|
||||
ranked = sorted(self.store.items(), key=lambda kv: dist(kv[1][2]))
|
||||
ranked = ranked[:n_results]
|
||||
return {
|
||||
"documents": [[v[0] for _, v in ranked]],
|
||||
"metadatas": [[v[1] for _, v in ranked]],
|
||||
"distances": [[dist(v[2]) for _, v in ranked]],
|
||||
}
|
||||
|
||||
|
||||
class TestIndexAndSearch:
|
||||
def _embed_map(self):
|
||||
# deterministic embeddings keyed by content keyword
|
||||
def fake_embed(texts):
|
||||
out = []
|
||||
for t in texts:
|
||||
if "apple" in t.lower():
|
||||
out.append([1.0, 0.0])
|
||||
elif "banana" in t.lower():
|
||||
out.append([0.0, 1.0])
|
||||
else:
|
||||
out.append([0.5, 0.5])
|
||||
return out
|
||||
|
||||
return fake_embed
|
||||
|
||||
def test_index_and_rank(self):
|
||||
coll = _FakeCollection()
|
||||
m1 = mt.save_meeting.__wrapped__(title="Apple meeting")["meeting_id"]
|
||||
m2 = mt.save_meeting.__wrapped__(title="Banana meeting")["meeting_id"]
|
||||
with patch.object(mt, "_get_vector_collection", return_value=coll), patch.object(
|
||||
mt, "_embed", side_effect=self._embed_map()
|
||||
):
|
||||
mt.index_meeting.__wrapped__(m1, transcript="all about apple pie")
|
||||
mt.index_meeting.__wrapped__(m2, transcript="all about banana bread")
|
||||
results = mt.search_meetings.__wrapped__("apple", limit=2)
|
||||
|
||||
assert results[0]["meeting_id"] == m1
|
||||
assert results[0]["score"] is not None
|
||||
assert results[0]["title"] == "Apple meeting"
|
||||
|
||||
def test_reindex_does_not_duplicate(self):
|
||||
coll = _FakeCollection()
|
||||
mid = mt.save_meeting.__wrapped__(title="M")["meeting_id"]
|
||||
with patch.object(mt, "_get_vector_collection", return_value=coll), patch.object(
|
||||
mt, "_embed", side_effect=self._embed_map()
|
||||
):
|
||||
r1 = mt.index_meeting.__wrapped__(mid, transcript="apple " * 600)
|
||||
count_after_first = len(coll.store)
|
||||
r2 = mt.index_meeting.__wrapped__(mid, transcript="apple " * 600)
|
||||
count_after_second = len(coll.store)
|
||||
|
||||
assert r1["chunks_indexed"] == r2["chunks_indexed"]
|
||||
assert count_after_first == count_after_second
|
||||
|
||||
def test_index_missing_transcript(self):
|
||||
mid = mt.save_meeting.__wrapped__(title="M")["meeting_id"]
|
||||
result = mt.index_meeting.__wrapped__(mid)
|
||||
assert "error" in result
|
||||
|
||||
def test_search_requires_query(self):
|
||||
assert "error" in mt.search_meetings.__wrapped__("")[0]
|
||||
|
||||
def test_segments_map_to_chunk_timestamps(self):
|
||||
coll = _FakeCollection()
|
||||
mid = mt.save_meeting.__wrapped__(title="Timed")["meeting_id"]
|
||||
segments = [
|
||||
{"start": 0.0, "end": 5.0, "text": "apple " * 200},
|
||||
{"start": 5.0, "end": 12.0, "text": "banana " * 200},
|
||||
]
|
||||
transcript = " ".join(s["text"].strip() for s in segments)
|
||||
with patch.object(mt, "_get_vector_collection", return_value=coll), patch.object(
|
||||
mt, "_embed", side_effect=self._embed_map()
|
||||
):
|
||||
mt.index_meeting.__wrapped__(mid, transcript=transcript, segments=segments)
|
||||
results = mt.search_meetings.__wrapped__("banana", limit=1)
|
||||
|
||||
assert results[0]["meeting_id"] == mid
|
||||
# The best match ("banana") must carry a non-zero timestamp derived
|
||||
# from the second segment, not the recording start.
|
||||
assert results[0]["timestamp_start"] > 0.0
|
||||
assert results[0]["timestamp_end"] >= results[0]["timestamp_start"]
|
||||
|
||||
def test_failed_reindex_preserves_existing_chunks(self):
|
||||
coll = _FakeCollection()
|
||||
mid = mt.save_meeting.__wrapped__(title="M")["meeting_id"]
|
||||
with patch.object(mt, "_get_vector_collection", return_value=coll), patch.object(
|
||||
mt, "_embed", side_effect=self._embed_map()
|
||||
):
|
||||
mt.index_meeting.__wrapped__(mid, transcript="apple " * 600)
|
||||
assert len(coll.store) > 0
|
||||
original = dict(coll.store)
|
||||
|
||||
def boom(_texts):
|
||||
raise RuntimeError("embedding backend down")
|
||||
|
||||
with patch.object(mt, "_get_vector_collection", return_value=coll), patch.object(
|
||||
mt, "_embed", side_effect=boom
|
||||
):
|
||||
result = mt.index_meeting.__wrapped__(mid, transcript="apple " * 600)
|
||||
|
||||
assert "error" in result
|
||||
# Previous chunks must survive a failed re-index.
|
||||
assert coll.store == original
|
||||
|
||||
def test_search_score_bounded_for_large_distance(self):
|
||||
coll = _FakeCollection()
|
||||
mid = mt.save_meeting.__wrapped__(title="Far")["meeting_id"]
|
||||
|
||||
def far_embed(texts):
|
||||
# Query and doc are far apart -> squared-L2 distance > 1.
|
||||
return [[10.0, 10.0] for _ in texts]
|
||||
|
||||
with patch.object(mt, "_get_vector_collection", return_value=coll), patch.object(
|
||||
mt, "_embed", side_effect=lambda t: [[0.0, 0.0] for _ in t]
|
||||
):
|
||||
mt.index_meeting.__wrapped__(mid, transcript="apple pie")
|
||||
with patch.object(mt, "_get_vector_collection", return_value=coll), patch.object(
|
||||
mt, "_embed", side_effect=far_embed
|
||||
):
|
||||
results = mt.search_meetings.__wrapped__("pie", limit=1)
|
||||
|
||||
assert 0.0 < results[0]["score"] <= 1.0
|
||||
|
||||
|
||||
# ── Lazy import hygiene ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_no_heavy_deps_at_import_time():
|
||||
import sys
|
||||
|
||||
# meeting_tools is already imported at top; ensure it did not pull heavy deps.
|
||||
mod = sys.modules["praisonai_tools.tools.meeting_tools"]
|
||||
src = open(mod.__file__).read()
|
||||
# No top-level (unindented) imports of openai/chromadb.
|
||||
for line in src.splitlines():
|
||||
if line.startswith("import openai") or line.startswith("from openai"):
|
||||
raise AssertionError("openai imported at module top level")
|
||||
if line.startswith("import chromadb") or line.startswith("from chromadb"):
|
||||
raise AssertionError("chromadb imported at module top level")
|
||||
|
||||
|
||||
def test_all_tools_are_registerable():
|
||||
import praisonai_tools
|
||||
|
||||
for name in (
|
||||
"transcribe_file",
|
||||
"save_meeting",
|
||||
"get_meeting",
|
||||
"list_meetings",
|
||||
"summarize_transcript",
|
||||
"extract_action_items",
|
||||
"index_meeting",
|
||||
"search_meetings",
|
||||
):
|
||||
exported = getattr(praisonai_tools, name, None)
|
||||
assert exported is not None, f"{name} is not exported"
|
||||
assert exported is getattr(mt, name), f"{name} export is shadowed"
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Unit tests for RedditTool."""
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from praisonai_tools.tools.reddit_tool import RedditTool
|
||||
|
||||
|
||||
# ── Import + schema (CI smoke) ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestImportAndSchema:
|
||||
def test_import_via_package(self):
|
||||
from praisonai_tools import RedditTool as PkgRedditTool
|
||||
|
||||
assert PkgRedditTool is RedditTool
|
||||
|
||||
def test_schema_generation(self):
|
||||
tool = RedditTool(client_id="cid", client_secret="secret")
|
||||
schema = tool.get_schema()
|
||||
assert schema["function"]["name"] == "reddit"
|
||||
assert "description" in schema["function"]
|
||||
assert "action" in schema["function"]["parameters"]["properties"]
|
||||
|
||||
|
||||
# ── User-Agent ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestUserAgent:
|
||||
def test_default_user_agent_references_app_id(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
tool = RedditTool(client_id="myappid", client_secret="secret")
|
||||
assert "myappid" in tool.user_agent
|
||||
|
||||
def test_explicit_user_agent_wins(self):
|
||||
tool = RedditTool(client_id="c", client_secret="s", user_agent="custom:ua:1.0")
|
||||
assert tool.user_agent == "custom:ua:1.0"
|
||||
|
||||
def test_env_user_agent_used(self):
|
||||
with patch.dict(os.environ, {"REDDIT_USER_AGENT": "env:ua:1.0"}, clear=True):
|
||||
tool = RedditTool(client_id="c", client_secret="s")
|
||||
assert tool.user_agent == "env:ua:1.0"
|
||||
|
||||
|
||||
# ── Write flag gating ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestWriteGating:
|
||||
def test_write_disabled_by_default(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
tool = RedditTool(client_id="c", client_secret="s")
|
||||
assert tool.allow_write is False
|
||||
|
||||
def test_env_enables_write(self):
|
||||
with patch.dict(os.environ, {"REDDIT_ALLOW_WRITE": "1"}, clear=True):
|
||||
tool = RedditTool(client_id="c", client_secret="s")
|
||||
assert tool.allow_write is True
|
||||
|
||||
def test_post_comment_blocked_when_write_disabled(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
tool = RedditTool(client_id="c", client_secret="s")
|
||||
result = tool.post_comment(post_id="abc", body="hi")
|
||||
assert "REDDIT_ALLOW_WRITE" in result["error"]
|
||||
|
||||
def test_submit_post_blocked_when_write_disabled(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
tool = RedditTool(client_id="c", client_secret="s")
|
||||
result = tool.submit_post(subreddit="test", title="T", body="B")
|
||||
assert "REDDIT_ALLOW_WRITE" in result["error"]
|
||||
|
||||
def test_write_requires_account_credentials(self):
|
||||
tool = RedditTool(client_id="c", client_secret="s", allow_write=True)
|
||||
result = tool.post_comment(post_id="abc", body="hi")
|
||||
assert "REDDIT_USERNAME" in result["error"]
|
||||
|
||||
|
||||
# ── Write actions (mocked praw) ─────────────────────────────────────
|
||||
|
||||
|
||||
def _writable_tool():
|
||||
return RedditTool(
|
||||
client_id="c",
|
||||
client_secret="s",
|
||||
username="u",
|
||||
password="p",
|
||||
allow_write=True,
|
||||
)
|
||||
|
||||
|
||||
class TestPostComment:
|
||||
def test_requires_post_id(self):
|
||||
tool = _writable_tool()
|
||||
assert tool.post_comment(post_id="", body="hi") == {"error": "post_id is required"}
|
||||
|
||||
def test_requires_body(self):
|
||||
tool = _writable_tool()
|
||||
assert tool.post_comment(post_id="abc", body="") == {"error": "body is required"}
|
||||
|
||||
def test_success(self):
|
||||
tool = _writable_tool()
|
||||
comment = MagicMock()
|
||||
comment.id = "c1"
|
||||
comment.permalink = "/r/test/comments/abc/x/c1/"
|
||||
submission = MagicMock()
|
||||
submission.reply.return_value = comment
|
||||
reddit = MagicMock()
|
||||
reddit.submission.return_value = submission
|
||||
with patch.object(RedditTool, "reddit", new=reddit):
|
||||
result = tool.post_comment(post_id="abc", body="hello")
|
||||
submission.reply.assert_called_once_with("hello")
|
||||
assert result["success"] is True
|
||||
assert result["id"] == "c1"
|
||||
|
||||
|
||||
class TestSubmitPost:
|
||||
def test_requires_subreddit(self):
|
||||
tool = _writable_tool()
|
||||
assert tool.submit_post(subreddit="", title="T") == {"error": "subreddit is required"}
|
||||
|
||||
def test_requires_title(self):
|
||||
tool = _writable_tool()
|
||||
assert tool.submit_post(subreddit="test", title="") == {"error": "title is required"}
|
||||
|
||||
def test_body_and_url_mutually_exclusive(self):
|
||||
tool = _writable_tool()
|
||||
result = tool.submit_post(subreddit="test", title="T", body="B", url="http://x")
|
||||
assert "not both" in result["error"]
|
||||
|
||||
def test_selftext_success(self):
|
||||
tool = _writable_tool()
|
||||
submission = MagicMock()
|
||||
submission.id = "p1"
|
||||
submission.permalink = "/r/test/comments/p1/"
|
||||
sub = MagicMock()
|
||||
sub.submit.return_value = submission
|
||||
reddit = MagicMock()
|
||||
reddit.subreddit.return_value = sub
|
||||
with patch.object(RedditTool, "reddit", new=reddit):
|
||||
result = tool.submit_post(subreddit="test", title="T", body="B")
|
||||
sub.submit.assert_called_once_with("T", selftext="B")
|
||||
assert result["id"] == "p1"
|
||||
|
||||
def test_link_success(self):
|
||||
tool = _writable_tool()
|
||||
submission = MagicMock()
|
||||
submission.id = "p2"
|
||||
submission.permalink = "/r/test/comments/p2/"
|
||||
sub = MagicMock()
|
||||
sub.submit.return_value = submission
|
||||
reddit = MagicMock()
|
||||
reddit.subreddit.return_value = sub
|
||||
with patch.object(RedditTool, "reddit", new=reddit):
|
||||
result = tool.submit_post(subreddit="test", title="T", url="http://x")
|
||||
sub.submit.assert_called_once_with("T", url="http://x")
|
||||
assert result["id"] == "p2"
|
||||
|
||||
|
||||
# ── run() dispatcher ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRunDispatcher:
|
||||
def test_unknown_action(self):
|
||||
tool = RedditTool(client_id="c", client_secret="s")
|
||||
assert tool.run(action="bogus") == {"error": "Unknown action: bogus"}
|
||||
|
||||
def test_routes_post_comment(self):
|
||||
tool = RedditTool(client_id="c", client_secret="s")
|
||||
with patch.object(tool, "post_comment", return_value={"ok": True}) as m:
|
||||
tool.run(action="post_comment", post_id="abc", body="hi")
|
||||
m.assert_called_once_with(post_id="abc", body="hi")
|
||||
|
||||
def test_routes_submit_post(self):
|
||||
tool = RedditTool(client_id="c", client_secret="s")
|
||||
with patch.object(tool, "submit_post", return_value={"ok": True}) as m:
|
||||
tool.run(action="submit-post", subreddit="test", title="T", body="B")
|
||||
m.assert_called_once_with(subreddit="test", title="T", body="B", url=None)
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Unit tests for StripeTool."""
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from praisonai_tools.tools.stripe_tool import StripeTool, get_stripe_customer
|
||||
|
||||
|
||||
def _mock_response(payload, status_code=200):
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = payload
|
||||
resp.status_code = status_code
|
||||
return resp
|
||||
|
||||
|
||||
# ── Key handling / mode banner ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestKeyAndMode:
|
||||
def test_env_secret_key_fallback(self):
|
||||
with patch.dict(os.environ, {"STRIPE_SECRET_KEY": "sk_test_x"}, clear=True):
|
||||
tool = StripeTool()
|
||||
assert tool.api_key == "sk_test_x"
|
||||
assert tool._mode() == "test"
|
||||
|
||||
def test_stripe_api_key_fallback(self):
|
||||
with patch.dict(os.environ, {"STRIPE_API_KEY": "sk_live_x"}, clear=True):
|
||||
tool = StripeTool()
|
||||
assert tool.api_key == "sk_live_x"
|
||||
assert tool._mode() == "live"
|
||||
|
||||
def test_restricted_key_prefixes(self):
|
||||
assert StripeTool(api_key="rk_test_x")._mode() == "test"
|
||||
assert StripeTool(api_key="rk_live_x")._mode() == "live"
|
||||
|
||||
def test_unknown_mode(self):
|
||||
assert StripeTool(api_key="weird")._mode() == "unknown"
|
||||
|
||||
def test_missing_key_returns_error(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
tool = StripeTool()
|
||||
assert tool.get_customer(customer_id="cus_1") == {
|
||||
"error": "STRIPE_SECRET_KEY (or STRIPE_API_KEY) is required"
|
||||
}
|
||||
|
||||
|
||||
# ── get_customer ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetCustomer:
|
||||
def test_requires_customer_id(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
assert tool.get_customer(customer_id="") == {"error": "customer_id is required"}
|
||||
|
||||
def test_returns_normalised_customer_with_banner(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
payload = {
|
||||
"id": "cus_1",
|
||||
"email": "[email protected]",
|
||||
"name": "Alice",
|
||||
"created": 123,
|
||||
"currency": "usd",
|
||||
"delinquent": False,
|
||||
}
|
||||
with patch("requests.request", return_value=_mock_response(payload)) as req:
|
||||
result = tool.get_customer(customer_id="cus_1")
|
||||
|
||||
args, kwargs = req.call_args
|
||||
assert args[0] == "get"
|
||||
assert args[1].endswith("/customers/cus_1")
|
||||
assert kwargs["headers"]["Authorization"] == "Bearer sk_test_x"
|
||||
assert result["mode"] == "test"
|
||||
assert result["id"] == "cus_1"
|
||||
assert result["email"] == "[email protected]"
|
||||
|
||||
def test_propagates_stripe_error(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
payload = {"error": {"message": "No such customer", "type": "invalid_request_error"}}
|
||||
with patch("requests.request", return_value=_mock_response(payload)):
|
||||
assert tool.get_customer(customer_id="cus_x") == {"error": "No such customer"}
|
||||
|
||||
def test_handles_request_exception(self):
|
||||
import requests
|
||||
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
with patch(
|
||||
"requests.request",
|
||||
side_effect=requests.exceptions.RequestException("boom"),
|
||||
):
|
||||
result = tool.get_customer(customer_id="cus_1")
|
||||
assert result["error"].startswith("API request failed")
|
||||
|
||||
def test_non_2xx_without_error_object_is_failure(self):
|
||||
# A non-2xx response whose body lacks Stripe's usual ``error`` object
|
||||
# must not be treated as a successful payload.
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
with patch(
|
||||
"requests.request",
|
||||
return_value=_mock_response({"unexpected": "shape"}, status_code=502),
|
||||
):
|
||||
result = tool.get_customer(customer_id="cus_1")
|
||||
assert result == {"error": "Stripe API returned HTTP 502"}
|
||||
|
||||
|
||||
# ── get_payment_intent ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetPaymentIntent:
|
||||
def test_requires_id(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
assert tool.get_payment_intent(payment_intent_id="") == {
|
||||
"error": "payment_intent_id is required"
|
||||
}
|
||||
|
||||
def test_returns_normalised_intent(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
payload = {
|
||||
"id": "pi_1",
|
||||
"amount": 500,
|
||||
"currency": "usd",
|
||||
"status": "succeeded",
|
||||
"customer": "cus_1",
|
||||
"created": 1,
|
||||
}
|
||||
with patch("requests.request", return_value=_mock_response(payload)):
|
||||
result = tool.get_payment_intent(payment_intent_id="pi_1")
|
||||
assert result["status"] == "succeeded"
|
||||
assert result["amount"] == 500
|
||||
assert result["mode"] == "test"
|
||||
|
||||
|
||||
# ── list_payment_links ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestListPaymentLinks:
|
||||
def test_returns_links(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
payload = {
|
||||
"data": [
|
||||
{"id": "plink_1", "url": "https://pay/1", "active": True, "metadata": {}},
|
||||
{"id": "plink_2", "url": "https://pay/2", "active": False, "metadata": {}},
|
||||
]
|
||||
}
|
||||
with patch("requests.request", return_value=_mock_response(payload)):
|
||||
result = tool.list_payment_links(limit=2)
|
||||
assert result["mode"] == "test"
|
||||
assert len(result["payment_links"]) == 2
|
||||
assert result["payment_links"][0]["id"] == "plink_1"
|
||||
|
||||
def test_clamps_limit(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
with patch("requests.request", return_value=_mock_response({"data": []})) as req:
|
||||
tool.list_payment_links(limit=999)
|
||||
assert "limit=100" in req.call_args[0][1]
|
||||
|
||||
def test_propagates_error(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
payload = {"error": {"message": "bad"}}
|
||||
with patch("requests.request", return_value=_mock_response(payload)):
|
||||
assert tool.list_payment_links() == {"error": "bad"}
|
||||
|
||||
|
||||
# ── create_payment_link (guardrails) ────────────────────────────────
|
||||
|
||||
|
||||
class TestCreatePaymentLink:
|
||||
def test_requires_price_id(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
assert tool.create_payment_link(
|
||||
price_id="", correlation_id="c1"
|
||||
) == {"error": "price_id is required"}
|
||||
|
||||
def test_requires_correlation_id(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
assert tool.create_payment_link(price_id="price_1") == {
|
||||
"error": "correlation_id is required (stored as metadata)"
|
||||
}
|
||||
|
||||
def test_rejects_bad_quantity(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
assert tool.create_payment_link(
|
||||
price_id="price_1", correlation_id="c1", quantity=0
|
||||
) == {"error": "quantity must be >= 1"}
|
||||
|
||||
def test_success_sends_metadata_and_idempotency_key(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
payload = {
|
||||
"id": "plink_1",
|
||||
"url": "https://pay/1",
|
||||
"active": True,
|
||||
"metadata": {"correlation_id": "order-42"},
|
||||
}
|
||||
with patch("requests.request", return_value=_mock_response(payload)) as req:
|
||||
result = tool.create_payment_link(
|
||||
price_id="price_1",
|
||||
quantity=2,
|
||||
correlation_id="order-42",
|
||||
idempotency_key="idem-1",
|
||||
)
|
||||
|
||||
args, kwargs = req.call_args
|
||||
assert args[0] == "post"
|
||||
assert args[1].endswith("/payment_links")
|
||||
assert kwargs["data"]["line_items[0][price]"] == "price_1"
|
||||
assert kwargs["data"]["line_items[0][quantity]"] == 2
|
||||
assert kwargs["data"]["metadata[correlation_id]"] == "order-42"
|
||||
assert kwargs["headers"]["Idempotency-Key"] == "idem-1"
|
||||
assert result["id"] == "plink_1"
|
||||
assert result["mode"] == "test"
|
||||
|
||||
|
||||
# ── run() dispatcher ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRunDispatcher:
|
||||
def test_unknown_action(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
assert tool.run(action="bogus") == {"error": "Unknown action: bogus"}
|
||||
|
||||
def test_routes_get_customer(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
with patch.object(tool, "get_customer", return_value={"ok": True}) as m:
|
||||
tool.run(action="get_customer", customer_id="cus_1")
|
||||
m.assert_called_once_with(customer_id="cus_1")
|
||||
|
||||
def test_routes_create_payment_link(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
with patch.object(tool, "create_payment_link", return_value={"ok": True}) as m:
|
||||
tool.run(
|
||||
action="create-payment-link",
|
||||
price_id="price_1",
|
||||
quantity=1,
|
||||
correlation_id="c1",
|
||||
idempotency_key="i1",
|
||||
)
|
||||
m.assert_called_once_with(
|
||||
price_id="price_1", quantity=1, correlation_id="c1", idempotency_key="i1"
|
||||
)
|
||||
|
||||
|
||||
# ── Module-level helper ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHelper:
|
||||
def test_delegates_to_tool(self):
|
||||
with patch.object(StripeTool, "get_customer", return_value={"id": "cus_1"}) as m:
|
||||
assert get_stripe_customer("cus_1", api_key="sk_test_x") == {"id": "cus_1"}
|
||||
m.assert_called_once_with(customer_id="cus_1")
|
||||
@@ -0,0 +1,50 @@
|
||||
"""CI-covered guard for automatic tool discovery.
|
||||
|
||||
The full discovery regression suite lives at ``tests/test_tool_discovery.py``,
|
||||
but the CI workflow only runs ``pytest tests/unit --ignore=tests/unit/video``.
|
||||
That means the most important safety net there - proving every shipped tool
|
||||
module actually parses (so discovery never silently drops a tool) - would not
|
||||
execute in CI. This thin module re-runs that guard from inside the CI-covered
|
||||
path so a syntactically broken shipped module fails loudly at CI time with the
|
||||
pointing ``SyntaxError`` instead of surfacing later as a mystery
|
||||
``AttributeError`` on ``from praisonai_tools import SomeTool``.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonai_tools import tools as tools_pkg
|
||||
from praisonai_tools.tools._discovery import _SKIP_MODULES, build_manifest
|
||||
|
||||
TOOLS_DIR = os.path.dirname(tools_pkg.__file__)
|
||||
|
||||
|
||||
def _tool_modules():
|
||||
for filename in sorted(os.listdir(TOOLS_DIR)):
|
||||
module_name = filename[:-3]
|
||||
if (
|
||||
filename.endswith(".py")
|
||||
and module_name not in _SKIP_MODULES
|
||||
and not module_name.startswith("_")
|
||||
):
|
||||
yield module_name
|
||||
|
||||
|
||||
def test_every_shipped_module_parses_in_ci():
|
||||
"""Every shipped tool module must parse under the running interpreter."""
|
||||
for module in _tool_modules():
|
||||
path = os.path.join(TOOLS_DIR, module + ".py")
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
source = fh.read()
|
||||
try:
|
||||
ast.parse(source)
|
||||
except SyntaxError as exc: # pragma: no cover - fails only if a module breaks
|
||||
pytest.fail(f"tools/{module}.py has a syntax error: {exc}")
|
||||
|
||||
|
||||
def test_no_discovery_collisions_in_ci():
|
||||
"""No public tool symbol may be defined in two shipped modules."""
|
||||
_, collisions = build_manifest(TOOLS_DIR)
|
||||
assert collisions == {}, f"Colliding tool symbols across modules: {collisions}"
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Unit tests for You.com tools (no network, no API key required)."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from praisonai_tools.tools.youdotcom_tool import (
|
||||
YouTools,
|
||||
ydc,
|
||||
ydc_search,
|
||||
ydc_contents,
|
||||
ydc_news,
|
||||
ydc_images,
|
||||
)
|
||||
|
||||
|
||||
_FIND_SPEC = "praisonai_tools.tools.youdotcom_tool.util.find_spec"
|
||||
|
||||
|
||||
def test_missing_api_key_returns_error(monkeypatch):
|
||||
monkeypatch.delenv("YDC_API_KEY", raising=False)
|
||||
with patch(_FIND_SPEC, return_value=object()):
|
||||
for fn, arg in (
|
||||
(ydc_search, "q"),
|
||||
(ydc_contents, "https://example.com"),
|
||||
(ydc_news, "q"),
|
||||
(ydc_images, "q"),
|
||||
):
|
||||
result = fn(arg)
|
||||
assert result == {
|
||||
"error": "YDC_API_KEY environment variable is not set. Please set it to use You.com tools."
|
||||
}
|
||||
|
||||
|
||||
def test_explicit_api_key_used_without_env(monkeypatch):
|
||||
monkeypatch.delenv("YDC_API_KEY", raising=False)
|
||||
fake_module = MagicMock()
|
||||
with patch(_FIND_SPEC, return_value=object()), patch.dict(
|
||||
"sys.modules", {"youdotcom": fake_module}
|
||||
):
|
||||
client, error = YouTools(api_key="k")._get_client()
|
||||
|
||||
assert error is None
|
||||
assert client is fake_module.You.return_value
|
||||
fake_module.You.assert_called_once_with(api_key_auth="k")
|
||||
|
||||
|
||||
def test_ydc_alias():
|
||||
assert ydc is ydc_search
|
||||
|
||||
|
||||
def test_search_uses_client_and_returns_dict():
|
||||
response = MagicMock()
|
||||
response.model_dump.return_value = {"results": {"web": [], "news": []}}
|
||||
client = MagicMock()
|
||||
client.search.unified.return_value = response
|
||||
|
||||
with patch.object(YouTools, "_get_client", return_value=(client, None)):
|
||||
result = YouTools().search("ai", count=3, freshness="week")
|
||||
|
||||
assert result == {"results": {"web": [], "news": []}}
|
||||
kwargs = client.search.unified.call_args.kwargs
|
||||
assert kwargs["query"] == "ai"
|
||||
assert kwargs["count"] == 3
|
||||
assert kwargs["freshness"] == "week"
|
||||
|
||||
|
||||
def test_search_exception_is_reported_as_error():
|
||||
client = MagicMock()
|
||||
client.search.unified.side_effect = RuntimeError("boom")
|
||||
|
||||
with patch.object(YouTools, "_get_client", return_value=(client, None)):
|
||||
result = YouTools().search("ai")
|
||||
|
||||
assert "boom" in result["error"]
|
||||
|
||||
|
||||
def test_discovered_via_package():
|
||||
import praisonai_tools.tools as tools_pkg
|
||||
|
||||
assert tools_pkg.YouTools is YouTools
|
||||
assert tools_pkg.ydc_search is ydc_search
|
||||
assert tools_pkg.ydc is ydc_search
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Pytest configuration for video/motion-graphics tests.
|
||||
|
||||
Most tests in this tree are fully mocked (protocols, dataclasses, mocked
|
||||
backends) and run without the optional ``video-motion`` dependencies. Only a
|
||||
subset actually constructs :class:`HtmlRenderBackend`, which hard-requires
|
||||
``playwright`` and ``imageio-ffmpeg`` at construction time.
|
||||
|
||||
Rather than skipping the whole tree when those packages are absent (for example
|
||||
a minimal ``pip install -e ".[dev]"`` where the wheels could not be resolved),
|
||||
gate *only* the dependency-bound tests. Mark them with
|
||||
``@pytest.mark.requires_video_deps`` and this hook skips just those when the
|
||||
extras are missing, preserving coverage for the dependency-independent tests.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
|
||||
import pytest
|
||||
|
||||
_VIDEO_DEPS = ("playwright", "imageio_ffmpeg")
|
||||
_MISSING_VIDEO_DEPS = [
|
||||
name for name in _VIDEO_DEPS if importlib.util.find_spec(name) is None
|
||||
]
|
||||
VIDEO_DEPS_AVAILABLE = not _MISSING_VIDEO_DEPS
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"requires_video_deps: mark test as requiring the optional "
|
||||
"video-motion extras (playwright, imageio-ffmpeg).",
|
||||
)
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
if VIDEO_DEPS_AVAILABLE:
|
||||
return
|
||||
skip_reason = (
|
||||
"video-motion extra not installed "
|
||||
"(pip install -e '.[dev,video-motion]'); missing: "
|
||||
+ ", ".join(_MISSING_VIDEO_DEPS)
|
||||
)
|
||||
skip_marker = pytest.mark.skip(reason=skip_reason)
|
||||
for item in items:
|
||||
if "requires_video_deps" in item.keywords:
|
||||
item.add_marker(skip_marker)
|
||||
@@ -13,6 +13,7 @@ except ImportError:
|
||||
|
||||
|
||||
@pytest.mark.skipif(not BACKEND_AVAILABLE, reason="Motion graphics backend not available")
|
||||
@pytest.mark.requires_video_deps
|
||||
class TestBackendSafety:
|
||||
"""Test workspace safety features."""
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Unit tests for HTML render backend."""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
import asyncio
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch, AsyncMock
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonai_tools.video.motion_graphics.backend_html import HtmlRenderBackend
|
||||
from praisonai_tools.video.motion_graphics.protocols import RenderOpts
|
||||
@@ -13,22 +14,32 @@ from praisonai_tools.video.motion_graphics.protocols import RenderOpts
|
||||
class TestHtmlRenderBackend:
|
||||
"""Test HTML render backend."""
|
||||
|
||||
@pytest.mark.requires_video_deps
|
||||
def test_import_error_handling(self):
|
||||
"""Test that import errors are handled properly."""
|
||||
with patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', None):
|
||||
with pytest.raises(ImportError, match="Playwright not installed"):
|
||||
HtmlRenderBackend()
|
||||
|
||||
with patch('praisonai_tools.video.motion_graphics.backend_html.imageio_ffmpeg', None):
|
||||
with pytest.raises(ImportError, match="imageio-ffmpeg not installed"):
|
||||
HtmlRenderBackend()
|
||||
with (
|
||||
patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', None),
|
||||
pytest.raises(ImportError, match="Playwright not installed"),
|
||||
):
|
||||
HtmlRenderBackend()
|
||||
|
||||
# Ensure the playwright guard passes so the imageio-ffmpeg guard is the
|
||||
# one under test, regardless of whether the optional deps are installed.
|
||||
with (
|
||||
patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', Mock()),
|
||||
patch('praisonai_tools.video.motion_graphics.backend_html.imageio_ffmpeg', None),
|
||||
pytest.raises(ImportError, match="imageio-ffmpeg not installed"),
|
||||
):
|
||||
HtmlRenderBackend()
|
||||
|
||||
def test_init_success(self):
|
||||
"""Test successful initialization."""
|
||||
with patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', Mock()):
|
||||
with patch('praisonai_tools.video.motion_graphics.backend_html.imageio_ffmpeg', Mock()):
|
||||
backend = HtmlRenderBackend()
|
||||
assert backend is not None
|
||||
with (
|
||||
patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', Mock()),
|
||||
patch('praisonai_tools.video.motion_graphics.backend_html.imageio_ffmpeg', Mock()),
|
||||
):
|
||||
backend = HtmlRenderBackend()
|
||||
assert backend is not None
|
||||
|
||||
|
||||
class TestLinting:
|
||||
@@ -36,9 +47,11 @@ class TestLinting:
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures."""
|
||||
with patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', Mock()):
|
||||
with patch('praisonai_tools.video.motion_graphics.backend_html.imageio_ffmpeg', Mock()):
|
||||
self.backend = HtmlRenderBackend()
|
||||
with (
|
||||
patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', Mock()),
|
||||
patch('praisonai_tools.video.motion_graphics.backend_html.imageio_ffmpeg', Mock()),
|
||||
):
|
||||
self.backend = HtmlRenderBackend()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lint_missing_index_html(self):
|
||||
@@ -198,9 +211,11 @@ class TestRendering:
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures."""
|
||||
with patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', Mock()):
|
||||
with patch('praisonai_tools.video.motion_graphics.backend_html.imageio_ffmpeg', Mock()):
|
||||
self.backend = HtmlRenderBackend()
|
||||
with (
|
||||
patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', Mock()),
|
||||
patch('praisonai_tools.video.motion_graphics.backend_html.imageio_ffmpeg', Mock()),
|
||||
):
|
||||
self.backend = HtmlRenderBackend()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_missing_index_html(self):
|
||||
@@ -321,8 +336,10 @@ class TestRendering:
|
||||
mock_process.returncode = 0
|
||||
mock_subprocess.return_value = mock_process
|
||||
|
||||
with patch.object(self.backend, '_get_ffmpeg_path', return_value='ffmpeg'):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with (
|
||||
patch.object(self.backend, '_get_ffmpeg_path', return_value='ffmpeg'),
|
||||
tempfile.TemporaryDirectory() as tmpdir,
|
||||
):
|
||||
# Create fake frame files
|
||||
frame_dir = Path(tmpdir)
|
||||
frame_paths = []
|
||||
@@ -357,8 +374,10 @@ class TestRendering:
|
||||
mock_process.returncode = 1
|
||||
mock_subprocess.return_value = mock_process
|
||||
|
||||
with patch.object(self.backend, '_get_ffmpeg_path', return_value='ffmpeg'):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with (
|
||||
patch.object(self.backend, '_get_ffmpeg_path', return_value='ffmpeg'),
|
||||
tempfile.TemporaryDirectory() as tmpdir,
|
||||
):
|
||||
frame_path = Path(tmpdir) / "frame_000000.png"
|
||||
frame_path.write_bytes(b"fake")
|
||||
|
||||
@@ -378,8 +397,10 @@ class TestRendering:
|
||||
mock_process.kill = Mock()
|
||||
mock_subprocess.return_value = mock_process
|
||||
|
||||
with patch.object(self.backend, '_get_ffmpeg_path', return_value='ffmpeg'):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with (
|
||||
patch.object(self.backend, '_get_ffmpeg_path', return_value='ffmpeg'),
|
||||
tempfile.TemporaryDirectory() as tmpdir,
|
||||
):
|
||||
frame_path = Path(tmpdir) / "frame_000000.png"
|
||||
frame_path.write_bytes(b"fake")
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@ from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from praisonai_tools.video.motion_graphics.agent import (
|
||||
create_motion_graphics_agent,
|
||||
RenderTools,
|
||||
_resolve_backend
|
||||
_resolve_backend,
|
||||
create_motion_graphics_agent,
|
||||
)
|
||||
from praisonai_tools.video.motion_graphics.protocols import RenderResult, LintResult
|
||||
from praisonai_tools.video.motion_graphics.protocols import LintResult, RenderResult
|
||||
|
||||
|
||||
class MockAgent:
|
||||
@@ -96,6 +96,7 @@ class TestRenderTools:
|
||||
class TestResolveBackend:
|
||||
"""Test backend resolution."""
|
||||
|
||||
@pytest.mark.requires_video_deps
|
||||
def test_resolve_string_backend(self):
|
||||
"""Test resolving string backend specification."""
|
||||
backend = _resolve_backend("html")
|
||||
@@ -119,6 +120,7 @@ class TestResolveBackend:
|
||||
class TestCreateMotionGraphicsAgent:
|
||||
"""Test motion graphics agent factory."""
|
||||
|
||||
@pytest.mark.requires_video_deps
|
||||
@patch('praisonai_tools.video.motion_graphics.agent.Agent', MockAgent)
|
||||
@patch('praisonai_tools.video.motion_graphics.agent.FileTools', MockFileTools)
|
||||
def test_create_agent_defaults(self):
|
||||
@@ -137,6 +139,7 @@ class TestCreateMotionGraphicsAgent:
|
||||
"lint_composition", "render_composition"} <= tool_names
|
||||
assert "motion graphics specialist" in agent.instructions.lower()
|
||||
|
||||
@pytest.mark.requires_video_deps
|
||||
@patch('praisonai_tools.video.motion_graphics.agent.Agent', MockAgent)
|
||||
@patch('praisonai_tools.video.motion_graphics.agent.FileTools', MockFileTools)
|
||||
def test_create_agent_custom_params(self):
|
||||
@@ -159,6 +162,7 @@ class TestCreateMotionGraphicsAgent:
|
||||
with pytest.raises(ImportError, match="praisonaiagents not available"):
|
||||
create_motion_graphics_agent()
|
||||
|
||||
@pytest.mark.requires_video_deps
|
||||
@patch('praisonai_tools.video.motion_graphics.agent.Agent', MockAgent)
|
||||
@patch('praisonai_tools.video.motion_graphics.agent.FileTools', MockFileTools)
|
||||
def test_create_agent_auto_workspace(self):
|
||||
@@ -183,6 +187,7 @@ class TestCreateMotionGraphicsAgent:
|
||||
|
||||
assert agent._motion_graphics_backend is mock_backend
|
||||
|
||||
@pytest.mark.requires_video_deps
|
||||
@patch('praisonai_tools.video.motion_graphics.agent.Agent', MockAgent)
|
||||
@patch('praisonai_tools.video.motion_graphics.agent.FileTools', MockFileTools)
|
||||
def test_create_agent_workspace_creation(self):
|
||||
@@ -195,6 +200,7 @@ class TestCreateMotionGraphicsAgent:
|
||||
assert workspace_path.exists()
|
||||
assert agent._motion_graphics_workspace == workspace_path
|
||||
|
||||
@pytest.mark.requires_video_deps
|
||||
@patch('praisonai_tools.video.motion_graphics.agent.Agent', MockAgent)
|
||||
@patch('praisonai_tools.video.motion_graphics.agent.FileTools', MockFileTools)
|
||||
def test_create_agent_skill_included(self):
|
||||
@@ -207,6 +213,7 @@ class TestCreateMotionGraphicsAgent:
|
||||
assert "timeline" in agent.instructions
|
||||
assert "window.__timelines" in agent.instructions
|
||||
|
||||
@pytest.mark.requires_video_deps
|
||||
@patch('praisonai_tools.video.motion_graphics.agent.Agent', MockAgent)
|
||||
@patch('praisonai_tools.video.motion_graphics.agent.FileTools', MockFileTools)
|
||||
def test_create_agent_output_validation(self):
|
||||
@@ -219,6 +226,7 @@ class TestCreateMotionGraphicsAgent:
|
||||
assert "Never fabricate file paths" in agent.instructions
|
||||
assert "3 failed attempts" in agent.instructions
|
||||
|
||||
@pytest.mark.requires_video_deps
|
||||
@patch('praisonai_tools.video.motion_graphics.agent.Agent', MockAgent)
|
||||
@patch('praisonai_tools.video.motion_graphics.agent.FileTools', MockFileTools)
|
||||
def test_agent_tools_configuration(self):
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from praisonai_tools.video.motion_graphics.protocols import (
|
||||
LintResult,
|
||||
RenderBackendProtocol,
|
||||
RenderOpts,
|
||||
LintResult,
|
||||
RenderResult,
|
||||
RenderBackendProtocol
|
||||
)
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ class TestRenderResult:
|
||||
class TestRenderBackendProtocol:
|
||||
"""Test RenderBackendProtocol protocol."""
|
||||
|
||||
@pytest.mark.requires_video_deps
|
||||
def test_protocol_runtime_checkable(self):
|
||||
"""Test that protocol is runtime checkable."""
|
||||
from praisonai_tools.video.motion_graphics.backend_html import HtmlRenderBackend
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Tests for render retry functionality."""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from praisonai_tools.video.motion_graphics.agent import RenderTools
|
||||
from praisonai_tools.video.motion_graphics.protocols import RenderResult, RenderOpts
|
||||
from praisonai_tools.video.motion_graphics.protocols import RenderOpts, RenderResult
|
||||
AGENT_AVAILABLE = True
|
||||
except ImportError:
|
||||
AGENT_AVAILABLE = False
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<html>mock visualization</html>
|
||||
Reference in new issue
Block a user