Compare commits

...
92 Commits
Author SHA1 Message Date
MervinPraison 17aa27739e Release v0.2.46 2026-10-31 20:34:10 +00:00
praisonai-triage-agent[bot] fb6fb48816 Merge pull request #118 from MervinPraison/claude/issue-6168-20261031
feat: add You.com tools (migrated from praisonaiagents core)
2026-10-31 20:33:07 +00:00
MervinPraisonandClaude Opus 5.5 0fac8a0aa5 fix: honour explicit YouTools api_key, expose ydc alias, tighten missing-key test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:21:52 +00:00
MervinPraisonandClaude Opus 5.5 072585870e feat: add You.com tools migrated from praisonaiagents core (fixes MervinPraison/PraisonAI#6168)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:17:30 +00:00
Mervin PraisonandClaude Opus 5.5 436a66e163 ci: bump github-automation-template to v1.1.20 (#117)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-29 02:38:18 +01:00
Mervin PraisonandClaude Opus 5.5 e18de78873 ci: bump github-automation-template to v1.1.19 (#116)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-26 11:42:24 +01:00
Mervin PraisonandClaude Opus 5.5 b8ee7dc5b2 chore(ci): github-automation-template v1.1.18 (#115)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-26 09:46:30 +01:00
Mervin PraisonandClaude Opus 5.5 edb4a66564 fix(ci): github-automation-template v1.1.17 (permission ceiling) (#114)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 09:34:43 +01:00
Mervin PraisonandClaude Opus 5.5 d332f6e28d chore(ci): github-automation-template v1.1.16 (#113)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 09:27:20 +01:00
d3de3800a2 chore(ci): bump github-automation-template to v1.1.15 (#112)
* chore(ci): bump github-automation-template to v1.1.15

Writers-only auto-Claude, least-privilege tokens for Claude on fork PRs and
in the merge-gate assessor, merge-gate held-out BLOCKs (no repeat verdicts on
an unchanged HEAD), portable prompts. auto-pr-comment sweep moves to hourly so
stale FINAL reviews are re-posted once a push settles.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix: sync automation.meta.json hubRef to v1.1.15

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 16:55:55 +01:00
Mervin PraisonandClaude Opus 5.5 7dbfbdf5ae fix(ci): github-automation-template v1.1.14 (fork PR isolation) (#111)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 08:58:50 +01:00
Mervin PraisonandClaude Opus 5.5 47c30e3c1d fix(ci): require write access to invoke @claude (template v1.1.13) (#110)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 08:30:31 +01:00
MervinPraisonandCursor c16bd22c6c chore(ci): bump github-automation-template hub to v1.1.12 (Opus/Sonnet 5.5)
Co-authored-by: Cursor <[email protected]>
2026-10-11 22:33:21 +01:00
MervinPraison 0237c6f4ce Release v0.2.45 2026-10-08 05:19:04 +00:00
aa8e7079f3 feat: add Keenable keyless web search tool (#105)
* feat: add Keenable keyless web search tool

Keenable is a web-search service (api.keenable.ai). Per AGENTS.md routing,
vendor search services belong in PraisonAI-Tools as standalone tool modules
(mirroring tavily_tool.py / brave_search_tool.py), not inline in the core
praisonaiagents web_search.py aggregator.

Keyless by default via the public endpoint (stdlib urllib, no install);
KEENABLE_API_KEY switches to the authenticated endpoint for higher limits.
Redirects are refused so X-API-Key is never resent to another host; results
are bounded (50 max, 1 MB body cap, title/URL/snippet length caps).

Auto-discovered by praisonai_tools.tools (no manual registration). 13 unit
tests cover endpoint selection, result mapping/bounds, and error handling.

Relates to MervinPraison/PraisonAI#5671

Co-authored-by: Mervin Praison <[email protected]>

* test: cover Keenable redirect refusal and bound limits

Address Greptile P2: the protective limits had no focused coverage and
build_opener was fully mocked, so _NoRedirect, the result cap, the
oversized title/url/snippet caps, and the response read limit could be
removed with the suite staying green.

- Lift _NoRedirect to module level so the redirect refusal is importable
  and unit-testable (was a closure inside search()).
- _FakeResponse.read now honours the requested byte count, so the
  oversized-response test exercises the actual read cap.
- Add TestProtections: result-count cap in the request body, title and
  snippet truncation, oversized-URL skip, read-limit enforcement, and
  _NoRedirect dropping a 302 (no follow-up request).
- Add the missing __init__ docstring.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-10-08 06:18:03 +01:00
Mervin PraisonandCursor 4e6a092548 chore: bump github-automation-template hub_ref to v1.1.11 (#103)
Picks up the 403 label fix in the hub callable workflows.

Co-authored-by: Cursor <[email protected]>
2026-09-30 08:38:13 +01:00
MervinPraison 363eaeafb1 Release v0.2.44 2026-09-28 03:39:50 +00:00
praisonai-triage-agent[bot] a9f01306da Merge pull request #95 from MervinPraison/claude/issue-37-20260902-1212
feat: add StripeTool (read paths + guarded payment link create)
2026-09-22 18:07:00 +00:00
MervinPraison b0132aa26a Release v0.2.43 2026-09-22 14:33:46 +00:00
praisonai-triage-agent[bot] 65659c7122 Merge pull request #93 from MervinPraison/claude/issue-55-20260902-1212
fix: enable running both MDX escaping test suites together
2026-09-22 14:32:18 +00:00
praisonai-triage-agent[bot] a43433ee60 Merge pull request #94 from MervinPraison/claude/issue-38-20260902-1212
feat: add FirefliesTool (curated GraphQL operations)
2026-09-22 14:32:16 +00:00
praisonai-triage-agent[bot] d8483b476e Merge pull request #96 from MervinPraison/claude/issue-39-20260902-1212
feat: gated Reddit write actions + User-Agent template + tests
2026-09-22 14:32:14 +00:00
MervinPraisonandCursor a1df45aef9 chore: bump automation hub to v1.1.10 for merge gate fix
Fixes claude-assess hub-paths.js module error and GitHub MCP permission
denials in the merge gate callable workflow.

Co-authored-by: Cursor <[email protected]>
2026-09-22 15:22:58 +01:00
MervinPraisonandMervin Praison 876cf29010 fix: enable running both MDX escaping test suites together (fixes #55)
Add empty scripts/__init__.py so scripts/tests/test_mdx_escaping.py
resolves as a unique module (scripts.tests.test_mdx_escaping) instead
of colliding with tests/test_mdx_escaping.py. This lets the root and
comprehensive suites be collected together in CI without the pytest
"import file mismatch" error (issue Fix 4).

Both suites pass together: 7 + 24 = 31 passed.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-17 07:18:41 +00:00
MervinPraisonandCursor d70fd1aaa4 chore(automation): bump hub to v1.1.9 for review-chain token fallback
Co-authored-by: Cursor <[email protected]>
2026-09-16 17:21:29 +01:00
MervinPraison 55edaf9a85 Release v0.2.42 2026-09-16 16:20:22 +00:00
MervinPraisonandCursor 4c35c4817c fix(ci): add httpx to dev extras for Notion tool unit tests
CI installs dev dependencies for pytest; Notion tests patch httpx.AsyncClient
and fail when httpx is not installed alongside httpx2 from openai.

Co-authored-by: Cursor <[email protected]>
2026-09-16 17:19:00 +01:00
MervinPraisonandCursor f28385458c chore(automation): bump hub to v1.1.8 for dual OAuth failover
Co-authored-by: Cursor <[email protected]>
2026-09-15 08:03:23 +01:00
MervinPraisonandClaude Opus 4.8 68f161f136 fix: drop redundant mode key in list_payment_links (banner adds it)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-07 13:01:03 +00:00
MervinPraison 6ac9056331 fix: align stripe extra with requests, map catalogue extra, harden HTTP status
- pyproject: [stripe] extra installs requests (the actual runtime dep) instead
  of the unused stripe SDK, matching sibling requests-based tools (swarmscore)
- catalogue: map stripe_tool -> ('stripe',) in _MODULE_EXTRAS so discovery
  reports the correct install extra
- stripe_tool: reject non-2xx responses lacking Stripe's error object instead
  of treating decoded JSON as success
- tests: give mock responses a real status_code; add non-2xx failure test
2026-09-07 12:57:49 +00:00
MervinPraisonandClaude Opus 4.8 838a7b7000 fix(fireflies): enforce HTTPS, surface HTTP errors, clamp limit, parse 429 payload
- Reject non-HTTPS FIREFLIES_API_URL so the API key never leaves over plaintext
- Return a distinct error for generic HTTP failures (e.g. 5xx) instead of
  normalizing an error body into an empty/missing result
- Parse GraphQL 429 payloads for extensions.metadata.retryAfter when the
  Retry-After header is absent
- Clamp limit to Fireflies' 1..50 page-size bound in list/search

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-07 12:57:09 +00:00
MervinPraisonandClaude Opus 4.8 77df0ca393 fix: advertise gated write actions in RedditTool description
Update the agent-facing description so post_comment/submit_post are
discoverable, addressing Greptile review feedback on PR #96.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-07 12:56:33 +00:00
Mervin Praison 997898a5fa Merge pull request #101 from MervinPraison/chore/hub-v1.1.6
fix(ci): bump github-automation hub to v1.1.6
2026-09-07 13:54:40 +01:00
MervinPraisonandCursor 1ce2299a3d fix(ci): bump github-automation hub to v1.1.6
Fixes startup_failure on Auto PR Comment, merge gate, and Claude Assistant
(reusable-workflow caller permissions + hub checkout before claude-code-action).
Slim claude.yml to hub callable; gate-config.js unchanged (tools profile).

Co-authored-by: Cursor <[email protected]>
2026-09-07 13:54:30 +01:00
MervinPraisonandCursor 9d4e0e047c fix(gate-config): add triggerLogins so owner-posted FINAL @claude counts
Hub install preserved gate-config but omitted triggerLogins; merge gate and
pipeline sync only recognised github-actions[bot] FINAL triggers.

Co-authored-by: Cursor <[email protected]>
2026-09-02 22:01:01 +01:00
MervinPraisonandCursor 799bf1a1f9 chore(automation): bump hub callers to v1.1.2 (fix merge gate callable parse)
Co-authored-by: Cursor <[email protected]>
2026-09-02 21:57:00 +01:00
MervinPraisonandCursor ff10557b99 fix(claude): support fork PR review checkout (e.g. external contributor PRs)
Fetch pull/N/head for all PRs and block origin push on read-only forks so
claude-code-action does not fail with "couldn't find remote ref" on upstream.

Co-authored-by: Cursor <[email protected]>
2026-09-02 13:16:26 +01:00
MervinPraisonandMervin Praison 820833c1b3 feat: add gated Reddit write actions + User-Agent template + tests (fixes #39)
Add REDDIT_ALLOW_WRITE-gated post_comment and submit_post actions to
RedditTool, a compliant default User-Agent referencing the app id, and
unit tests covering import, schema generation, write-flag gating, and
mocked write flows.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 12:15:07 +00:00
MervinPraisonandMervin Praison 361b1e70b9 feat: add StripeTool (read paths + guarded payment link create) (fixes #37)
Adds a narrow, audited StripeTool following existing tool patterns
(lazy optional requests import, clear errors). Covers Phase 1 reads
(get_customer, get_payment_intent, list_payment_links) and a Phase 2
guarded write (create_payment_link) requiring a correlation_id and
supporting an optional Idempotency-Key. Includes test/live key prefix
detection with a mode banner, audit-only logging (no payloads), the
[stripe] pyproject extra, .env.example, and unit tests.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 12:14:56 +00:00
MervinPraisonandMervin Praison 8c0ac62c02 feat: add FirefliesTool with curated GraphQL operations (fixes #38)
Add a Fireflies.ai meeting-intelligence tool exposing curated GraphQL
operations (list_recent_meetings, get_transcript, search) with
FIREFLIES_API_KEY Bearer auth, lazy requests import, cursor pagination,
and error handling that distinguishes a missing key from HTTP 401/403
and surfaces retry_after on rate limits.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 12:14:46 +00:00
MervinPraisonandCursor 5f4f694dc0 chore(automation): upgrade to hub v1.1.1 and fix pre-labelled issue Claude trigger
Switch merge-gate, review chain, CI failure, and pipeline workflows to thin
hub callers; keep local claude.yml for tools-specific prompts. Remove
redundant auto-issue-comment.yml — claude.yml now handles issues opened
with the claude label already present.

Co-authored-by: Cursor <[email protected]>
2026-09-02 13:08:31 +01:00
MervinPraison bd84a17340 Release v0.2.41 2026-09-02 11:43:17 +00:00
Mervin Praison 25e8bc6c6e Merge pull request #92 from MervinPraison/claude/issue-91-20260902-0915
feat: 8 meeting-agent tools (transcribe, CRUD, summarize, extract, index, search)
2026-09-02 12:42:18 +01:00
MervinPraisonandMervin Praison d4bdd82739 fix(meeting-tools): real chunk timestamps, safe re-index, bounded score
Address reviewer findings on PR #92:
- index_meeting now accepts optional segments and maps each chunk to the
  covering segment start/end times instead of hardcoding 0.0 (Greptile/CodeRabbit)
- embed before delete so a failed re-index never wipes prior searchable vectors
- search_meetings uses bounded 1/(1+distance) score for Chroma squared-L2 space
- raise openai floor to >=1.12.0 (timestamp_granularities support)
- use contextlib.closing for SQLite connections (leak-safe on error)
- strengthen registration test to detect shadowed exports
- add tests: segment->timestamp mapping, failed-reindex preservation, bounded score

Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 09:39:00 +00:00
MervinPraisonandMervin Praison 88993be922 feat: add 8 meeting-agent tools (transcribe, CRUD, summarize, extract, index, search) (fixes #91)
Implements PAI-MEET-TOOLS: transcribe_file, save_meeting, get_meeting,
list_meetings, summarize_transcript, extract_action_items, index_meeting,
search_meetings as @tool functions with lazy openai/chromadb imports,
SQLite persistence, deterministic JSON output, and clear error mapping.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 09:19:39 +00:00
MervinPraison 4c76cb69a3 Release v0.2.40 2026-08-28 09:32:21 +00:00
Mervin Praison 01e14d91f8 Merge pull request #89 from MervinPraison/fix/docs-generator-nav-update-errors
fix: update docs/docs.json path for Mintlify subpath hosting
2026-08-26 18:47:54 +01:00
MervinPraison 0b5eb93bb6 fix: update docs/docs.json path for Mintlify subpath hosting
Generator looked for docs.json at repo root; PraisonAIDocs uses
docs/docs.json so nav was never updated after auto-gen. Also run
sync_sdk_reference_nav.py after generation as a safety net.
2026-08-25 21:49:23 +01:00
MervinPraison 936ff4f3fa Release v0.2.39 2026-08-25 01:07:44 +00:00
Mervin Praison 30c1b54fe5 Merge pull request #88 from MervinPraison/fix/docs-generator-nav-update-errors
fix: stop swallowing docs.json nav update failures
2026-08-20 10:19:12 +01:00
MervinPraisonandMervin Praison 8da1746df8 fix(docs): report actual docs.json page count after legacy filtering
Legacy layout strips /index and /__init__ before writing package_group['pages'],
so logging len(unique_pages) overstated the count. Count the final navigation
entries instead (granular sums sub-group pages).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-19 21:04:13 +00:00
MervinPraison 8928ed870a fix: stop swallowing docs.json nav update failures
Auto-generate was writing MDX without updating docs.json when update_docs_json failed silently, breaking nav-check on every docs PR.
2026-08-19 21:48:01 +01:00
MervinPraison 1eabeb4b25 Release v0.2.38 2026-08-19 01:05:53 +00:00
MervinPraison 3819b3d51d fix(docs): serialize generate-docs pushes and rebase before push
Prevent parallel workflow runs from racing on PraisonAIDocs main; pull
--rebase before push when SDK reference pages were regenerated.
2026-08-16 07:22:18 +01:00
MervinPraison fe205e3b77 fix(docs): use CI docs-root in generator path tracking
Track generated MDX paths relative to the passed --docs-root instead of a
hardcoded local PraisonAIDocs path so generate-docs runs in GitHub Actions
can commit navigation updates.
2026-08-16 07:21:15 +01:00
Mervin Praison 193bd05793 Merge pull request #86 from MervinPraison/claude/issue-85-20260816-0512
fix: unwrap describe envelope and structured cost in registry_proxy
2026-08-16 07:18:11 +01:00
MervinPraisonandMervin Praison 7e23cddd9b fix: reject non-finite/negative prices in registry_proxy spend guard
NaN/inf prices passed float() but made both per-call and session-spend
comparisons evaluate false, bypassing the configured guard and poisoning
_session_spend. Fail closed on non-finite/negative prices in _check_budget
and validate the response charge in spend recording so a malformed response
cannot corrupt cumulative spend.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-16 05:31:42 +00:00
MervinPraisonandMervin Praison 96b7a2f8e4 fix: unwrap describe envelope and structured cost in registry_proxy (fixes #85)
Both _extract_method and _extract_price now unwrap the live registry's
`endpoint` envelope before reading fields, and _extract_price prefers the
structured cost object's `usd` value (guard budgets are USD). Flat forms
and fail-closed behaviour are preserved. Fixtures mirror the live schema.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-16 05:14:11 +00:00
praisonai-triage-agent[bot] 0d3e97a6b1 Merge pull request #84 from MervinPraison/claude/issue-83-20260815-1921
fix: registry_proxy describe path + HTTP method support
2026-08-15 20:30:27 +00:00
MervinPraisonandMervin Praison 23e3a88eb0 fix(registry_proxy): validate describe_path, encode tool_id, normalize+whitelist method
Address reviewer findings on PR #84:
- describe_path template validated (must contain {tool_id}; malformed templates
  return a tool-result error instead of raising, honoring the error contract)
- tool_id URL-encoded on both describe and /call routes (prevents path escape)
- explicit method stripped/upper-cased and validated against an allowed set
- deny-path spend-guard tests assert client.request (not client.post) not called

Co-authored-by: Mervin Praison <[email protected]>
2026-08-15 19:45:24 +00:00
MervinPraisonandMervin Praison 923425b443 fix: registry_proxy describe path + HTTP method support (fixes #83)
- describe now uses /catalog/endpoints/{tool_id} (configurable via
  TOOL_PROXY_DESCRIBE_PATH); fixes 404s that also broke spend-guarded calls
- registry_call derives the endpoint's HTTP method from describe (reusing the
  guard's describe fetch); GET sends query params, POST/PUT send a JSON body;
  method can be passed explicitly to skip the lookup
- docs: describe-path env var, HTTP-method behaviour, passthrough-URL/SSRF note

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Mervin Praison <[email protected]>
2026-08-15 19:26:59 +00:00
praisonai-triage-agent[bot] 310f15d6eb Merge pull request #72 from MervinPraison/claude/issue-63-20260727-0909
fix: mock langextract in test_agent_usage_pattern
2026-08-15 12:35:50 +00:00
MervinPraison 893cfea6e7 fix(docs): use GH_TOKEN fallback for PraisonAIDocs checkout/push
DOCS_GITHUB_TOKEN was never synced to this repo; fall back to GH_TOKEN so
generate-docs can checkout and push reference docs after main merges.
2026-08-15 13:34:30 +01:00
MervinPraison 0605eec551 fix(ci): unblock auto-merge after conflict rebase and sync PraisonAIDocs
Recognise MervinPraison Claude finished replies in stale-FINAL detection,
re-post FINAL immediately after conflict rebase pushes, broaden generate-docs
triggers to all praisonai_tools changes plus post-main CI, and sync pipeline
labels on push/PR events.
2026-08-15 13:33:24 +01:00
praisonai-triage-agent[bot] 2098ab469d Merge pull request #53 from MervinPraison/claude/issue-52-20260713-2242
feat: automatic tool discovery (fixes #52)
2026-08-15 12:21:22 +00:00
MervinPraisonandMervin Praison 1424ce7769 test: isolate langextract test output to temp dirs (fixes #63)
Address Greptile finding: langextract integration/render tests wrote
generated HTML into the working directory. Pass temporary output_path
in test_agent_usage_pattern, test_contract_analysis_scenario, and
test_render_file_success so tests no longer modify the checkout or
collide across runs.

Also remove committed test artifacts (contract-001.html,
financial-analysis.html, tmpc8_hvm6m.html) that leaked from these
CWD writes in the original langextract PR.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-15 11:41:09 +00:00
MervinPraisonandMervin Praison 3a67c9bc59 test: run tool-discovery parse guard inside CI-covered path
CI runs `pytest tests/unit --ignore=tests/unit/video`, so the parse-failure
and collision guards in tests/test_tool_discovery.py (at tests/ root) never
executed in CI. Adds a thin bridge under tests/unit/tools/ so a syntactically
broken shipped tool module fails loudly at CI time instead of silently
dropping out of discovery and surfacing later as an AttributeError.

Closes the remaining Greptile P1 (parse test misses CI).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-15 11:40:59 +00:00
MervinPraisonandMervin Praison 5a233cb3b5 test: account for top-level catalogue exports after rebase onto main
Main (#74) added ToolCatalogueEntry/list_tools/get_tool_names to the
top-level __all__. The top-level surface now equals the tools-package
surface plus those catalogue helpers, so test_all_matches_manifest is
updated accordingly.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-15 11:40:59 +00:00
MervinPraisonandMervin Praison 44cbefe9b7 test: fail loudly if a shipped tool module cannot be parsed
Discovery intentionally swallows a per-module SyntaxError so one broken
module cannot break the whole package import, but that meant a broken
shipped module silently dropped from the manifest (resolving as
AttributeError instead of the real parse error). The existing coverage
test shared the same swallowing helper, so it could not catch it.

Add test_every_shipped_module_parses which ast.parse()s each module
directly, surfacing the pointing SyntaxError at CI time. Addresses the
re-raised Greptile P1.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-15 11:40:59 +00:00
MervinPraisonandMervin Praison 5c302e2acc fix: respect __all__, skip private modules, cache discovery scan
Address reviewer feedback on PR #53:
- Honour an explicit module-level __all__ (CodeRabbit/Gemini): modules that
  expose tools via aliases can opt them in; bare aliases are otherwise not
  auto-exported to avoid cross-module name collisions.
- Skip underscore-prefixed modules so internal code cannot leak into the
  public surface (Greptile).
- Warn instead of silently dropping tools when a module fails to parse
  (Greptile).
- Guard the drop-in test for read-only installs and use a non-underscore
  probe module (Gemini); add an n8n external-symbol resolution test
  (CodeRabbit) and an explicit-__all__ test.
- Memoise the AST scan in a fingerprinted on-disk cache, cutting import
  time from ~260ms to ~70ms (AGENTS.md <200ms budget) while keeping
  discovery automatic; add cache round-trip/corruption tests and gitignore.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Mervin Praison <[email protected]>
2026-08-15 11:40:59 +00:00
MervinPraisonandMervin Praison 517a3b8bcb feat: replace hand-maintained tool import map with automatic discovery (fixes #52)
Adding a tool now means adding one file under praisonai_tools/tools/.
An AST-based scan builds the name->module manifest without importing any
tool module, preserving per-symbol lazy import. __all__ at both package
levels is derived from the same manifest so they cannot drift, and
collisions are detected at discovery time. README now documents that this
package uses in-package discovery while external packages use the
praisonaiagents.tools entry-point group.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-15 11:40:59 +00:00
Mervin Praison 6d6330087e Merge pull request #67 from MervinPraison/claude/issue-58-20260727-0903
fix(tests): mock langextract in agent usage integration test
2026-08-15 12:39:39 +01:00
Mervin Praison ea56295eac Merge pull request #71 from MervinPraison/claude/issue-61-20260727-0908
fix: sync marketplace tool wrapper docstrings with signature tests
2026-08-15 12:38:52 +01:00
Mervin Praison 251ed8b800 Merge pull request #73 from MervinPraison/claude/issue-60-20260727-0907
fix(docs): repair MDX escaping utilities (fixes #60)
2026-08-15 12:38:06 +01:00
Mervin Praison 2720359927 Merge pull request #77 from MervinPraison/claude/issue-76-20260806-0854
feat: source-derive the tool catalogue (fixes #76)
2026-08-15 12:37:37 +01:00
Mervin Praison 6087b47bf8 Merge pull request #81 from MervinPraison/claude/issue-80-20260814-1422
fix: silence benign 'Registry is None' WARNING spam on bare import
2026-08-15 12:36:53 +01:00
praisonai-triage-agent[bot] b7d3a4a178 Merge pull request #82 from MervinPraison/claude/issue-79-20260814-1422
feat: registry_proxy wire-compatibility — configurable auth header and price-field tolerance
2026-08-14 15:33:01 +00:00
MervinPraisonandpraisonai-triage-agent[bot] 5011f3311c fix: add noqa E402 for intentional post-code imports in decorator
The logging-suppression filter must run before the @tool re-exports are
imported, requiring those imports to sit after module-level code. Mark them
with noqa: E402 to satisfy ruff while preserving the required ordering.

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
2026-08-14 14:47:07 +00:00
MervinPraisonandMervin Praison 9699ff1513 feat: registry_proxy wire-compat — configurable auth header and price-field tolerance (fixes #79)
- Add TOOL_PROXY_AUTH_HEADER / auth_header param: default Authorization sends
  Bearer, a custom header name (e.g. X-Treg-Token) sends the raw token.
- Harmonise budget check to accept price_per_call -> price -> cost (same
  tolerance as spend recording); keep fail-closed when none parse.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-14 14:25:39 +00:00
MervinPraisonandMervin Praison dfd2d18357 fix: silence benign 'Registry is None' WARNING spam on bare import (fixes #80)
Importing praisonai_tools tools (e.g. registry_proxy) outside a running
agent context emitted one WARNING per @tool-decorated function from
praisonaiagents.tools.decorator ("Registry is None for tool …"). On a bare
import that is the normal state, not an anomaly, so it polluted logs/CI for
every library consumer and scaled with the tool catalogue.

Attach a targeted logging.Filter (to the root logger and its handlers, where
praisonaiagents routes the record) that drops only that specific message and
passes all other logging through untouched. Registration behaviour is
unchanged; the root fix (defer/downgrade) belongs in praisonaiagents core.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-14 14:25:32 +00:00
MervinPraisonandMervin Praison ce9cbe08e6 fix: read annotated tool descriptions and scope catalogue fallback to nested tools
Address reviewer findings on PR #77:
- _scan_module now also reads annotated `description: str = "..."` class
  attributes (AnnAssign), so CapsuleTool/NexusPredictionMarketTool surface
  their real descriptions instead of name-synthesised summaries.
- Restrict the _TOOL_MAP catalogue fallback to nested (absolute-module) tools
  only; flat *_tool.py classes stay fully source-derived, preserving the
  no-hand-maintained-bookkeeping guarantee.
- Fix phantom-guard test to resolve absolute tool modules correctly so a
  missing nested first-party module is flagged as a phantom, not tolerated as
  an optional dependency.
- Tighten CalculatorTool summary assertion and add regression coverage for
  annotated descriptions.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-06 09:18:35 +00:00
MervinPraisonandMervin Praison 9d17d0e7a1 feat: source-derive the tool catalogue to eliminate hand-maintained bookkeeping (fixes #76)
Derive the first-party tool catalogue from the source tree instead of the
hand-maintained _TOOL_MAP, so adding a tool becomes a single-file change and
phantom names (advertised tools with no file) are impossible by construction.

- catalogue.py: AST-scan praisonai_tools/tools/*_tool.py for *Tool classes and
  their literal description (cheap, import-free, no optional deps). Summaries now
  come from each tool's real description rather than name-synthesis.
- tools/__init__.py: extend lazy __getattr__ to fall back to the source-derived
  scan, so dropping a *_tool.py makes its class importable with no _TOOL_MAP edit.
  Auto-discovers previously-unmapped ClaudeMemoryTool/CrowPayTool/NightmarketTool.
- tests/test_catalogue_no_phantoms.py: CI guard asserting every catalogue name
  resolves (tolerating only missing optional deps), maps to a real file, and that
  auto-discovery + real-description summaries work.

Lazy-import behaviour and cheap `import praisonai_tools` are preserved; existing
public import names remain stable.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-06 08:59:52 +00:00
MervinPraisonandMervin Praison f44138e6d9 fix(docs): make Mintlify JSX components case-sensitive in MDX escaping
HTML tags stay case-insensitive (<div>/<DIV>) but JSX components are now
matched exactly, so a mis-cased component such as <badge> is escaped as
prose instead of being emitted as a broken component. escape_mdx and
validate_mdx share a single _is_valid_mdx_tag helper so they stay in
lockstep. Resolves the outstanding Greptile P1 tag-casing finding.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 12:06:09 +00:00
MervinPraisonandMervin Praison ffeeae549b fix(ci): pin ruff rule set to E,F to restore green lint
ruff 0.16.0 expanded its default rule set, causing `ruff check` in CI to
flag thousands of pre-existing, repo-wide style items (UP006/UP045/I001/
RUF022/BLE001/...) in files unrelated to this PR. This failure is present
on `main` (base b96bfbc) as well.

Add a [tool.ruff.lint] select = ["E", "F"] to pyproject.toml to pin the
historically-green pycodestyle+pyflakes contract regardless of ruff
version. No test behaviour is changed; MDX suites still pass (7/7 + 24/24).

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 12:01:49 +00:00
MervinPraisonandMervin Praison 9dba6906a5 fix(docs): unify MDX tag-casing allowlist, harden placeholders, wrap mixed examples
Addresses PR #73 reviewer findings:
- validate_mdx now shares escape_mdx's case-insensitive allowlist via
  _VALID_MDX_TAGS_LOWER, so mis-cased components (e.g. <badge>) are treated
  consistently by both and don't fail validation after being preserved.
- Replace collision-prone __MDX_TAG_n__/__CODE_BLOCK_n__ placeholders with
  per-call random NUL-delimited sentinels so literal docstring text can't be
  corrupted during restore.
- _wrap_docstring_code_examples no longer bails on the first fence; it tracks
  fenced regions and still wraps later indented Usage/Example sections.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 12:01:35 +00:00
MervinPraisonandMervin Praison 192f40ee61 fix(docs): repair MDX escaping utilities and add reference-docs shim (fixes #60)
- Rewrite sanitize_type_for_mdx to collapse Callable/Literal/Coroutine and
  nested Optional[Union[...]] while preserving simple generics
- Make escape_mdx selectively escape unknown <tag>/{expr} while preserving
  valid Mintlify components and wrapping docstring code examples
- Auto-simplify complex nested types in escape_for_table
- Add validate_docs_json_structure and module-level VALID_MDX_TAGS
- Add missing hooks/mcp/policy icons to ICON_MAP
- Add scripts/generate_reference_docs.py shim (stdlib-only) so both test
  suites resolve the canonical generator implementation

tests/test_mdx_escaping.py: 7/7 pass; scripts/tests/test_mdx_escaping.py: 24/24 pass

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 12:01:35 +00:00
MervinPraisonandMervin Praison 28b4598ac6 fix(tests): resolve ruff lint errors in langextract test file
Fix I001 (import ordering) and SIM117 (nested with statements)
introduced by the temp-dir isolation changes, so the ruff lint
check passes on the changed file.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 09:40:47 +00:00
MervinPraisonandMervin Praison b8d0694f36 test: align trust config default assertion with code (min_score=1.5)
The test asserted min_score == 3.0 but TrustConfig deliberately defaults to
1.5 (Joy network avg is 1.1, 'standard' threshold), consistent with from_env.
Updated the stale assertion to match the intentional code default.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 09:35:24 +00:00
MervinPraisonandMervin Praison e78237f4d2 docs: complete return-shape docs for marketplace wrappers (fixes #61)
Address Greptile P2 review feedback on PR #71:
- verify_agent_identity: document agent_url (always present) and timestamp
  (present on success) return fields
- pinchwork_delegate: document error-string return paths (missing input,
  missing httpx, connection/HTTP errors, unexpected exceptions)

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 09:28:21 +00:00
MervinPraisonandMervin Praison 3505e63a3d fix(tests): direct langextract HTML output to temp dir to avoid test pollution
Integration and render_file tests called the tool without output_path,
so the real writer left *.html artifacts (financial-analysis.html,
contract-001.html, tmp<stem>.html) in the working directory. Route
output into TemporaryDirectory (matching test_extract_success) so no
files escape test isolation.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 09:17:13 +00:00
MervinPraisonandMervin Praison 292ef01194 fix: add Google-style Args to marketplace tool wrapper docstrings (fixes #61)
Expand thin-wrapper docstrings for pinchwork_delegate, verify_agent_identity,
check_behavioral_trust, and verify_task_delegation_safety with Args/Returns
sections so signature tests in tests/test_marketplace_tools.py pass.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 09:10:08 +00:00
MervinPraisonandMervin Praison 6a6c8646e4 fix(tests): mock langextract in agent usage integration test (fixes #58)
test_agent_usage_pattern invoked langextract_extract without mocking
_get_langextract, causing failure on dev-only installs where the optional
langextract package is absent. Added the same mocks used by the sibling
test_contract_analysis_scenario for consistency.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 09:04:50 +00:00
57 changed files with 6097 additions and 3055 deletions

No files matched your search

+12
View File
@@ -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
+9
View File
@@ -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"
}
+4
View File
@@ -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);
+1
View File
@@ -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: [
+15
View File
@@ -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 };
+33
View File
@@ -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)
+37 -1
View File
@@ -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,
+111
View File
@@ -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);
-37
View File
@@ -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.'
});
+12 -788
View File
@@ -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
+23
View File
@@ -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 || '' }}
+6 -76
View File
@@ -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 || '' }}
+24 -467
View File
@@ -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' }}
+21
View File
@@ -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
View File
@@ -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
+36 -3
View File
@@ -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
+19 -131
View File
@@ -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();
+4 -27
View File
@@ -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
+36
View File
@@ -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"
+3
View File
@@ -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
+49
View File
@@ -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
-1
View File
@@ -1 +0,0 @@
<html>mock visualization</html>
+26
View File
@@ -31,6 +31,13 @@ pip install "praisonai-tools[registry-proxy]"
```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
@@ -69,6 +76,25 @@ 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
-189
View File
@@ -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>
+18 -99
View File
@@ -43,6 +43,10 @@ from praisonai_tools.catalogue import (
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")
except PackageNotFoundError:
@@ -58,105 +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",
# Tool discovery / catalogue
# 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",
# 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",
]
]
# 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__))
+130 -22
View File
@@ -26,11 +26,20 @@ installed.
from __future__ import annotations
import ast
import os
from dataclasses import dataclass, field
from typing import Tuple
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
@@ -46,6 +55,7 @@ _MODULE_EXTRAS = {
"langextract_tool": ("langextract",),
"swarmscore_tool": ("swarmscore",),
"composio_tool": ("composio",),
"stripe_tool": ("stripe",),
}
@@ -70,8 +80,9 @@ class ToolCatalogueEntry:
def _summary_for(name: str) -> str:
"""Derive a concise one-line summary for a tool class name.
We avoid importing the tool (which could pull heavy/optional deps) and
instead produce a stable, human-friendly summary from the name itself.
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]
@@ -80,30 +91,127 @@ def _summary_for(name: str) -> str:
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 source of truth is ``praisonai_tools.tools._TOOL_MAP`` which maps every
public name (both classes and helper functions) to its defining module. We
expose only the tool *classes* (names ending in ``Tool``) in the catalogue,
since those are what ``agents.yaml`` and the SDK reference.
"""
from praisonai_tools.tools import _TOOL_MAP
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``.
entries = []
for name, module in sorted(_TOOL_MAP.items()):
if not name.endswith("Tool"):
continue
extras = _MODULE_EXTRAS.get(module, ())
entries.append(
ToolCatalogueEntry(
name=name,
module=module,
extras=extras,
summary=_summary_for(name),
)
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,
)
return entries
# 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]":
+295 -48
View File
@@ -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('{', '&#123;').replace('}', '&#125;')
# 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('<', '&lt;').replace('>', '&gt;')
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('<', '&lt;').replace('>', '&gt;')
@@ -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
+220 -32
View File
@@ -19,8 +19,10 @@ Design notes:
"""
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
@@ -34,6 +36,11 @@ _INSTALL_HINT = "httpx not installed. Install with: pip install 'praisonai-tools
# 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."""
@@ -51,6 +58,8 @@ class RegistryProxyTool(BaseTool):
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("/")
@@ -64,6 +73,18 @@ class RegistryProxyTool(BaseTool):
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
@@ -93,13 +114,20 @@ class RegistryProxyTool(BaseTool):
if action == "describe":
return self.describe(kwargs.get("tool_id", ""))
if action == "call":
return self.call(kwargs.get("tool_id", ""), kwargs.get("params"))
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:
headers["Authorization"] = f"Bearer {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]]:
@@ -162,10 +190,27 @@ class RegistryProxyTool(BaseTool):
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}/catalog/{tool_id}",
f"{self.proxy_url}{path}",
headers=self._headers(),
)
response.raise_for_status()
@@ -178,19 +223,79 @@ class RegistryProxyTool(BaseTool):
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)``: ``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``).
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
return None, None, None
described = self.describe(tool_id)
if isinstance(described, dict) and "error" in described:
return described, None
price = described.get("price_per_call") if isinstance(described, dict) else None
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):
@@ -198,17 +303,27 @@ class RegistryProxyTool(BaseTool):
# validated, so we cannot guarantee the budget - deny the call.
return {
"error": (
"Denied: price_per_call is missing or invalid, cannot enforce "
"the configured spend guard."
"Denied: price (price_per_call/price/cost) is missing or "
"invalid, cannot enforce the configured spend guard."
)
}, None
}, 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
}, price, described
if (
self.max_session_spend is not None
and self._session_spend + price > self.max_session_spend
@@ -219,30 +334,63 @@ class RegistryProxyTool(BaseTool):
f"{self._session_spend + price}, exceeding max_session_spend "
f"{self.max_session_spend}."
)
}, price
return None, price
}, price, described
return None, price, described
def call(self, tool_id: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Invoke an endpoint through the proxy (credential injected server-side)."""
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 = self._check_budget(tool_id)
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:
response = client.post(
f"{self.proxy_url}/call/{tool_id}",
json=params or {},
headers=self._headers(),
)
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:
@@ -256,14 +404,18 @@ class RegistryProxyTool(BaseTool):
# 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 = None
if isinstance(result, dict):
charged = result.get("price_per_call", result.get("cost"))
charged = self._extract_price(result)
try:
self._session_spend += float(charged)
charged = float(charged)
if not math.isfinite(charged) or charged < 0:
raise ValueError("non-finite or negative charge")
except (TypeError, ValueError):
if quoted_price is not None:
self._session_spend += quoted_price
# 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
@@ -272,6 +424,7 @@ 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.
@@ -282,11 +435,15 @@ def registry_search(
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).search(query)
return RegistryProxyTool(
proxy_url=proxy_url, token=token, auth_header=auth_header
).search(query)
@tool
@@ -294,6 +451,8 @@ 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.
@@ -304,11 +463,19 @@ def registry_describe(
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).describe(tool_id)
return RegistryProxyTool(
proxy_url=proxy_url, token=token, auth_header=auth_header,
describe_path=describe_path,
).describe(tool_id)
@tool
@@ -319,6 +486,9 @@ def registry_call(
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.
@@ -326,6 +496,15 @@ def registry_call(
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.
@@ -333,6 +512,13 @@ def registry_call(
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.
@@ -342,4 +528,6 @@ def registry_call(
token=token,
max_cost_per_call=max_cost_per_call,
max_session_spend=max_session_spend,
).call(tool_id, params)
auth_header=auth_header,
describe_path=describe_path,
).call(tool_id, params, method)
+47 -902
View File
@@ -14,919 +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
# Mapping of every public tool name (classes and functions) to the module that
# defines it. This is the single source of truth for lazy loading AND for the
# catalogue/discovery contract (see ``praisonai_tools.catalogue``). Keeping it a
# module-level constant lets other modules enumerate the shipped tools without
# importing any optional dependency.
_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",
"firecrawl_search": "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",
}
# Lazy imports for tools to avoid loading dependencies until needed
def __getattr__(name):
"""Lazy load tool classes."""
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",
"firecrawl_search",
# 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))
+208
View File
@@ -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
+22 -2
View File
@@ -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,
+11 -1
View File
@@ -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)
+44 -1
View File
@@ -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,
+268
View File
@@ -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)
+700
View File
@@ -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
+14 -1
View File
@@ -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)
+146 -7
View File
@@ -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 = {
+338
View File
@@ -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)
+484
View File
@@ -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"]
+9 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "praisonai-tools"
version = "0.2.37"
version = "0.2.46"
description = "Extended tools for PraisonAI Agents"
authors = [
{name = "Mervin Praison"}
@@ -40,6 +40,7 @@ praisonai-tools = "praisonai_tools.cli:main"
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"httpx>=0.27.0",
"playwright>=1.40",
"imageio-ffmpeg>=0.5",
]
@@ -80,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"
View File
Whitespace-only changes.
+55
View File
@@ -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",
]
+109
View File
@@ -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
+68 -47
View File
@@ -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)
+1 -1
View File
@@ -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')
+528 -14
View File
@@ -84,6 +84,36 @@ class TestConfiguration:
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
@@ -127,32 +157,148 @@ class TestDescribe:
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.post.return_value = response
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"})
result = tool.call("seo.backlinks", {"domain": "example.com"}, method="POST")
assert result["data"]["backlinks"] == 42
client.post.assert_called_once_with(
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
@@ -160,6 +306,293 @@ class TestCall:
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):
@@ -183,11 +616,11 @@ class TestErrorTaxonomy:
resp.status_code = 402
resp.text = "Payment Required"
client = Mock()
client.post.side_effect = mock_httpx.HTTPStatusError("402", response=resp)
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", {})
result = tool.call("seo.backlinks", {}, method="POST")
assert "Insufficient balance" in result["error"]
def test_upstream_4xx_passthrough(self, mock_httpx):
@@ -197,11 +630,11 @@ class TestErrorTaxonomy:
resp.status_code = 404
resp.text = "Not Found"
client = Mock()
client.post.side_effect = mock_httpx.HTTPStatusError("404", response=resp)
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", {})
result = tool.call("seo.backlinks", {}, method="POST")
assert "HTTP 404" in result["error"]
def test_timeout_error(self, mock_httpx):
@@ -240,7 +673,7 @@ class TestSpendGuards:
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.post.assert_not_called()
client.request.assert_not_called()
def test_max_session_spend_denies(self, mock_httpx):
from praisonai_tools.registry_proxy import RegistryProxyTool
@@ -256,7 +689,7 @@ class TestSpendGuards:
tool._session_spend = 0.50
result = tool.call("seo.backlinks", {})
assert "max_session_spend" in result["error"]
client.post.assert_not_called()
client.request.assert_not_called()
def test_within_budget_allows_and_tracks_spend(self, mock_httpx):
from praisonai_tools.registry_proxy import RegistryProxyTool
@@ -269,7 +702,28 @@ class TestSpendGuards:
call_resp.raise_for_status.return_value = None
client = Mock()
client.get.return_value = describe_resp
client.post.return_value = call_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)
@@ -290,7 +744,44 @@ class TestSpendGuards:
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.post.assert_not_called()
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."""
@@ -304,7 +795,7 @@ class TestSpendGuards:
call_resp.raise_for_status.return_value = None
client = Mock()
client.get.return_value = describe_resp
client.post.return_value = call_resp
client.request.return_value = call_resp
mock_httpx.Client.return_value.__enter__.return_value = client
first = registry_call(
@@ -332,7 +823,9 @@ class TestDecoratedFunctions:
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")
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")
@@ -356,8 +849,29 @@ class TestDecoratedFunctions:
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"
)
instance.call.assert_called_once_with("seo.backlinks", {"domain": "x"})
def test_smoke_import():
+185
View File
@@ -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"
+279
View File
@@ -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"
+387
View File
@@ -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"
+176
View File
@@ -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)
+248
View File
@@ -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}"
+81
View File
@@ -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
-1
View File
@@ -1 +0,0 @@
<html>mock visualization</html>