Compare commits

...
Author SHA1 Message Date
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
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 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
MervinPraison f10936de79 Release v0.2.37 2026-08-14 14:16:18 +00:00
Mervin Praison 49b717ecfe Merge pull request #78 from MervinPraison/claude/issue-3937-20260814
feat: optional tool-registry proxy connector
2026-08-14 15:15:24 +01:00
MervinPraisonandMervin Praison 6acddaaa95 fix(registry-proxy): bind token to trusted config, persist session spend, fail-closed budget
Addresses reviewer findings on PR #78:
- Do not attach the ambient TOOL_PROXY_TOKEN when an explicit proxy_url is
  supplied without a matching token, preventing token exfiltration to an
  attacker-controlled endpoint (Critical).
- Persist cumulative spend per (proxy_url, token) via a module-level store so
  max_session_spend enforces across separate registry_call invocations (Major).
- Fail closed in _check_budget when a guard is configured but price cannot be
  validated; record spend deterministically from the quoted price (Major).
- Add regression tests + autouse spend-state reset fixture; update docs.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-14 14:05:23 +00:00
MervinPraisonandMervinPraison ed07dfc6a5 feat: optional tool-registry proxy connector (fixes MervinPraison/PraisonAI#3937)
Add a bring-your-own-account connector mirroring PraisonAI's deferred
tool-search bridge: registry_search / registry_describe / registry_call
over a registry's plain-HTTP proxy (token header, configurable base URL).
Credentials are injected server-side by the registry, never held by the
agent. Connector is disabled unless TOOL_PROXY_URL is set; httpx is
lazy-imported; errors surface as {"error": ...} tool results. Includes
optional per-call/per-session spend guards, docs (Tiers 0/1/2) and mocked
HTTP unit tests plus a skills-dir compatibility test.

Co-authored-by: MervinPraison <[email protected]>
2026-08-14 13:45:49 +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
MervinPraison 8e1fe1d860 Release v0.2.36 2026-08-04 11:41:31 +00:00
praisonai-triage-agent[bot] 0629d18a4e Merge pull request #75 from MervinPraison/claude/issue-74-20260804-0919
feat: add tool discovery/catalogue contract
2026-08-04 11:40:29 +00:00
MervinPraisonandMervin Praison 1950368530 feat: add tool discovery/catalogue contract (fixes #74)
Expose a programmatic catalogue of shipped tools plus a `praisonai.tools`
entry-point discovery contract so validators/CLI/docs/third-party packages
resolve against the live catalogue instead of a hand-maintained allowlist
that drifts.

- praisonai_tools.catalogue: ToolCatalogueEntry, list_tools(), get_tool_names()
- Derive catalogue from the existing _TOOL_MAP source of truth (no optional deps)
- Discover third-party tools via `praisonai.tools` entry points
- Add `praisonai-tools list [--extras]` CLI subcommand
- Document the entry-point group in pyproject.toml
- Tests in tests/test_catalogue.py

Co-authored-by: Mervin Praison <[email protected]>
2026-08-04 09:24:51 +00:00
MervinPraison ea270f747a Release v0.2.35 2026-07-31 02:37:35 +00:00
praisonai-triage-agent[bot] d06254fc68 Merge pull request #66 from MervinPraison/claude/issue-57-20260727-0902
fix(video): align motion graphics team tests with hierarchical API
2026-07-27 14:58:54 +00:00
MervinPraisonandMervin Praison e0b164c530 fix(video): use hierarchical coordinator in motion graphics example
Replace stale team.leader.name access with team.agents[0].name to match
the hierarchical AgentTeam API (no static leader in this mode).

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 14:14:49 +00:00
MervinPraisonandMervin Praison a8627e295e fix(video): align motion graphics team tests with hierarchical API (fixes #57)
The integration tests asserted `team.leader` and an exact `kwargs` dict,
but the preset uses `process="hierarchical"` where the coordinator is the
first agent (`team.agents[0]`) and `process`/`manager_llm` are forwarded.
Update tests to the hierarchical semantics and document the pattern in the
`motion_graphics_team` docstring.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 14:14:49 +00:00
praisonai-triage-agent[bot] 2b159d8816 Merge pull request #70 from MervinPraison/claude/issue-62-20260727-0908
fix: align motion graphics team tests with hierarchical AgentTeam API
2026-07-27 14:12:05 +00:00
praisonai-triage-agent[bot] 27b0f5e7a3 Merge pull request #65 from MervinPraison/claude/issue-54-20260727-0859
fix: add video-motion deps to dev extra; skip video tests when missing
2026-07-27 14:06:18 +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 ea9407b34b style(video-tests): fix ruff I001/SIM117 in video test files
Clears this PR's lint contribution (video dir 13 -> 0 ruff errors):
sort imports and combine nested `with` statements. No behaviour change;
video tests still pass (54 passed with deps). Repo-wide ruff debt in
praisonai_tools/ (~5321 pre-existing errors) is out of scope for this
video-test-deps PR.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 12:03:57 +00:00
MervinPraisonandMervin Praison ae16e3eebb test(video): narrow video-dep guards to backend-constructing tests
Replace the directory-wide pytest.importorskip in tests/unit/video/conftest.py
with per-test skipif guards on only the tests that build a real
HtmlRenderBackend. Dependency-independent protocol, mock-backend and retry
tests now run on a minimal `pip install -e '.[dev]'` (40 passed / 14 skipped
without video-motion; 54 passed with it), addressing Greptile's feedback that
the broad skip dropped useful coverage.

Also make test_import_error_handling deterministic by patching async_playwright
so the imageio-ffmpeg guard is what's under test regardless of installed deps.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 12:03:57 +00:00
MervinPraisonandMervin Praison 803f2b219e fix: add video-motion deps to dev extra and skip video tests when missing (fixes #54)
- Add playwright and imageio-ffmpeg to the [dev] extra so the full pytest
  suite runs after `pip install -e ".[dev]"` (Option A).
- Add tests/unit/video/conftest.py with pytest.importorskip guards so
  minimal installs skip video tests gracefully instead of erroring (Option C).
- Document the video-motion render deps and `playwright install chromium`
  step in README Testing section.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 12:03:42 +00:00
MervinPraisonandMervin Praison 7b36689195 fix(ci): pin ruff to E/F rule families to unblock CI lint
The CI `ruff check praisonai_tools tests` step had no ruff config, so a
newer ruff release pulled in strict opinionated defaults (UP/I/W/SIM/RUF)
flagging 5000+ pre-existing, non-functional findings across untouched
files. This pre-dated PR #62 (failing on main at b96bfbc).

Add [tool.ruff.lint] selecting the historical E/F baseline that the
existing `--ignore E…,F…` CI flags already imply, restoring green
without editing unrelated files or weakening any tests.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 12:02:27 +00:00
MervinPraisonandMervin Praison f519a92927 fix(tests): align motion graphics team tests with hierarchical API (fixes #62)
Update integration tests to match the hierarchical process pattern used by
motion_graphics_team(): the coordinator is the semantic leader at agents[0]
and process/manager_llm are forwarded into AgentTeam kwargs. Document the
hierarchical coordinator role in the team docstring.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 12:02:06 +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
MervinPraison a79978d3b7 Release v0.2.34 2026-07-27 12:01:13 +00:00
praisonai-triage-agent[bot] fb45d41170 Merge pull request #69 from MervinPraison/claude/issue-59-20260727-0906
fix: include video-motion deps in dev extra + graceful skip
2026-07-27 12:00:18 +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 191a4a6641 fix(ci): pin ruff rule set to historical default to stop spurious lint failures
The CI "Lint with ruff" step installs ruff unpinned; ruff 0.16.0 enabled
thousands of new default rules across the pre-existing codebase, failing
main and every PR (including this one) regardless of their diff. Pin
[tool.ruff.lint] select to E4/E7/E9/F — the rules the CI --ignore flags
were written against — restoring deterministic, intent-preserving linting
without mass-rewriting existing code.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 09:36:58 +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 3060daa92c test(video): scope video-dep skips to backend-bound tests (addresses Greptile P2)
Replace the directory-wide pytest.importorskip guard in the video conftest
with a `requires_video_deps` marker + collection hook that skips ONLY the
tests that construct HtmlRenderBackend (which hard-requires playwright /
imageio-ffmpeg). Dependency-independent tests (protocol definitions,
dataclasses, mocked backends) now retain coverage on minimal installs.

Verified:
- deps absent:  39 passed, 15 skipped (previously entire tree skipped)
- deps present: 54 passed (unchanged)
- full unit:    227 passed, 6 skipped

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 09:29:43 +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 bcaa5a5219 fix: include video-motion deps in dev extra and skip video tests when absent (fixes #59)
- Add playwright and imageio-ffmpeg to the [dev] extra so a standard
  `pip install -e ".[dev]"` runs the video/motion-graphics tests green.
- Add tests/unit/video/conftest.py with importorskip guards so minimal
  installs skip the video suite gracefully instead of raising ImportError.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 09:08:46 +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
MervinPraison b96bfbc7e2 fix(release): 3-day patch cadence and explicit PyPI __token__ auth
Extend release-gate dedupe from daily to every 3 days and configure uv publish with UV_PUBLISH_USERNAME=__token__ per PyPI API token guidance.
2026-07-25 18:24:01 +01:00
MervinPraison f146c6c7ad Release v0.2.33 2026-07-10 04:01:00 +00:00
Mervin Praison 82fc2031f4 Merge pull request #41 from rakshith48/firecrawl-v2-upgrade
Upgrade Firecrawl tool to firecrawl-py v2 SDK + add search()
2026-07-09 18:35:36 +01:00
MervinPraison c427e2588e Release v0.2.32 2026-07-09 17:05:21 +00:00
praisonai-triage-agent[bot] 30719720cd Merge pull request #30 from MervinPraison/claude/issue-1593-20260501
feat: Add SwarmScore integration tool for agent trust ratings
2026-07-09 16:41:05 +00:00
Mervin Praison d4ac1e42ce Merge pull request #51 from MervinPraison/fix/merge-gate-stash-config
fix(merge-gate): stash gate-config.js with merge-gate.js
2026-07-09 16:21:23 +01:00
MervinPraisonandMervin Praison d8ee45d8b7 style(swarmscore): remove extraneous f-string prefixes in example (ruff F541)
Co-authored-by: Mervin Praison <[email protected]>
2026-07-09 14:57:17 +00:00
MervinPraison 3bf058e687 fix(merge-gate): stash gate-config.js with merge-gate.js
After PR checkout, require(/tmp/merge-gate.js) failed looking for
./gate-config.js. Keep both scripts in /tmp/merge-gate-scripts/.
2026-07-09 15:57:13 +01:00
Mervin Praison 04f1d165ff Merge pull request #42 from MervinPraison/claude/issue-1887-20260610
feat: add Inkog security analysis tool for AI agent code
2026-07-09 15:56:21 +01:00
MervinPraisonandMervin Praison d8d3ef8df0 fix(swarmscore): default run() action to 'load' for graceful degradation
Every other multi-action tool in the repo provides a default for `action`.
Without it, a bare `tool.run()` raised a raw TypeError bypassing the
ToolResult(success=False) error path. Add default + regression test.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-09 13:42:35 +00:00
Mervin Praison 11f67da66f Merge pull request #50 from MervinPraison/fix/merge-gate-claude-action
fix(merge-gate): add claude-code-action and restore after PR checkout
2026-07-09 14:37:41 +01:00
MervinPraisonandMervin Praison 222787293c fix(swarmscore): URL-encode slug in load-by-slug path
Prevents malformed request paths when a slug contains special
characters (e.g. '/', spaces). Adds a regression test asserting
the encoded path.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-09 13:33:46 +00:00
MervinPraisonandMervin Praison 5353ea28d6 fix: guard analyze_findings against non-dict scan output
Move the "error" key guard inside the try block and short-circuit
non-dict (text/HTML) scan results to prevent an unhandled TypeError
when a string containing "error" is passed. Add regression test.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-09 13:32:24 +00:00
MervinPraisonandMervin Praison d873ccd07b fix: standardize Inkog CLI flags to single-dash across all scan methods
skill_scan and mcp_scan used double-dash flags (--deep, --output, --repo)
while scan_directory and the version check (inkog -version) use single-dash
Go-style flags. Align subcommands and docs on single-dash for consistency.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-09 13:32:24 +00:00
MervinPraisonandpraisonai-triage-agent[bot] 11ca0e8bde fix: honor InkogTool api_key in availability check and add severity to scan_agent_code
- _check_inkog_available now accepts an api_key arg so the constructor's
  api_key is respected instead of only reading INKOG_API_KEY env var
- scan_agent_code now forwards a severity parameter (matches docs/CI examples)
- add regression tests for both fixes

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
2026-07-09 13:32:24 +00:00
MervinPraison f39b01afda Fix ruff F541 in Inkog tool. 2026-07-09 13:32:24 +00:00
MervinPraison ef02203d46 Fix ruff lint in Inkog tool tests. 2026-07-09 13:32:24 +00:00
MervinPraison 5a08365e15 feat: add Inkog security analysis tool for AI agent code
- Add InkogTool class with comprehensive security analysis capabilities
- Support for token bombing, prompt injection, recursive tool calling detection
- Multi-format output: JSON, text, HTML, SARIF
- Compliance mapping to EU AI Act, NIST AI RMF, OWASP LLM Top 10
- Support for 21+ frameworks (LangChain, CrewAI, AutoGen, etc.)
- Deep scan with AI orchestrator analysis
- MCP server security auditing
- Skill package vulnerability scanning
- Comprehensive test suite and documentation
- CI/CD integration examples

Fixes MervinPraison/PraisonAI#1887
2026-07-09 13:32:24 +00:00
MervinPraison da6ce8387c fix(merge-gate): add claude-code-action and restore after PR checkout
Assess was failing because Tools had no local action, and checkout of
the PR branch would drop it even when present. Stash/restore from main.
2026-07-09 14:30:10 +01:00
MervinPraisonandMervin Praison dd7fe0efa4 fix(swarmscore): derive discovery manifest URL from domain root via urlparse
Replace fragile split("/v1/") host derivation in get_discovery_manifest
with urlparse-based origin (scheme+netloc), so .well-known resolves to the
domain root (RFC 8615) regardless of version prefix (/v1/, /v2/, /api/, none).
Adds a parametrized test covering multiple base-URL shapes.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-09 09:05:32 +00:00
MervinPraisonandMervin Praison 94a0167653 fix(swarmscore): use ToolResult.output in docs/example; export in __all__
- Replace all result.data references with result.output in docs and example
  (ToolResult exposes .output, not .data)
- Add SwarmScoreTool and standalone functions to tools __all__ for wildcard imports

Co-authored-by: Mervin Praison <[email protected]>
2026-07-08 22:32:06 +00:00
MervinPraisonandMervin Praison 65a53de573 fix: align SwarmScore tool with ToolResult API and resolve rebase
- Use ToolResult(output=..., success=..., metadata=...) instead of
  non-existent data/message kwargs
- Call super().__init__() for BaseTool registration
- Derive discovery manifest URL from api_base_url instead of hardcoding
- Remove unused urllib.parse.urljoin import
- Add swarmscore optional-dependency group (requests) to match docs
- Fix tests to mock real requests.exceptions namespace and read result.output

Co-authored-by: Mervin Praison <[email protected]>
2026-07-08 13:41:23 +00:00
MervinPraisonandpraisonai-triage-agent[bot] 6c25336a1b feat: add SwarmScore integration tool for PraisonAI (fixes MervinPraison/PraisonAI#1593)
Add SwarmScore trust rating integration to PraisonAI-Tools:

- SwarmScoreTool class for loading and verifying agent trust scores
- Standalone utility functions for easy integration
- Comprehensive documentation and examples
- Complete test coverage
- Integration with PraisonAI agent workflows

SwarmScore provides portable, cryptographically signed trust ratings
based on verified execution history and success rates.

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
2026-07-08 13:38:01 +00:00
Rakshith RamprakashandClaude Opus 4.8 ca809f7f4d fix(firecrawl): coerce crawl/search limit to int (LLM string inputs)
LLMs/agents pass `limit` as a string ("10") or None; without coercion
the `data[:limit]` / `web[:limit]` slices raise TypeError. Coerce with a
try/except fallback to the method default. Addresses gemini-code-assist
review on PR #41.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-05 12:48:05 +05:30
Rakshith RamprakashandClaude Opus 4.8 a5d92e8cb4 feat(firecrawl): register firecrawl_search helper
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-04 14:49:31 +05:30
Rakshith RamprakashandClaude Opus 4.8 ca4267be06 feat(firecrawl): add search() to the tool + firecrawl_search helper (v2 /search)
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-04 14:49:28 +05:30
rramprakashandClaude Opus 4.8 4b03908c11 Upgrade Firecrawl tool to firecrawl-py v2 SDK (>=4)
Migrate FirecrawlTool from the legacy v1 SDK to the v2 SDK while
preserving the tool's public surface and return shapes.

- Import `Firecrawl` instead of `FirecrawlApp`; client is `Firecrawl(api_key=...)`.
- scrape: `client.scrape(url, formats=[...])` (kwargs, not a params dict).
  v2 returns a typed `Document`, so read `.markdown` via attribute access
  and coerce the typed `DocumentMetadata` to a plain dict via `model_dump`.
- crawl: `client.crawl(url, limit=..., poll_interval=...)` returns a typed
  `CrawlJob`; iterate `.data` (list of `Document`) and read
  `.metadata.source_url` / `.metadata.title` via attribute access.
- run() dispatcher and the module-level `firecrawl_scrape` helper are
  unchanged; scrape() still returns a dict and crawl() still returns a
  list[dict] so agents relying on these shapes keep working.

No pin change: firecrawl-py is not pinned in pyproject.toml (runtime-only
optional install); install guidance updated to `firecrawl-py>=4`.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-04 14:41:46 +05:30
57 changed files with 6753 additions and 1345 deletions

No files matched your search

@@ -0,0 +1,290 @@
name: "Claude Code Action"
description: "Run Claude Code in GitHub Actions workflows"
inputs:
anthropic_api_key:
description: "Anthropic API key"
required: false
claude_code_oauth_token:
description: "Claude Code OAuth Token for Claude Pro"
required: false
github_token:
description: "GitHub token for Claude to operate with"
required: false
default: ${{ github.token }}
trigger_phrase:
description: "The trigger phrase to look for in comments, issue/PR bodies, and issue titles"
required: false
default: "@claude"
assignee_trigger:
description: "The assignee username that triggers the action (e.g. @claude). Only used for issue assignment"
required: false
max_turns:
description: "Maximum number of conversation turns Claude can take"
required: false
timeout_minutes:
description: "Timeout in minutes for execution"
required: false
default: "30"
model:
description: "Model to use (provider-specific format required for Bedrock/Vertex)"
required: false
use_bedrock:
description: "Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API"
required: false
default: "false"
use_vertex:
description: "Use Google Vertex AI with OIDC authentication instead of direct Anthropic API"
required: false
default: "false"
allowed_tools:
description: "Additional tools for Claude to use (the base GitHub tools will always be included)"
required: false
default: ""
disallowed_tools:
description: "Tools that Claude should never use"
required: false
default: ""
custom_instructions:
description: "Additional custom instructions to include in the prompt for Claude"
required: false
default: ""
mcp_config:
description: "Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers"
required: false
default: ""
claude_env:
description: "Custom environment variables to pass to Claude Code execution (YAML format)"
required: false
default: ""
direct_prompt:
description: "Direct prompt for Claude to execute automatically without needing a trigger (for automated workflows)"
required: false
runs:
using: "composite"
steps:
- name: Install Claude Code
shell: bash
run: npm install -g @anthropic-ai/claude-code
- name: Install GitHub MCP Server
shell: bash
run: |
claude mcp add-json github '{
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-e",
"GITHUB_PERSONAL_ACCESS_TOKEN",
"ghcr.io/github/github-mcp-server:sha-ff3036d"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${{ inputs.github_token }}"
}
}'
- name: Extract GitHub Context and Create Prompt
shell: bash
id: prepare_context
env:
# Pass user-controlled inputs via environment variables to prevent script injection (GHSL-2025-093)
EVENT_COMMENT_BODY: ${{ github.event.comment.body }}
EVENT_ISSUE_TITLE: ${{ github.event.issue.title }}
EVENT_ISSUE_BODY: ${{ github.event.issue.body }}
EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
EVENT_PR_TITLE: ${{ github.event.pull_request.title }}
EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
EVENT_REVIEW_BODY: ${{ github.event.review.body }}
EVENT_ASSIGNEE_LOGIN: ${{ github.event.assignee.login }}
INPUT_TRIGGER_PHRASE: ${{ inputs.trigger_phrase }}
INPUT_ASSIGNEE_TRIGGER: ${{ inputs.assignee_trigger }}
INPUT_DIRECT_PROMPT: ${{ inputs.direct_prompt }}
EVENT_ACTION: ${{ github.event.action }}
EVENT_NAME: ${{ github.event_name }}
REPO_NAME: ${{ github.repository }}
run: |
echo "🔍 Extracting GitHub context from event: $EVENT_NAME"
# Function to check for trigger phrase
check_trigger() {
local text="$1"
local trigger="$INPUT_TRIGGER_PHRASE"
if [[ "$text" == *"$trigger"* ]]; then
return 0
fi
return 1
}
# Extract context based on event type
TRIGGER_FOUND="false"
USER_REQUEST=""
CONTEXT_INFO=""
case "$EVENT_NAME" in
"issue_comment")
COMMENT_BODY="$EVENT_COMMENT_BODY"
ISSUE_TITLE="$EVENT_ISSUE_TITLE"
ISSUE_NUMBER="$EVENT_ISSUE_NUMBER"
if check_trigger "$COMMENT_BODY"; then
TRIGGER_FOUND="true"
USER_REQUEST="$COMMENT_BODY"
CONTEXT_INFO="Issue Comment on #$ISSUE_NUMBER: $ISSUE_TITLE"
fi
;;
"pull_request_review_comment")
COMMENT_BODY="$EVENT_COMMENT_BODY"
PR_TITLE="$EVENT_PR_TITLE"
PR_NUMBER="$EVENT_PR_NUMBER"
if check_trigger "$COMMENT_BODY"; then
TRIGGER_FOUND="true"
USER_REQUEST="$COMMENT_BODY"
CONTEXT_INFO="PR Comment on #$PR_NUMBER: $PR_TITLE"
fi
;;
"pull_request_review")
REVIEW_BODY="$EVENT_REVIEW_BODY"
PR_TITLE="$EVENT_PR_TITLE"
PR_NUMBER="$EVENT_PR_NUMBER"
if check_trigger "$REVIEW_BODY"; then
TRIGGER_FOUND="true"
USER_REQUEST="$REVIEW_BODY"
CONTEXT_INFO="PR Review on #$PR_NUMBER: $PR_TITLE"
fi
;;
"issues")
ISSUE_BODY="$EVENT_ISSUE_BODY"
ISSUE_TITLE="$EVENT_ISSUE_TITLE"
ISSUE_NUMBER="$EVENT_ISSUE_NUMBER"
if check_trigger "$ISSUE_TITLE" || check_trigger "$ISSUE_BODY"; then
TRIGGER_FOUND="true"
USER_REQUEST="$ISSUE_BODY"
CONTEXT_INFO="Issue #$ISSUE_NUMBER: $ISSUE_TITLE"
elif [[ "$EVENT_ACTION" == "assigned" && -n "$INPUT_ASSIGNEE_TRIGGER" ]]; then
ASSIGNEE="$EVENT_ASSIGNEE_LOGIN"
if [[ "$ASSIGNEE" == "$INPUT_ASSIGNEE_TRIGGER" ]]; then
TRIGGER_FOUND="true"
USER_REQUEST="$ISSUE_BODY"
CONTEXT_INFO="Issue #$ISSUE_NUMBER assigned to $ASSIGNEE: $ISSUE_TITLE"
fi
fi
;;
esac
# Check for direct prompt override
if [[ -n "$INPUT_DIRECT_PROMPT" ]]; then
TRIGGER_FOUND="true"
USER_REQUEST="$INPUT_DIRECT_PROMPT"
CONTEXT_INFO="Automated GitHub workflow"
fi
if [[ "$TRIGGER_FOUND" != "true" ]]; then
echo "❌ No trigger phrase found or direct prompt provided. Exiting gracefully."
echo "SKIP_EXECUTION=true" >> $GITHUB_ENV
exit 0
fi
echo "✅ Trigger found! Context: $CONTEXT_INFO"
# Create comprehensive prompt
mkdir -p /tmp/claude-action
cat > /tmp/claude-action/github-context-prompt.txt << EOF
You are Claude Code, an AI assistant helping with GitHub workflows and code.
Repository: $REPO_NAME
Context: $CONTEXT_INFO
Event: $EVENT_NAME
User Request:
$USER_REQUEST
Please analyze the request and provide helpful assistance.
CRITICAL INSTRUCTION: If your task involves modifying codebase files to add a feature or fix a bug:
STEP 0 — SETUP GIT IDENTITY:
git config user.name "MervinPraison"
git config user.email "[email protected]"
STEP 1 — READ GUIDELINES:
MANDATORY: Before writing any code, you MUST carefully read "src/praisonai-agents/AGENTS.md" to understand this repository's extremely specific Architecture, Protocol-First design rules, and Agent-Centric Philosophy.
STEP 2 — ARCHITECTURE VALIDATION & ROUTING (MANDATORY before writing code):
Before implementing anything, answer these questions:
- CORE vs WRAPPER vs TOOLS ROUTING:
1. Core SDK (praisonaiagents/): Only core protocols, base classes, decorators. No heavy implementations.
2. Wrapper (praisonai/): CLI, heavy implementations, optional dependencies.
3. Tools (PraisonAI-Tools): Specific integrations, external tools (e.g. SurrealDB, Slack), and community extensions MUST go to the `MervinPraison/PraisonAI-Tools` repository. If the feature is a tool, clone `https://github.com/MervinPraison/PraisonAI-Tools`, implement it there, and create the PR using `gh pr create -R MervinPraison/PraisonAI-Tools`.
- Does it duplicate existing functionality? Check if Agent already supports this via existing params (reflection, planning, tools, hooks, memory).
- Does it inherit from Agent properly? New agent types MUST inherit Agent, not wrap it with composition.
- Does it add new dependencies? Only optional deps allowed, must be lazy-imported.
- Will agent.py grow larger? If the change adds >50 lines to agent.py, find a way to extract instead.
- Is there a name collision with existing exports in __init__.py?
If ANY of these conceptual checks fail (excluding routing), add a comment to the issue explaining why and close it. Do NOT create a PR.
STEP 3 — IMPLEMENT:
- Create a new git branch appropriately named (e.g. \`feature/issue-<number>\`)
- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper
- Keep changes small and backward-compatible
STEP 4 — TEST:
- Run: cd src/praisonai-agents && PYTHONPATH=. python -m pytest tests/ -x -q --timeout=30
- Ensure no regressions
STEP 5 — CREATE PR:
- Commit with descriptive message, push the branch
- Use the GitHub CLI (\`gh pr create --title "..." --body "Fixes #<number>"\`) to open a Pull Request
CRITICAL: You MUST create the PR automatically using \`gh pr create\`. Do NOT just provide a link.
Respond naturally and helpfully to the user's request using the available tools.
EOF
echo "PROMPT_FILE=/tmp/claude-action/github-context-prompt.txt" >> $GITHUB_ENV
echo "SKIP_EXECUTION=false" >> $GITHUB_ENV
- name: Run Claude Code
if: env.SKIP_EXECUTION != 'true'
shell: bash
run: |
echo "🚀 Running Claude Code with GitHub context..."
# Build command arguments
CMD_ARGS=("-p" "--verbose" "--output-format" "stream-json")
# Add max turns if specified
if [[ -n "${{ inputs.max_turns }}" ]]; then
CMD_ARGS+=("--max-turns" "${{ inputs.max_turns }}")
fi
# Add allowed tools (include GitHub tools by default)
TOOLS="mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues,mcp__github__create_comment,Read,Write,Edit,Bash"
if [[ -n "${{ inputs.allowed_tools }}" ]]; then
TOOLS="$TOOLS,${{ inputs.allowed_tools }}"
fi
CMD_ARGS+=("--allowedTools" "$TOOLS")
# Add disallowed tools
if [[ -n "${{ inputs.disallowed_tools }}" ]]; then
CMD_ARGS+=("--disallowedTools" "${{ inputs.disallowed_tools }}")
fi
echo "📝 Executing Claude Code with prompt from file..."
# Execute Claude Code with timeout, using stdin for the prompt
TIMEOUT_SECONDS=$((${{ inputs.timeout_minutes }} * 60))
timeout $TIMEOUT_SECONDS claude "${CMD_ARGS[@]}" < "${{ env.PROMPT_FILE }}"
echo "✅ Claude Code execution completed"
env:
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ inputs.claude_code_oauth_token }}
GITHUB_TOKEN: ${{ inputs.github_token }}
@@ -0,0 +1,91 @@
name: "Claude Issue Triage Action"
description: "Automatically triage GitHub issues using Claude Code"
inputs:
timeout_minutes:
description: "Timeout in minutes for execution"
required: false
default: "5"
claude_code_oauth_token:
description: "Claude Code OAuth token for Pro subscription access"
required: true
github_token:
description: "GitHub token with repo and issues permissions"
required: true
runs:
using: "composite"
steps:
- name: Checkout repository code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Create prompt
id: generate_prompt
shell: bash
run: |
mkdir -p /tmp/claude-prompts
cat > /tmp/claude-prompts/claude-issue-triage-prompt.txt << 'EOF'
You're an issue triage assistant for GitHub issues. Your task is to analyze the issue and select appropriate labels from the provided list.
IMPORTANT: Don't post any comments or messages to the issue. Your only action should be to apply labels.
Issue Information:
- REPO: ${{ github.repository }}
- ISSUE_NUMBER: ${{ github.event.issue.number }}
TASK OVERVIEW:
1. First, fetch the list of labels available in this repository by running: `gh label list`. Run exactly this command with nothing else.
2. Next, use the GitHub tools to get context about the issue:
- You have access to these tools:
- mcp__github__get_issue: Use this to retrieve the current issue's details including title, description, and existing labels
- mcp__github__get_issue_comments: Use this to read any discussion or additional context provided in the comments
- mcp__github__update_issue: Use this to apply labels to the issue (do not use this for commenting)
- mcp__github__search_issues: Use this to find similar issues that might provide context for proper categorization and to identify potential duplicate issues
- mcp__github__list_issues: Use this to understand patterns in how other issues are labeled
- Start by using mcp__github__get_issue to get the issue details
3. Analyze the issue content, considering:
- The issue title and description
- The type of issue (bug report, feature request, question, etc.)
- Technical areas mentioned
- Severity or priority indicators
- User impact
- Components affected
4. Select appropriate labels from the available labels list provided above:
- Choose labels that accurately reflect the issue's nature
- Be specific but comprehensive
- Select priority labels if you can determine urgency (high-priority, med-priority, or low-priority)
- Consider platform labels (android, ios) if applicable
- If you find similar issues using mcp__github__search_issues, consider using a "duplicate" label if appropriate. Only do so if the issue is a duplicate of another OPEN issue.
5. Apply the selected labels:
- Use mcp__github__update_issue to apply your selected labels
- DO NOT post any comments explaining your decision
- DO NOT communicate directly with users
- If no labels are clearly applicable, do not apply any labels
IMPORTANT GUIDELINES:
- Be thorough in your analysis
- Only select labels from the provided list above
- DO NOT post any comments to the issue
- Your ONLY action should be to apply labels using mcp__github__update_issue
- It's okay to not add any labels if none are clearly applicable
EOF
echo "PROMPT<<DELIMITER" >> $GITHUB_OUTPUT
cat /tmp/claude-prompts/claude-issue-triage-prompt.txt >> $GITHUB_OUTPUT
echo "DELIMITER" >> $GITHUB_OUTPUT
- name: Run Claude Code
uses: ./.github/actions/claude-code-action
with:
direct_prompt: ${{ steps.generate_prompt.outputs.PROMPT }}
allowed_tools: "Bash(gh label list),mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues"
timeout_minutes: ${{ inputs.timeout_minutes }}
claude_code_oauth_token: ${{ inputs.claude_code_oauth_token }}
github_token: ${{ inputs.github_token }}
+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,
+50 -1
View File
@@ -29,4 +29,53 @@ try {
assert('PACKAGE_PATHS includes pyproject', rg.PACKAGE_PATHS.includes('pyproject.toml'));
process.exit(failed ? 1 : 0);
(async () => {
const noonUtc = new Date('2026-07-08T12:00:00Z');
const mockGithub = {
rest: {
actions: {
listWorkflowRuns: async () => ({
data: {
workflow_runs: [
{
conclusion: 'success',
created_at: '2026-07-08T09:00:00Z',
status: 'completed',
},
],
},
}),
},
},
};
assert('PATCH_RELEASE_INTERVAL_DAYS is 3', rg.PATCH_RELEASE_INTERVAL_DAYS === 3);
assert(
'hasSuccessfulReleaseWithinDays blocks recent release',
await rg.hasSuccessfulReleaseWithinDays(mockGithub, 'o', 'r', noonUtc)
);
const oldReleaseGithub = {
rest: {
actions: {
listWorkflowRuns: async () => ({
data: {
workflow_runs: [
{
conclusion: 'success',
created_at: '2026-07-04T09:00:00Z',
status: 'completed',
},
],
},
}),
},
},
};
assert(
'hasSuccessfulReleaseWithinDays allows after 3 days',
!(await rg.hasSuccessfulReleaseWithinDays(oldReleaseGithub, 'o', 'r', noonUtc))
);
process.exit(failed ? 1 : 0);
})();
+28 -6
View File
@@ -6,6 +6,8 @@ const https = require('https');
const config = require('./gate-config.js');
const PACKAGE_PATHS = config.packagePaths;
/** Minimum days between successful patch auto-releases. */
const PATCH_RELEASE_INTERVAL_DAYS = 3;
const ACTIVE_RELEASE_STATUSES = new Set([
'queued', 'in_progress', 'waiting', 'pending', 'requested',
]);
@@ -61,8 +63,18 @@ function utcDayStart(now = new Date()) {
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
}
async function hasSuccessfulReleaseToday(github, owner, repo, now = new Date()) {
const dayStart = utcDayStart(now);
function releaseIntervalStart(now = new Date(), intervalDays = PATCH_RELEASE_INTERVAL_DAYS) {
return new Date(now.getTime() - intervalDays * 24 * 60 * 60 * 1000);
}
async function hasSuccessfulReleaseWithinDays(
github,
owner,
repo,
now = new Date(),
intervalDays = PATCH_RELEASE_INTERVAL_DAYS,
) {
const windowStart = releaseIntervalStart(now, intervalDays);
const runs = await github.rest.actions.listWorkflowRuns({
owner,
repo,
@@ -71,10 +83,15 @@ async function hasSuccessfulReleaseToday(github, owner, repo, now = new Date())
per_page: 30,
});
return runs.data.workflow_runs.some(
(r) => r.conclusion === 'success' && new Date(r.created_at) >= dayStart
(r) => r.conclusion === 'success' && new Date(r.created_at) >= windowStart
);
}
/** @deprecated Use hasSuccessfulReleaseWithinDays — kept for selftests. */
async function hasSuccessfulReleaseToday(github, owner, repo, now = new Date()) {
return hasSuccessfulReleaseWithinDays(github, owner, repo, now, 1);
}
async function lastGreenCiSha(github, owner, repo) {
const runs = await github.rest.actions.listWorkflowRuns({
owner,
@@ -116,9 +133,11 @@ async function evaluateReleasePreflight(github, owner, repo, options, core) {
}
const referenceTime = options.now instanceof Date ? options.now : new Date();
if (await hasSuccessfulReleaseToday(github, owner, repo, referenceTime)) {
const day = referenceTime.toISOString().slice(0, 10);
reasons.push(`already released today (UTC ${day}); max one patch release per day`);
if (await hasSuccessfulReleaseWithinDays(github, owner, repo, referenceTime)) {
reasons.push(
`already released within last ${PATCH_RELEASE_INTERVAL_DAYS} days; `
+ `max one patch release every ${PATCH_RELEASE_INTERVAL_DAYS} days`
);
return out;
}
@@ -196,6 +215,9 @@ module.exports = {
bumpPatch,
readVersionsFromTree,
pypiVersionExists,
PATCH_RELEASE_INTERVAL_DAYS,
releaseIntervalStart,
hasSuccessfulReleaseWithinDays,
hasSuccessfulReleaseToday,
utcDayStart,
evaluateReleasePreflight,
+11 -3
View File
@@ -251,7 +251,11 @@ jobs:
});
- name: Stash merge gate helpers
run: cp .github/scripts/merge-gate.js /tmp/merge-gate.js
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:
@@ -259,6 +263,10 @@ jobs:
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
@@ -315,7 +323,7 @@ jobs:
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const mergeGate = require('/tmp/merge-gate.js');
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 }};
@@ -354,7 +362,7 @@ jobs:
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const mergeGate = require('/tmp/merge-gate.js');
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 }}
);
+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
+3 -2
View File
@@ -2,11 +2,12 @@ name: Nightly Release Gate
# Preflight before PyPI Release (patch bump).
# Primary: CI success on main (workflow_run).
# Backstop: nightly cron at 00:00 UTC.
# Backstop: cron every 3 days at 00:00 UTC.
# Dedupe: max one successful patch release every 3 days (see release-gate.js).
on:
schedule:
- cron: '0 0 * * *'
- cron: '0 0 */3 * *'
workflow_dispatch:
inputs:
dry_run:
+5 -1
View File
@@ -3,9 +3,13 @@ name: Pipeline Status Sync
# Sync pipeline/* stage and blocker labels on open internal PRs.
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
schedule:
- cron: '*/10 * * * *'
workflow_dispatch:
permissions:
pull-requests: read
+3 -1
View File
@@ -133,7 +133,9 @@ jobs:
- name: Publish to PyPI
if: inputs.dry_run != true
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN || secrets.PYPI_API_TOKEN }}
# PyPI API token auth: username __token__, password = pypi-… token (see pypi.org/help/#invalid-auth)
UV_PUBLISH_USERNAME: __token__
UV_PUBLISH_PASSWORD: ${{ secrets.PYPI_TOKEN || secrets.PYPI_API_TOKEN }}
run: |
uv publish
+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
+48 -2
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
@@ -638,20 +669,22 @@ air = weather.get_air_quality("Tokyo")
| TTSTool | `OPENAI_API_KEY` or `ELEVENLABS_API_KEY` |
| YouTubeTool | `YOUTUBE_API_KEY` |
| WeatherTool | `OPENWEATHER_API_KEY` |
| SwarmScoreTool | None (uses public API) |
### Using with PraisonAI Agents
```python
from praisonaiagents import Agent
from praisonai_tools import EmailTool, SlackTool, GitHubTool, WeatherTool
from praisonai_tools import EmailTool, SlackTool, GitHubTool, WeatherTool, SwarmScoreTool
agent = Agent(
instructions="You are a helpful assistant with access to email, slack, github, and weather",
instructions="You are a helpful assistant with access to email, slack, github, weather, and trust ratings",
tools=[
EmailTool(provider="gmail"),
SlackTool(),
GitHubTool(),
WeatherTool(),
SwarmScoreTool(),
]
)
@@ -701,6 +734,19 @@ pip install praisonai-tools[dev]
pytest tests/ -v
```
The `dev` extra installs the video/motion-graphics render deps
(`playwright`, `imageio-ffmpeg`) so the full suite runs. To exercise the
HTML render backend you also need the Chromium browser:
```bash
pip install -e ".[dev]"
playwright install chromium
pytest tests/ -v
```
If you use a minimal install without those deps, the video tests under
`tests/unit/video/` are skipped automatically rather than failing.
---
## License
-1
View File
@@ -1 +0,0 @@
<html>mock visualization</html>
+378
View File
@@ -0,0 +1,378 @@
# Inkog Security Analysis Integration
This document describes the integration of [Inkog](https://github.com/inkog-io/inkog) security analysis capabilities into PraisonAI-Tools.
## Overview
Inkog is a specialized security analysis tool for AI agent code that detects vulnerabilities unique to agentic systems. It performs static analysis to catch bugs that only break in agent code, such as:
- **Token bombing** - loops where the LLM controls termination, draining API budgets
- **Recursive tool calling** - one user request fans out into thousands of LLM invocations
- **Prompt injection sinks** - RAG output flowing into system prompts without review
- **Missing oversight** - destructive tools firing without human approval
- **Cross-tenant leakage** - global state shared between agent invocations
- **MCP tool poisoning** - malicious tool descriptions hijacking agents
## Features
### Framework Support
- **21+ frameworks**: LangChain, CrewAI, AutoGen, OpenAI Agents, LangGraph, DSPy, Phidata, and more
- **Multiple languages**: Python, TypeScript, JavaScript, and no-code platforms
- **MCP servers**: Audit Model Context Protocol servers for security issues
### Compliance Mapping
- **EU AI Act** Articles 14/15 (human oversight requirements)
- **NIST AI RMF** (risk management framework)
- **ISO 42001** (AI management systems)
- **OWASP LLM Top 10** (security vulnerabilities)
### Scan Types
- **Core scan**: Fast static analysis with vulnerability detection
- **Deep scan**: AI orchestrator-driven analysis with enhanced context
- **Skill scan**: Security analysis of SKILL.md packages and agent tools
- **MCP scan**: Audit MCP servers for tool poisoning and privilege escalation
## Installation
### Prerequisites
1. **Install Inkog CLI**:
```bash
# macOS (Homebrew)
brew tap inkog-io/inkog && brew install inkog
# Or install via Go
go install github.com/inkog-io/inkog/cmd/inkog@latest
# Or download binary from releases
# https://github.com/inkog-io/inkog/releases
```
2. **Get API Key**:
- Visit [app.inkog.io](https://app.inkog.io) to get a free API key
- Set environment variable: `export INKOG_API_KEY=your_key_here`
3. **Verify Installation**:
```bash
inkog -version
```
## Usage
### Basic Usage
```python
from praisonai_tools import InkogTool
# Initialize the tool
inkog = InkogTool()
# Scan current directory for vulnerabilities
result = inkog.scan_directory(
path=".",
output_format="json",
policy="balanced"
)
# Analyze results
analysis = inkog.analyze_findings(result)
print(analysis)
```
### Standalone Functions
```python
from praisonai_tools.tools.inkog_tool import scan_agent_code, scan_skill_package
# Quick scan using standalone function
result = scan_agent_code("./my-agent", policy="low-noise")
# Scan a skill package
skill_result = scan_skill_package("./my-skill", deep=True)
```
### Advanced Scanning
```python
# Deep scan with AI orchestrator (requires Inkog Deep role)
deep_result = inkog.scan_directory(
path="./agent-code",
output_format="json",
policy="comprehensive",
deep=True,
severity="high"
)
# Generate HTML report
html_report = inkog.scan_directory(
path="./agent-code",
output_format="html",
policy="balanced"
)
# Save report to file
with open("security_report.html", "w") as f:
f.write(html_report)
```
### MCP Server Auditing
```python
# Scan MCP server by name
mcp_result = inkog.mcp_scan("github")
# Scan MCP server with custom repository
mcp_result = inkog.mcp_scan(
"my-server",
repo="https://github.com/org/my-mcp-server",
deep=True
)
```
## Configuration
### Environment Variables
- **`INKOG_API_KEY`** (required): API key from app.inkog.io
- **`INKOG_SERVER_URL`** (optional): Custom server URL for enterprise deployments
### Security Policies
Choose the appropriate policy based on your use case:
- **`low-noise`**: Only exploitable vulnerabilities (good for CI/CD)
- **`balanced`**: Vulnerabilities + risk patterns (default, good for code review)
- **`comprehensive`**: All findings including hardening recommendations (good for security audits)
### Output Formats
- **`json`**: Structured data for programmatic processing
- **`text`**: Human-readable terminal output
- **`html`**: Interactive report with visualizations
- **`sarif`**: SARIF format for GitHub Security tab integration
### Severity Levels
- **`critical`**: Immediate security threats
- **`high`**: Serious vulnerabilities that should be addressed soon
- **`medium`**: Moderate issues worth reviewing
- **`low`**: Minor issues and best practice recommendations
## Integration Examples
### PraisonAI Agent Security Audit
```python
from praisonaiagents import Agent
from praisonai_tools import InkogTool
# Create an agent with security analysis capability
security_agent = Agent(
name="security_auditor",
instructions="You analyze AI agent code for security vulnerabilities",
tools=[InkogTool()]
)
# Audit agent code
audit_result = security_agent.start("""
Scan the ./my-agent directory for security vulnerabilities.
Focus on critical and high severity issues.
Provide a summary of findings and remediation recommendations.
""")
print(audit_result)
```
### CI/CD Pipeline Integration
```python
import sys
from praisonai_tools.tools.inkog_tool import scan_agent_code
def security_check():
"""Security check for CI/CD pipeline."""
result = scan_agent_code(
path=".",
policy="low-noise", # Only critical vulnerabilities
severity="critical"
)
if "error" in result:
print(f"Security scan failed: {result['error']}")
return 1
critical_count = result.get("summary", {}).get("critical", 0)
if critical_count > 0:
print(f"❌ Found {critical_count} critical security issues")
return 1
print("✅ No critical security vulnerabilities found")
return 0
if __name__ == "__main__":
sys.exit(security_check())
```
### Multi-Agent Security Review
```python
from praisonaiagents import Agent, AgentTeam
from praisonai_tools import InkogTool
# Security analysis agent
security_analyzer = Agent(
name="security_analyzer",
instructions="Analyze code for security vulnerabilities using Inkog",
tools=[InkogTool()]
)
# Code reviewer agent
code_reviewer = Agent(
name="code_reviewer",
instructions="Review security findings and provide remediation advice"
)
# Create security review team
security_team = AgentTeam(
name="security_review_team",
agents=[security_analyzer, code_reviewer],
tasks=[
{"agent": "security_analyzer", "task": "Scan the codebase for vulnerabilities"},
{"agent": "code_reviewer", "task": "Review findings and create remediation plan"}
]
)
# Run security review
review_result = security_team.start("Perform comprehensive security review of ./agent-code")
```
## Error Handling
The tool provides detailed error messages for common issues:
```python
result = inkog.scan_directory("./nonexistent")
if "error" in result:
error_msg = result["error"]
if "not installed" in error_msg:
print("Install inkog CLI first")
elif "API_KEY" in error_msg:
print("Set INKOG_API_KEY environment variable")
elif "timeout" in error_msg:
print("Scan timed out - try smaller directory or increase timeout")
else:
print(f"Scan error: {error_msg}")
```
## Best Practices
### 1. Development Workflow
```bash
# Add to your development script
inkog . -policy balanced -output json > security_report.json
```
### 2. Pre-commit Hooks
```yaml
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: inkog-security-scan
name: Inkog Security Scan
entry: inkog
args: [".", "-policy", "low-noise", "-severity", "critical"]
language: system
always_run: true
```
### 3. GitHub Actions
```yaml
name: Security Scan
on: [push, pull_request]
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install Inkog
run: |
curl -fsSL https://inkog.io/install.sh | sh
echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Run Security Scan
env:
INKOG_API_KEY: ${{ secrets.INKOG_API_KEY }}
run: inkog . -output sarif > results.sarif
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: results.sarif
```
## Privacy & Security
Inkog uses "surgical redaction" to protect sensitive data:
### What Gets Redacted
- AWS access keys, GitHub tokens, Stripe keys
- Database connection strings with passwords
- Private keys and JWT tokens
- High-entropy strings (API keys)
### What is NOT Redacted
- Prompts and templates (needed for injection detection)
- Business logic (needed for flow analysis)
- Configuration values (model names, etc.)
- Normal strings that don't match credential patterns
## Troubleshooting
### Common Issues
1. **"inkog CLI not installed"**
- Install via Homebrew: `brew tap inkog-io/inkog && brew install inkog`
- Or download from releases: https://github.com/inkog-io/inkog/releases
2. **"INKOG_API_KEY required"**
- Get free key at https://app.inkog.io
- Set environment variable: `export INKOG_API_KEY=your_key`
3. **"Deep scan requires Inkog Deep role"**
- Deep scans need special account permissions
- Use standard scan instead: `deep=False`
4. **"Scan timed out"**
- Large codebases may timeout
- Scan specific directories instead of entire project
- Use policy="low-noise" for faster scans
### Debug Mode
Enable verbose output for troubleshooting:
```python
result = inkog.scan_directory(
path=".",
verbose=True # Shows detailed analysis steps
)
```
## Support
- **Documentation**: https://docs.inkog.io
- **GitHub Issues**: https://github.com/inkog-io/inkog/issues
- **Community**: https://discord.gg/NuG4SSGRH
- **Security**: security@inkog.io
## References
- [Inkog Repository](https://github.com/inkog-io/inkog)
- [State of AI Agent Security 2026 Report](https://inkog.io/report)
- [EU AI Act Compliance Guide](https://docs.inkog.io/compliance/eu-ai-act)
- [OWASP LLM Top 10](https://owasp.org/www-project-top-10-for-large-language-model-applications/)
+141
View File
@@ -0,0 +1,141 @@
# Tool-Registry Proxy Connector
An **optional, bring-your-own-account** connector that lets any PraisonAI agent
discover and call a large catalogue of third-party API endpoints (SEO,
enrichment, social data, scraping, ads) through a single proxy token — with
**credentials injected server-side by the registry, never held by the agent**.
PraisonAI ships **no registry code, no bundled binary and no default endpoint**.
You supply your own account token or a self-hosted base URL. The connector is
disabled unless `TOOL_PROXY_URL` is configured.
## Overview
The connector mirrors PraisonAI's deferred tool-search bridge
(`tool_search` / `tool_describe` / `tool_call`) with three functions:
| Function | Cost | Purpose |
| --- | --- | --- |
| `registry_search(query)` | free | capability search over the catalogue |
| `registry_describe(tool_id)` | free | params, price-per-call, example response |
| `registry_call(tool_id, params)` | paid | invoke via proxy; credential injected server-side |
## Installation
```bash
pip install "praisonai-tools[registry-proxy]"
```
## Setup (3 lines)
```bash
export TOOL_PROXY_URL="https://your-registry.example.com" # or a self-hosted instance
export TOOL_PROXY_TOKEN="your-proxy-token"
# Optional: override the auth header for registries that expect a custom header.
# Default is "Authorization" (sent as "Bearer <token>"); any other name (e.g.
# "X-Treg-Token") sends the raw token in that header.
export TOOL_PROXY_AUTH_HEADER="Authorization"
# Optional: override the describe route for registries with a different layout.
# Must contain "{tool_id}"; defaults to the live registry route below.
export TOOL_PROXY_DESCRIBE_PATH="/catalog/endpoints/{tool_id}"
```
```python
from praisonaiagents import Agent
from praisonai_tools.registry_proxy import (
registry_search, registry_describe, registry_call,
)
agent = Agent(
instructions="SEO analyst",
tools=[registry_search, registry_describe, registry_call],
)
agent.start("Find the top backlink sources for example.com and summarise")
# → registry_search("backlinks for a domain") → describe → call
# one token, no vendor keys in the agent
```
## Spend safety
`registry_call` accepts optional budget guards. When a configured budget would
be exceeded, the call is **denied and reported** as a tool-result error (never
raised as an exception):
```python
registry_call(
"seo.backlinks",
{"domain": "example.com"},
max_cost_per_call=0.10, # deny if price-per-call exceeds this
max_session_spend=5.00, # deny if cumulative spend would exceed this
)
```
Per-call price is read from `registry_describe`, so guards work without any
vendor-specific configuration. Guards **fail closed**: if a budget is set but
the price cannot be validated, the call is denied. Cumulative `max_session_spend`
persists across separate `registry_call` invocations within the same process,
scoped per `(proxy_url, token)` so distinct accounts never share a budget.
## HTTP methods
The registry enforces each endpoint's declared HTTP method on `/call/{id}`.
`registry_call` derives the method automatically from `registry_describe`
(the endpoint doc carries `method`): `GET` endpoints send `params` as the query
string, `POST`/`PUT` send them as a JSON body. Pass `method="GET"` explicitly to
skip the describe lookup when you already know the method. Only the standard
`GET`/`POST`/`PUT`/`PATCH`/`DELETE` verbs are accepted; any other value (e.g. a
mislabelled endpoint doc) is rejected as a tool-result error before any request.
`tool_id` is URL-encoded before being placed in the describe/call route, so it
cannot alter the requested path.
## Passthrough-URL calls
Passthrough-URL calls require the upstream to be **registered on the registry
first**. Loopback/private upstreams (e.g. `127.0.0.1`, RFC-1918 addresses) are
**refused by the registry's SSRF guard by design** — the base URL must be a
public `http(s)` address. This is registry-side policy, not a connector setting.
## Credential safety
The proxy token is only sent to the endpoint it was configured for. If a caller
(or a prompt-injected agent) supplies an explicit `proxy_url` without a matching
`token`, the ambient `TOOL_PROXY_TOKEN` is **not** attached — preventing the
token from being exfiltrated to an untrusted endpoint. For normal use, leave
`proxy_url`/`token` unset and configure `TOOL_PROXY_URL`/`TOOL_PROXY_TOKEN` via
the environment.
## Error taxonomy
All failures are returned as `{"error": ...}` tool results:
- Authentication failed (HTTP 401/403)
- Insufficient balance (HTTP 402)
- Upstream 4xx/5xx passed through
- Request timeout
- `httpx` not installed → install hint
## Tier 0 — Skills directory compatibility (zero code)
Registries that expose `skill install <name>` install skills into
`./.claude/skills/`. PraisonAI **already scans** this directory, so
registry-installed skills are discoverable by PraisonAI agents today with no
additional code:
- `praisonaiagents/skills/discovery.py` — `.claude/skills` compatibility scan
plus an ancestor walk for monorepos.
## Tier 2 — Environment / CLI passthrough (config-only)
A registry's `shell` / `run <vendor> -- args` inject vendor-CLI credentials at
execution time. Using a PraisonAI environment definition, install the registry
CLI and log in via a token env var in `setup:`; the agent's `execute_command`
then has authenticated vendor CLIs (e.g. `run gh -- pr list`) without any key in
the container. Sandbox / board workers inherit the same via the environment
definition.
## Licensing note
Some registries are Apache-2.0 **with additional terms** prohibiting embedding
in commercially distributed products without permission. This connector is
therefore **optional and bring-your-own-account only**: it ships no registry
code and no default endpoint — the same posture as other BYO-account connectors.
+357
View File
@@ -0,0 +1,357 @@
# SwarmScore Integration for PraisonAI
SwarmScore is a portable trust rating system for AI agents that provides cryptographically signed reputation scores based on verified execution history. This integration allows PraisonAI agents to leverage trust ratings for enhanced security and decision-making.
## Overview
SwarmScore provides:
- **Portable Trust Ratings**: Cryptographically signed scores that travel with your agent across platforms
- **Verified Execution History**: Trust based on actual performance, not self-reported metrics
- **Consistency Tracking**: 80 jobs at 95% success rate beats 1 job at 100%
- **Agent-to-Agent Discovery**: Machine-readable trust data for autonomous agent interactions
## Installation
```bash
pip install praisonai-tools[swarmscore]
# or
pip install praisonai-tools requests
```
## Quick Start
### Basic Usage
```python
from praisonai_tools.tools import SwarmScoreTool
# Initialize the tool
swarmscore = SwarmScoreTool()
# Load your agent's trust score
result = swarmscore.load_swarmscore("your-agent-slug")
if result.success:
score_data = result.output
print(f"Trust Score: {score_data['score']}")
print(f"Trust Tier: {score_data['tier']}")
print(f"Success Rate: {score_data['success_rate']}%")
else:
print(f"Error: {result.error}")
```
### Standalone Functions
```python
from praisonai_tools.tools import (
load_swarmscore_by_slug,
verify_swarmscore_freshness,
get_agent_discovery_manifest
)
# Load score data
try:
score_data = load_swarmscore_by_slug("your-agent-slug")
print(f"Score: {score_data['score']}")
except Exception as e:
print(f"Failed to load score: {e}")
# Verify score freshness
try:
verification = verify_swarmscore_freshness(score_data['verify_payload'])
print(f"Verification: {verification}")
except Exception as e:
print(f"Verification failed: {e}")
# Get discovery manifest
try:
manifest = get_agent_discovery_manifest()
print(f"Discovery data: {manifest}")
except Exception as e:
print(f"Failed to get manifest: {e}")
```
## Integration Patterns
### 1. Trust-Based Decision Making
```python
from praisonai_tools.tools import SwarmScoreTool
def execute_with_trust_level(agent_slug, task):
"""Execute task based on agent's trust level."""
swarmscore = SwarmScoreTool()
result = swarmscore.load_swarmscore(agent_slug)
if not result.success:
# Default to restricted mode if score unavailable
return execute_restricted(task)
trust_score = result.output.get('score', 0)
if trust_score >= 80:
# High trust - full autonomy
return execute_autonomous(task)
elif trust_score >= 60:
# Medium trust - human oversight
return execute_supervised(task)
else:
# Low trust - restricted operations
return execute_restricted(task)
```
### 2. Agent Authentication
```python
def authenticate_agent(agent_slug):
"""Verify agent identity and trust level."""
swarmscore = SwarmScoreTool()
# Load and verify score
load_result = swarmscore.load_swarmscore(agent_slug)
if not load_result.success:
return False, "Failed to load trust score"
# Verify freshness
verify_payload = load_result.output.get('verify_payload')
if verify_payload:
verify_result = swarmscore.verify_swarmscore(verify_payload)
if not verify_result.success:
return False, "Trust score verification failed"
trust_score = load_result.output.get('score', 0)
return trust_score >= 50, f"Trust score: {trust_score}"
```
### 3. Multi-Agent Coordination
```python
def find_trusted_collaborators(task_requirements):
"""Find agents suitable for collaboration based on trust scores."""
swarmscore = SwarmScoreTool()
# Get discovery manifest for agent lookup
manifest_result = swarmscore.get_discovery_manifest()
if not manifest_result.success:
return []
trusted_agents = []
discovery_data = manifest_result.output
# Example: Filter agents by trust score and capabilities
for agent_id in discovery_data.get('agents', []):
score_result = swarmscore.load_swarmscore(agent_id)
if score_result.success:
score_data = score_result.output
if score_data.get('score', 0) >= 70:
trusted_agents.append({
'id': agent_id,
'score': score_data['score'],
'capabilities': score_data.get('capabilities', [])
})
return trusted_agents
```
## PraisonAI Agent Integration
### Using with PraisonAI Agent Tools
```python
from praisonaiagents import Agent, tool
from praisonai_tools.tools import SwarmScoreTool
@tool
def check_agent_trust_score(agent_slug: str) -> str:
"""Check the trust score for an agent."""
swarmscore = SwarmScoreTool()
result = swarmscore.load_swarmscore(agent_slug)
if result.success:
score_data = result.output
return f"Agent {agent_slug} has trust score {score_data.get('score', 'unknown')} with {score_data.get('success_rate', 'unknown')}% success rate"
else:
return f"Failed to load trust score for {agent_slug}: {result.error}"
# Create agent with SwarmScore capability
agent = Agent(
name="trust-aware-agent",
instructions="You can check agent trust scores using the check_agent_trust_score tool.",
tools=[check_agent_trust_score]
)
# Use the agent
response = agent.start("What is the trust score for agent 'trading-bot-v2'?")
print(response)
```
### Agent with Built-in Trust Management
```python
from praisonaiagents import Agent
from praisonai_tools.tools import SwarmScoreTool
class TrustAwareAgent(Agent):
"""PraisonAI Agent with built-in SwarmScore integration."""
def __init__(self, agent_slug: str, **kwargs):
super().__init__(**kwargs)
self.agent_slug = agent_slug
self.swarmscore = SwarmScoreTool()
self._trust_score = None
async def check_trust_level(self):
"""Check and cache current trust level."""
result = self.swarmscore.load_swarmscore(self.agent_slug)
if result.success:
self._trust_score = result.output.get('score', 0)
return self._trust_score
def can_execute_action(self, action_type: str) -> bool:
"""Determine if action can be executed based on trust level."""
if self._trust_score is None:
return False # Default to safe
# Define trust requirements for different actions
trust_requirements = {
'read': 30,
'write': 60,
'execute': 80,
'admin': 95
}
required_trust = trust_requirements.get(action_type, 100)
return self._trust_score >= required_trust
# Usage
agent = TrustAwareAgent(
agent_slug="my-agent-123",
name="secure-agent",
instructions="Execute tasks based on trust level"
)
# Check trust before executing
trust_score = await agent.check_trust_level()
if agent.can_execute_action('execute'):
print("Proceeding with high-trust execution")
else:
print("Restricted to low-trust operations")
```
## API Reference
### SwarmScoreTool Class
#### Methods
**`__init__(api_base_url: str = "https://api.swarmsync.ai/v1/swarmscore/")`**
- Initialize SwarmScore tool
- `api_base_url`: Base URL for SwarmScore API
**`load_swarmscore(slug: str) -> ToolResult`**
- Load SwarmScore data by agent slug
- Returns: ToolResult with passport, certificate, and verification data
**`verify_swarmscore(verify_payload: Dict[str, Any]) -> ToolResult`**
- Verify SwarmScore freshness using verification payload
- Returns: ToolResult with verification status
**`get_discovery_manifest() -> ToolResult`**
- Get machine-readable agent discovery manifest
- Returns: ToolResult with discovery data
**`run(action: str, **kwargs) -> ToolResult`**
- Execute SwarmScore action
- Actions: 'load', 'verify', 'discover'
### Standalone Functions
**`load_swarmscore_by_slug(slug: str) -> Dict[str, Any]`**
- Load SwarmScore data by agent slug
- Raises: Exception if loading fails
**`verify_swarmscore_freshness(verify_payload: Dict[str, Any]) -> Dict[str, Any]`**
- Verify SwarmScore freshness
- Raises: Exception if verification fails
**`get_agent_discovery_manifest() -> Dict[str, Any]`**
- Get agent discovery manifest
- Raises: Exception if retrieval fails
## SwarmScore Data Structure
```json
{
"score": 85,
"tier": "gold",
"jobs_completed": 150,
"success_rate": 94.5,
"consistency_score": 88,
"signed_certificate": "...",
"verify_payload": {
"signature": "...",
"timestamp": "...",
"agent_id": "..."
},
"capabilities": ["trading", "analysis", "reporting"],
"last_updated": "2024-01-15T10:30:00Z"
}
```
## Error Handling
```python
from praisonai_tools.tools import SwarmScoreTool
swarmscore = SwarmScoreTool()
result = swarmscore.load_swarmscore("agent-123")
if not result.success:
error_msg = result.error
if "network" in error_msg.lower():
# Handle network issues
print("Network connectivity problem")
elif "not found" in error_msg.lower():
# Handle missing agent
print("Agent not registered in SwarmScore")
else:
# Handle other errors
print(f"Unexpected error: {error_msg}")
```
## Best Practices
1. **Cache Trust Scores**: Cache scores temporarily to avoid excessive API calls
2. **Graceful Degradation**: Have fallback behavior when SwarmScore is unavailable
3. **Verify Freshness**: Always verify score freshness for critical decisions
4. **Trust Thresholds**: Define clear trust score requirements for different operations
5. **Error Handling**: Handle network and API errors gracefully
## Getting Started with SwarmScore
1. **Register**: Create an account at [SwarmSync.AI](https://swarmsync.ai)
2. **Get Your Slug**: Obtain your agent identifier from the dashboard
3. **Start Building**: Begin tracking execution history to build trust
4. **Integrate**: Use this tool to incorporate trust ratings into your workflows
## Resources
- [SwarmScore Documentation](https://swarmsync.ai/docs/protocol-specs/swarmscore)
- [SwarmScore GitHub Spec](https://github.com/swarmsync-ai/swarmscore-spec)
- [SwarmSync.AI Platform](https://swarmsync.ai)
- [Example Implementation](../examples/swarmscore_example.py)
## Contributing
To contribute improvements to the SwarmScore integration:
1. Fork the PraisonAI-Tools repository
2. Create a feature branch
3. Make your changes
4. Add tests for new functionality
5. Submit a pull request
## License
This integration is part of PraisonAI-Tools and follows the same license terms.
+261
View File
@@ -0,0 +1,261 @@
"""
Inkog Security Analysis Tool Example
This example demonstrates how to use the Inkog tool to analyze AI agent code
for security vulnerabilities like token bombing, prompt injection, and more.
Prerequisites:
1. Install inkog CLI: brew tap inkog-io/inkog && brew install inkog
2. Get free API key from https://app.inkog.io
3. Set environment variable: export INKOG_API_KEY=your_key_here
The Inkog tool can detect:
- Token bombing attacks (loops where LLM controls termination)
- Prompt injection vulnerabilities
- Recursive tool calling without cycle detection
- Missing human oversight for destructive operations
- Cross-tenant data leakage
- MCP tool poisoning
- EU AI Act / NIST / OWASP compliance issues
"""
import os
from praisonai_tools import InkogTool, scan_agent_code
def basic_security_scan():
"""Perform a basic security scan of the current directory."""
print("🔍 Basic Security Scan Example")
print("=" * 50)
# Method 1: Using the class
inkog = InkogTool()
result = inkog.scan_directory(
path=".",
output_format="json",
policy="balanced" # balanced, low-noise, comprehensive
)
if "error" in result:
print(f"❌ Scan failed: {result['error']}")
return
# Analyze the results
analysis = inkog.analyze_findings(result)
print(analysis)
print("\n" + "=" * 50)
def advanced_security_scan():
"""Perform an advanced security scan with deep analysis."""
print("🔬 Advanced Security Scan Example (Deep Analysis)")
print("=" * 60)
inkog = InkogTool()
# Deep scan provides enhanced analysis with AI orchestrator
result = inkog.scan_directory(
path=".",
output_format="json",
policy="comprehensive", # Show all findings including best practices
deep=True, # Enable AI orchestrator analysis (requires Inkog Deep role)
verbose=True
)
if "error" in result:
print(f"❌ Deep scan failed: {result['error']}")
print("Note: Deep scans require the Inkog Deep role on your account")
return
# Show detailed analysis
analysis = inkog.analyze_findings(result)
print(analysis)
# Show raw findings for debugging
if "server_findings" in result:
print(f"\n📊 Raw Findings ({len(result['server_findings'])} total):")
for i, finding in enumerate(result["server_findings"][:3]): # Show first 3
print(f"{i+1}. {finding.get('pattern', 'Unknown')} - {finding.get('severity', 'N/A')}")
print(f" File: {finding.get('file', 'N/A')}:{finding.get('line', 'N/A')}")
print(f" Message: {finding.get('message', 'N/A')}")
print()
print("=" * 60)
def skill_package_scan():
"""Scan a SKILL.md package or MCP server."""
print("📦 Skill Package Security Scan Example")
print("=" * 50)
inkog = InkogTool()
# Scan current directory as a skill package
result = inkog.skill_scan(
path=".",
output_format="json",
deep=False
)
if "error" in result:
print(f"❌ Skill scan failed: {result['error']}")
return
analysis = inkog.analyze_findings(result)
print(analysis)
print("\n" + "=" * 50)
def mcp_server_scan():
"""Scan an MCP server from the registry."""
print("🔌 MCP Server Security Scan Example")
print("=" * 50)
inkog = InkogTool()
# Scan a popular MCP server (GitHub)
result = inkog.mcp_scan(
server_name="github",
output_format="json"
)
if "error" in result:
print(f"❌ MCP scan failed: {result['error']}")
return
analysis = inkog.analyze_findings(result)
print(analysis)
print("\n" + "=" * 50)
def standalone_function_example():
"""Example using standalone functions for quick scans."""
print("⚡ Standalone Function Examples")
print("=" * 40)
# Quick scan using standalone function
print("Running quick security scan...")
result = scan_agent_code(
path=".",
output_format="json",
policy="low-noise", # Only critical vulnerabilities
deep=False
)
if isinstance(result, dict) and "error" not in result:
tool = InkogTool()
analysis = tool.analyze_findings(result)
print(analysis)
else:
print(f"Scan result: {result}")
print("\n" + "=" * 40)
def html_report_example():
"""Generate an HTML security report."""
print("📄 HTML Report Generation Example")
print("=" * 45)
inkog = InkogTool()
# Generate HTML report
html_report = inkog.scan_directory(
path=".",
output_format="html",
policy="comprehensive",
deep=False
)
if isinstance(html_report, str) and not html_report.startswith("Error"):
# Save report to file
report_path = "inkog_security_report.html"
with open(report_path, "w", encoding="utf-8") as f:
f.write(html_report)
print(f"✅ HTML report generated: {report_path}")
print(f"📄 Report size: {len(html_report)} characters")
else:
print(f"❌ HTML report failed: {html_report}")
print("=" * 45)
def compliance_scan_example():
"""Example focused on compliance mapping."""
print("🏛️ Compliance Mapping Example")
print("=" * 40)
# Use comprehensive policy to see compliance mappings
inkog = InkogTool()
result = inkog.scan_directory(
path=".",
output_format="json",
policy="comprehensive", # Shows compliance mappings
deep=False
)
if "error" in result:
print(f"❌ Compliance scan failed: {result['error']}")
return
# Look for compliance information
findings = result.get("server_findings", [])
compliance_findings = []
for finding in findings:
if any(term in finding.get("message", "").lower()
for term in ["eu ai act", "nist", "owasp", "iso", "article"]):
compliance_findings.append(finding)
if compliance_findings:
print(f"📋 Found {len(compliance_findings)} compliance-related findings:")
for finding in compliance_findings[:3]: # Show first 3
print(f"• {finding.get('pattern', 'Unknown')}")
print(f" {finding.get('message', 'N/A')[:100]}...")
else:
print("✅ No specific compliance violations found")
print("=" * 40)
def main():
"""Run all examples."""
print("Inkog Security Analysis Tool Examples")
print("=" * 60)
# Check if inkog is available
api_key = os.environ.get("INKOG_API_KEY")
if not api_key:
print("⚠️ WARNING: INKOG_API_KEY environment variable not set")
print(" Get your free API key at https://app.inkog.io")
print(" Set it with: export INKOG_API_KEY=your_key_here")
print()
try:
# Run examples in order of complexity
basic_security_scan()
standalone_function_example()
skill_package_scan()
html_report_example()
compliance_scan_example()
# Advanced features (may require special access)
print("\n🎓 Advanced Examples (may require Inkog Deep role):")
print("-" * 50)
advanced_security_scan()
mcp_server_scan()
except Exception as e:
print(f"❌ Example failed: {e}")
print("This may be due to:")
print("1. Inkog CLI not installed")
print("2. Missing INKOG_API_KEY environment variable")
print("3. Network connectivity issues")
print("4. Insufficient permissions for deep scans")
if __name__ == "__main__":
main()
+1 -1
View File
@@ -194,7 +194,7 @@ def example_team_preset():
print("✓ Motion graphics team created successfully!")
print(f" Agents: {[agent.name for agent in team.agents]}")
print(f" Leader: {team.leader.name}")
print(f" Coordinator: {team.agents[0].name}")
print(f" Workspace: {team._motion_graphics_workspace}")
except ImportError as e:
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""
SwarmScore Integration Example for PraisonAI
This example demonstrates how to integrate SwarmScore trust ratings
with PraisonAI agents for portable reputation management.
SwarmScore provides cryptographically signed trust ratings based on
verified execution history, success rates, and consistency.
Requirements:
pip install requests praisonai-tools
Usage:
python swarmscore_example.py
"""
import json
from praisonai_tools.tools import SwarmScoreTool, load_swarmscore_by_slug
def main():
"""Demonstrate SwarmScore integration with PraisonAI."""
print("🔍 SwarmScore Integration Example")
print("=" * 50)
# Initialize SwarmScore tool
swarmscore = SwarmScoreTool()
# Example agent slug (replace with actual agent identifier)
agent_slug = "example-agent-123"
print(f"\n📊 Loading SwarmScore for agent: {agent_slug}")
# Load SwarmScore data
result = swarmscore.load_swarmscore(agent_slug)
if result.success:
print("✅ SwarmScore loaded successfully!")
# Display basic score information
score_data = result.output
if 'score' in score_data:
print(f" Trust Score: {score_data['score']}")
if 'tier' in score_data:
print(f" Trust Tier: {score_data['tier']}")
if 'jobs_completed' in score_data:
print(f" Jobs Completed: {score_data['jobs_completed']}")
if 'success_rate' in score_data:
print(f" Success Rate: {score_data['success_rate']}%")
print("\n🔍 Full SwarmScore data:")
print(json.dumps(score_data, indent=2))
# Verify score freshness
if 'verify_payload' in score_data:
print("\n🔐 Verifying score freshness...")
verify_result = swarmscore.verify_swarmscore(score_data['verify_payload'])
if verify_result.success:
print("✅ Score verification successful!")
print(f" Verification data: {verify_result.output}")
else:
print(f"❌ Score verification failed: {verify_result.error}")
else:
print(f"❌ Failed to load SwarmScore: {result.error}")
print("\n💡 This might happen if:")
print(" • The agent slug doesn't exist in SwarmScore")
print(" • The SwarmScore API is temporarily unavailable")
print(" • Network connectivity issues")
# Get discovery manifest
print("\n🌐 Loading agent discovery manifest...")
manifest_result = swarmscore.get_discovery_manifest()
if manifest_result.success:
print("✅ Discovery manifest loaded!")
print(" This contains machine-readable data for agent-to-agent discovery")
print(f" Manifest keys: {list(manifest_result.output.keys())}")
else:
print(f"❌ Failed to load manifest: {manifest_result.error}")
# Demonstrate standalone function usage
print("\n🛠️ Using standalone functions:")
try:
# This will likely fail with the example slug, but shows the API
score_data = load_swarmscore_by_slug(agent_slug)
print(f"✅ Standalone function succeeded: {score_data}")
except Exception as e:
print(f"❌ Standalone function failed (expected): {e}")
print("\n📚 Next Steps:")
print(" 1. Register your agent at https://swarmsync.ai")
print(" 2. Get your agent slug from the SwarmSync dashboard")
print(" 3. Replace 'example-agent-123' with your actual slug")
print(" 4. Integrate SwarmScore checks into your agent workflows")
print("\n 📖 Documentation: https://swarmsync.ai/docs/protocol-specs/swarmscore")
def agent_workflow_example():
"""Example of how to integrate SwarmScore into an agent workflow."""
print("\n🤖 Agent Workflow Integration Example")
print("=" * 45)
# Simulated agent execution flow
agent_id = "my-trading-agent"
# 1. Load current trust score before executing task
swarmscore = SwarmScoreTool()
score_result = swarmscore.load_swarmscore(agent_id)
if score_result.success:
trust_score = score_result.output.get('score', 0)
print(f"🔍 Agent {agent_id} current trust score: {trust_score}")
# 2. Make decisions based on trust level
if trust_score >= 80:
print("✅ High trust - proceeding with full autonomy")
task_approach = "autonomous"
elif trust_score >= 60:
print("⚠️ Medium trust - requesting human approval for critical actions")
task_approach = "supervised"
else:
print("❌ Low trust - operating in restricted mode")
task_approach = "restricted"
# 3. Execute task based on trust level
print(f"🚀 Executing task with {task_approach} approach...")
# 4. After task completion, the score would be updated by SwarmSync
# (This happens externally based on verified execution results)
print("📝 Task completed - SwarmScore will be updated based on results")
else:
print(f"⚠️ Could not load trust score: {score_result.error}")
print("🔄 Falling back to default security settings")
if __name__ == "__main__":
main()
agent_workflow_example()
-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>
+26 -98
View File
@@ -37,6 +37,15 @@ from importlib.metadata import version, PackageNotFoundError
from praisonai_tools.tools.base import BaseTool, ToolResult, ToolValidationError, validate_tool
from praisonai_tools.tools.decorator import tool, FunctionTool, is_tool, get_tool_schema
from praisonai_tools.catalogue import (
ToolCatalogueEntry,
list_tools,
get_tool_names,
)
# Imported at top (not below ``__getattr__``) to satisfy ruff E402. This only
# reads the auto-discovered manifest - no tool module is eagerly imported.
from praisonai_tools.tools import __all__ as _tools_all
try:
__version__ = version("praisonai-tools")
@@ -53,101 +62,20 @@ def __getattr__(name):
except AttributeError:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
# Base classes for custom tools
"BaseTool",
"ToolResult",
"ToolValidationError",
"validate_tool",
# Decorator for function-based tools
"tool",
"FunctionTool",
"is_tool",
"get_tool_schema",
# Email Tool
"EmailTool",
"send_email",
"read_emails",
"search_emails",
# Slack Tool
"SlackTool",
"send_slack_message",
"get_slack_channels",
"get_slack_history",
# Discord Tool
"DiscordTool",
"send_discord_webhook",
"send_discord_message",
# GitHub Tool
"GitHubTool",
"search_github_repos",
"get_github_repo",
"create_github_issue",
# Image Tool
"ImageTool",
"generate_image",
# Weather Tool
"WeatherTool",
"get_weather",
"get_forecast",
"get_air_quality",
# YouTube Tool
"YouTubeTool",
"search_youtube",
"get_youtube_video",
"get_youtube_transcript",
# TTS Tool
"TTSTool",
"text_to_speech",
"list_tts_voices",
# Telegram Tool
"TelegramTool",
"send_telegram_message",
# Notion Tool
"NotionTool",
"search_notion",
"create_notion_page",
# PostgreSQL Tool
"PostgresTool",
"query_postgres",
"list_postgres_tables",
# Reddit Tool
"RedditTool",
"search_reddit",
"get_reddit_hot",
# Docker Tool
"DockerTool",
"list_docker_containers",
"run_docker_container",
# Signal Tool
"SignalTool",
"send_signal_message",
"check_signal_connection",
# LINE Tool
"LineTool",
"send_line_message",
"broadcast_line_message",
# iMessage Tool
"iMessageTool",
"send_imessage",
"check_imessage_availability",
# WhatsApp Tool
"WhatsAppTool",
"send_whatsapp_message",
# Marketplace Tools
"PinchworkTool",
"pinchwork_delegate",
"AgentIDTool",
"verify_agent_identity",
"JoyTrustTool",
"check_trust_score",
"AgentFolioTool",
"check_behavioral_trust",
"verify_task_delegation_safety",
# Composio
"ComposioTool",
"ComposioTools",
"composio_execute",
"composio_tools",
"composio_list_apps",
]
# Catalogue/discovery helpers exported eagerly above (not tool symbols, so they
# are not part of the auto-discovered manifest).
_CATALOGUE_EXPORTS = [
"ToolCatalogueEntry",
"list_tools",
"get_tool_names",
]
# The remainder of ``__all__`` is derived from the tools package's
# automatically-discovered manifest so the two levels cannot drift apart. Only
# source files are parsed (via AST) to build it - no tool module is eagerly
# imported here.
__all__ = sorted(set(_tools_all) | set(_CATALOGUE_EXPORTS))
def __dir__():
return sorted(set(globals()) | set(__all__))
+273
View File
@@ -0,0 +1,273 @@
"""Tool discovery / catalogue contract for PraisonAI Tools.
This module provides a single, programmatic way to enumerate every tool that
``praisonai-tools`` ships, plus any third-party tool registered via the
``praisonai.tools`` entry-point group. It exists so that the wider SDK (CLI,
docs, YAML validators, third-party packages) can answer *"what tools can I
actually use here?"* against a live catalogue derived from the real registry
rather than a hand-maintained allowlist that drifts.
Usage:
from praisonai_tools import list_tools
for entry in list_tools():
print(entry.name, entry.module, entry.extras, entry.summary)
Third-party packages can register their own tools by declaring an entry point:
# pyproject.toml (third-party tool package)
[project.entry-points."praisonai.tools"]
my_tool = "my_pkg:MyTool"
Nothing here imports optional/heavy dependencies: the catalogue is built from
the module map alone, so ``list_tools()`` is safe to call without any extras
installed.
"""
from __future__ import annotations
import ast
import os
from dataclasses import dataclass, field
from functools import lru_cache
from typing import Dict, Optional, Tuple
ENTRY_POINT_GROUP = "praisonai.tools"
# Directory that holds the first-party ``*_tool.py`` files. The catalogue is
# derived by scanning this directory, so dropping a new ``*_tool.py`` that
# defines a ``*Tool`` class makes the tool discoverable with no edits to any
# hand-maintained list.
_TOOLS_DIR = os.path.join(os.path.dirname(__file__), "tools")
# Map from the module (as recorded in ``_TOOL_MAP``) to the optional-dependency
# extras required to use tools defined in it. Modules absent from this mapping
# have no extra requirements (they rely only on the standard library or on the
# base dependencies). Keep this aligned with ``[project.optional-dependencies]``
# in ``pyproject.toml``.
_MODULE_EXTRAS = {
"wordpress_tool": ("wordpress",),
"pinchwork_tool": ("pinchwork", "marketplace"),
"agentid_tool": ("agentid", "marketplace"),
"joy_trust_tool": ("joy-trust", "marketplace"),
"agentfolio_tool": ("agentfolio", "marketplace"),
"praisonai_tools.n8n.n8n_workflow": ("n8n",),
"langextract_tool": ("langextract",),
"swarmscore_tool": ("swarmscore",),
"composio_tool": ("composio",),
}
@dataclass(frozen=True)
class ToolCatalogueEntry:
"""A single discoverable tool.
Attributes:
name: Public name used to import/reference the tool, e.g. ``"QdrantTool"``.
module: Dotted or relative module path that defines the tool.
extras: Optional-dependency extras required to use the tool, e.g.
``("swarmscore",)``. Empty when no extra install is needed.
summary: A one-line human-readable description of the tool.
"""
name: str
module: str
extras: Tuple[str, ...] = field(default_factory=tuple)
summary: str = ""
def _summary_for(name: str) -> str:
"""Derive a concise one-line summary for a tool class name.
Fallback used only when a tool class does not declare a literal
``description``. We avoid importing the tool (which could pull heavy/optional
deps) and instead produce a stable, human-friendly summary from the name.
"""
if name.endswith("Tool"):
base = name[:-4]
else:
base = name
return f"{base} integration tool".strip()
def _first_line(text: str) -> str:
"""Return the first non-empty line of ``text`` (a tool ``description``)."""
for line in text.splitlines():
line = line.strip()
if line:
return line
return ""
def _scan_module(path: str) -> "list[tuple[str, str]]":
"""AST-scan a ``*_tool.py`` file for ``*Tool`` classes and descriptions.
Returns a list of ``(class_name, summary)`` tuples. The file is parsed, not
imported, so this stays cheap and never triggers optional dependencies.
Classes without a literal ``description`` fall back to a name-synthesised
summary.
"""
try:
with open(path, "r", encoding="utf-8") as fh:
tree = ast.parse(fh.read(), filename=path)
except (OSError, SyntaxError): # pragma: no cover - defensive
return []
found = []
for node in tree.body:
if not isinstance(node, ast.ClassDef) or not node.name.endswith("Tool"):
continue
description: Optional[str] = None
for stmt in node.body:
# Accept both plain ``description = "..."`` and annotated
# ``description: str = "..."`` class attributes.
if isinstance(stmt, ast.Assign):
targets = [t.id for t in stmt.targets if isinstance(t, ast.Name)]
value = stmt.value
elif isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name):
targets = [stmt.target.id]
value = stmt.value
else:
continue
if (
"description" in targets
and isinstance(value, ast.Constant)
and isinstance(value.value, str)
):
description = value.value
summary = _first_line(description) if description else _summary_for(node.name)
found.append((node.name, summary or _summary_for(node.name)))
return found
@lru_cache(maxsize=1)
def _scan_source_tree() -> "Dict[str, Tuple[str, str]]":
"""Derive the first-party tool catalogue from the source tree.
Scans every ``praisonai_tools/tools/*_tool.py`` file for ``*Tool`` classes
and returns ``{class_name: (module, summary)}``. Because the mapping is
derived from the files that actually exist, phantom names (advertised tools
with no file) are impossible by construction, and adding a tool is a single
new-file change.
"""
derived: "Dict[str, Tuple[str, str]]" = {}
try:
filenames = sorted(os.listdir(_TOOLS_DIR))
except OSError: # pragma: no cover - defensive
return derived
for filename in filenames:
if not filename.endswith("_tool.py"):
continue
module = filename[:-3] # strip ".py"; relative module name within tools
path = os.path.join(_TOOLS_DIR, filename)
for class_name, summary in _scan_module(path):
derived.setdefault(class_name, (module, summary))
return derived
def _first_party_entries() -> "list[ToolCatalogueEntry]":
"""Build catalogue entries for every first-party tool class.
The catalogue is source-derived: it is built by scanning the actual
``*_tool.py`` files on disk (see ``_scan_source_tree``) rather than a
hand-maintained dictionary, so it can never advertise a tool whose file does
not exist. Summaries come from each tool's own ``description``.
A few tools live in nested packages (e.g. the n8n workflow tool under
``praisonai_tools.n8n``) rather than flat ``*_tool.py`` files; those are
still surfaced via ``_TOOL_MAP`` so their public names remain stable.
"""
entries: "Dict[str, ToolCatalogueEntry]" = {}
for name, (module, summary) in _scan_source_tree().items():
entries[name] = ToolCatalogueEntry(
name=name,
module=module,
extras=_MODULE_EXTRAS.get(module, ()),
summary=summary,
)
# Include tool classes defined outside the flat ``*_tool.py`` layout (e.g.
# nested subpackages such as ``praisonai_tools.n8n``) that the scan cannot
# reach, keeping their names stable. Only *nested* (absolute-module) tools
# are merged here: flat ``*_tool.py`` classes are already source-derived by
# the scan above, so restoring them from ``_TOOL_MAP`` would reintroduce the
# hand-maintained bookkeeping this catalogue exists to eliminate.
try:
from praisonai_tools.tools import _TOOL_MAP
except Exception: # pragma: no cover - defensive
_TOOL_MAP = {}
for name, module in _TOOL_MAP.items():
if not name.endswith("Tool") or name in entries:
continue
if not module.startswith("praisonai_tools."):
continue
entries[name] = ToolCatalogueEntry(
name=name,
module=module,
extras=_MODULE_EXTRAS.get(module, ()),
summary=_summary_for(name),
)
return list(entries.values())
def _entry_point_entries() -> "list[ToolCatalogueEntry]":
"""Build catalogue entries for third-party tools registered via entry points.
Third-party packages register under the ``praisonai.tools`` entry-point
group. We do not import the target object (to keep discovery cheap and
dependency-free); we record the declared name and module value instead.
"""
try:
from importlib.metadata import entry_points
except ImportError: # pragma: no cover - Python < 3.8
return []
entries = []
try:
eps = entry_points()
# importlib.metadata API differs across versions.
if hasattr(eps, "select"):
selected = eps.select(group=ENTRY_POINT_GROUP)
else: # pragma: no cover - Python < 3.10 dict interface
selected = eps.get(ENTRY_POINT_GROUP, [])
except Exception: # pragma: no cover - defensive
return []
for ep in selected:
entries.append(
ToolCatalogueEntry(
name=ep.name,
module=getattr(ep, "value", "") or "",
extras=(),
summary=f"Third-party tool registered via {ENTRY_POINT_GROUP}",
)
)
return entries
def list_tools() -> "list[ToolCatalogueEntry]":
"""Return the full catalogue of available tools.
Includes every first-party tool shipped with ``praisonai-tools`` plus any
third-party tool registered via the ``praisonai.tools`` entry-point group.
Entries are de-duplicated by name (first-party wins) and returned sorted by
name.
"""
seen = {}
for entry in _first_party_entries():
seen[entry.name] = entry
for entry in _entry_point_entries():
seen.setdefault(entry.name, entry)
return sorted(seen.values(), key=lambda e: e.name)
def get_tool_names() -> "set[str]":
"""Return the set of every available tool name.
Convenience helper for validators that only need membership checks (e.g. to
replace a hand-maintained allowlist).
"""
return {entry.name for entry in list_tools()}
+18 -1
View File
@@ -11,6 +11,7 @@ def main():
print("PraisonAI Developer Tools CLI")
print("\nUsage: praisonai-tools [tool] [args]")
print("\nAvailable tools:")
print(" list List the available tool catalogue")
print(" docs-generate Generate SDK reference documentation")
print(" video AI-powered video editing tools")
print(" fcp Final Cut Pro automation tools")
@@ -19,7 +20,23 @@ def main():
tool = sys.argv[1]
tool_args = sys.argv[2:]
if tool == "docs-generate":
if tool == "list":
parser = argparse.ArgumentParser(prog="praisonai-tools list")
parser.add_argument("--extras", action="store_true",
help="Only show tools that require optional-dependency extras")
args = parser.parse_args(tool_args)
from .catalogue import list_tools
entries = list_tools()
if args.extras:
entries = [e for e in entries if e.extras]
width = max((len(e.name) for e in entries), default=0)
for entry in entries:
extras = f" [extras: {', '.join(entry.extras)}]" if entry.extras else ""
print(f"{entry.name.ljust(width)} {entry.summary}{extras}")
print(f"\n{len(entries)} tools")
return 0
elif tool == "docs-generate":
# Handle docs-generate with its own parser for better help
parser = argparse.ArgumentParser(prog="praisonai-tools docs-generate")
parser.add_argument("--package", choices=["praisonaiagents", "praisonai", "typescript", "all"],
+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
@@ -0,0 +1,43 @@
"""Tool-registry proxy connector for PraisonAI Agents.
Optional bring-your-own-account connector that lets an agent discover and call
a large catalogue of third-party API endpoints through a single proxy token,
with credentials injected server-side by the registry (never held by the agent).
Mirrors PraisonAI's deferred tool-search bridge:
registry_search(query) -> capability search over the catalogue
registry_describe(tool_id) -> params, price-per-call, example response
registry_call(tool_id, ...) -> invoke via proxy (credential injected server-side)
The connector is disabled unless ``TOOL_PROXY_URL`` is configured. PraisonAI
ships no registry code, no bundled binary and no default endpoint - the user
supplies their own account token or self-hosted base URL.
Usage:
from praisonai_tools.registry_proxy import (
registry_search, registry_describe, registry_call,
)
agent = Agent(
instructions="SEO analyst",
tools=[registry_search, registry_describe, registry_call],
)
Environment Variables:
TOOL_PROXY_URL: base URL of the tool registry (unset -> connector disabled)
TOOL_PROXY_TOKEN: proxy token sent as the auth header
"""
from .registry_proxy import (
RegistryProxyTool,
registry_search,
registry_describe,
registry_call,
)
__all__ = [
"RegistryProxyTool",
"registry_search",
"registry_describe",
"registry_call",
]
@@ -0,0 +1,533 @@
"""Tool-registry proxy connector implementation.
Three agent-callable functions over a registry's plain-HTTP surface:
- ``registry_search(query)`` free capability search over the catalogue
- ``registry_describe(tool_id)`` free read: params, price-per-call, example
- ``registry_call(tool_id, ...)``paid invoke through the proxy; the registry
injects the upstream vendor credential server-side.
Design notes:
- ``httpx`` is lazy-imported so the connector adds zero import-time overhead and
no hard dependency when unconfigured.
- The connector is disabled unless ``TOOL_PROXY_URL`` is set.
- Errors (auth / insufficient balance / upstream 4xx / timeout) are returned as
``{"error": ...}`` tool results, never raised as exceptions.
- ``registry_call`` supports optional per-call and per-session spend guards; the
price is read from ``registry_describe`` and the call is denied + reported when
a configured budget would be exceeded.
"""
import os
import math
import logging
from typing import Any, Dict, Optional
from urllib.parse import quote
from praisonai_tools.tools.base import BaseTool
from praisonai_tools.tools.decorator import tool
logger = logging.getLogger(__name__)
_INSTALL_HINT = "httpx not installed. Install with: pip install 'praisonai-tools[registry-proxy]'"
# Cumulative spend is tracked per (proxy_url, token) so that ``max_session_spend``
# persists across the short-lived connector instances created by the decorated
# functions, without mixing budgets across distinct accounts/endpoints.
_SESSION_SPEND: Dict[tuple, float] = {}
# The registry enforces the endpoint's declared HTTP method on ``/call/{id}``.
# Restricting to a known set keeps the wire behaviour predictable and returns a
# clear tool-result error instead of forwarding a mislabelled/unexpected verb.
_ALLOWED_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"})
class RegistryProxyTool(BaseTool):
"""Connector for a self-hostable tool registry exposing a plain-HTTP proxy."""
name = "registry_proxy"
description = (
"Discover and call third-party API endpoints through a tool registry "
"proxy; credentials are injected server-side, never held by the agent."
)
def __init__(
self,
proxy_url: Optional[str] = None,
token: Optional[str] = None,
timeout: float = 60.0,
max_cost_per_call: Optional[float] = None,
max_session_spend: Optional[float] = None,
auth_header: Optional[str] = None,
describe_path: Optional[str] = None,
):
url_from_env = proxy_url is None
self.proxy_url = (proxy_url or os.getenv("TOOL_PROXY_URL") or "").rstrip("/")
# Security: only fall back to the environment token when the URL is also
# env-derived (trusted config). An agent-supplied ``proxy_url`` must never
# receive the ambient ``TOOL_PROXY_TOKEN`` - otherwise a prompt-injected
# agent could exfiltrate the token to an attacker-controlled endpoint.
if token is not None:
self.token = token
elif url_from_env:
self.token = os.getenv("TOOL_PROXY_TOKEN")
else:
self.token = None
# Auth header is configurable for wire-compatibility with different
# registry deployments. Default ``Authorization`` sends ``Bearer {token}``;
# a custom header name (e.g. ``X-Treg-Token``) sends the raw token instead.
self.auth_header = auth_header or os.getenv("TOOL_PROXY_AUTH_HEADER") or "Authorization"
# The describe path template is configurable for wire-compatibility with
# different registry deployments. It must contain ``{tool_id}``; the
# default mirrors the live registry route (``/catalog/endpoints/{id}``).
self.describe_path = (
describe_path
or os.getenv("TOOL_PROXY_DESCRIBE_PATH")
or "/catalog/endpoints/{tool_id}"
)
self.timeout = timeout
self.max_cost_per_call = max_cost_per_call
self.max_session_spend = max_session_spend
super().__init__()
@property
def _spend_key(self) -> tuple:
return (self.proxy_url, self.token)
@property
def _session_spend(self) -> float:
return _SESSION_SPEND.get(self._spend_key, 0.0)
@_session_spend.setter
def _session_spend(self, value: float) -> None:
_SESSION_SPEND[self._spend_key] = value
def run(self, action: str, **kwargs: Any) -> Dict[str, Any]:
"""Dispatch to search/describe/call.
Args:
action: One of ``"search"``, ``"describe"`` or ``"call"``.
**kwargs: Arguments forwarded to the selected operation.
"""
if action == "search":
return self.search(kwargs.get("query", ""))
if action == "describe":
return self.describe(kwargs.get("tool_id", ""))
if action == "call":
return self.call(
kwargs.get("tool_id", ""),
kwargs.get("params"),
kwargs.get("method"),
)
return {"error": f"Unknown action '{action}'. Use search, describe or call."}
def _headers(self) -> Dict[str, str]:
headers = {"Content-Type": "application/json"}
if self.token:
if self.auth_header.lower() == "authorization":
headers[self.auth_header] = f"Bearer {self.token}"
else:
headers[self.auth_header] = self.token
return headers
def _require_config(self) -> Optional[Dict[str, Any]]:
if not self.proxy_url:
return {
"error": (
"Tool registry proxy is not configured. Set TOOL_PROXY_URL "
"(and optionally TOOL_PROXY_TOKEN) to enable the connector."
)
}
return None
@staticmethod
def _http_error(exc: Any) -> Dict[str, Any]:
response = getattr(exc, "response", None)
status = getattr(response, "status_code", None)
text = getattr(response, "text", "") if response is not None else str(exc)
if status == 401 or status == 403:
return {"error": f"Authentication failed (HTTP {status}): {text}"}
if status == 402:
return {"error": f"Insufficient balance (HTTP {status}): {text}"}
return {"error": f"HTTP {status}: {text}"}
def search(self, query: str) -> Dict[str, Any]:
"""Search the registry catalogue by capability."""
missing = self._require_config()
if missing:
return missing
if not query:
return {"error": "query is required"}
try:
import httpx
except ImportError:
return {"error": _INSTALL_HINT}
try:
with httpx.Client(timeout=self.timeout) as client:
response = client.get(
f"{self.proxy_url}/catalog/search",
params={"q": query},
headers=self._headers(),
)
response.raise_for_status()
return response.json()
except httpx.TimeoutException:
return {"error": f"Registry search timed out after {self.timeout} seconds"}
except httpx.HTTPStatusError as exc:
return self._http_error(exc)
except Exception as exc: # noqa: BLE001 - surface as tool-result error
logger.error("registry search error: %s", exc)
return {"error": str(exc)}
def describe(self, tool_id: str) -> Dict[str, Any]:
"""Describe one catalogue entry: params, price-per-call, example."""
missing = self._require_config()
if missing:
return missing
if not tool_id:
return {"error": "tool_id is required"}
try:
import httpx
except ImportError:
return {"error": _INSTALL_HINT}
if "{tool_id}" not in self.describe_path:
return {
"error": (
f"Invalid describe path template '{self.describe_path}': "
"it must contain '{tool_id}'."
)
}
try:
path = self.describe_path.format(tool_id=quote(tool_id, safe=""))
except (KeyError, IndexError, ValueError) as exc:
return {
"error": (
f"Invalid describe path template '{self.describe_path}': {exc}"
)
}
if not path.startswith("/"):
path = "/" + path
try:
with httpx.Client(timeout=self.timeout) as client:
response = client.get(
f"{self.proxy_url}{path}",
headers=self._headers(),
)
response.raise_for_status()
return response.json()
except httpx.TimeoutException:
return {"error": f"Registry describe timed out after {self.timeout} seconds"}
except httpx.HTTPStatusError as exc:
return self._http_error(exc)
except Exception as exc: # noqa: BLE001 - surface as tool-result error
logger.error("registry describe error: %s", exc)
return {"error": str(exc)}
@staticmethod
def _unwrap_envelope(data: Any) -> Any:
"""Return the dict that carries the endpoint fields.
The live registry nests ``method``/``cost`` under an ``endpoint`` key
(``{"endpoint": {...}, "provider": {}, ...}``); flatter registry
implementations put them at the top level. Prefer ``data["endpoint"]``
when it is a dict, else use ``data`` itself.
"""
if not isinstance(data, dict):
return data
endpoint = data.get("endpoint")
if isinstance(endpoint, dict):
return endpoint
return data
@classmethod
def _extract_method(cls, data: Any) -> Optional[str]:
"""Read the HTTP method from a describe response, if present.
The live registry's endpoint doc carries ``method: "GET"`` (or POST/PUT)
nested under ``endpoint``; the registry enforces the declared method on
``/call/{id}``. Flat implementations expose it at the top level.
"""
data = cls._unwrap_envelope(data)
if not isinstance(data, dict):
return None
method = data.get("method")
if isinstance(method, str) and method.strip():
return method.strip().upper()
return None
@classmethod
def _extract_price(cls, data: Any) -> Any:
"""Read a price from a response, tolerating field-name/shape variation.
Unwraps the ``endpoint`` envelope, then tries ``price_per_call`` then
``price`` then ``cost`` so the budget check and spend recording share the
same tolerance against the live schema. ``cost`` may be a structured
object (``{"value": 25, "currency": "credit", "usd": 0.0299, ...}``); in
that case prefer ``usd`` since the guard budgets are in USD (``value`` is
in provider credits). Flat numeric forms are returned unchanged.
"""
data = cls._unwrap_envelope(data)
if not isinstance(data, dict):
return None
for key in ("price_per_call", "price", "cost"):
if key in data:
value = data[key]
if isinstance(value, dict):
return value.get("usd")
return value
return None
def _check_budget(self, tool_id: str):
"""Deny + report if a configured spend guard would be exceeded.
Returns ``(denial, price, described)``: ``denial`` is a ``{"error": ...}``
dict when the call must be rejected (else ``None``); ``price`` is the
validated per-call price when a guard applies (else ``None``);
``described`` is the describe response fetched for the guard (else
``None``) so the caller can reuse it (e.g. to derive the HTTP method)
without a second network round-trip.
"""
if self.max_cost_per_call is None and self.max_session_spend is None:
return None, None, None
described = self.describe(tool_id)
if isinstance(described, dict) and "error" in described:
return described, None, None
# Accept the same price field names tolerated by spend recording so the
# budget check does not fail closed on a field-name mismatch against the
# live registry schema (``price_per_call`` -> ``price`` -> ``cost``).
price = self._extract_price(described)
try:
price = float(price)
except (TypeError, ValueError):
# Fail closed: a spend guard is configured but the price cannot be
# validated, so we cannot guarantee the budget - deny the call.
return {
"error": (
"Denied: price (price_per_call/price/cost) is missing or "
"invalid, cannot enforce the configured spend guard."
)
}, None, described
# Fail closed on non-finite (NaN/inf) or negative prices: NaN comparisons
# are always false, so an unvalidated NaN would silently bypass both the
# per-call and session guards and then corrupt ``_session_spend``.
if not math.isfinite(price) or price < 0:
return {
"error": (
"Denied: price (price_per_call/price/cost) is missing or "
"invalid, cannot enforce the configured spend guard."
)
}, None, described
if self.max_cost_per_call is not None and price > self.max_cost_per_call:
return {
"error": (
f"Denied: price per call {price} exceeds max_cost_per_call "
f"{self.max_cost_per_call}."
)
}, price, described
if (
self.max_session_spend is not None
and self._session_spend + price > self.max_session_spend
):
return {
"error": (
f"Denied: session spend would reach "
f"{self._session_spend + price}, exceeding max_session_spend "
f"{self.max_session_spend}."
)
}, price, described
return None, price, described
def call(
self,
tool_id: str,
params: Optional[Dict[str, Any]] = None,
method: Optional[str] = None,
) -> Dict[str, Any]:
"""Invoke an endpoint through the proxy (credential injected server-side).
The registry enforces each endpoint's declared HTTP method on
``/call/{id}``. When ``method`` is not supplied it is derived from
``describe`` (the endpoint doc carries ``method``); GET requests send
``params`` as the query string, POST/PUT (and others) send them as a
JSON body.
"""
missing = self._require_config()
if missing:
return missing
if not tool_id:
return {"error": "tool_id is required"}
denied, quoted_price, described = self._check_budget(tool_id)
if denied:
return denied
# Derive the HTTP method from the endpoint doc when not supplied. Reuse
# the describe fetched by the spend guard when present to avoid a second
# round-trip; otherwise fetch describe only when the method is unknown.
if method is None:
if described is None:
described = self.describe(tool_id)
if isinstance(described, dict) and "error" in described:
return described
method = self._extract_method(described)
method = (method or "POST").strip().upper()
if method not in _ALLOWED_METHODS:
return {
"error": (
f"Unsupported HTTP method '{method}'. Allowed: "
f"{', '.join(sorted(_ALLOWED_METHODS))}."
)
}
try:
import httpx
except ImportError:
return {"error": _INSTALL_HINT}
url = f"{self.proxy_url}/call/{quote(tool_id, safe='')}"
try:
with httpx.Client(timeout=self.timeout) as client:
if method == "GET":
response = client.get(
url, params=params or {}, headers=self._headers()
)
else:
response = client.request(
method, url, json=params or {}, headers=self._headers()
)
response.raise_for_status()
result = response.json()
except httpx.TimeoutException:
return {"error": f"Registry call timed out after {self.timeout} seconds"}
except httpx.HTTPStatusError as exc:
return self._http_error(exc)
except Exception as exc: # noqa: BLE001 - surface as tool-result error
logger.error("registry call error: %s", exc)
return {"error": str(exc)}
# Record cumulative spend. Prefer the charge reported by the proxy; fall
# back to the price quoted at budget-check time so the session budget
# still accrues when the response omits cost fields.
charged = self._extract_price(result)
try:
charged = float(charged)
if not math.isfinite(charged) or charged < 0:
raise ValueError("non-finite or negative charge")
except (TypeError, ValueError):
# The response omitted a usable charge; fall back to the finite,
# non-negative price validated at budget-check time so a malformed
# (NaN/inf/negative) response cannot corrupt cumulative spend.
charged = quoted_price
if charged is not None:
self._session_spend += charged
return result
@tool
def registry_search(
query: str,
proxy_url: Optional[str] = None,
token: Optional[str] = None,
auth_header: Optional[str] = None,
) -> Dict[str, Any]:
"""Search the connected tool registry by capability.
Free read. Returns catalogue matches for a natural-language capability query
such as "backlinks for a domain" or "company enrichment by email".
Args:
query: Capability to search for.
proxy_url: Registry base URL (defaults to TOOL_PROXY_URL env var).
token: Proxy token (defaults to TOOL_PROXY_TOKEN env var).
auth_header: Auth header name (defaults to TOOL_PROXY_AUTH_HEADER env var,
then ``Authorization``).
Returns:
Dict with catalogue matches, or ``{"error": ...}`` on failure.
"""
return RegistryProxyTool(
proxy_url=proxy_url, token=token, auth_header=auth_header
).search(query)
@tool
def registry_describe(
tool_id: str,
proxy_url: Optional[str] = None,
token: Optional[str] = None,
auth_header: Optional[str] = None,
describe_path: Optional[str] = None,
) -> Dict[str, Any]:
"""Describe one registry catalogue entry.
Free read. Returns the endpoint's parameters, price-per-call and an example
response so the model can decide whether and how to invoke it.
Args:
tool_id: Catalogue entry identifier (from ``registry_search``).
proxy_url: Registry base URL (defaults to TOOL_PROXY_URL env var).
token: Proxy token (defaults to TOOL_PROXY_TOKEN env var).
auth_header: Auth header name (defaults to TOOL_PROXY_AUTH_HEADER env var,
then ``Authorization``).
describe_path: Path template for the describe route (defaults to
TOOL_PROXY_DESCRIBE_PATH env var, then ``/catalog/endpoints/{tool_id}``).
Must contain ``{tool_id}``.
Returns:
Dict describing the entry, or ``{"error": ...}`` on failure.
"""
return RegistryProxyTool(
proxy_url=proxy_url, token=token, auth_header=auth_header,
describe_path=describe_path,
).describe(tool_id)
@tool
def registry_call(
tool_id: str,
params: Optional[Dict[str, Any]] = None,
proxy_url: Optional[str] = None,
token: Optional[str] = None,
max_cost_per_call: Optional[float] = None,
max_session_spend: Optional[float] = None,
auth_header: Optional[str] = None,
method: Optional[str] = None,
describe_path: Optional[str] = None,
) -> Dict[str, Any]:
"""Invoke a registry endpoint through the proxy.
Paid call. The registry injects the upstream vendor credential server-side,
so no vendor keys are ever held by the agent. Optional spend guards deny +
report the call when a configured budget would be exceeded.
The registry enforces each endpoint's declared HTTP method. When ``method``
is not supplied it is derived from ``registry_describe`` (the endpoint doc
carries ``method``); GET params are sent as the query string, POST/PUT as a
JSON body.
Note: passthrough-URL calls require the upstream to be registered on the
registry first; loopback/private upstreams are refused by the registry's
SSRF guard by design.
Args:
tool_id: Catalogue entry identifier (from ``registry_search``).
params: Parameters for the upstream endpoint.
proxy_url: Registry base URL (defaults to TOOL_PROXY_URL env var).
token: Proxy token (defaults to TOOL_PROXY_TOKEN env var).
max_cost_per_call: Deny the call if its price exceeds this value.
max_session_spend: Deny the call if it would push cumulative spend past this value.
auth_header: Auth header name (defaults to TOOL_PROXY_AUTH_HEADER env var,
then ``Authorization``). ``Authorization`` sends ``Bearer {token}``;
any other name (e.g. ``X-Treg-Token``) sends the raw token.
method: HTTP method for the endpoint (e.g. ``GET``/``POST``). When
omitted it is derived from the endpoint's describe doc.
describe_path: Path template for the describe route (defaults to
TOOL_PROXY_DESCRIBE_PATH env var, then ``/catalog/endpoints/{tool_id}``).
Returns:
Dict with the endpoint response, or ``{"error": ...}`` on failure/denial.
"""
return RegistryProxyTool(
proxy_url=proxy_url,
token=token,
max_cost_per_call=max_cost_per_call,
max_session_spend=max_session_spend,
auth_header=auth_header,
describe_path=describe_path,
).call(tool_id, params, method)
+47 -874
View File
@@ -14,891 +14,64 @@ Ready-to-use tools:
- WeatherTool: Get weather data
- YouTubeTool: Search YouTube, get transcripts
- TTSTool: Text-to-speech conversion
Tools are discovered automatically: adding a module under
``praisonai_tools/tools/`` exposes its public top-level classes and functions
without any manual registration. Imports remain lazy - a tool module (and its
optional dependencies) is only imported the first time one of its symbols is
accessed.
"""
import os
from praisonai_tools.tools.base import BaseTool, ToolResult, ToolValidationError, validate_tool
from praisonai_tools.tools.decorator import tool, FunctionTool, is_tool, get_tool_schema
from praisonai_tools.tools._discovery import build_manifest
# Lazy imports for tools to avoid loading dependencies until needed
def __getattr__(name):
"""Lazy load tool classes."""
tool_map = {
# Email
"EmailTool": "email_tool",
"send_email": "email_tool",
"read_emails": "email_tool",
"search_emails": "email_tool",
# Slack
"SlackTool": "slack_tool",
"send_slack_message": "slack_tool",
"get_slack_channels": "slack_tool",
"get_slack_history": "slack_tool",
# Discord
"DiscordTool": "discord_tool",
"send_discord_webhook": "discord_tool",
"send_discord_message": "discord_tool",
# GitHub
"GitHubTool": "github_tool",
"search_github_repos": "github_tool",
"get_github_repo": "github_tool",
"create_github_issue": "github_tool",
# Image Generation
"ImageTool": "image_tool",
"generate_image": "image_tool",
# Weather
"WeatherTool": "weather_tool",
"get_weather": "weather_tool",
"get_forecast": "weather_tool",
"get_air_quality": "weather_tool",
# YouTube
"YouTubeTool": "youtube_tool",
"search_youtube": "youtube_tool",
"get_youtube_video": "youtube_tool",
"get_youtube_transcript": "youtube_tool",
# Text-to-Speech
"TTSTool": "tts_tool",
"text_to_speech": "tts_tool",
"list_tts_voices": "tts_tool",
# Telegram
"TelegramTool": "telegram_tool",
"send_telegram_message": "telegram_tool",
# Notion
"NotionTool": "notion_tool",
"search_notion": "notion_tool",
"create_notion_page": "notion_tool",
# PostgreSQL
"PostgresTool": "postgres_tool",
"query_postgres": "postgres_tool",
"list_postgres_tables": "postgres_tool",
# Reddit
"RedditTool": "reddit_tool",
"search_reddit": "reddit_tool",
"get_reddit_hot": "reddit_tool",
# Docker
"DockerTool": "docker_tool",
"list_docker_containers": "docker_tool",
"run_docker_container": "docker_tool",
# MySQL
"MySQLTool": "mysql_tool",
"query_mysql": "mysql_tool",
# SQLite
"SQLiteTool": "sqlite_tool",
"query_sqlite": "sqlite_tool",
# MongoDB
"MongoDBTool": "mongodb_tool",
"query_mongodb": "mongodb_tool",
# Redis
"RedisTool": "redis_tool",
"redis_get": "redis_tool",
"redis_set": "redis_tool",
# DuckDuckGo
"DuckDuckGoTool": "duckduckgo_tool",
"duckduckgo_search": "duckduckgo_tool",
# Tavily
"TavilyTool": "tavily_tool",
"tavily_search": "tavily_tool",
"tavily_extract": "tavily_tool",
# Wikipedia
"WikipediaTool": "wikipedia_tool",
"wikipedia_search": "wikipedia_tool",
# ArXiv
"ArxivTool": "arxiv_tool",
"arxiv_search": "arxiv_tool",
# Calculator
"CalculatorTool": "calculator_tool",
"calculate": "calculator_tool",
# Shell
"ShellTool": "shell_tool",
"shell_execute": "shell_tool",
# File
"FileTool": "file_tool",
"read_file": "file_tool",
"write_file": "file_tool",
# Firecrawl
"FirecrawlTool": "firecrawl_tool",
"firecrawl_scrape": "firecrawl_tool",
# Serper
"SerperTool": "serper_tool",
"serper_search": "serper_tool",
# Jina
"JinaTool": "jina_tool",
"jina_read": "jina_tool",
# Google Calendar
"GoogleCalendarTool": "google_calendar_tool",
"list_calendar_events": "google_calendar_tool",
# Google Sheets
"GoogleSheetsTool": "google_sheets_tool",
"read_google_sheet": "google_sheets_tool",
# Jira
"JiraTool": "jira_tool",
"jira_search": "jira_tool",
"jira_list_boards": "jira_tool",
"jira_get_board": "jira_tool",
"jira_get_board_issues": "jira_tool",
"jira_get_backlog": "jira_tool",
"jira_get_transitions": "jira_tool",
"jira_move_issue": "jira_tool",
"jira_create_task": "jira_tool",
"jira_watch_issue": "jira_watch_tool",
"jira_watch_project": "jira_watch_tool",
"jira_get_issue_info": "jira_watch_tool",
"jira_search_issues": "jira_watch_tool",
"jira_tools": "jira_watch_tool",
# Trello
"TrelloTool": "trello_tool",
"list_trello_boards": "trello_tool",
# YFinance
"YFinanceTool": "yfinance_tool",
"get_stock_price": "yfinance_tool",
# Pinecone
"PineconeTool": "pinecone_tool",
"pinecone_query": "pinecone_tool",
# Qdrant
"QdrantTool": "qdrant_tool",
"qdrant_search": "qdrant_tool",
# Chroma
"ChromaTool": "chroma_tool",
"chroma_query": "chroma_tool",
# HackerNews
"HackerNewsTool": "hackernews_tool",
"get_hackernews_top": "hackernews_tool",
# Twilio
"TwilioTool": "twilio_tool",
"send_sms": "twilio_tool",
# Spotify
"SpotifyTool": "spotify_tool",
"spotify_search": "spotify_tool",
# Linear
"LinearTool": "linear_tool",
"list_linear_issues": "linear_tool",
# Crawl4AI
"Crawl4AITool": "crawl4ai_tool",
"crawl4ai_crawl": "crawl4ai_tool",
# Exa
"ExaTool": "exa_tool",
"exa_search": "exa_tool",
# DuckDB
"DuckDBTool": "duckdb_tool",
"duckdb_query": "duckdb_tool",
# Neo4j
"Neo4jTool": "neo4j_tool",
"neo4j_query": "neo4j_tool",
# Weaviate
"WeaviateTool": "weaviate_tool",
"weaviate_search": "weaviate_tool",
# LanceDB
"LanceDBTool": "lancedb_tool",
"lancedb_search": "lancedb_tool",
# Supabase
"SupabaseTool": "supabase_tool",
"supabase_select": "supabase_tool",
# Milvus
"MilvusTool": "milvus_tool",
"milvus_search": "milvus_tool",
# Google Maps
"GoogleMapsTool": "google_maps_tool",
"geocode_address": "google_maps_tool",
# Zendesk
"ZendeskTool": "zendesk_tool",
"list_zendesk_tickets": "zendesk_tool",
# Shopify
"ShopifyTool": "shopify_tool",
"list_shopify_products": "shopify_tool",
# Brave Search
"BraveSearchTool": "brave_search_tool",
"brave_search": "brave_search_tool",
# PubMed
"PubMedTool": "pubmed_tool",
"pubmed_search": "pubmed_tool",
# SerpAPI
"SerpAPITool": "serpapi_tool",
"serpapi_search": "serpapi_tool",
# SearxNG
"SearxNGTool": "searxng_tool",
"searxng_search": "searxng_tool",
# LinkUp
"LinkUpTool": "linkup_tool",
"linkup_search": "linkup_tool",
# Baidu Search
"BaiduSearchTool": "baidu_search_tool",
"baidu_search": "baidu_search_tool",
# Valyu
"ValyuTool": "valyu_tool",
"valyu_search": "valyu_tool",
# Gmail
"GmailTool": "gmail_tool",
"list_gmail_emails": "gmail_tool",
"gmail_archive_email": "gmail_tool",
"gmail_label_email": "gmail_tool",
"gmail_mark_read": "gmail_tool",
"gmail_draft_email": "gmail_tool",
"gmail_trash_email": "gmail_tool",
# Outlook
"OutlookTool": "outlook_tool",
"outlook_list_emails": "outlook_tool",
"outlook_send_email": "outlook_tool",
"outlook_search_emails": "outlook_tool",
"outlook_archive_email": "outlook_tool",
"outlook_draft_email": "outlook_tool",
# WhatsApp
"WhatsAppTool": "whatsapp_tool",
"send_whatsapp_message": "whatsapp_tool",
# Signal
"SignalTool": "signal_tool",
"send_signal_message": "signal_tool",
"check_signal_connection": "signal_tool",
# LINE
"LineTool": "line_tool",
"send_line_message": "line_tool",
"broadcast_line_message": "line_tool",
# iMessage
"iMessageTool": "imessage_tool",
"send_imessage": "imessage_tool",
"check_imessage_availability": "imessage_tool",
# Zoom
"ZoomTool": "zoom_tool",
"list_zoom_meetings": "zoom_tool",
# Webex
"WebexTool": "webex_tool",
"list_webex_rooms": "webex_tool",
# X (Twitter)
"XTool": "x_tool",
"post_to_x": "x_tool",
# Newspaper
"NewspaperTool": "newspaper_tool",
"extract_article": "newspaper_tool",
# Trafilatura
"TrafilaturaTool": "trafilatura_tool",
"trafilatura_extract": "trafilatura_tool",
# Spider
"SpiderTool": "spider_tool",
"spider_crawl": "spider_tool",
# BrowserBase
"BrowserBaseTool": "browserbase_tool",
"browserbase_scrape": "browserbase_tool",
# BrightData
"BrightDataTool": "brightdata_tool",
"brightdata_scrape": "brightdata_tool",
# Oxylabs
"OxylabsTool": "oxylabs_tool",
"oxylabs_scrape": "oxylabs_tool",
# ScrapeGraph
"ScrapeGraphTool": "scrapegraph_tool",
"scrapegraph_scrape": "scrapegraph_tool",
# AgentQL
"AgentQLTool": "agentql_tool",
"agentql_query": "agentql_tool",
# DynamoDB
"DynamoDBTool": "dynamodb_tool",
"dynamodb_scan": "dynamodb_tool",
# Firestore
"FirestoreTool": "firestore_tool",
"firestore_list": "firestore_tool",
# GCS
"GCSTool": "gcs_tool",
"gcs_list_objects": "gcs_tool",
# BigQuery
"BigQueryTool": "bigquery_tool",
"bigquery_query": "bigquery_tool",
# SingleStore
"SingleStoreTool": "singlestore_tool",
"singlestore_query": "singlestore_tool",
# SurrealDB
"SurrealDBTool": "surrealdb_tool",
"surrealdb_query": "surrealdb_tool",
# CSV
"CSVTool": "csv_tool",
"read_csv": "csv_tool",
# Pandas
"PandasTool": "pandas_tool",
"pandas_read_csv": "pandas_tool",
# Redshift
"RedshiftTool": "redshift_tool",
"redshift_query": "redshift_tool",
# ElevenLabs
"ElevenLabsTool": "elevenlabs_tool",
"elevenlabs_speak": "elevenlabs_tool",
"elevenlabs_speak_to_file": "elevenlabs_tool",
"elevenlabs_list_voices": "elevenlabs_tool",
"elevenlabs_get_voice": "elevenlabs_tool",
"elevenlabs_list_models": "elevenlabs_tool",
"elevenlabs_get_history": "elevenlabs_tool",
"elevenlabs_get_user_info": "elevenlabs_tool",
# Replicate
"ReplicateTool": "replicate_tool",
"replicate_run": "replicate_tool",
# Fal
"FalTool": "fal_tool",
"fal_run": "fal_tool",
# Giphy
"GiphyTool": "giphy_tool",
"giphy_search": "giphy_tool",
# Apify
"ApifyTool": "apify_tool",
"apify_run_actor": "apify_tool",
# Airflow
"AirflowTool": "airflow_tool",
"list_airflow_dags": "airflow_tool",
# AWS Lambda
"AWSLambdaTool": "aws_lambda_tool",
"invoke_lambda": "aws_lambda_tool",
# AWS SES
"AWSSESTool": "aws_ses_tool",
"ses_send_email": "aws_ses_tool",
# Resend
"ResendTool": "resend_tool",
"resend_send_email": "resend_tool",
# ClickUp
"ClickUpTool": "clickup_tool",
"clickup_list_tasks": "clickup_tool",
# Confluence
"ConfluenceTool": "confluence_tool",
"confluence_search": "confluence_tool",
# Todoist
"TodoistTool": "todoist_tool",
"todoist_list_tasks": "todoist_tool",
# Bitbucket
"BitbucketTool": "bitbucket_tool",
"bitbucket_list_repos": "bitbucket_tool",
# OpenBB
"OpenBBTool": "openbb_tool",
"openbb_stock": "openbb_tool",
# E2B
"E2BTool": "e2b_tool",
"e2b_run_code": "e2b_tool",
# Sleep
"SleepTool": "sleep_tool",
"sleep_wait": "sleep_tool",
# Python
"PythonTool": "python_tool",
"python_execute": "python_tool",
# Capsule
"CapsuleTool": "capsule_tool",
"capsule_run_code": "capsule_tool",
# Mem0
"Mem0Tool": "mem0_tool",
"mem0_add": "mem0_tool",
# PGVector
"PGVectorTool": "pgvector_tool",
"pgvector_search": "pgvector_tool",
# Cassandra
"CassandraTool": "cassandra_tool",
"cassandra_query": "cassandra_tool",
# ClickHouse
"ClickHouseTool": "clickhouse_tool",
"clickhouse_query": "clickhouse_tool",
# Upstash
"UpstashTool": "upstash_tool",
"upstash_get": "upstash_tool",
# Couchbase
"CouchbaseTool": "couchbase_tool",
"couchbase_get": "couchbase_tool",
# Google Drive
"GoogleDriveTool": "google_drive_tool",
"list_drive_files": "google_drive_tool",
# LumaLabs
"LumaLabsTool": "lumalabs_tool",
"lumalabs_generate": "lumalabs_tool",
# Langextract
"LangExtractTool": "langextract_tool",
"langextract_extract": "langextract_tool",
"langextract_render_file": "langextract_tool",
# HeyGen
"HeyGenTool": "heygen_tool",
"heygen_list_avatars": "heygen_tool",
"heygen_list_avatar_groups": "heygen_tool",
"heygen_list_group_avatars": "heygen_tool",
"heygen_get_avatar_details": "heygen_tool",
"heygen_list_voices": "heygen_tool",
"heygen_list_voice_locales": "heygen_tool",
"heygen_generate_video": "heygen_tool",
"heygen_generate_video_with_audio": "heygen_tool",
"heygen_upload_asset": "heygen_tool",
"heygen_video_status": "heygen_tool",
"heygen_wait_for_video": "heygen_tool",
"heygen_list_videos": "heygen_tool",
"heygen_delete_video": "heygen_tool",
"heygen_list_assets": "heygen_tool",
"heygen_delete_asset": "heygen_tool",
"heygen_get_remaining_quota": "heygen_tool",
"heygen_get_user_info": "heygen_tool",
"heygen_list_templates": "heygen_tool",
"heygen_generate_from_template": "heygen_tool",
"heygen_translate_video": "heygen_tool",
# Higgsfield
"HiggsfieldTool": "higgsfield_tool",
"higgsfield_generate": "higgsfield_tool",
"higgsfield_status": "higgsfield_tool",
"higgsfield_upload": "higgsfield_tool",
# OpenCV
"OpenCVTool": "opencv_tool",
"opencv_resize": "opencv_tool",
# CalCom
"CalComTool": "calcom_tool",
"calcom_list_bookings": "calcom_tool",
# Cartesia
"CartesiaTool": "cartesia_tool",
"cartesia_speak": "cartesia_tool",
# Brandfetch
"BrandfetchTool": "brandfetch_tool",
"brandfetch_get_brand": "brandfetch_tool",
# Daytona
"DaytonaTool": "daytona_tool",
"daytona_list_workspaces": "daytona_tool",
# Composio
"ComposioTool": "composio_tool",
"ComposioTools": "composio_tool",
"composio_execute": "composio_tool",
"composio_tools": "composio_tool",
"composio_list_apps": "composio_tool",
# JSON
"JSONTool": "json_tool",
"read_json": "json_tool",
# Zep
"ZepTool": "zep_tool",
"zep_add_memory": "zep_tool",
# WebBrowser
"WebBrowserTool": "webbrowser_tool",
"webbrowser_get_page": "webbrowser_tool",
# WebTools
"WebToolsTool": "webtools_tool",
"webtools_fetch": "webtools_tool",
# LocalFileSystem
"LocalFileSystemTool": "local_filesystem_tool",
"list_local_directory": "local_filesystem_tool",
# Knowledge
"KnowledgeTool": "knowledge_tool",
"knowledge_search": "knowledge_tool",
# Visualization
"VisualizationTool": "visualization_tool",
"create_bar_chart": "visualization_tool",
"SQLiteVectorTool": "sqlite_vector_tool",
"sqlite_vector_query": "sqlite_vector_tool",
"sqlite_vector_add": "sqlite_vector_tool",
# WordPress
"WordPressTool": "wordpress_tool",
"check_wp_duplicate": "wordpress_tool",
"create_wp_post": "wordpress_tool",
# Marketplace Tools
"PinchworkTool": "pinchwork_tool",
"pinchwork_delegate": "pinchwork_tool",
"AgentIDTool": "agentid_tool",
"verify_agent_identity": "agentid_tool",
"JoyTrustTool": "joy_trust_tool",
"check_trust_score": "joy_trust_tool",
"verify_handoff_safety": "joy_trust_tool",
"trust_verified_handoff": "joy_trust_tool",
"is_trust_verification_enabled": "joy_trust_tool",
"get_trust_config": "joy_trust_tool",
"AgentFolioTool": "agentfolio_tool",
"check_behavioral_trust": "agentfolio_tool",
"verify_task_delegation_safety": "agentfolio_tool",
# LinkedIn
"LinkedInTool": "linkedin_tool",
"linkedin_post_text": "linkedin_tool",
"linkedin_post_image": "linkedin_tool",
"linkedin_post_article": "linkedin_tool",
"linkedin_get_profile": "linkedin_tool",
"linkedin_delete_post": "linkedin_tool",
# Facebook
"FacebookTool": "facebook_tool",
"facebook_post_text": "facebook_tool",
"facebook_post_image": "facebook_tool",
"facebook_post_link": "facebook_tool",
"facebook_post_video": "facebook_tool",
"facebook_get_page_info": "facebook_tool",
"facebook_delete_post": "facebook_tool",
# Instagram
"InstagramTool": "instagram_tool",
"instagram_post_image": "instagram_tool",
"instagram_post_carousel": "instagram_tool",
"instagram_post_reel": "instagram_tool",
"instagram_get_media": "instagram_tool",
"instagram_get_account_info": "instagram_tool",
# TikTok
"TikTokTool": "tiktok_tool",
"tiktok_post_video": "tiktok_tool",
"tiktok_post_photo": "tiktok_tool",
"tiktok_get_creator_info": "tiktok_tool",
"tiktok_get_post_status": "tiktok_tool",
# Threads
"ThreadsTool": "threads_tool",
"threads_post_text": "threads_tool",
"threads_post_image": "threads_tool",
"threads_post_video": "threads_tool",
"threads_get_threads": "threads_tool",
"threads_get_profile": "threads_tool",
# Pinterest
"PinterestTool": "pinterest_tool",
"pinterest_create_pin": "pinterest_tool",
"pinterest_list_boards": "pinterest_tool",
"pinterest_get_pin": "pinterest_tool",
"pinterest_delete_pin": "pinterest_tool",
"pinterest_get_account_info": "pinterest_tool",
# Medium
"MediumTool": "medium_tool",
"medium_publish_post": "medium_tool",
"medium_get_user": "medium_tool",
"medium_list_publications": "medium_tool",
"medium_publish_to_publication": "medium_tool",
# n8n Tools
"N8nWorkflowTool": "praisonai_tools.n8n.n8n_workflow",
"n8n_workflow": "praisonai_tools.n8n.n8n_workflow",
"n8n_list_workflows": "praisonai_tools.n8n.n8n_workflow",
# NEXUS Prediction Market Tool
"NexusPredictionMarketTool": "nexus_prediction_market_tool",
"nexus_prediction_market_tool": "nexus_prediction_market_tool",
}
if name in tool_map:
module_name = tool_map[name]
from importlib import import_module
# Handle absolute module paths (e.g., praisonai_tools.n8n.n8n_workflow)
if module_name.startswith("praisonai_tools."):
module = import_module(module_name)
else:
# Handle relative module paths within tools package
module = import_module(f".{module_name}", __package__)
return getattr(module, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
# Base classes
# Symbols exported eagerly above (base classes + decorator helpers).
_BASE_EXPORTS = [
"BaseTool",
"ToolResult",
"ToolValidationError",
"validate_tool",
# Decorator
"tool",
"FunctionTool",
"is_tool",
"get_tool_schema",
# Email Tool
"EmailTool",
"send_email",
"read_emails",
"search_emails",
# Slack Tool
"SlackTool",
"send_slack_message",
"get_slack_channels",
"get_slack_history",
# Discord Tool
"DiscordTool",
"send_discord_webhook",
"send_discord_message",
# GitHub Tool
"GitHubTool",
"search_github_repos",
"get_github_repo",
"create_github_issue",
# Image Tool
"ImageTool",
"generate_image",
# Weather Tool
"WeatherTool",
"get_weather",
"get_forecast",
"get_air_quality",
# YouTube Tool
"YouTubeTool",
"search_youtube",
"get_youtube_video",
"get_youtube_transcript",
# TTS Tool
"TTSTool",
"text_to_speech",
"list_tts_voices",
# Telegram Tool
"TelegramTool",
"send_telegram_message",
# Notion Tool
"NotionTool",
"search_notion",
"create_notion_page",
# PostgreSQL Tool
"PostgresTool",
"query_postgres",
"list_postgres_tables",
# Reddit Tool
"RedditTool",
"search_reddit",
"get_reddit_hot",
# Docker Tool
"DockerTool",
"list_docker_containers",
"run_docker_container",
# MySQL Tool
"MySQLTool",
"query_mysql",
# SQLite Tool
"SQLiteTool",
"query_sqlite",
# MongoDB Tool
"MongoDBTool",
"query_mongodb",
# Redis Tool
"RedisTool",
"redis_get",
"redis_set",
# DuckDuckGo Tool
"DuckDuckGoTool",
"duckduckgo_search",
# Tavily Tool
"TavilyTool",
"tavily_search",
"tavily_extract",
# Wikipedia Tool
"WikipediaTool",
"wikipedia_search",
# ArXiv Tool
"ArxivTool",
"arxiv_search",
# Calculator Tool
"CalculatorTool",
"calculate",
# Shell Tool
"ShellTool",
"shell_execute",
# File Tool
"FileTool",
"read_file",
"write_file",
# Firecrawl Tool
"FirecrawlTool",
"firecrawl_scrape",
# Serper Tool
"SerperTool",
"serper_search",
# Jina Tool
"JinaTool",
"jina_read",
# Google Calendar Tool
"GoogleCalendarTool",
"list_calendar_events",
# Google Sheets Tool
"GoogleSheetsTool",
"read_google_sheet",
# Jira Tool
"JiraTool",
"jira_search",
"jira_list_boards",
"jira_get_board",
"jira_get_board_issues",
"jira_get_backlog",
"jira_get_transitions",
"jira_move_issue",
"jira_create_task",
"jira_watch_issue",
"jira_watch_project",
"jira_get_issue_info",
"jira_search_issues",
"jira_tools",
# Trello Tool
"TrelloTool",
"list_trello_boards",
# YFinance Tool
"YFinanceTool",
"get_stock_price",
# Pinecone Tool
"PineconeTool",
"pinecone_query",
# Qdrant Tool
"QdrantTool",
"qdrant_search",
# Chroma Tool
"ChromaTool",
"chroma_query",
# HackerNews Tool
"HackerNewsTool",
"get_hackernews_top",
# Twilio Tool
"TwilioTool",
"send_sms",
# Spotify Tool
"SpotifyTool",
"spotify_search",
# Linear Tool
"LinearTool",
"list_linear_issues",
# Crawl4AI Tool
"Crawl4AITool",
"crawl4ai_crawl",
# Exa Tool
"ExaTool",
"exa_search",
# DuckDB Tool
"DuckDBTool",
"duckdb_query",
# Neo4j Tool
"Neo4jTool",
"neo4j_query",
# Weaviate Tool
"WeaviateTool",
"weaviate_search",
# LanceDB Tool
"LanceDBTool",
"lancedb_search",
# Supabase Tool
"SupabaseTool",
"supabase_select",
# Milvus Tool
"MilvusTool",
"milvus_search",
# Google Maps Tool
"GoogleMapsTool",
"geocode_address",
# Zendesk Tool
"ZendeskTool",
"list_zendesk_tickets",
# Shopify Tool
"ShopifyTool",
"list_shopify_products",
# SQLite Vector Store (zero deps)
"SQLiteVectorTool",
"sqlite_vector_query",
"sqlite_vector_add",
# WordPress Tool (requires praisonaiwp)
"WordPressTool",
"check_wp_duplicate",
"create_wp_post",
# HeyGen Tool
"HeyGenTool",
"heygen_list_avatars",
"heygen_list_avatar_groups",
"heygen_list_group_avatars",
"heygen_get_avatar_details",
"heygen_list_voices",
"heygen_list_voice_locales",
"heygen_generate_video",
"heygen_generate_video_with_audio",
"heygen_upload_asset",
"heygen_video_status",
"heygen_wait_for_video",
"heygen_list_videos",
"heygen_delete_video",
"heygen_list_assets",
"heygen_delete_asset",
"heygen_get_remaining_quota",
"heygen_get_user_info",
"heygen_list_templates",
"heygen_generate_from_template",
"heygen_translate_video",
# ElevenLabs Tool
"ElevenLabsTool",
"elevenlabs_speak",
"elevenlabs_speak_to_file",
"elevenlabs_list_voices",
"elevenlabs_get_voice",
"elevenlabs_list_models",
"elevenlabs_get_history",
"elevenlabs_get_user_info",
# Higgsfield Tool
"HiggsfieldTool",
"higgsfield_generate",
"higgsfield_status",
"higgsfield_upload",
# Signal Tool
"SignalTool",
"send_signal_message",
"check_signal_connection",
# LINE Tool
"LineTool",
"send_line_message",
"broadcast_line_message",
# iMessage Tool
"iMessageTool",
"send_imessage",
"check_imessage_availability",
# Gmail Tool (extended)
"GmailTool",
"list_gmail_emails",
"gmail_archive_email",
"gmail_label_email",
"gmail_mark_read",
"gmail_draft_email",
"gmail_trash_email",
# Outlook Tool
"OutlookTool",
"outlook_list_emails",
"outlook_send_email",
"outlook_search_emails",
"outlook_archive_email",
"outlook_draft_email",
# Marketplace Tools
"PinchworkTool",
"pinchwork_delegate",
"AgentIDTool",
"verify_agent_identity",
"JoyTrustTool",
"check_trust_score",
"verify_handoff_safety",
"trust_verified_handoff",
"is_trust_verification_enabled",
"get_trust_config",
"AgentFolioTool",
"check_behavioral_trust",
"verify_task_delegation_safety",
# LinkedIn Tool
"LinkedInTool",
"linkedin_post_text",
"linkedin_post_image",
"linkedin_post_article",
"linkedin_get_profile",
"linkedin_delete_post",
# Facebook Tool
"FacebookTool",
"facebook_post_text",
"facebook_post_image",
"facebook_post_link",
"facebook_post_video",
"facebook_get_page_info",
"facebook_delete_post",
# Instagram Tool
"InstagramTool",
"instagram_post_image",
"instagram_post_carousel",
"instagram_post_reel",
"instagram_get_media",
"instagram_get_account_info",
# TikTok Tool
"TikTokTool",
"tiktok_post_video",
"tiktok_post_photo",
"tiktok_get_creator_info",
"tiktok_get_post_status",
# Threads Tool
"ThreadsTool",
"threads_post_text",
"threads_post_image",
"threads_post_video",
"threads_get_threads",
"threads_get_profile",
# Pinterest Tool
"PinterestTool",
"pinterest_create_pin",
"pinterest_list_boards",
"pinterest_get_pin",
"pinterest_delete_pin",
"pinterest_get_account_info",
# Medium Tool
"MediumTool",
"medium_publish_post",
"medium_get_user",
"medium_list_publications",
"medium_publish_to_publication",
# n8n Tool
"N8nWorkflowTool",
"n8n_workflow",
"n8n_list_workflows",
# Langextract Tool
"LangExtractTool",
"langextract_extract",
"langextract_render_file",
# NEXUS Prediction Market Tool
"NexusPredictionMarketTool",
"nexus_prediction_market_tool",
]
# 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,
+86 -18
View File
@@ -25,7 +25,7 @@ class FirecrawlTool(BaseTool):
"""Tool for web scraping using Firecrawl."""
name = "firecrawl"
description = "Scrape and crawl websites using Firecrawl."
description = "Scrape, crawl, and search the web using Firecrawl."
def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key or os.getenv("FIRECRAWL_API_KEY")
@@ -38,10 +38,12 @@ class FirecrawlTool(BaseTool):
if not self.api_key:
raise ValueError("FIRECRAWL_API_KEY is required")
try:
from firecrawl import FirecrawlApp
from firecrawl import Firecrawl
except ImportError:
raise ImportError("firecrawl-py not installed. Install with: pip install firecrawl-py")
self._client = FirecrawlApp(api_key=self.api_key)
raise ImportError(
"firecrawl-py>=4 not installed. Install with: pip install 'firecrawl-py>=4'"
)
self._client = Firecrawl(api_key=self.api_key)
return self._client
def run(
@@ -56,6 +58,8 @@ class FirecrawlTool(BaseTool):
return self.scrape(url=url, **kwargs)
elif action == "crawl":
return self.crawl(url=url, **kwargs)
elif action == "search":
return self.search(query=kwargs.get("query") or url, limit=kwargs.get("limit", 5))
else:
return {"error": f"Unknown action: {action}"}
@@ -68,16 +72,28 @@ class FirecrawlTool(BaseTool):
return {"error": "FIRECRAWL_API_KEY not configured"}
try:
params = {}
# firecrawl-py v2 (>=4): kwargs instead of a params dict; returns a
# typed Document, not a dict.
kwargs: Dict[str, Any] = {}
if formats:
params["formats"] = formats
result = self.client.scrape_url(url, params=params if params else None)
kwargs["formats"] = formats
result = self.client.scrape(url, **kwargs)
# v2 returns a Document object; metadata is a typed DocumentMetadata.
# Coerce to a plain dict to keep this tool's return shape stable.
metadata = getattr(result, "metadata", None)
if metadata is not None and hasattr(metadata, "model_dump"):
metadata = metadata.model_dump(exclude_none=True)
elif metadata is None:
metadata = {}
markdown = getattr(result, "markdown", None) or ""
return {
"url": url,
"markdown": result.get("markdown", "")[:5000],
"metadata": result.get("metadata", {}),
"markdown": markdown[:5000],
"metadata": metadata,
}
except Exception as e:
logger.error(f"Firecrawl scrape error: {e}")
@@ -92,25 +108,77 @@ class FirecrawlTool(BaseTool):
return [{"error": "FIRECRAWL_API_KEY not configured"}]
try:
result = self.client.crawl_url(
# `limit` may arrive as a string (e.g. "10") or None when invoked by
# an LLM/agent; coerce to int so the SDK call and `data[:limit]` slice
# below don't raise TypeError.
try:
limit = int(limit) if limit is not None else 10
except (ValueError, TypeError):
limit = 10
# firecrawl-py v2 (>=4): kwargs instead of a params dict; returns a
# typed CrawlJob whose `.data` is a list of Document objects.
result = self.client.crawl(
url,
params={"limit": limit},
limit=limit,
poll_interval=5,
)
data = getattr(result, "data", None) or []
pages = []
for page in result.get("data", [])[:limit]:
for page in data[:limit]:
metadata = getattr(page, "metadata", None)
pages.append({
"url": page.get("metadata", {}).get("sourceURL"),
"title": page.get("metadata", {}).get("title"),
"markdown": page.get("markdown", "")[:2000],
"url": getattr(metadata, "source_url", None),
"title": getattr(metadata, "title", None),
"markdown": (getattr(page, "markdown", None) or "")[:2000],
})
return pages
except Exception as e:
logger.error(f"Firecrawl crawl error: {e}")
return [{"error": str(e)}]
def search(self, query: str, limit: int = 5) -> List[Dict[str, Any]]:
"""Search the web and return results."""
if not query:
return [{"error": "query is required"}]
if not self.api_key:
return [{"error": "FIRECRAWL_API_KEY not configured"}]
try:
# `limit` may arrive as a string or None from an LLM/agent; coerce to
# int so the SDK call and `web[:limit]` slice below don't raise.
try:
limit = int(limit) if limit is not None else 5
except (ValueError, TypeError):
limit = 5
# firecrawl-py v2 returns a typed SearchData object whose results are
# grouped by source; read the web results and coerce to plain dicts.
result = self.client.search(query, limit=limit)
web = getattr(result, "web", None) or []
results = []
for item in web[:limit]:
results.append({
"url": getattr(item, "url", None),
"title": getattr(item, "title", None),
"description": getattr(item, "description", None),
"markdown": (getattr(item, "markdown", None) or "")[:2000],
})
return results
except Exception as e:
logger.error(f"Firecrawl search error: {e}")
return [{"error": str(e)}]
def firecrawl_scrape(url: str) -> Dict[str, Any]:
"""Scrape URL with Firecrawl."""
return FirecrawlTool().scrape(url=url)
def firecrawl_search(query: str, limit: int = 5) -> List[Dict[str, Any]]:
"""Search the web with Firecrawl."""
return FirecrawlTool().search(query=query, limit=limit)
+569
View File
@@ -0,0 +1,569 @@
"""Inkog Security Analysis Tool for PraisonAI Agents.
Security analysis for AI agent code using Inkog.
Detects token bombing, prompt injection, missing oversight, and compliance gaps.
Usage:
from praisonai_tools import InkogTool
inkog = InkogTool()
results = inkog.scan_directory("./my-agent")
# Or use standalone functions
from praisonai_tools.tools.inkog_tool import scan_agent_code
results = scan_agent_code("./my-agent")
Features:
- Static analysis of AI agent code
- Token bombing detection
- Prompt injection vulnerability scanning
- Recursive tool calling detection
- EU AI Act / NIST / OWASP compliance mapping
- Support for 21+ frameworks (LangChain, CrewAI, AutoGen, etc.)
- Multiple output formats (text, JSON, HTML, SARIF)
Environment Variables:
INKOG_API_KEY: API key from app.inkog.io (required)
INKOG_SERVER_URL: Custom server URL (optional)
"""
import os
import json
import logging
import subprocess
from typing import Any, Dict, Optional, Union
from praisonai_tools.tools.base import BaseTool
logger = logging.getLogger(__name__)
def _check_inkog_available(api_key: Optional[str] = None) -> tuple[bool, Optional[str]]:
"""Check if inkog CLI is available and properly configured.
Args:
api_key: Optional API key to validate. Falls back to the
INKOG_API_KEY environment variable when not provided.
Returns:
Tuple of (is_available, error_message)
"""
# Check if inkog CLI is installed
try:
result = subprocess.run(
["inkog", "-version"],
capture_output=True,
text=True,
timeout=10
)
if result.returncode != 0:
return False, "inkog CLI is not properly installed. Install with: brew tap inkog-io/inkog && brew install inkog"
except FileNotFoundError:
return False, "inkog CLI is not installed. Install with: brew tap inkog-io/inkog && brew install inkog"
except subprocess.TimeoutExpired:
return False, "inkog CLI is not responding. Please check installation."
except Exception as e:
return False, f"Error checking inkog installation: {e}"
# Check for API key (instance-provided key takes precedence over env var)
api_key = api_key or os.environ.get("INKOG_API_KEY")
if not api_key:
return False, "INKOG_API_KEY environment variable is required. Get your free key at https://app.inkog.io"
return True, None
class InkogTool(BaseTool):
"""Comprehensive security analysis tool for AI agent code using Inkog.
Inkog provides static analysis specifically designed for AI agents, detecting:
- Token bombing attacks (loops where LLM controls termination)
- Prompt injection vulnerabilities
- Recursive tool calling without cycle detection
- Missing human oversight for destructive operations
- Cross-tenant data leakage
- MCP tool poisoning
Supports 21+ frameworks including LangChain, CrewAI, AutoGen, OpenAI Agents,
and provides compliance mapping to EU AI Act, NIST AI RMF, and OWASP LLM Top 10.
Requires:
- inkog CLI: brew tap inkog-io/inkog && brew install inkog
- INKOG_API_KEY environment variable (free at app.inkog.io)
"""
name = "inkog_security"
description = "Analyze AI agent code for security vulnerabilities using Inkog static analysis."
def __init__(self, api_key: Optional[str] = None, server_url: Optional[str] = None):
"""Initialize InkogTool.
Args:
api_key: Optional API key. If not provided, uses INKOG_API_KEY env var.
server_url: Optional server URL. If not provided, uses INKOG_SERVER_URL env var.
"""
super().__init__()
self._api_key = api_key
self._server_url = server_url
def _get_api_key(self) -> Optional[str]:
"""Get API key from instance or environment."""
return self._api_key or os.environ.get("INKOG_API_KEY")
def _get_server_url(self) -> Optional[str]:
"""Get server URL from instance or environment."""
return self._server_url or os.environ.get("INKOG_SERVER_URL")
def run(
self,
action: str = "scan",
path: str = ".",
output_format: str = "json",
policy: str = "balanced",
severity: str = "low",
deep: bool = False,
**kwargs
) -> Union[str, Dict[str, Any]]:
"""Execute inkog security analysis.
Args:
action: Action to perform ("scan", "skill-scan", "mcp-scan")
path: Path to scan (directory or file)
output_format: Output format ("json", "text", "html", "sarif")
policy: Security policy ("low-noise", "balanced", "comprehensive")
severity: Minimum severity ("critical", "high", "medium", "low")
deep: Enable deep scan with AI orchestrator analysis
**kwargs: Additional arguments
Returns:
Scan results as dict (JSON) or formatted string (text/html)
"""
action = action.lower().replace("-", "_")
if action == "scan":
return self.scan_directory(
path=path,
output_format=output_format,
policy=policy,
severity=severity,
deep=deep
)
elif action == "skill_scan":
return self.skill_scan(
path=path,
output_format=output_format,
deep=deep,
repo=kwargs.get("repo")
)
elif action == "mcp_scan":
return self.mcp_scan(
server_name=kwargs.get("server_name", path),
output_format=output_format,
deep=deep,
repo=kwargs.get("repo")
)
else:
return {"error": f"Unknown action: {action}"}
def scan_directory(
self,
path: str = ".",
output_format: str = "json",
policy: str = "balanced",
severity: str = "low",
deep: bool = False,
verbose: bool = False
) -> Union[Dict[str, Any], str]:
"""Scan a directory or file for AI agent security vulnerabilities.
Args:
path: Path to scan (directory or file)
output_format: Output format ("json", "text", "html", "sarif")
policy: Security policy ("low-noise", "balanced", "comprehensive")
severity: Minimum severity level ("critical", "high", "medium", "low")
deep: Enable deep scan with AI orchestrator analysis
verbose: Enable verbose output
Returns:
Scan results as dict (JSON format) or string (other formats)
"""
is_available, error = _check_inkog_available(self._get_api_key())
if not is_available:
logger.error(error)
return {"error": error}
try:
# Build command
cmd = ["inkog"]
if deep:
cmd.append("-deep")
cmd.extend([
"-output", output_format,
"-policy", policy,
"-severity", severity,
])
if verbose:
cmd.append("-verbose")
# Add server URL if specified
server_url = self._get_server_url()
if server_url:
cmd.extend(["-server", server_url])
# Add path
cmd.append(path)
# Set environment
env = os.environ.copy()
api_key = self._get_api_key()
if api_key:
env["INKOG_API_KEY"] = api_key
# Execute scan
result = subprocess.run(
cmd,
capture_output=True,
text=True,
env=env,
timeout=300 # 5 minutes timeout
)
if result.returncode != 0:
error_msg = f"Inkog scan failed: {result.stderr or result.stdout}"
logger.error(error_msg)
return {"error": error_msg}
# Parse output based on format
if output_format == "json":
try:
return json.loads(result.stdout)
except json.JSONDecodeError as e:
return {"error": f"Failed to parse JSON output: {e}", "raw_output": result.stdout}
else:
return result.stdout
except subprocess.TimeoutExpired:
error_msg = "Inkog scan timed out after 5 minutes"
logger.error(error_msg)
return {"error": error_msg}
except Exception as e:
error_msg = f"Inkog scan error: {str(e)}"
logger.error(error_msg)
return {"error": error_msg}
def skill_scan(
self,
path: str = ".",
output_format: str = "json",
deep: bool = False,
repo: Optional[str] = None
) -> Union[Dict[str, Any], str]:
"""Scan SKILL.md packages and agent tools for security vulnerabilities.
Args:
path: Path to scan or server name
output_format: Output format ("json", "text", "html", "sarif")
deep: Enable deep scan with AI orchestrator analysis
repo: Repository URL to scan
Returns:
Scan results as dict (JSON format) or string (other formats)
"""
is_available, error = _check_inkog_available(self._get_api_key())
if not is_available:
logger.error(error)
return {"error": error}
try:
# Build command
cmd = ["inkog", "skill-scan"]
if deep:
cmd.append("-deep")
cmd.extend(["-output", output_format])
if repo:
cmd.extend(["-repo", repo])
else:
cmd.append(path)
# Set environment
env = os.environ.copy()
api_key = self._get_api_key()
if api_key:
env["INKOG_API_KEY"] = api_key
# Execute scan
result = subprocess.run(
cmd,
capture_output=True,
text=True,
env=env,
timeout=300
)
if result.returncode != 0:
error_msg = f"Inkog skill scan failed: {result.stderr or result.stdout}"
logger.error(error_msg)
return {"error": error_msg}
# Parse output based on format
if output_format == "json":
try:
return json.loads(result.stdout)
except json.JSONDecodeError as e:
return {"error": f"Failed to parse JSON output: {e}", "raw_output": result.stdout}
else:
return result.stdout
except subprocess.TimeoutExpired:
error_msg = "Inkog skill scan timed out after 5 minutes"
logger.error(error_msg)
return {"error": error_msg}
except Exception as e:
error_msg = f"Inkog skill scan error: {str(e)}"
logger.error(error_msg)
return {"error": error_msg}
def mcp_scan(
self,
server_name: str,
output_format: str = "json",
deep: bool = False,
repo: Optional[str] = None
) -> Union[Dict[str, Any], str]:
"""Scan MCP servers for security vulnerabilities.
Args:
server_name: MCP server name to scan
output_format: Output format ("json", "text", "html", "sarif")
deep: Enable deep scan with AI orchestrator analysis
repo: Repository URL for the MCP server source code
Returns:
Scan results as dict (JSON format) or string (other formats)
"""
is_available, error = _check_inkog_available(self._get_api_key())
if not is_available:
logger.error(error)
return {"error": error}
try:
# Build command
cmd = ["inkog", "mcp-scan"]
if deep:
cmd.append("-deep")
cmd.extend(["-output", output_format])
if repo:
cmd.extend(["-repo", repo])
cmd.append(server_name)
# Set environment
env = os.environ.copy()
api_key = self._get_api_key()
if api_key:
env["INKOG_API_KEY"] = api_key
# Execute scan
result = subprocess.run(
cmd,
capture_output=True,
text=True,
env=env,
timeout=300
)
if result.returncode != 0:
error_msg = f"Inkog MCP scan failed: {result.stderr or result.stdout}"
logger.error(error_msg)
return {"error": error_msg}
# Parse output based on format
if output_format == "json":
try:
return json.loads(result.stdout)
except json.JSONDecodeError as e:
return {"error": f"Failed to parse JSON output: {e}", "raw_output": result.stdout}
else:
return result.stdout
except subprocess.TimeoutExpired:
error_msg = "Inkog MCP scan timed out after 5 minutes"
logger.error(error_msg)
return {"error": error_msg}
except Exception as e:
error_msg = f"Inkog MCP scan error: {str(e)}"
logger.error(error_msg)
return {"error": error_msg}
def analyze_findings(self, scan_results: Union[Dict[str, Any], str]) -> str:
"""Analyze scan results and provide a summary.
Args:
scan_results: Results from a JSON format scan. Non-dict inputs
(e.g. text/HTML output) are returned as-is.
Returns:
Human-readable analysis summary
"""
try:
if not isinstance(scan_results, dict):
return str(scan_results)
if "error" in scan_results:
return f"Scan failed: {scan_results['error']}"
summary = scan_results.get("summary", {})
total_findings = summary.get("total_findings", 0)
if total_findings == 0:
return "✅ No security vulnerabilities found in the agent code."
# Build severity breakdown
critical = summary.get("critical", 0)
high = summary.get("high", 0)
medium = summary.get("medium", 0)
low = summary.get("low", 0)
analysis = "🔍 Security Analysis Results:\n"
analysis += f"Total Findings: {total_findings}\n\n"
if critical > 0:
analysis += f"🔴 CRITICAL: {critical} findings - Immediate attention required\n"
if high > 0:
analysis += f"🟠 HIGH: {high} findings - Should be addressed soon\n"
if medium > 0:
analysis += f"🟡 MEDIUM: {medium} findings - Review recommended\n"
if low > 0:
analysis += f"⚪ LOW: {low} findings - Minor issues\n"
# Add key finding types if available
findings = scan_results.get("server_findings", [])
if findings:
patterns = set()
for finding in findings[:5]: # Show top 5 patterns
pattern = finding.get("pattern", "Unknown")
patterns.add(pattern)
if patterns:
analysis += "\nKey Vulnerability Types Detected:\n"
for pattern in sorted(patterns):
analysis += f"• {pattern}\n"
# Risk recommendations
if critical > 0 or high > 0:
analysis += "\n⚠️ Recommendation: Address critical and high severity findings before deployment."
return analysis
except Exception as e:
return f"Error analyzing scan results: {e}"
# Standalone functions for direct import and use
def scan_agent_code(
path: str = ".",
output_format: str = "json",
policy: str = "balanced",
severity: str = "low",
deep: bool = False
) -> Union[Dict[str, Any], str]:
"""Scan AI agent code for security vulnerabilities using Inkog.
Args:
path: Path to scan (directory or file)
output_format: Output format ("json", "text", "html", "sarif")
policy: Security policy ("low-noise", "balanced", "comprehensive")
severity: Minimum severity level ("critical", "high", "medium", "low")
deep: Enable deep scan with AI orchestrator analysis
Returns:
Scan results as dict (JSON format) or string (other formats)
"""
tool = InkogTool()
return tool.scan_directory(
path=path,
output_format=output_format,
policy=policy,
severity=severity,
deep=deep
)
def scan_skill_package(
path: str = ".",
deep: bool = False,
repo: Optional[str] = None
) -> Dict[str, Any]:
"""Scan SKILL.md packages for security vulnerabilities.
Args:
path: Path to scan
deep: Enable deep scan with AI orchestrator analysis
repo: Repository URL to scan
Returns:
Scan results as dict
"""
tool = InkogTool()
return tool.skill_scan(path=path, deep=deep, repo=repo)
def scan_mcp_server(
server_name: str,
deep: bool = False,
repo: Optional[str] = None
) -> Dict[str, Any]:
"""Scan MCP server for security vulnerabilities.
Args:
server_name: MCP server name to scan
deep: Enable deep scan with AI orchestrator analysis
repo: Repository URL for the MCP server
Returns:
Scan results as dict
"""
tool = InkogTool()
return tool.mcp_scan(server_name=server_name, deep=deep, repo=repo)
# Example usage and testing
if __name__ == "__main__":
print("\n" + "="*60)
print("Inkog Security Analysis Tool Demonstration")
print("="*60 + "\n")
# Check if inkog is available
is_available, error = _check_inkog_available()
if not is_available:
print(f"❌ Error: {error}")
print("\nTo use Inkog tools:")
print("1. Install CLI: brew tap inkog-io/inkog && brew install inkog")
print("2. Get API key: https://app.inkog.io")
print("3. Set environment: export INKOG_API_KEY=your_key_here")
else:
print("✅ Inkog is available and configured!")
# Example usage with current directory
try:
print("\nScanning current directory for security vulnerabilities...")
result = scan_agent_code(".", output_format="json", policy="balanced")
if isinstance(result, dict) and "error" not in result:
tool = InkogTool()
analysis = tool.analyze_findings(result)
print(analysis)
else:
print("Scan completed:", result)
except Exception as e:
print(f"Demo error: {e}")
+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)
+259
View File
@@ -0,0 +1,259 @@
"""SwarmScore integration tool for PraisonAI.
SwarmScore is a portable trust rating system for AI agents based on verified execution history.
This tool allows agents to:
- Load their SwarmScore rating and certificate
- Verify SwarmScore freshness
- Access discovery manifest for agent-to-agent lookup
More information: https://swarmsync.ai/docs/protocol-specs/swarmscore
"""
import json
import logging
from typing import Dict, Any
from urllib.parse import urlparse, quote
try:
import requests
except ImportError:
requests = None
from .base import BaseTool, ToolResult
logger = logging.getLogger(__name__)
class SwarmScoreTool(BaseTool):
"""Tool for integrating with SwarmScore trust ratings."""
name = "swarmscore"
description = "Load and verify SwarmScore trust ratings for AI agents"
def __init__(self, api_base_url: str = "https://api.swarmsync.ai/v1/swarmscore/"):
"""Initialize SwarmScore tool.
Args:
api_base_url: Base URL for SwarmScore API
"""
if requests is None:
raise ImportError("requests is required for SwarmScoreTool. Install with: pip install requests")
super().__init__()
self.api_base_url = api_base_url.rstrip('/')
def load_swarmscore(self, slug: str) -> ToolResult:
"""Load SwarmScore data by agent slug.
Args:
slug: Agent slug identifier
Returns:
ToolResult containing SwarmScore data including passport, certificate, and verification payload
"""
try:
url = f"{self.api_base_url}/load-by-slug/{quote(slug, safe='')}"
response = requests.get(url, timeout=30)
response.raise_for_status()
data = response.json()
return ToolResult(
output=data,
success=True,
metadata={"message": f"Successfully loaded SwarmScore for agent '{slug}'"}
)
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON response for SwarmScore {slug}: {e}")
return ToolResult(
output=None,
success=False,
error="Invalid response format from SwarmScore API"
)
except requests.exceptions.RequestException as e:
logger.error(f"Failed to load SwarmScore for {slug}: {e}")
return ToolResult(
output=None,
success=False,
error=f"Failed to load SwarmScore: {str(e)}"
)
def verify_swarmscore(self, verify_payload: Dict[str, Any]) -> ToolResult:
"""Verify SwarmScore freshness using verification payload.
Args:
verify_payload: Verification payload from load_swarmscore response
Returns:
ToolResult containing verification status
"""
try:
url = f"{self.api_base_url}/verify"
response = requests.post(
url,
json=verify_payload,
headers={"Content-Type": "application/json"},
timeout=30
)
response.raise_for_status()
data = response.json()
return ToolResult(
output=data,
success=True,
metadata={"message": "SwarmScore verification completed"}
)
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON response for SwarmScore verification: {e}")
return ToolResult(
output=None,
success=False,
error="Invalid response format from verification API"
)
except requests.exceptions.RequestException as e:
logger.error(f"Failed to verify SwarmScore: {e}")
return ToolResult(
output=None,
success=False,
error=f"Failed to verify SwarmScore: {str(e)}"
)
def get_discovery_manifest(self) -> ToolResult:
"""Get machine-readable agent discovery manifest.
Returns:
ToolResult containing discovery manifest data
"""
try:
parsed = urlparse(self.api_base_url)
base = f"{parsed.scheme}://{parsed.netloc}"
url = f"{base}/.well-known/agent-card.json"
response = requests.get(url, timeout=30)
response.raise_for_status()
data = response.json()
return ToolResult(
output=data,
success=True,
metadata={"message": "Retrieved SwarmScore discovery manifest"}
)
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON in discovery manifest: {e}")
return ToolResult(
output=None,
success=False,
error="Invalid discovery manifest format"
)
except requests.exceptions.RequestException as e:
logger.error(f"Failed to get discovery manifest: {e}")
return ToolResult(
output=None,
success=False,
error=f"Failed to get discovery manifest: {str(e)}"
)
def run(self, action: str = "load", **kwargs) -> ToolResult:
"""Execute SwarmScore action.
Args:
action: Action to perform ('load', 'verify', or 'discover')
**kwargs: Action-specific parameters
Returns:
ToolResult with action results
"""
if action == "load":
slug = kwargs.get("slug")
if not slug:
return ToolResult(
output=None,
success=False,
error="Missing required parameter 'slug' for load action"
)
return self.load_swarmscore(slug)
elif action == "verify":
verify_payload = kwargs.get("verify_payload")
if not verify_payload:
return ToolResult(
output=None,
success=False,
error="Missing required parameter 'verify_payload' for verify action"
)
return self.verify_swarmscore(verify_payload)
elif action == "discover":
return self.get_discovery_manifest()
else:
return ToolResult(
output=None,
success=False,
error=f"Unknown action '{action}'. Supported actions: load, verify, discover"
)
# Standalone functions for easy agent integration
def load_swarmscore_by_slug(slug: str) -> Dict[str, Any]:
"""Load SwarmScore data by agent slug.
Args:
slug: Agent slug identifier
Returns:
Dictionary containing SwarmScore data
Raises:
Exception: If loading fails
"""
tool = SwarmScoreTool()
result = tool.load_swarmscore(slug)
if not result.success:
raise Exception(result.error)
return result.output
def verify_swarmscore_freshness(verify_payload: Dict[str, Any]) -> Dict[str, Any]:
"""Verify SwarmScore freshness.
Args:
verify_payload: Verification payload from load response
Returns:
Dictionary containing verification results
Raises:
Exception: If verification fails
"""
tool = SwarmScoreTool()
result = tool.verify_swarmscore(verify_payload)
if not result.success:
raise Exception(result.error)
return result.output
def get_agent_discovery_manifest() -> Dict[str, Any]:
"""Get machine-readable agent discovery manifest.
Returns:
Dictionary containing discovery manifest
Raises:
Exception: If retrieval fails
"""
tool = SwarmScoreTool()
result = tool.get_discovery_manifest()
if not result.success:
raise Exception(result.error)
return result.output
@@ -35,6 +35,11 @@ def motion_graphics_team(
- Researcher: Optional web search for content research
- CodeExplorer: Optional git repository exploration
- Animator: HTML/GSAP composition authoring and rendering
The team runs with ``process="hierarchical"``: the ``manager_llm``
orchestrates the worker agents at runtime rather than exposing a static
``leader`` reference. The coordinator is the semantic leader and is always
the first agent in ``team.agents``.
Args:
research: Include research specialist
@@ -46,6 +51,12 @@ def motion_graphics_team(
Returns:
AgentTeam configured for motion graphics creation
Note:
The team uses ``process="hierarchical"`` where the ``manager_llm``
orchestrates the worker agents at runtime. The coordinator is the
semantic leader and is always the first agent (``team.agents[0]``);
there is no static ``team.leader`` reference in this mode.
"""
if AgentTeam is None:
raise ImportError(
+24 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "praisonai-tools"
version = "0.2.31"
version = "0.2.37"
description = "Extended tools for PraisonAI Agents"
authors = [
{name = "Mervin Praison"}
@@ -27,11 +27,21 @@ dependencies = [
[project.scripts]
praisonai-tools = "praisonai_tools.cli:main"
# Discovery contract for third-party tool packages. They register agent-callable
# tools under the ``praisonai.tools`` entry-point group so that
# ``pip install some-praisonai-tool`` makes them discoverable via
# ``praisonai_tools.list_tools()`` without editing core, e.g.:
#
# [project.entry-points."praisonai.tools"]
# my_tool = "my_pkg:MyTool"
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"playwright>=1.40",
"imageio-ffmpeg>=0.5",
]
wordpress = [
"praisonaiwp[ai]>=0.1.0",
@@ -54,9 +64,15 @@ agentfolio = [
n8n = [
"httpx>=0.27.0",
]
registry-proxy = [
"httpx>=0.24.0",
]
langextract = [
"langextract>=0.1.0",
]
swarmscore = [
"requests>=2.28.0",
]
video-motion = [
"playwright>=1.40",
"imageio-ffmpeg>=0.5",
@@ -80,4 +96,10 @@ packages = ["praisonai_tools"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
asyncio_mode = "auto"
[tool.ruff.lint]
# Pin the rule set to the historical default (Pyflakes + a subset of
# pycodestyle) so an unpinned ruff upgrade does not spuriously fail CI by
# enabling thousands of new default rules across the pre-existing codebase.
select = ["E4", "E7", "E9", "F"]
+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",
]
+14 -6
View File
@@ -164,8 +164,11 @@ class TestMotionGraphicsTeam:
custom_param="test"
)
# Check that custom parameters are passed through
assert team.kwargs == {"custom_param": "test"}
# Hierarchical teams forward process + manager_llm alongside
# any user-supplied kwargs, so assert individual entries.
assert team.kwargs["custom_param"] == "test"
assert team.kwargs["process"] == "hierarchical"
assert team.kwargs["manager_llm"] == "gpt-4"
# Check that workspace is set
assert team._motion_graphics_workspace == Path(tmpdir)
@@ -209,15 +212,20 @@ class TestMotionGraphicsTeam:
@patch('praisonai_tools.video.motion_graphics.team.search_web', MockSearch())
@patch('praisonai_tools.video.motion_graphics.team.create_motion_graphics_agent')
def test_coordinator_is_leader(self, mock_create_agent):
"""Test that coordinator is set as team leader."""
"""Test that coordinator is the semantic leader (first agent).
In hierarchical process mode the manager LLM orchestrates workers,
so the coordinator is the first agent rather than a static
``team.leader`` reference.
"""
mock_animator = MockAgent(name="animator")
mock_create_agent.return_value = mock_animator
with tempfile.TemporaryDirectory() as tmpdir:
team = motion_graphics_team(workspace=tmpdir)
assert team.leader is not None
assert team.leader.name == "coordinator"
coordinator = team.agents[0]
assert coordinator.name == "coordinator"
@patch('praisonai_tools.video.motion_graphics.team.AgentTeam', MockAgentTeam)
@patch('praisonai_tools.video.motion_graphics.team.Agent', MockAgent)
@@ -278,7 +286,7 @@ class TestMotionGraphicsTeam:
with tempfile.TemporaryDirectory() as tmpdir:
team = motion_graphics_team(workspace=tmpdir)
coordinator = team.leader
coordinator = team.agents[0]
instructions = coordinator.instructions.lower()
assert "coordinator" in instructions
+79
View File
@@ -0,0 +1,79 @@
"""Tests for the tool discovery / catalogue contract."""
def test_list_tools_importable_from_top_level():
from praisonai_tools import list_tools, ToolCatalogueEntry, get_tool_names
assert callable(list_tools)
assert callable(get_tool_names)
assert ToolCatalogueEntry is not None
def test_list_tools_returns_entries():
from praisonai_tools import list_tools
from praisonai_tools.catalogue import ToolCatalogueEntry
entries = list_tools()
assert isinstance(entries, list)
assert len(entries) > 100 # ships a large catalogue
assert all(isinstance(e, ToolCatalogueEntry) for e in entries)
def test_entries_only_expose_tool_classes():
from praisonai_tools import list_tools
for entry in list_tools():
assert entry.name.endswith("Tool"), entry.name
def test_entries_are_sorted_and_unique():
from praisonai_tools import list_tools
names = [e.name for e in list_tools()]
assert names == sorted(names)
assert len(names) == len(set(names))
def test_catalogue_derived_from_tool_map_source_of_truth():
from praisonai_tools import list_tools
from praisonai_tools.tools import _TOOL_MAP
expected = {name for name in _TOOL_MAP if name.endswith("Tool")}
actual = {e.name for e in list_tools()}
# Every class in the map must be catalogued (third-party EPs may add more).
assert expected <= actual
def test_previously_missing_optional_tool_is_now_discoverable():
# QdrantTool was a valid tool absent from the wrapper's hand-maintained
# allowlist; it must now be discoverable via the catalogue.
from praisonai_tools import get_tool_names
names = get_tool_names()
assert "QdrantTool" in names
def test_extras_are_populated_for_optional_tools():
from praisonai_tools import list_tools
by_name = {e.name for e in list_tools()}
entries = {e.name: e for e in list_tools()}
assert "SwarmScoreTool" in by_name
assert "swarmscore" in entries["SwarmScoreTool"].extras
def test_entries_have_summary_and_module():
from praisonai_tools import list_tools
for entry in list_tools():
assert entry.module
assert entry.summary
def test_list_tools_does_not_require_optional_deps():
# Building the catalogue must not import any tool module / optional dep.
from praisonai_tools import list_tools
entries = list_tools()
assert entries # smoke: no ImportError raised
+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
+305
View File
@@ -0,0 +1,305 @@
"""Tests for Inkog Security Analysis Tool."""
from unittest.mock import patch, MagicMock
from praisonai_tools.tools.inkog_tool import InkogTool, _check_inkog_available, scan_agent_code
class TestInkogTool:
"""Test suite for InkogTool."""
def test_init(self):
"""Test InkogTool initialization."""
# Test default initialization
tool = InkogTool()
assert tool.name == "inkog_security"
assert "security" in tool.description.lower()
# Test with custom parameters
tool = InkogTool(api_key="test_key", server_url="http://test.com")
assert tool._api_key == "test_key"
assert tool._server_url == "http://test.com"
@patch.dict("os.environ", {"INKOG_API_KEY": "test_env_key"})
def test_get_api_key(self):
"""Test API key retrieval."""
tool = InkogTool()
assert tool._get_api_key() == "test_env_key"
# Instance key takes precedence
tool = InkogTool(api_key="instance_key")
assert tool._get_api_key() == "instance_key"
@patch.dict("os.environ", {"INKOG_SERVER_URL": "http://env.com"})
def test_get_server_url(self):
"""Test server URL retrieval."""
tool = InkogTool()
assert tool._get_server_url() == "http://env.com"
# Instance URL takes precedence
tool = InkogTool(server_url="http://instance.com")
assert tool._get_server_url() == "http://instance.com"
def test_analyze_findings_empty(self):
"""Test analyze_findings with empty results."""
tool = InkogTool()
# Test with error
error_results = {"error": "test error"}
analysis = tool.analyze_findings(error_results)
assert "Scan failed" in analysis
assert "test error" in analysis
# Test with no findings
empty_results = {"summary": {"total_findings": 0}}
analysis = tool.analyze_findings(empty_results)
assert "No security vulnerabilities found" in analysis
def test_analyze_findings_with_data(self):
"""Test analyze_findings with security findings."""
tool = InkogTool()
results = {
"summary": {
"total_findings": 5,
"critical": 2,
"high": 2,
"medium": 1,
"low": 0
},
"server_findings": [
{"pattern": "Token Bombing"},
{"pattern": "Prompt Injection"},
{"pattern": "Recursive Tool Calling"}
]
}
analysis = tool.analyze_findings(results)
assert "Total Findings: 5" in analysis
assert "CRITICAL: 2 findings" in analysis
assert "HIGH: 2 findings" in analysis
assert "MEDIUM: 1 findings" in analysis
assert "Token Bombing" in analysis
assert "Immediate attention required" in analysis
def test_analyze_findings_string_input(self):
"""String (text/HTML) scan output is returned as-is, even when it
contains the word 'error' (regression: no TypeError)."""
tool = InkogTool()
text_output = "Finding: potential error in tool loop termination."
analysis = tool.analyze_findings(text_output)
assert analysis == text_output
class TestInkogAvailability:
"""Test inkog availability checking."""
@patch("subprocess.run")
def test_check_inkog_available_success(self, mock_run):
"""Test successful inkog availability check."""
# Mock successful version check
mock_run.return_value = MagicMock(returncode=0)
with patch.dict("os.environ", {"INKOG_API_KEY": "test_key"}):
is_available, error = _check_inkog_available()
assert is_available is True
assert error is None
@patch("subprocess.run")
def test_check_inkog_not_installed(self, mock_run):
"""Test inkog not installed."""
mock_run.side_effect = FileNotFoundError()
is_available, error = _check_inkog_available()
assert is_available is False
assert "not installed" in error.lower()
@patch("subprocess.run")
def test_check_inkog_no_api_key(self, mock_run):
"""Test inkog available but no API key."""
mock_run.return_value = MagicMock(returncode=0)
with patch.dict("os.environ", {}, clear=True):
is_available, error = _check_inkog_available()
assert is_available is False
assert "INKOG_API_KEY" in error
@patch("subprocess.run")
def test_check_inkog_available_with_instance_api_key(self, mock_run):
"""Instance-provided api_key satisfies the availability check."""
mock_run.return_value = MagicMock(returncode=0)
with patch.dict("os.environ", {}, clear=True):
is_available, error = _check_inkog_available(api_key="instance_key")
assert is_available is True
assert error is None
class TestInkogScanOperations:
"""Test inkog scan operations."""
@patch("praisonai_tools.tools.inkog_tool._check_inkog_available")
@patch("subprocess.run")
@patch.dict("os.environ", {"INKOG_API_KEY": "test_key"})
def test_scan_directory_json_success(self, mock_run, mock_check):
"""Test successful directory scan with JSON output."""
mock_check.return_value = (True, None)
mock_run.return_value = MagicMock(
returncode=0,
stdout='{"summary": {"total_findings": 0}}'
)
tool = InkogTool()
result = tool.scan_directory(path="./test", output_format="json")
assert isinstance(result, dict)
assert "summary" in result
assert result["summary"]["total_findings"] == 0
@patch("praisonai_tools.tools.inkog_tool._check_inkog_available")
@patch("subprocess.run")
@patch.dict("os.environ", {"INKOG_API_KEY": "test_key"})
def test_scan_directory_text_success(self, mock_run, mock_check):
"""Test successful directory scan with text output."""
mock_check.return_value = (True, None)
mock_run.return_value = MagicMock(
returncode=0,
stdout="No vulnerabilities found."
)
tool = InkogTool()
result = tool.scan_directory(path="./test", output_format="text")
assert isinstance(result, str)
assert "vulnerabilities" in result.lower()
@patch("praisonai_tools.tools.inkog_tool._check_inkog_available")
def test_scan_directory_not_available(self, mock_check):
"""Test scan when inkog is not available."""
mock_check.return_value = (False, "inkog not installed")
tool = InkogTool()
result = tool.scan_directory()
assert isinstance(result, dict)
assert "error" in result
assert "not installed" in result["error"]
@patch("praisonai_tools.tools.inkog_tool._check_inkog_available")
@patch("subprocess.run")
def test_scan_directory_command_failure(self, mock_run, mock_check):
"""Test scan when inkog command fails."""
mock_check.return_value = (True, None)
mock_run.return_value = MagicMock(
returncode=1,
stderr="Command failed",
stdout=""
)
tool = InkogTool()
result = tool.scan_directory()
assert isinstance(result, dict)
assert "error" in result
assert "failed" in result["error"].lower()
@patch("praisonai_tools.tools.inkog_tool._check_inkog_available")
@patch("subprocess.run")
@patch.dict("os.environ", {"INKOG_API_KEY": "test_key"})
def test_skill_scan_success(self, mock_run, mock_check):
"""Test successful skill package scan."""
mock_check.return_value = (True, None)
mock_run.return_value = MagicMock(
returncode=0,
stdout='{"scan_type": "skill", "findings": []}'
)
tool = InkogTool()
result = tool.skill_scan(path="./skill", deep=True)
assert isinstance(result, dict)
assert "scan_type" in result or "findings" in result
@patch("praisonai_tools.tools.inkog_tool._check_inkog_available")
@patch("subprocess.run")
@patch.dict("os.environ", {"INKOG_API_KEY": "test_key"})
def test_mcp_scan_success(self, mock_run, mock_check):
"""Test successful MCP server scan."""
mock_check.return_value = (True, None)
mock_run.return_value = MagicMock(
returncode=0,
stdout='{"mcp_server": "github", "vulnerabilities": []}'
)
tool = InkogTool()
result = tool.mcp_scan("github", deep=True)
assert isinstance(result, dict)
# Should have some scan results structure
def test_run_method_routing(self):
"""Test the run method routes to correct functions."""
tool = InkogTool()
with patch.object(tool, 'scan_directory') as mock_scan:
mock_scan.return_value = {"test": "result"}
tool.run(action="scan", path="./test")
mock_scan.assert_called_once_with(
path="./test",
output_format="json",
policy="balanced",
severity="low",
deep=False
)
with patch.object(tool, 'skill_scan') as mock_skill:
mock_skill.return_value = {"test": "result"}
tool.run(action="skill-scan", path="./test")
mock_skill.assert_called_once_with(
path="./test",
output_format="json",
deep=False,
repo=None
)
class TestStandaloneFunctions:
"""Test standalone function interfaces."""
@patch("praisonai_tools.tools.inkog_tool.InkogTool")
def test_scan_agent_code(self, mock_tool_class):
"""Test scan_agent_code standalone function."""
mock_tool = MagicMock()
mock_tool.scan_directory.return_value = {"result": "test"}
mock_tool_class.return_value = mock_tool
scan_agent_code("./test", deep=True)
mock_tool_class.assert_called_once()
mock_tool.scan_directory.assert_called_once_with(
path="./test",
output_format="json",
policy="balanced",
severity="low",
deep=True
)
if __name__ == "__main__":
# Run basic smoke test
print("Running Inkog Tool smoke tests...")
# Test basic initialization
tool = InkogTool()
print(f"✓ Tool name: {tool.name}")
print(f"✓ Tool description contains 'security': {'security' in tool.description.lower()}")
# Test availability check (will likely fail without actual installation)
is_available, error = _check_inkog_available()
if is_available:
print("✓ Inkog is available and configured")
else:
print(f"⚠ Inkog not available: {error}")
print("This is expected if inkog CLI is not installed")
print("Smoke tests completed!")
+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')
+901
View File
@@ -0,0 +1,901 @@
"""Unit tests for the tool-registry proxy connector."""
import os
import pytest
from unittest.mock import Mock, patch, MagicMock
@pytest.fixture(autouse=True)
def reset_session_spend():
"""Isolate cumulative-spend state between tests."""
from praisonai_tools.registry_proxy import registry_proxy as mod
mod._SESSION_SPEND.clear()
yield
mod._SESSION_SPEND.clear()
@pytest.fixture
def mock_httpx():
"""Mock httpx module with proper exception classes."""
mock_module = MagicMock()
mock_module.TimeoutException = type("TimeoutException", (Exception,), {})
class MockHTTPStatusError(Exception):
def __init__(self, message, request=None, response=None):
super().__init__(message)
self.request = request
self.response = response
mock_module.HTTPStatusError = MockHTTPStatusError
with patch.dict("sys.modules", {"httpx": mock_module}):
yield mock_module
class TestConfiguration:
def test_disabled_when_unconfigured(self):
from praisonai_tools.registry_proxy import RegistryProxyTool
with patch.dict(os.environ, {}, clear=True):
tool = RegistryProxyTool()
result = tool.search("backlinks for a domain")
assert "not configured" in result["error"]
def test_reads_env_vars(self):
from praisonai_tools.registry_proxy import RegistryProxyTool
with patch.dict(os.environ, {
"TOOL_PROXY_URL": "https://registry.example.com/",
"TOOL_PROXY_TOKEN": "secret-token",
}):
tool = RegistryProxyTool()
assert tool.proxy_url == "https://registry.example.com"
assert tool.token == "secret-token"
assert tool._headers()["Authorization"] == "Bearer secret-token"
def test_explicit_args_override_env(self):
from praisonai_tools.registry_proxy import RegistryProxyTool
tool = RegistryProxyTool(proxy_url="https://self.host/", token="tok")
assert tool.proxy_url == "https://self.host"
assert tool.token == "tok"
def test_env_token_not_leaked_to_supplied_url(self):
"""Security: an agent-supplied proxy_url must not receive the env token."""
from praisonai_tools.registry_proxy import RegistryProxyTool
with patch.dict(os.environ, {
"TOOL_PROXY_URL": "https://trusted.example.com",
"TOOL_PROXY_TOKEN": "secret-token",
}):
tool = RegistryProxyTool(proxy_url="https://attacker.example.com")
assert tool.proxy_url == "https://attacker.example.com"
assert tool.token is None
assert "Authorization" not in tool._headers()
def test_env_token_used_for_env_url(self):
from praisonai_tools.registry_proxy import RegistryProxyTool
with patch.dict(os.environ, {
"TOOL_PROXY_URL": "https://trusted.example.com",
"TOOL_PROXY_TOKEN": "secret-token",
}):
tool = RegistryProxyTool()
assert tool._headers()["Authorization"] == "Bearer secret-token"
class TestAuthHeader:
def test_auth_header_configurable(self):
"""Default sends Bearer; a custom header name sends the raw token."""
from praisonai_tools.registry_proxy import RegistryProxyTool
default = RegistryProxyTool(proxy_url="https://r.example.com", token="tok")
assert default._headers()["Authorization"] == "Bearer tok"
assert "X-Treg-Token" not in default._headers()
custom = RegistryProxyTool(
proxy_url="https://r.example.com", token="tok", auth_header="X-Treg-Token"
)
headers = custom._headers()
assert headers["X-Treg-Token"] == "tok"
assert "Authorization" not in headers
def test_auth_header_from_env(self):
from praisonai_tools.registry_proxy import RegistryProxyTool
with patch.dict(os.environ, {
"TOOL_PROXY_URL": "https://trusted.example.com",
"TOOL_PROXY_TOKEN": "secret-token",
"TOOL_PROXY_AUTH_HEADER": "X-Treg-Token",
}):
tool = RegistryProxyTool()
headers = tool._headers()
assert headers["X-Treg-Token"] == "secret-token"
assert "Authorization" not in headers
class TestSearch:
def test_search_success(self, mock_httpx):
from praisonai_tools.registry_proxy import RegistryProxyTool
response = Mock()
response.json.return_value = {"matches": [{"id": "seo.backlinks"}]}
response.raise_for_status.return_value = None
client = Mock()
client.get.return_value = response
mock_httpx.Client.return_value.__enter__.return_value = client
tool = RegistryProxyTool(proxy_url="https://r.example.com", token="t")
result = tool.search("backlinks for a domain")
assert result["matches"][0]["id"] == "seo.backlinks"
client.get.assert_called_once_with(
"https://r.example.com/catalog/search",
params={"q": "backlinks for a domain"},
headers={"Content-Type": "application/json", "Authorization": "Bearer t"},
)
def test_search_requires_query(self):
from praisonai_tools.registry_proxy import RegistryProxyTool
tool = RegistryProxyTool(proxy_url="https://r.example.com")
assert tool.search("")["error"] == "query is required"
class TestDescribe:
def test_describe_success(self, mock_httpx):
from praisonai_tools.registry_proxy import RegistryProxyTool
response = Mock()
response.json.return_value = {"id": "seo.backlinks", "price_per_call": 0.01}
response.raise_for_status.return_value = None
client = Mock()
client.get.return_value = response
mock_httpx.Client.return_value.__enter__.return_value = client
tool = RegistryProxyTool(proxy_url="https://r.example.com")
result = tool.describe("seo.backlinks")
assert result["price_per_call"] == 0.01
client.get.assert_called_once_with(
"https://r.example.com/catalog/endpoints/seo.backlinks",
headers={"Content-Type": "application/json"},
)
def test_describe_uses_endpoints_path(self, mock_httpx):
"""The real registry route is /catalog/endpoints/{id}, not /catalog/{id}."""
from praisonai_tools.registry_proxy import RegistryProxyTool
response = Mock()
response.json.return_value = {"id": "diffbot.people.enrich", "method": "GET"}
response.raise_for_status.return_value = None
client = Mock()
client.get.return_value = response
mock_httpx.Client.return_value.__enter__.return_value = client
tool = RegistryProxyTool(proxy_url="https://r.example.com", token="t")
tool.describe("diffbot.people.enrich")
client.get.assert_called_once_with(
"https://r.example.com/catalog/endpoints/diffbot.people.enrich",
headers={"Content-Type": "application/json", "Authorization": "Bearer t"},
)
def test_describe_path_configurable(self, mock_httpx):
from praisonai_tools.registry_proxy import RegistryProxyTool
response = Mock()
response.json.return_value = {"id": "seo.backlinks"}
response.raise_for_status.return_value = None
client = Mock()
client.get.return_value = response
mock_httpx.Client.return_value.__enter__.return_value = client
tool = RegistryProxyTool(
proxy_url="https://r.example.com",
describe_path="/catalog/{tool_id}",
)
tool.describe("seo.backlinks")
client.get.assert_called_once_with(
"https://r.example.com/catalog/seo.backlinks",
headers={"Content-Type": "application/json"},
)
def test_describe_path_from_env(self, mock_httpx):
from praisonai_tools.registry_proxy import RegistryProxyTool
response = Mock()
response.json.return_value = {"id": "seo.backlinks"}
response.raise_for_status.return_value = None
client = Mock()
client.get.return_value = response
mock_httpx.Client.return_value.__enter__.return_value = client
with patch.dict(os.environ, {
"TOOL_PROXY_URL": "https://r.example.com",
"TOOL_PROXY_DESCRIBE_PATH": "/v2/endpoints/{tool_id}",
}):
tool = RegistryProxyTool()
tool.describe("seo.backlinks")
client.get.assert_called_once_with(
"https://r.example.com/v2/endpoints/seo.backlinks",
headers={"Content-Type": "application/json"},
)
def test_describe_path_missing_placeholder_errors(self, mock_httpx):
"""A template lacking {tool_id} must return an error, not silently misroute."""
from praisonai_tools.registry_proxy import RegistryProxyTool
client = Mock()
mock_httpx.Client.return_value.__enter__.return_value = client
tool = RegistryProxyTool(
proxy_url="https://r.example.com",
describe_path="/catalog/static",
)
result = tool.describe("seo.backlinks")
assert "must contain '{tool_id}'" in result["error"]
client.get.assert_not_called()
def test_describe_path_malformed_template_errors(self, mock_httpx):
"""A malformed template must return an error instead of raising."""
from praisonai_tools.registry_proxy import RegistryProxyTool
client = Mock()
mock_httpx.Client.return_value.__enter__.return_value = client
tool = RegistryProxyTool(
proxy_url="https://r.example.com",
describe_path="/catalog/{tool_id}/{unknown}",
)
result = tool.describe("seo.backlinks")
assert "Invalid describe path template" in result["error"]
client.get.assert_not_called()
def test_describe_encodes_tool_id(self, mock_httpx):
"""A tool_id with path-changing characters is URL-encoded."""
from praisonai_tools.registry_proxy import RegistryProxyTool
response = Mock()
response.json.return_value = {"id": "x"}
response.raise_for_status.return_value = None
client = Mock()
client.get.return_value = response
mock_httpx.Client.return_value.__enter__.return_value = client
tool = RegistryProxyTool(proxy_url="https://r.example.com")
tool.describe("../admin?x=1")
client.get.assert_called_once_with(
"https://r.example.com/catalog/endpoints/..%2Fadmin%3Fx%3D1",
headers={"Content-Type": "application/json"},
)
class TestCall:
def test_call_success(self, mock_httpx):
"""Explicit method=POST skips describe and posts a JSON body."""
from praisonai_tools.registry_proxy import RegistryProxyTool
response = Mock()
response.json.return_value = {"data": {"backlinks": 42}}
response.raise_for_status.return_value = None
client = Mock()
client.request.return_value = response
mock_httpx.Client.return_value.__enter__.return_value = client
tool = RegistryProxyTool(proxy_url="https://r.example.com", token="t")
result = tool.call("seo.backlinks", {"domain": "example.com"}, method="POST")
assert result["data"]["backlinks"] == 42
client.request.assert_called_once_with(
"POST",
"https://r.example.com/call/seo.backlinks",
json={"domain": "example.com"},
headers={"Content-Type": "application/json", "Authorization": "Bearer t"},
)
client.get.assert_not_called()
def test_call_requires_tool_id(self):
from praisonai_tools.registry_proxy import RegistryProxyTool
tool = RegistryProxyTool(proxy_url="https://r.example.com")
assert tool.call("")["error"] == "tool_id is required"
def test_call_normalizes_explicit_method_whitespace(self, mock_httpx):
"""An explicit method with whitespace/casing is normalized (GET -> query)."""
from praisonai_tools.registry_proxy import RegistryProxyTool
response = Mock()
response.json.return_value = {"data": {"ok": True}}
response.raise_for_status.return_value = None
client = Mock()
client.get.return_value = response
mock_httpx.Client.return_value.__enter__.return_value = client
tool = RegistryProxyTool(proxy_url="https://r.example.com")
result = tool.call("t.id", {"q": "x"}, method=" get ")
assert result["data"]["ok"] is True
client.get.assert_called_once_with(
"https://r.example.com/call/t.id",
params={"q": "x"},
headers={"Content-Type": "application/json"},
)
client.request.assert_not_called()
def test_call_rejects_unsupported_method(self, mock_httpx):
"""An unsupported/mislabelled method is rejected before any request."""
from praisonai_tools.registry_proxy import RegistryProxyTool
client = Mock()
mock_httpx.Client.return_value.__enter__.return_value = client
tool = RegistryProxyTool(proxy_url="https://r.example.com")
result = tool.call("t.id", {}, method="TRACE")
assert "Unsupported HTTP method 'TRACE'" in result["error"]
client.get.assert_not_called()
client.request.assert_not_called()
def test_call_encodes_tool_id_in_url(self, mock_httpx):
"""A tool_id with path-changing characters is URL-encoded on /call."""
from praisonai_tools.registry_proxy import RegistryProxyTool
response = Mock()
response.json.return_value = {"data": "ok"}
response.raise_for_status.return_value = None
client = Mock()
client.request.return_value = response
mock_httpx.Client.return_value.__enter__.return_value = client
tool = RegistryProxyTool(proxy_url="https://r.example.com")
tool.call("../admin", {}, method="POST")
client.request.assert_called_once_with(
"POST",
"https://r.example.com/call/..%2Fadmin",
json={},
headers={"Content-Type": "application/json"},
)
def test_call_derives_post_method_from_describe(self, mock_httpx):
from praisonai_tools.registry_proxy import RegistryProxyTool
describe_resp = Mock()
describe_resp.json.return_value = {"id": "seo.backlinks", "method": "POST"}
describe_resp.raise_for_status.return_value = None
call_resp = Mock()
call_resp.json.return_value = {"data": {"backlinks": 42}}
call_resp.raise_for_status.return_value = None
client = Mock()
client.get.return_value = describe_resp
client.request.return_value = call_resp
mock_httpx.Client.return_value.__enter__.return_value = client
tool = RegistryProxyTool(proxy_url="https://r.example.com", token="t")
result = tool.call("seo.backlinks", {"domain": "example.com"})
assert result["data"]["backlinks"] == 42
client.request.assert_called_once_with(
"POST",
"https://r.example.com/call/seo.backlinks",
json={"domain": "example.com"},
headers={"Content-Type": "application/json", "Authorization": "Bearer t"},
)
def test_call_uses_declared_method(self, mock_httpx):
"""describe says GET -> GET with query params (no JSON body)."""
from praisonai_tools.registry_proxy import RegistryProxyTool
describe_resp = Mock()
describe_resp.json.return_value = {
"id": "diffbot.people.enrich", "method": "GET"
}
describe_resp.raise_for_status.return_value = None
call_resp = Mock()
call_resp.json.return_value = {"data": {"name": "Jane"}}
call_resp.raise_for_status.return_value = None
client = Mock()
client.get.side_effect = [describe_resp, call_resp]
mock_httpx.Client.return_value.__enter__.return_value = client
tool = RegistryProxyTool(proxy_url="https://r.example.com", token="t")
result = tool.call("diffbot.people.enrich", {"query": "type=email"})
assert result["data"]["name"] == "Jane"
client.request.assert_not_called()
# First get = describe, second get = the GET call with query params.
assert client.get.call_count == 2
call_args = client.get.call_args_list[1]
assert call_args.args[0] == "https://r.example.com/call/diffbot.people.enrich"
assert call_args.kwargs["params"] == {"query": "type=email"}
def test_call_explicit_get_skips_describe(self, mock_httpx):
from praisonai_tools.registry_proxy import RegistryProxyTool
call_resp = Mock()
call_resp.json.return_value = {"data": "ok"}
call_resp.raise_for_status.return_value = None
client = Mock()
client.get.return_value = call_resp
mock_httpx.Client.return_value.__enter__.return_value = client
tool = RegistryProxyTool(proxy_url="https://r.example.com", token="t")
result = tool.call("diffbot.people.enrich", {"q": "x"}, method="GET")
assert result["data"] == "ok"
client.get.assert_called_once_with(
"https://r.example.com/call/diffbot.people.enrich",
params={"q": "x"},
headers={"Content-Type": "application/json", "Authorization": "Bearer t"},
)
def test_call_defaults_to_post_when_method_absent(self, mock_httpx):
"""describe omits method -> default to POST with JSON body."""
from praisonai_tools.registry_proxy import RegistryProxyTool
describe_resp = Mock()
describe_resp.json.return_value = {"id": "seo.backlinks"}
describe_resp.raise_for_status.return_value = None
call_resp = Mock()
call_resp.json.return_value = {"data": "ok"}
call_resp.raise_for_status.return_value = None
client = Mock()
client.get.return_value = describe_resp
client.request.return_value = call_resp
mock_httpx.Client.return_value.__enter__.return_value = client
tool = RegistryProxyTool(proxy_url="https://r.example.com")
result = tool.call("seo.backlinks", {"domain": "x"})
assert result["data"] == "ok"
client.request.assert_called_once_with(
"POST",
"https://r.example.com/call/seo.backlinks",
json={"domain": "x"},
headers={"Content-Type": "application/json"},
)
def test_guarded_call_works_on_get_endpoint(self, mock_httpx):
"""Regression: spend guard + GET endpoint end-to-end (describe fetched once)."""
from praisonai_tools.registry_proxy import RegistryProxyTool
describe_resp = Mock()
describe_resp.json.return_value = {
"id": "diffbot.people.enrich", "method": "GET", "price_per_call": 0.05
}
describe_resp.raise_for_status.return_value = None
call_resp = Mock()
call_resp.json.return_value = {"data": "ok", "price_per_call": 0.05}
call_resp.raise_for_status.return_value = None
client = Mock()
client.get.side_effect = [describe_resp, call_resp]
mock_httpx.Client.return_value.__enter__.return_value = client
tool = RegistryProxyTool(
proxy_url="https://r.example.com", max_session_spend=1.0
)
result = tool.call("diffbot.people.enrich", {"query": "type=email"})
assert result["data"] == "ok"
assert tool._session_spend == pytest.approx(0.05)
client.request.assert_not_called()
# describe reused from the guard: exactly one describe + one GET call.
assert client.get.call_count == 2
assert client.get.call_args_list[1].kwargs["params"] == {"query": "type=email"}
# The live registry (v0.11.0) nests the endpoint fields under an ``endpoint``
# key and expresses cost as a structured object; fixtures mirror that shape so
# unit tests exercise the real wire schema, not a flattened approximation.
_LIVE_ENDPOINT_DOC = {
"endpoint": {
"id": "diffbot.people.enrich",
"method": "GET",
"cost": {
"type": "per_call",
"value": 25,
"currency": "credit",
"per": 1,
"usd": 0.0299,
},
},
"provider": {},
"siblings": [],
"call_template": "…",
"example_response": {},
"hints": [],
}
class TestExtractors:
def test_extract_method_nested_endpoint_envelope(self):
from praisonai_tools.registry_proxy import RegistryProxyTool
assert RegistryProxyTool._extract_method(
{"endpoint": {"method": "GET"}}
) == "GET"
# Flat form still works.
assert RegistryProxyTool._extract_method({"method": "post"}) == "POST"
# Absent -> None (default POST applies downstream).
assert RegistryProxyTool._extract_method({"endpoint": {}}) is None
assert RegistryProxyTool._extract_method({}) is None
def test_extract_price_structured_cost_usd(self):
from praisonai_tools.registry_proxy import RegistryProxyTool
# Nested endpoint.cost.usd is preferred (guard budgets are USD).
assert RegistryProxyTool._extract_price(_LIVE_ENDPOINT_DOC) == 0.0299
assert RegistryProxyTool._extract_price(
{"cost": {"usd": 0.0299, "value": 25}}
) == 0.0299
# Flat numeric forms unchanged.
assert RegistryProxyTool._extract_price({"price_per_call": 0.05}) == 0.05
assert RegistryProxyTool._extract_price({"price": 0.02}) == 0.02
assert RegistryProxyTool._extract_price({"cost": 0.03}) == 0.03
# Nested flat number under the envelope.
assert RegistryProxyTool._extract_price(
{"endpoint": {"price_per_call": 0.07}}
) == 0.07
# Non-numeric/absent -> None (fail-closed downstream).
assert RegistryProxyTool._extract_price({"cost": {"value": 25}}) is None
assert RegistryProxyTool._extract_price({}) is None
class TestLiveEnvelope:
def test_call_auto_derives_get_from_live_shape(self, mock_httpx):
"""The live nested envelope must yield an auto-derived GET (no method=)."""
from praisonai_tools.registry_proxy import RegistryProxyTool
describe_resp = Mock()
describe_resp.json.return_value = _LIVE_ENDPOINT_DOC
describe_resp.raise_for_status.return_value = None
call_resp = Mock()
call_resp.json.return_value = {"data": {"name": "Jane"}}
call_resp.raise_for_status.return_value = None
client = Mock()
client.get.side_effect = [describe_resp, call_resp]
mock_httpx.Client.return_value.__enter__.return_value = client
tool = RegistryProxyTool(proxy_url="https://r.example.com", token="t")
result = tool.call("diffbot.people.enrich", {"query": "type=email"})
assert result["data"]["name"] == "Jane"
client.request.assert_not_called()
assert client.get.call_count == 2
call_args = client.get.call_args_list[1]
assert call_args.args[0] == "https://r.example.com/call/diffbot.people.enrich"
assert call_args.kwargs["params"] == {"query": "type=email"}
def test_guard_quotes_real_price_from_live_shape(self, mock_httpx):
"""Guard must quote the structured cost's usd, not fail-closed."""
from praisonai_tools.registry_proxy import RegistryProxyTool
describe_resp = Mock()
describe_resp.json.return_value = _LIVE_ENDPOINT_DOC
describe_resp.raise_for_status.return_value = None
client = Mock()
client.get.return_value = describe_resp
mock_httpx.Client.return_value.__enter__.return_value = client
tool = RegistryProxyTool(
proxy_url="https://r.example.com", max_cost_per_call=0.01
)
result = tool.call("diffbot.people.enrich", {"query": "type=email"})
assert "exceeds max_cost_per_call" in result["error"]
assert "0.0299" in result["error"]
assert "cannot enforce" not in result["error"]
client.request.assert_not_called()
class TestErrorTaxonomy:
def test_auth_error(self, mock_httpx):
from praisonai_tools.registry_proxy import RegistryProxyTool
resp = Mock()
resp.status_code = 401
resp.text = "Unauthorized"
client = Mock()
client.get.side_effect = mock_httpx.HTTPStatusError("401", response=resp)
mock_httpx.Client.return_value.__enter__.return_value = client
tool = RegistryProxyTool(proxy_url="https://r.example.com", token="bad")
result = tool.search("x")
assert "Authentication failed" in result["error"]
def test_insufficient_balance_error(self, mock_httpx):
from praisonai_tools.registry_proxy import RegistryProxyTool
resp = Mock()
resp.status_code = 402
resp.text = "Payment Required"
client = Mock()
client.request.side_effect = mock_httpx.HTTPStatusError("402", response=resp)
mock_httpx.Client.return_value.__enter__.return_value = client
tool = RegistryProxyTool(proxy_url="https://r.example.com")
result = tool.call("seo.backlinks", {}, method="POST")
assert "Insufficient balance" in result["error"]
def test_upstream_4xx_passthrough(self, mock_httpx):
from praisonai_tools.registry_proxy import RegistryProxyTool
resp = Mock()
resp.status_code = 404
resp.text = "Not Found"
client = Mock()
client.request.side_effect = mock_httpx.HTTPStatusError("404", response=resp)
mock_httpx.Client.return_value.__enter__.return_value = client
tool = RegistryProxyTool(proxy_url="https://r.example.com")
result = tool.call("seo.backlinks", {}, method="POST")
assert "HTTP 404" in result["error"]
def test_timeout_error(self, mock_httpx):
from praisonai_tools.registry_proxy import RegistryProxyTool
client = Mock()
client.get.side_effect = mock_httpx.TimeoutException("timed out")
mock_httpx.Client.return_value.__enter__.return_value = client
tool = RegistryProxyTool(proxy_url="https://r.example.com", timeout=5.0)
result = tool.search("x")
assert "timed out after 5.0" in result["error"]
def test_missing_httpx(self):
from praisonai_tools.registry_proxy import RegistryProxyTool
tool = RegistryProxyTool(proxy_url="https://r.example.com")
with patch.dict("sys.modules", {"httpx": None}):
with patch("builtins.__import__", side_effect=ImportError("no httpx")):
result = tool.search("x")
assert "httpx not installed" in result["error"]
assert "praisonai-tools[registry-proxy]" in result["error"]
class TestSpendGuards:
def test_max_cost_per_call_denies(self, mock_httpx):
from praisonai_tools.registry_proxy import RegistryProxyTool
describe_resp = Mock()
describe_resp.json.return_value = {"id": "seo.backlinks", "price_per_call": 0.50}
describe_resp.raise_for_status.return_value = None
client = Mock()
client.get.return_value = describe_resp
mock_httpx.Client.return_value.__enter__.return_value = client
tool = RegistryProxyTool(proxy_url="https://r.example.com", max_cost_per_call=0.10)
result = tool.call("seo.backlinks", {})
assert "exceeds max_cost_per_call" in result["error"]
client.request.assert_not_called()
def test_max_session_spend_denies(self, mock_httpx):
from praisonai_tools.registry_proxy import RegistryProxyTool
describe_resp = Mock()
describe_resp.json.return_value = {"price_per_call": 0.60}
describe_resp.raise_for_status.return_value = None
client = Mock()
client.get.return_value = describe_resp
mock_httpx.Client.return_value.__enter__.return_value = client
tool = RegistryProxyTool(proxy_url="https://r.example.com", max_session_spend=1.0)
tool._session_spend = 0.50
result = tool.call("seo.backlinks", {})
assert "max_session_spend" in result["error"]
client.request.assert_not_called()
def test_within_budget_allows_and_tracks_spend(self, mock_httpx):
from praisonai_tools.registry_proxy import RegistryProxyTool
describe_resp = Mock()
describe_resp.json.return_value = {"price_per_call": 0.05}
describe_resp.raise_for_status.return_value = None
call_resp = Mock()
call_resp.json.return_value = {"data": "ok", "price_per_call": 0.05}
call_resp.raise_for_status.return_value = None
client = Mock()
client.get.return_value = describe_resp
client.request.return_value = call_resp
mock_httpx.Client.return_value.__enter__.return_value = client
tool = RegistryProxyTool(proxy_url="https://r.example.com", max_session_spend=1.0)
result = tool.call("seo.backlinks", {})
assert result["data"] == "ok"
assert tool._session_spend == pytest.approx(0.05)
@pytest.mark.parametrize("field", ["price_per_call", "price", "cost"])
def test_budget_check_accepts_price_and_cost_fields(self, mock_httpx, field):
"""Budget check tolerates price_per_call/price/cost, matching recording."""
from praisonai_tools.registry_proxy import RegistryProxyTool
describe_resp = Mock()
describe_resp.json.return_value = {"id": "seo.backlinks", field: 0.05}
describe_resp.raise_for_status.return_value = None
call_resp = Mock()
call_resp.json.return_value = {"data": "ok", field: 0.05}
call_resp.raise_for_status.return_value = None
client = Mock()
client.get.return_value = describe_resp
client.request.return_value = call_resp
mock_httpx.Client.return_value.__enter__.return_value = client
tool = RegistryProxyTool(proxy_url="https://r.example.com", max_session_spend=1.0)
result = tool.call("seo.backlinks", {})
assert result["data"] == "ok"
assert tool._session_spend == pytest.approx(0.05)
def test_missing_price_fails_closed(self, mock_httpx):
from praisonai_tools.registry_proxy import RegistryProxyTool
describe_resp = Mock()
describe_resp.json.return_value = {"id": "seo.backlinks"}
describe_resp.raise_for_status.return_value = None
client = Mock()
client.get.return_value = describe_resp
mock_httpx.Client.return_value.__enter__.return_value = client
tool = RegistryProxyTool(proxy_url="https://r.example.com", max_cost_per_call=0.10)
result = tool.call("seo.backlinks", {})
assert "cannot enforce" in result["error"]
client.request.assert_not_called()
@pytest.mark.parametrize("bad_price", ["NaN", "Infinity", "-Infinity", -0.01])
def test_non_finite_or_negative_price_fails_closed(self, mock_httpx, bad_price):
"""NaN/inf/negative prices must be rejected, not silently bypass the guard."""
from praisonai_tools.registry_proxy import RegistryProxyTool
describe_resp = Mock()
describe_resp.json.return_value = {"price_per_call": bad_price}
describe_resp.raise_for_status.return_value = None
client = Mock()
client.get.return_value = describe_resp
mock_httpx.Client.return_value.__enter__.return_value = client
tool = RegistryProxyTool(proxy_url="https://r.example.com", max_cost_per_call=0.10)
result = tool.call("seo.backlinks", {})
assert "cannot enforce" in result["error"]
client.request.assert_not_called()
def test_non_finite_response_charge_does_not_corrupt_spend(self, mock_httpx):
"""A NaN/inf charge in the call response must not poison cumulative spend."""
from praisonai_tools.registry_proxy import RegistryProxyTool
describe_resp = Mock()
describe_resp.json.return_value = {"price_per_call": 0.20}
describe_resp.raise_for_status.return_value = None
call_resp = Mock()
call_resp.json.return_value = {"data": "ok", "price_per_call": "NaN"}
call_resp.raise_for_status.return_value = None
client = Mock()
client.get.return_value = describe_resp
client.request.return_value = call_resp
mock_httpx.Client.return_value.__enter__.return_value = client
tool = RegistryProxyTool(proxy_url="https://r.example.com", max_session_spend=1.0)
result = tool.call("seo.backlinks", {})
assert result["data"] == "ok"
assert tool._session_spend == 0.20
def test_session_spend_persists_across_registry_call(self, mock_httpx):
"""max_session_spend must accumulate across separate registry_call calls."""
from praisonai_tools.registry_proxy import registry_call
describe_resp = Mock()
describe_resp.json.return_value = {"price_per_call": 0.60}
describe_resp.raise_for_status.return_value = None
call_resp = Mock()
call_resp.json.return_value = {"data": "ok", "price_per_call": 0.60}
call_resp.raise_for_status.return_value = None
client = Mock()
client.get.return_value = describe_resp
client.request.return_value = call_resp
mock_httpx.Client.return_value.__enter__.return_value = client
first = registry_call(
"seo.backlinks", {},
proxy_url="https://r.example.com", token="t",
max_session_spend=1.0,
)
assert first["data"] == "ok"
second = registry_call(
"seo.backlinks", {},
proxy_url="https://r.example.com", token="t",
max_session_spend=1.0,
)
assert "max_session_spend" in second["error"]
class TestDecoratedFunctions:
@patch("praisonai_tools.registry_proxy.registry_proxy.RegistryProxyTool")
def test_registry_search_function(self, mock_cls):
from praisonai_tools.registry_proxy import registry_search
instance = Mock()
instance.search.return_value = {"matches": []}
mock_cls.return_value = instance
registry_search("q", proxy_url="https://r", token="t")
mock_cls.assert_called_once_with(
proxy_url="https://r", token="t", auth_header=None
)
instance.search.assert_called_once_with("q")
@patch("praisonai_tools.registry_proxy.registry_proxy.RegistryProxyTool")
def test_registry_call_function_passes_guards(self, mock_cls):
from praisonai_tools.registry_proxy import registry_call
instance = Mock()
instance.call.return_value = {"data": "ok"}
mock_cls.return_value = instance
registry_call(
"seo.backlinks",
{"domain": "x"},
proxy_url="https://r",
token="t",
max_cost_per_call=0.1,
max_session_spend=1.0,
)
mock_cls.assert_called_once_with(
proxy_url="https://r",
token="t",
max_cost_per_call=0.1,
max_session_spend=1.0,
auth_header=None,
describe_path=None,
)
instance.call.assert_called_once_with("seo.backlinks", {"domain": "x"}, None)
@patch("praisonai_tools.registry_proxy.registry_proxy.RegistryProxyTool")
def test_registry_call_function_passes_method(self, mock_cls):
from praisonai_tools.registry_proxy import registry_call
instance = Mock()
instance.call.return_value = {"data": "ok"}
mock_cls.return_value = instance
registry_call(
"diffbot.people.enrich",
{"query": "type=email"},
proxy_url="https://r",
token="t",
method="GET",
)
instance.call.assert_called_once_with(
"diffbot.people.enrich", {"query": "type=email"}, "GET"
)
def test_smoke_import():
from praisonai_tools.registry_proxy import (
RegistryProxyTool,
registry_search,
registry_describe,
registry_call,
)
assert callable(registry_search)
assert callable(registry_describe)
assert callable(registry_call)
assert RegistryProxyTool().name == "registry_proxy"
@pytest.mark.integration
def test_live_smoke():
"""Gated live smoke test; skipped unless TOOL_PROXY_URL is configured."""
proxy_url = os.getenv("TOOL_PROXY_URL")
if not proxy_url:
pytest.skip("TOOL_PROXY_URL not set")
from praisonai_tools.registry_proxy import registry_search
result = registry_search("backlinks for a domain")
assert isinstance(result, dict)
assert "error" not in result
+25
View File
@@ -0,0 +1,25 @@
"""Tier 0: pin that registry-installed skills (./.claude/skills/) are discoverable.
Registries that expose ``skill install <name>`` install into ``./.claude/skills/``.
PraisonAI's skill discovery already scans that directory, so registry-installed
skills are discoverable with zero connector code. This test pins that contract.
"""
import os
import pytest
def test_claude_skills_dir_is_scanned(tmp_path, monkeypatch):
discovery = pytest.importorskip("praisonaiagents.skills.discovery")
skills_dir = tmp_path / ".claude" / "skills" / "my-registry-skill"
skills_dir.mkdir(parents=True)
monkeypatch.chdir(tmp_path)
dirs = discovery.get_default_skill_dirs()
claude_skills = tmp_path / ".claude" / "skills"
assert any(os.path.samefile(str(d), str(claude_skills)) for d in dirs), (
"PraisonAI must scan ./.claude/skills/ so registry-installed skills are "
"discoverable with zero connector code."
)
+451
View File
@@ -0,0 +1,451 @@
"""Tests for SwarmScore tool integration."""
import json
import pytest
import requests as real_requests
from urllib.parse import urlparse
from unittest.mock import Mock, patch
from praisonai_tools.tools.swarmscore_tool import (
SwarmScoreTool,
load_swarmscore_by_slug,
verify_swarmscore_freshness,
get_agent_discovery_manifest
)
from praisonai_tools.tools.base import ToolResult
class TestSwarmScoreTool:
"""Test cases for SwarmScoreTool class."""
def setup_method(self):
"""Set up test fixtures."""
self.tool = SwarmScoreTool()
self.sample_score_data = {
"score": 85,
"tier": "gold",
"jobs_completed": 150,
"success_rate": 94.5,
"verify_payload": {
"signature": "test_signature",
"timestamp": "2024-01-15T10:30:00Z",
"agent_id": "test-agent-123"
}
}
@patch('praisonai_tools.tools.swarmscore_tool.requests')
def test_load_swarmscore_success(self, mock_requests):
"""Test successful SwarmScore loading."""
# Setup mock response
mock_response = Mock()
mock_response.json.return_value = self.sample_score_data
mock_response.raise_for_status.return_value = None
mock_requests.get.return_value = mock_response
# Execute
result = self.tool.load_swarmscore("test-agent")
# Verify
assert result.success is True
assert result.output == self.sample_score_data
assert "test-agent" in result.metadata["message"]
mock_requests.get.assert_called_once()
@patch('praisonai_tools.tools.swarmscore_tool.requests')
def test_load_swarmscore_slug_url_encoded(self, mock_requests):
"""Slugs with special characters must be URL-encoded to avoid malformed paths."""
mock_response = Mock()
mock_response.json.return_value = self.sample_score_data
mock_response.raise_for_status.return_value = None
mock_requests.get.return_value = mock_response
result = self.tool.load_swarmscore("acme/agent one")
assert result.success is True
called_url = mock_requests.get.call_args[0][0]
assert called_url.endswith("/load-by-slug/acme%2Fagent%20one")
@patch('praisonai_tools.tools.swarmscore_tool.requests')
def test_load_swarmscore_network_error(self, mock_requests):
"""Test SwarmScore loading with network error."""
# Setup mock to raise exception
mock_requests.exceptions = real_requests.exceptions
mock_requests.get.side_effect = real_requests.exceptions.RequestException("Network error")
# Execute
result = self.tool.load_swarmscore("test-agent")
# Verify
assert result.success is False
assert "Network error" in result.error
@patch('praisonai_tools.tools.swarmscore_tool.requests')
def test_load_swarmscore_invalid_json(self, mock_requests):
"""Test SwarmScore loading with invalid JSON response."""
# Setup mock response with invalid JSON
mock_response = Mock()
mock_requests.exceptions = real_requests.exceptions
mock_response.json.side_effect = json.JSONDecodeError("Invalid JSON", "", 0)
mock_response.raise_for_status.return_value = None
mock_requests.get.return_value = mock_response
# Execute
result = self.tool.load_swarmscore("test-agent")
# Verify
assert result.success is False
assert "Invalid response format" in result.error
@patch('praisonai_tools.tools.swarmscore_tool.requests')
def test_verify_swarmscore_success(self, mock_requests):
"""Test successful SwarmScore verification."""
# Setup mock response
verify_data = {"verified": True, "timestamp": "2024-01-15T10:30:00Z"}
mock_response = Mock()
mock_response.json.return_value = verify_data
mock_response.raise_for_status.return_value = None
mock_requests.post.return_value = mock_response
# Execute
verify_payload = self.sample_score_data["verify_payload"]
result = self.tool.verify_swarmscore(verify_payload)
# Verify
assert result.success is True
assert result.output == verify_data
mock_requests.post.assert_called_once()
@patch('praisonai_tools.tools.swarmscore_tool.requests')
def test_verify_swarmscore_error(self, mock_requests):
"""Test SwarmScore verification with error."""
# Setup mock to raise exception
mock_requests.exceptions = real_requests.exceptions
mock_requests.post.side_effect = real_requests.exceptions.RequestException("Verification failed")
# Execute
result = self.tool.verify_swarmscore({"test": "payload"})
# Verify
assert result.success is False
assert "Verification failed" in result.error
@patch('praisonai_tools.tools.swarmscore_tool.requests')
def test_get_discovery_manifest_success(self, mock_requests):
"""Test successful discovery manifest retrieval."""
# Setup mock response
manifest_data = {
"agents": ["agent1", "agent2"],
"capabilities": ["trading", "analysis"],
"version": "1.0"
}
mock_response = Mock()
mock_response.json.return_value = manifest_data
mock_response.raise_for_status.return_value = None
mock_requests.get.return_value = mock_response
# Execute
result = self.tool.get_discovery_manifest()
# Verify
assert result.success is True
assert result.output == manifest_data
mock_requests.get.assert_called_once()
@patch('praisonai_tools.tools.swarmscore_tool.requests')
def test_get_discovery_manifest_url_derivation(self, mock_requests):
"""Discovery manifest URL must resolve to the domain root regardless of version prefix."""
mock_response = Mock()
mock_response.json.return_value = {"agents": []}
mock_response.raise_for_status.return_value = None
mock_requests.get.return_value = mock_response
for base_url in [
"https://api.swarmsync.ai/v1/swarmscore/",
"https://api.swarmsync.ai/v2/swarmscore/",
"https://staging.swarmsync.ai/api/swarmscore/",
"https://api.swarmsync.ai/swarmscore",
]:
mock_requests.get.reset_mock()
tool = SwarmScoreTool(api_base_url=base_url)
result = tool.get_discovery_manifest()
assert result.success is True
called_url = mock_requests.get.call_args[0][0]
expected_root = f"{urlparse(base_url).scheme}://{urlparse(base_url).netloc}"
assert called_url == f"{expected_root}/.well-known/agent-card.json"
@patch('praisonai_tools.tools.swarmscore_tool.requests')
def test_get_discovery_manifest_error(self, mock_requests):
"""Test discovery manifest retrieval with error."""
# Setup mock to raise exception
mock_requests.exceptions = real_requests.exceptions
mock_requests.get.side_effect = real_requests.exceptions.RequestException("Manifest error")
# Execute
result = self.tool.get_discovery_manifest()
# Verify
assert result.success is False
assert "Manifest error" in result.error
def test_run_load_action(self):
"""Test run method with load action."""
with patch.object(self.tool, 'load_swarmscore') as mock_load:
mock_load.return_value = ToolResult(output=self.sample_score_data, success=True)
result = self.tool.run("load", slug="test-agent")
assert result.success is True
mock_load.assert_called_once_with("test-agent")
def test_run_verify_action(self):
"""Test run method with verify action."""
with patch.object(self.tool, 'verify_swarmscore') as mock_verify:
mock_verify.return_value = ToolResult(output={"verified": True}, success=True)
verify_payload = {"test": "payload"}
result = self.tool.run("verify", verify_payload=verify_payload)
assert result.success is True
mock_verify.assert_called_once_with(verify_payload)
def test_run_discover_action(self):
"""Test run method with discover action."""
with patch.object(self.tool, 'get_discovery_manifest') as mock_discover:
mock_discover.return_value = ToolResult(output={"agents": []}, success=True)
result = self.tool.run("discover")
assert result.success is True
mock_discover.assert_called_once()
def test_run_default_action(self):
"""Bare run() defaults to 'load' and degrades gracefully without a slug."""
result = self.tool.run()
assert result.success is False
assert "Missing required parameter 'slug'" in result.error
def test_run_invalid_action(self):
"""Test run method with invalid action."""
result = self.tool.run("invalid_action")
assert result.success is False
assert "Unknown action" in result.error
def test_run_missing_parameters(self):
"""Test run method with missing required parameters."""
# Test load without slug
result = self.tool.run("load")
assert result.success is False
assert "Missing required parameter 'slug'" in result.error
# Test verify without verify_payload
result = self.tool.run("verify")
assert result.success is False
assert "Missing required parameter 'verify_payload'" in result.error
class TestStandaloneFunctions:
"""Test cases for standalone utility functions."""
def setup_method(self):
"""Set up test fixtures."""
self.sample_data = {
"score": 75,
"tier": "silver",
"verify_payload": {"test": "payload"}
}
@patch('praisonai_tools.tools.swarmscore_tool.SwarmScoreTool')
def test_load_swarmscore_by_slug_success(self, mock_tool_class):
"""Test successful standalone load function."""
# Setup mock
mock_tool = Mock()
mock_tool.load_swarmscore.return_value = ToolResult(
output=self.sample_data,
success=True
)
mock_tool_class.return_value = mock_tool
# Execute
result = load_swarmscore_by_slug("test-agent")
# Verify
assert result == self.sample_data
mock_tool.load_swarmscore.assert_called_once_with("test-agent")
@patch('praisonai_tools.tools.swarmscore_tool.SwarmScoreTool')
def test_load_swarmscore_by_slug_failure(self, mock_tool_class):
"""Test standalone load function with failure."""
# Setup mock
mock_tool = Mock()
mock_tool.load_swarmscore.return_value = ToolResult(
output=None,
success=False,
error="Load failed"
)
mock_tool_class.return_value = mock_tool
# Execute and verify exception
with pytest.raises(Exception, match="Load failed"):
load_swarmscore_by_slug("test-agent")
@patch('praisonai_tools.tools.swarmscore_tool.SwarmScoreTool')
def test_verify_swarmscore_freshness_success(self, mock_tool_class):
"""Test successful standalone verify function."""
# Setup mock
verify_data = {"verified": True}
mock_tool = Mock()
mock_tool.verify_swarmscore.return_value = ToolResult(
output=verify_data,
success=True
)
mock_tool_class.return_value = mock_tool
# Execute
payload = {"test": "payload"}
result = verify_swarmscore_freshness(payload)
# Verify
assert result == verify_data
mock_tool.verify_swarmscore.assert_called_once_with(payload)
@patch('praisonai_tools.tools.swarmscore_tool.SwarmScoreTool')
def test_verify_swarmscore_freshness_failure(self, mock_tool_class):
"""Test standalone verify function with failure."""
# Setup mock
mock_tool = Mock()
mock_tool.verify_swarmscore.return_value = ToolResult(
output=None,
success=False,
error="Verification failed"
)
mock_tool_class.return_value = mock_tool
# Execute and verify exception
with pytest.raises(Exception, match="Verification failed"):
verify_swarmscore_freshness({"test": "payload"})
@patch('praisonai_tools.tools.swarmscore_tool.SwarmScoreTool')
def test_get_agent_discovery_manifest_success(self, mock_tool_class):
"""Test successful standalone discovery function."""
# Setup mock
manifest_data = {"agents": ["test1", "test2"]}
mock_tool = Mock()
mock_tool.get_discovery_manifest.return_value = ToolResult(
output=manifest_data,
success=True
)
mock_tool_class.return_value = mock_tool
# Execute
result = get_agent_discovery_manifest()
# Verify
assert result == manifest_data
mock_tool.get_discovery_manifest.assert_called_once()
@patch('praisonai_tools.tools.swarmscore_tool.SwarmScoreTool')
def test_get_agent_discovery_manifest_failure(self, mock_tool_class):
"""Test standalone discovery function with failure."""
# Setup mock
mock_tool = Mock()
mock_tool.get_discovery_manifest.return_value = ToolResult(
output=None,
success=False,
error="Discovery failed"
)
mock_tool_class.return_value = mock_tool
# Execute and verify exception
with pytest.raises(Exception, match="Discovery failed"):
get_agent_discovery_manifest()
class TestToolInitialization:
"""Test tool initialization and dependencies."""
def test_tool_initialization_default(self):
"""Test tool initialization with default parameters."""
tool = SwarmScoreTool()
assert "api.swarmsync.ai" in tool.api_base_url
assert not tool.api_base_url.endswith('/')
def test_tool_initialization_custom_url(self):
"""Test tool initialization with custom URL."""
custom_url = "https://custom.api.com/v1/swarmscore/"
tool = SwarmScoreTool(api_base_url=custom_url)
assert tool.api_base_url == "https://custom.api.com/v1/swarmscore"
@patch('praisonai_tools.tools.swarmscore_tool.requests', None)
def test_tool_initialization_missing_requests(self):
"""Test tool initialization without requests library."""
with pytest.raises(ImportError, match="requests is required"):
SwarmScoreTool()
class TestIntegrationScenarios:
"""Test realistic integration scenarios."""
@patch('praisonai_tools.tools.swarmscore_tool.requests')
def test_full_workflow_success(self, mock_requests):
"""Test complete workflow: load -> verify -> discover."""
tool = SwarmScoreTool()
# Setup mock responses
load_data = {
"score": 90,
"tier": "platinum",
"verify_payload": {"signature": "test123"}
}
verify_data = {"verified": True}
manifest_data = {"agents": ["agent1"]}
mock_responses = [
Mock(json=lambda: load_data, raise_for_status=lambda: None),
Mock(json=lambda: verify_data, raise_for_status=lambda: None),
Mock(json=lambda: manifest_data, raise_for_status=lambda: None)
]
# Configure mock to return different responses for different calls
mock_requests.exceptions = real_requests.exceptions
mock_requests.get.side_effect = [mock_responses[0], mock_responses[2]]
mock_requests.post.return_value = mock_responses[1]
# Execute workflow
load_result = tool.load_swarmscore("test-agent")
assert load_result.success is True
assert load_result.output["score"] == 90
verify_result = tool.verify_swarmscore(load_data["verify_payload"])
assert verify_result.success is True
assert verify_result.output["verified"] is True
manifest_result = tool.get_discovery_manifest()
assert manifest_result.success is True
assert "agent1" in manifest_result.output["agents"]
@patch('praisonai_tools.tools.swarmscore_tool.requests')
def test_error_recovery(self, mock_requests):
"""Test graceful error handling and recovery."""
tool = SwarmScoreTool()
# First call fails, second succeeds
mock_requests.exceptions = real_requests.exceptions
mock_requests.get.side_effect = [
real_requests.exceptions.RequestException("Network timeout"),
Mock(
json=lambda: {"score": 80},
raise_for_status=lambda: None
)
]
# First attempt fails
result1 = tool.load_swarmscore("test-agent")
assert result1.success is False
assert "Network timeout" in result1.error
# Second attempt succeeds
result2 = tool.load_swarmscore("test-agent")
assert result2.success is True
assert result2.output["score"] == 80
+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"
@@ -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}"
+45
View File
@@ -0,0 +1,45 @@
"""Pytest configuration for video/motion-graphics tests.
Most tests in this tree are fully mocked (protocols, dataclasses, mocked
backends) and run without the optional ``video-motion`` dependencies. Only a
subset actually constructs :class:`HtmlRenderBackend`, which hard-requires
``playwright`` and ``imageio-ffmpeg`` at construction time.
Rather than skipping the whole tree when those packages are absent (for example
a minimal ``pip install -e ".[dev]"`` where the wheels could not be resolved),
gate *only* the dependency-bound tests. Mark them with
``@pytest.mark.requires_video_deps`` and this hook skips just those when the
extras are missing, preserving coverage for the dependency-independent tests.
"""
import importlib.util
import pytest
_VIDEO_DEPS = ("playwright", "imageio_ffmpeg")
_MISSING_VIDEO_DEPS = [
name for name in _VIDEO_DEPS if importlib.util.find_spec(name) is None
]
VIDEO_DEPS_AVAILABLE = not _MISSING_VIDEO_DEPS
def pytest_configure(config):
config.addinivalue_line(
"markers",
"requires_video_deps: mark test as requiring the optional "
"video-motion extras (playwright, imageio-ffmpeg).",
)
def pytest_collection_modifyitems(config, items):
if VIDEO_DEPS_AVAILABLE:
return
skip_reason = (
"video-motion extra not installed "
"(pip install -e '.[dev,video-motion]'); missing: "
+ ", ".join(_MISSING_VIDEO_DEPS)
)
skip_marker = pytest.mark.skip(reason=skip_reason)
for item in items:
if "requires_video_deps" in item.keywords:
item.add_marker(skip_marker)
+1
View File
@@ -13,6 +13,7 @@ except ImportError:
@pytest.mark.skipif(not BACKEND_AVAILABLE, reason="Motion graphics backend not available")
@pytest.mark.requires_video_deps
class TestBackendSafety:
"""Test workspace safety features."""
+47 -26
View File
@@ -1,10 +1,11 @@
"""Unit tests for HTML render backend."""
import pytest
import tempfile
import asyncio
import tempfile
from pathlib import Path
from unittest.mock import Mock, patch, AsyncMock
from unittest.mock import AsyncMock, Mock, patch
import pytest
from praisonai_tools.video.motion_graphics.backend_html import HtmlRenderBackend
from praisonai_tools.video.motion_graphics.protocols import RenderOpts
@@ -13,22 +14,32 @@ from praisonai_tools.video.motion_graphics.protocols import RenderOpts
class TestHtmlRenderBackend:
"""Test HTML render backend."""
@pytest.mark.requires_video_deps
def test_import_error_handling(self):
"""Test that import errors are handled properly."""
with patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', None):
with pytest.raises(ImportError, match="Playwright not installed"):
HtmlRenderBackend()
with patch('praisonai_tools.video.motion_graphics.backend_html.imageio_ffmpeg', None):
with pytest.raises(ImportError, match="imageio-ffmpeg not installed"):
HtmlRenderBackend()
with (
patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', None),
pytest.raises(ImportError, match="Playwright not installed"),
):
HtmlRenderBackend()
# Ensure the playwright guard passes so the imageio-ffmpeg guard is the
# one under test, regardless of whether the optional deps are installed.
with (
patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', Mock()),
patch('praisonai_tools.video.motion_graphics.backend_html.imageio_ffmpeg', None),
pytest.raises(ImportError, match="imageio-ffmpeg not installed"),
):
HtmlRenderBackend()
def test_init_success(self):
"""Test successful initialization."""
with patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', Mock()):
with patch('praisonai_tools.video.motion_graphics.backend_html.imageio_ffmpeg', Mock()):
backend = HtmlRenderBackend()
assert backend is not None
with (
patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', Mock()),
patch('praisonai_tools.video.motion_graphics.backend_html.imageio_ffmpeg', Mock()),
):
backend = HtmlRenderBackend()
assert backend is not None
class TestLinting:
@@ -36,9 +47,11 @@ class TestLinting:
def setup_method(self):
"""Set up test fixtures."""
with patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', Mock()):
with patch('praisonai_tools.video.motion_graphics.backend_html.imageio_ffmpeg', Mock()):
self.backend = HtmlRenderBackend()
with (
patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', Mock()),
patch('praisonai_tools.video.motion_graphics.backend_html.imageio_ffmpeg', Mock()),
):
self.backend = HtmlRenderBackend()
@pytest.mark.asyncio
async def test_lint_missing_index_html(self):
@@ -198,9 +211,11 @@ class TestRendering:
def setup_method(self):
"""Set up test fixtures."""
with patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', Mock()):
with patch('praisonai_tools.video.motion_graphics.backend_html.imageio_ffmpeg', Mock()):
self.backend = HtmlRenderBackend()
with (
patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', Mock()),
patch('praisonai_tools.video.motion_graphics.backend_html.imageio_ffmpeg', Mock()),
):
self.backend = HtmlRenderBackend()
@pytest.mark.asyncio
async def test_render_missing_index_html(self):
@@ -321,8 +336,10 @@ class TestRendering:
mock_process.returncode = 0
mock_subprocess.return_value = mock_process
with patch.object(self.backend, '_get_ffmpeg_path', return_value='ffmpeg'):
with tempfile.TemporaryDirectory() as tmpdir:
with (
patch.object(self.backend, '_get_ffmpeg_path', return_value='ffmpeg'),
tempfile.TemporaryDirectory() as tmpdir,
):
# Create fake frame files
frame_dir = Path(tmpdir)
frame_paths = []
@@ -357,8 +374,10 @@ class TestRendering:
mock_process.returncode = 1
mock_subprocess.return_value = mock_process
with patch.object(self.backend, '_get_ffmpeg_path', return_value='ffmpeg'):
with tempfile.TemporaryDirectory() as tmpdir:
with (
patch.object(self.backend, '_get_ffmpeg_path', return_value='ffmpeg'),
tempfile.TemporaryDirectory() as tmpdir,
):
frame_path = Path(tmpdir) / "frame_000000.png"
frame_path.write_bytes(b"fake")
@@ -378,8 +397,10 @@ class TestRendering:
mock_process.kill = Mock()
mock_subprocess.return_value = mock_process
with patch.object(self.backend, '_get_ffmpeg_path', return_value='ffmpeg'):
with tempfile.TemporaryDirectory() as tmpdir:
with (
patch.object(self.backend, '_get_ffmpeg_path', return_value='ffmpeg'),
tempfile.TemporaryDirectory() as tmpdir,
):
frame_path = Path(tmpdir) / "frame_000000.png"
frame_path.write_bytes(b"fake")
+11 -3
View File
@@ -6,11 +6,11 @@ from pathlib import Path
from unittest.mock import patch
from praisonai_tools.video.motion_graphics.agent import (
create_motion_graphics_agent,
RenderTools,
_resolve_backend
_resolve_backend,
create_motion_graphics_agent,
)
from praisonai_tools.video.motion_graphics.protocols import RenderResult, LintResult
from praisonai_tools.video.motion_graphics.protocols import LintResult, RenderResult
class MockAgent:
@@ -96,6 +96,7 @@ class TestRenderTools:
class TestResolveBackend:
"""Test backend resolution."""
@pytest.mark.requires_video_deps
def test_resolve_string_backend(self):
"""Test resolving string backend specification."""
backend = _resolve_backend("html")
@@ -119,6 +120,7 @@ class TestResolveBackend:
class TestCreateMotionGraphicsAgent:
"""Test motion graphics agent factory."""
@pytest.mark.requires_video_deps
@patch('praisonai_tools.video.motion_graphics.agent.Agent', MockAgent)
@patch('praisonai_tools.video.motion_graphics.agent.FileTools', MockFileTools)
def test_create_agent_defaults(self):
@@ -137,6 +139,7 @@ class TestCreateMotionGraphicsAgent:
"lint_composition", "render_composition"} <= tool_names
assert "motion graphics specialist" in agent.instructions.lower()
@pytest.mark.requires_video_deps
@patch('praisonai_tools.video.motion_graphics.agent.Agent', MockAgent)
@patch('praisonai_tools.video.motion_graphics.agent.FileTools', MockFileTools)
def test_create_agent_custom_params(self):
@@ -159,6 +162,7 @@ class TestCreateMotionGraphicsAgent:
with pytest.raises(ImportError, match="praisonaiagents not available"):
create_motion_graphics_agent()
@pytest.mark.requires_video_deps
@patch('praisonai_tools.video.motion_graphics.agent.Agent', MockAgent)
@patch('praisonai_tools.video.motion_graphics.agent.FileTools', MockFileTools)
def test_create_agent_auto_workspace(self):
@@ -183,6 +187,7 @@ class TestCreateMotionGraphicsAgent:
assert agent._motion_graphics_backend is mock_backend
@pytest.mark.requires_video_deps
@patch('praisonai_tools.video.motion_graphics.agent.Agent', MockAgent)
@patch('praisonai_tools.video.motion_graphics.agent.FileTools', MockFileTools)
def test_create_agent_workspace_creation(self):
@@ -195,6 +200,7 @@ class TestCreateMotionGraphicsAgent:
assert workspace_path.exists()
assert agent._motion_graphics_workspace == workspace_path
@pytest.mark.requires_video_deps
@patch('praisonai_tools.video.motion_graphics.agent.Agent', MockAgent)
@patch('praisonai_tools.video.motion_graphics.agent.FileTools', MockFileTools)
def test_create_agent_skill_included(self):
@@ -207,6 +213,7 @@ class TestCreateMotionGraphicsAgent:
assert "timeline" in agent.instructions
assert "window.__timelines" in agent.instructions
@pytest.mark.requires_video_deps
@patch('praisonai_tools.video.motion_graphics.agent.Agent', MockAgent)
@patch('praisonai_tools.video.motion_graphics.agent.FileTools', MockFileTools)
def test_create_agent_output_validation(self):
@@ -219,6 +226,7 @@ class TestCreateMotionGraphicsAgent:
assert "Never fabricate file paths" in agent.instructions
assert "3 failed attempts" in agent.instructions
@pytest.mark.requires_video_deps
@patch('praisonai_tools.video.motion_graphics.agent.Agent', MockAgent)
@patch('praisonai_tools.video.motion_graphics.agent.FileTools', MockFileTools)
def test_agent_tools_configuration(self):
@@ -3,10 +3,10 @@
import pytest
from pathlib import Path
from praisonai_tools.video.motion_graphics.protocols import (
LintResult,
RenderBackendProtocol,
RenderOpts,
LintResult,
RenderResult,
RenderBackendProtocol
)
@@ -112,6 +112,7 @@ class TestRenderResult:
class TestRenderBackendProtocol:
"""Test RenderBackendProtocol protocol."""
@pytest.mark.requires_video_deps
def test_protocol_runtime_checkable(self):
"""Test that protocol is runtime checkable."""
from praisonai_tools.video.motion_graphics.backend_html import HtmlRenderBackend
+3 -2
View File
@@ -1,12 +1,13 @@
"""Tests for render retry functionality."""
import pytest
import tempfile
from pathlib import Path
import pytest
try:
from praisonai_tools.video.motion_graphics.agent import RenderTools
from praisonai_tools.video.motion_graphics.protocols import RenderResult, RenderOpts
from praisonai_tools.video.motion_graphics.protocols import RenderOpts, RenderResult
AGENT_AVAILABLE = True
except ImportError:
AGENT_AVAILABLE = False
-1
View File
@@ -1 +0,0 @@
<html>mock visualization</html>