mirror of
https://github.com/MervinPraison/PraisonAI-Tools.git
synced 2026-11-08 17:31:00 -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,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,473 +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: |
|
||||
# Keep scripts together so require('./gate-config.js') works from /tmp
|
||||
mkdir -p /tmp/merge-gate-scripts /tmp/gh-actions
|
||||
cp .github/scripts/merge-gate.js .github/scripts/gate-config.js /tmp/merge-gate-scripts/
|
||||
cp -R .github/actions/claude-code-action /tmp/gh-actions/
|
||||
|
||||
- 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}"
|
||||
# PR branches may predate pipeline actions — restore from main checkout
|
||||
mkdir -p .github/actions
|
||||
rm -rf .github/actions/claude-code-action
|
||||
cp -R /tmp/gh-actions/claude-code-action .github/actions/
|
||||
|
||||
- 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-scripts/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-scripts/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
|
||||
@@ -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,6 +686,7 @@ 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
|
||||
@@ -703,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,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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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,911 +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",
|
||||
# SwarmScore
|
||||
"SwarmScoreTool": "swarmscore_tool",
|
||||
"load_swarmscore_by_slug": "swarmscore_tool",
|
||||
"verify_swarmscore_freshness": "swarmscore_tool",
|
||||
"get_agent_discovery_manifest": "swarmscore_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",
|
||||
# NEXUS Prediction Market Tool
|
||||
"NexusPredictionMarketTool": "nexus_prediction_market_tool",
|
||||
"nexus_prediction_market_tool": "nexus_prediction_market_tool",
|
||||
# Inkog Security Tool
|
||||
"InkogTool": "inkog_tool",
|
||||
"scan_agent_code": "inkog_tool",
|
||||
"scan_skill_package": "inkog_tool",
|
||||
"scan_mcp_server": "inkog_tool",
|
||||
}
|
||||
|
||||
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",
|
||||
# SwarmScore Tool
|
||||
"SwarmScoreTool",
|
||||
"load_swarmscore_by_slug",
|
||||
"verify_swarmscore_freshness",
|
||||
"get_agent_discovery_manifest",
|
||||
# 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",
|
||||
# NEXUS Prediction Market Tool
|
||||
"NexusPredictionMarketTool",
|
||||
"nexus_prediction_market_tool",
|
||||
# Inkog Security Tool
|
||||
"InkogTool",
|
||||
"scan_agent_code",
|
||||
"scan_skill_package",
|
||||
"scan_mcp_server",
|
||||
]
|
||||
|
||||
# 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,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
|
||||
@@ -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,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(
|
||||
|
||||
+29
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-tools"
|
||||
version = "0.2.32"
|
||||
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,6 +65,9 @@ agentfolio = [
|
||||
n8n = [
|
||||
"httpx>=0.27.0",
|
||||
]
|
||||
registry-proxy = [
|
||||
"httpx>=0.24.0",
|
||||
]
|
||||
langextract = [
|
||||
"langextract>=0.1.0",
|
||||
]
|
||||
@@ -67,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"
|
||||
@@ -83,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
|
||||
@@ -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,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,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