mirror of
https://github.com/MervinPraison/PraisonAI-Tools.git
synced 2026-11-10 17:29:40 -10:00
Compare commits
No files matched your search
@@ -29,4 +29,53 @@ try {
|
||||
|
||||
assert('PACKAGE_PATHS includes pyproject', rg.PACKAGE_PATHS.includes('pyproject.toml'));
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
(async () => {
|
||||
const noonUtc = new Date('2026-07-08T12:00:00Z');
|
||||
const mockGithub = {
|
||||
rest: {
|
||||
actions: {
|
||||
listWorkflowRuns: async () => ({
|
||||
data: {
|
||||
workflow_runs: [
|
||||
{
|
||||
conclusion: 'success',
|
||||
created_at: '2026-07-08T09:00:00Z',
|
||||
status: 'completed',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
assert('PATCH_RELEASE_INTERVAL_DAYS is 3', rg.PATCH_RELEASE_INTERVAL_DAYS === 3);
|
||||
assert(
|
||||
'hasSuccessfulReleaseWithinDays blocks recent release',
|
||||
await rg.hasSuccessfulReleaseWithinDays(mockGithub, 'o', 'r', noonUtc)
|
||||
);
|
||||
|
||||
const oldReleaseGithub = {
|
||||
rest: {
|
||||
actions: {
|
||||
listWorkflowRuns: async () => ({
|
||||
data: {
|
||||
workflow_runs: [
|
||||
{
|
||||
conclusion: 'success',
|
||||
created_at: '2026-07-04T09:00:00Z',
|
||||
status: 'completed',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
assert(
|
||||
'hasSuccessfulReleaseWithinDays allows after 3 days',
|
||||
!(await rg.hasSuccessfulReleaseWithinDays(oldReleaseGithub, 'o', 'r', noonUtc))
|
||||
);
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
})();
|
||||
@@ -6,6 +6,8 @@ const https = require('https');
|
||||
const config = require('./gate-config.js');
|
||||
|
||||
const PACKAGE_PATHS = config.packagePaths;
|
||||
/** Minimum days between successful patch auto-releases. */
|
||||
const PATCH_RELEASE_INTERVAL_DAYS = 3;
|
||||
const ACTIVE_RELEASE_STATUSES = new Set([
|
||||
'queued', 'in_progress', 'waiting', 'pending', 'requested',
|
||||
]);
|
||||
@@ -61,8 +63,18 @@ function utcDayStart(now = new Date()) {
|
||||
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||
}
|
||||
|
||||
async function hasSuccessfulReleaseToday(github, owner, repo, now = new Date()) {
|
||||
const dayStart = utcDayStart(now);
|
||||
function releaseIntervalStart(now = new Date(), intervalDays = PATCH_RELEASE_INTERVAL_DAYS) {
|
||||
return new Date(now.getTime() - intervalDays * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
async function hasSuccessfulReleaseWithinDays(
|
||||
github,
|
||||
owner,
|
||||
repo,
|
||||
now = new Date(),
|
||||
intervalDays = PATCH_RELEASE_INTERVAL_DAYS,
|
||||
) {
|
||||
const windowStart = releaseIntervalStart(now, intervalDays);
|
||||
const runs = await github.rest.actions.listWorkflowRuns({
|
||||
owner,
|
||||
repo,
|
||||
@@ -71,10 +83,15 @@ async function hasSuccessfulReleaseToday(github, owner, repo, now = new Date())
|
||||
per_page: 30,
|
||||
});
|
||||
return runs.data.workflow_runs.some(
|
||||
(r) => r.conclusion === 'success' && new Date(r.created_at) >= dayStart
|
||||
(r) => r.conclusion === 'success' && new Date(r.created_at) >= windowStart
|
||||
);
|
||||
}
|
||||
|
||||
/** @deprecated Use hasSuccessfulReleaseWithinDays — kept for selftests. */
|
||||
async function hasSuccessfulReleaseToday(github, owner, repo, now = new Date()) {
|
||||
return hasSuccessfulReleaseWithinDays(github, owner, repo, now, 1);
|
||||
}
|
||||
|
||||
async function lastGreenCiSha(github, owner, repo) {
|
||||
const runs = await github.rest.actions.listWorkflowRuns({
|
||||
owner,
|
||||
@@ -116,9 +133,11 @@ async function evaluateReleasePreflight(github, owner, repo, options, core) {
|
||||
}
|
||||
|
||||
const referenceTime = options.now instanceof Date ? options.now : new Date();
|
||||
if (await hasSuccessfulReleaseToday(github, owner, repo, referenceTime)) {
|
||||
const day = referenceTime.toISOString().slice(0, 10);
|
||||
reasons.push(`already released today (UTC ${day}); max one patch release per day`);
|
||||
if (await hasSuccessfulReleaseWithinDays(github, owner, repo, referenceTime)) {
|
||||
reasons.push(
|
||||
`already released within last ${PATCH_RELEASE_INTERVAL_DAYS} days; `
|
||||
+ `max one patch release every ${PATCH_RELEASE_INTERVAL_DAYS} days`
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -196,6 +215,9 @@ module.exports = {
|
||||
bumpPatch,
|
||||
readVersionsFromTree,
|
||||
pypiVersionExists,
|
||||
PATCH_RELEASE_INTERVAL_DAYS,
|
||||
releaseIntervalStart,
|
||||
hasSuccessfulReleaseWithinDays,
|
||||
hasSuccessfulReleaseToday,
|
||||
utcDayStart,
|
||||
evaluateReleasePreflight,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -703,6 +703,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
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# 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"
|
||||
```
|
||||
|
||||
```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.
|
||||
|
||||
## Credential safety
|
||||
|
||||
The proxy token is only sent to the endpoint it was configured for. If a caller
|
||||
(or a prompt-injected agent) supplies an explicit `proxy_url` without a matching
|
||||
`token`, the ambient `TOOL_PROXY_TOKEN` is **not** attached — preventing the
|
||||
token from being exfiltrated to an untrusted endpoint. For normal use, leave
|
||||
`proxy_url`/`token` unset and configure `TOOL_PROXY_URL`/`TOOL_PROXY_TOKEN` via
|
||||
the environment.
|
||||
|
||||
## Error taxonomy
|
||||
|
||||
All failures are returned as `{"error": ...}` tool results:
|
||||
|
||||
- Authentication failed (HTTP 401/403)
|
||||
- Insufficient balance (HTTP 402)
|
||||
- Upstream 4xx/5xx passed through
|
||||
- Request timeout
|
||||
- `httpx` not installed → install hint
|
||||
|
||||
## Tier 0 — Skills directory compatibility (zero code)
|
||||
|
||||
Registries that expose `skill install <name>` install skills into
|
||||
`./.claude/skills/`. PraisonAI **already scans** this directory, so
|
||||
registry-installed skills are discoverable by PraisonAI agents today with no
|
||||
additional code:
|
||||
|
||||
- `praisonaiagents/skills/discovery.py` — `.claude/skills` compatibility scan
|
||||
plus an ancestor walk for monorepos.
|
||||
|
||||
## Tier 2 — Environment / CLI passthrough (config-only)
|
||||
|
||||
A registry's `shell` / `run <vendor> -- args` inject vendor-CLI credentials at
|
||||
execution time. Using a PraisonAI environment definition, install the registry
|
||||
CLI and log in via a token env var in `setup:`; the agent's `execute_command`
|
||||
then has authenticated vendor CLIs (e.g. `run gh -- pr list`) without any key in
|
||||
the container. Sandbox / board workers inherit the same via the environment
|
||||
definition.
|
||||
|
||||
## Licensing note
|
||||
|
||||
Some registries are Apache-2.0 **with additional terms** prohibiting embedding
|
||||
in commercially distributed products without permission. This connector is
|
||||
therefore **optional and bring-your-own-account only**: it ships no registry
|
||||
code and no default endpoint — the same posture as other BYO-account connectors.
|
||||
@@ -194,7 +194,7 @@ def example_team_preset():
|
||||
|
||||
print("✓ Motion graphics team created successfully!")
|
||||
print(f" Agents: {[agent.name for agent in team.agents]}")
|
||||
print(f" Leader: {team.leader.name}")
|
||||
print(f" Coordinator: {team.agents[0].name}")
|
||||
print(f" Workspace: {team._motion_graphics_workspace}")
|
||||
|
||||
except ImportError as e:
|
||||
|
||||
@@ -37,6 +37,11 @@ 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,
|
||||
)
|
||||
|
||||
try:
|
||||
__version__ = version("praisonai-tools")
|
||||
@@ -64,6 +69,10 @@ __all__ = [
|
||||
"FunctionTool",
|
||||
"is_tool",
|
||||
"get_tool_schema",
|
||||
# Tool discovery / catalogue
|
||||
"ToolCatalogueEntry",
|
||||
"list_tools",
|
||||
"get_tool_names",
|
||||
# Email Tool
|
||||
"EmailTool",
|
||||
"send_email",
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""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
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Tuple
|
||||
|
||||
ENTRY_POINT_GROUP = "praisonai.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.
|
||||
|
||||
We avoid importing the tool (which could pull heavy/optional deps) and
|
||||
instead produce a stable, human-friendly summary from the name itself.
|
||||
"""
|
||||
if name.endswith("Tool"):
|
||||
base = name[:-4]
|
||||
else:
|
||||
base = name
|
||||
return f"{base} integration tool".strip()
|
||||
|
||||
|
||||
def _first_party_entries() -> "list[ToolCatalogueEntry]":
|
||||
"""Build catalogue entries for every first-party tool class.
|
||||
|
||||
The source of truth is ``praisonai_tools.tools._TOOL_MAP`` which maps every
|
||||
public name (both classes and helper functions) to its defining module. We
|
||||
expose only the tool *classes* (names ending in ``Tool``) in the catalogue,
|
||||
since those are what ``agents.yaml`` and the SDK reference.
|
||||
"""
|
||||
from praisonai_tools.tools import _TOOL_MAP
|
||||
|
||||
entries = []
|
||||
for name, module in sorted(_TOOL_MAP.items()):
|
||||
if not name.endswith("Tool"):
|
||||
continue
|
||||
extras = _MODULE_EXTRAS.get(module, ())
|
||||
entries.append(
|
||||
ToolCatalogueEntry(
|
||||
name=name,
|
||||
module=module,
|
||||
extras=extras,
|
||||
summary=_summary_for(name),
|
||||
)
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def _entry_point_entries() -> "list[ToolCatalogueEntry]":
|
||||
"""Build catalogue entries for third-party tools registered via entry points.
|
||||
|
||||
Third-party packages register under the ``praisonai.tools`` entry-point
|
||||
group. We do not import the target object (to keep discovery cheap and
|
||||
dependency-free); we record the declared name and module value instead.
|
||||
"""
|
||||
try:
|
||||
from importlib.metadata import entry_points
|
||||
except ImportError: # pragma: no cover - Python < 3.8
|
||||
return []
|
||||
|
||||
entries = []
|
||||
try:
|
||||
eps = entry_points()
|
||||
# importlib.metadata API differs across versions.
|
||||
if hasattr(eps, "select"):
|
||||
selected = eps.select(group=ENTRY_POINT_GROUP)
|
||||
else: # pragma: no cover - Python < 3.10 dict interface
|
||||
selected = eps.get(ENTRY_POINT_GROUP, [])
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return []
|
||||
|
||||
for ep in selected:
|
||||
entries.append(
|
||||
ToolCatalogueEntry(
|
||||
name=ep.name,
|
||||
module=getattr(ep, "value", "") or "",
|
||||
extras=(),
|
||||
summary=f"Third-party tool registered via {ENTRY_POINT_GROUP}",
|
||||
)
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def list_tools() -> "list[ToolCatalogueEntry]":
|
||||
"""Return the full catalogue of available tools.
|
||||
|
||||
Includes every first-party tool shipped with ``praisonai-tools`` plus any
|
||||
third-party tool registered via the ``praisonai.tools`` entry-point group.
|
||||
Entries are de-duplicated by name (first-party wins) and returned sorted by
|
||||
name.
|
||||
"""
|
||||
seen = {}
|
||||
for entry in _first_party_entries():
|
||||
seen[entry.name] = entry
|
||||
for entry in _entry_point_entries():
|
||||
seen.setdefault(entry.name, entry)
|
||||
return sorted(seen.values(), key=lambda e: e.name)
|
||||
|
||||
|
||||
def get_tool_names() -> "set[str]":
|
||||
"""Return the set of every available tool name.
|
||||
|
||||
Convenience helper for validators that only need membership checks (e.g. to
|
||||
replace a hand-maintained allowlist).
|
||||
"""
|
||||
return {entry.name for entry in list_tools()}
|
||||
+18
-1
@@ -11,6 +11,7 @@ def main():
|
||||
print("PraisonAI Developer Tools CLI")
|
||||
print("\nUsage: praisonai-tools [tool] [args]")
|
||||
print("\nAvailable tools:")
|
||||
print(" list List the available tool catalogue")
|
||||
print(" docs-generate Generate SDK reference documentation")
|
||||
print(" video AI-powered video editing tools")
|
||||
print(" fcp Final Cut Pro automation tools")
|
||||
@@ -19,7 +20,23 @@ def main():
|
||||
tool = sys.argv[1]
|
||||
tool_args = sys.argv[2:]
|
||||
|
||||
if tool == "docs-generate":
|
||||
if tool == "list":
|
||||
parser = argparse.ArgumentParser(prog="praisonai-tools list")
|
||||
parser.add_argument("--extras", action="store_true",
|
||||
help="Only show tools that require optional-dependency extras")
|
||||
args = parser.parse_args(tool_args)
|
||||
from .catalogue import list_tools
|
||||
entries = list_tools()
|
||||
if args.extras:
|
||||
entries = [e for e in entries if e.extras]
|
||||
width = max((len(e.name) for e in entries), default=0)
|
||||
for entry in entries:
|
||||
extras = f" [extras: {', '.join(entry.extras)}]" if entry.extras else ""
|
||||
print(f"{entry.name.ljust(width)} {entry.summary}{extras}")
|
||||
print(f"\n{len(entries)} tools")
|
||||
return 0
|
||||
|
||||
elif tool == "docs-generate":
|
||||
# Handle docs-generate with its own parser for better help
|
||||
parser = argparse.ArgumentParser(prog="praisonai-tools docs-generate")
|
||||
parser.add_argument("--package", choices=["praisonaiagents", "praisonai", "typescript", "all"],
|
||||
|
||||
@@ -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,345 @@
|
||||
"""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 logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
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] = {}
|
||||
|
||||
|
||||
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,
|
||||
):
|
||||
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
|
||||
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"))
|
||||
return {"error": f"Unknown action '{action}'. Use search, describe or call."}
|
||||
|
||||
def _headers(self) -> Dict[str, str]:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
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}
|
||||
try:
|
||||
with httpx.Client(timeout=self.timeout) as client:
|
||||
response = client.get(
|
||||
f"{self.proxy_url}/catalog/{tool_id}",
|
||||
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)}
|
||||
|
||||
def _check_budget(self, tool_id: str):
|
||||
"""Deny + report if a configured spend guard would be exceeded.
|
||||
|
||||
Returns ``(denial, price)``: ``denial`` is a ``{"error": ...}`` dict when
|
||||
the call must be rejected (else ``None``); ``price`` is the validated
|
||||
per-call price when a guard applies (else ``None``).
|
||||
"""
|
||||
if self.max_cost_per_call is None and self.max_session_spend is None:
|
||||
return None, None
|
||||
described = self.describe(tool_id)
|
||||
if isinstance(described, dict) and "error" in described:
|
||||
return described, None
|
||||
price = described.get("price_per_call") if isinstance(described, dict) else None
|
||||
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_per_call is missing or invalid, cannot enforce "
|
||||
"the configured spend guard."
|
||||
)
|
||||
}, None
|
||||
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
|
||||
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
|
||||
return None, price
|
||||
|
||||
def call(self, tool_id: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""Invoke an endpoint through the proxy (credential injected server-side)."""
|
||||
missing = self._require_config()
|
||||
if missing:
|
||||
return missing
|
||||
if not tool_id:
|
||||
return {"error": "tool_id is required"}
|
||||
denied, quoted_price = self._check_budget(tool_id)
|
||||
if denied:
|
||||
return denied
|
||||
try:
|
||||
import httpx
|
||||
except ImportError:
|
||||
return {"error": _INSTALL_HINT}
|
||||
try:
|
||||
with httpx.Client(timeout=self.timeout) as client:
|
||||
response = client.post(
|
||||
f"{self.proxy_url}/call/{tool_id}",
|
||||
json=params or {},
|
||||
headers=self._headers(),
|
||||
)
|
||||
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 = None
|
||||
if isinstance(result, dict):
|
||||
charged = result.get("price_per_call", result.get("cost"))
|
||||
try:
|
||||
self._session_spend += float(charged)
|
||||
except (TypeError, ValueError):
|
||||
if quoted_price is not None:
|
||||
self._session_spend += quoted_price
|
||||
return result
|
||||
|
||||
|
||||
@tool
|
||||
def registry_search(
|
||||
query: str,
|
||||
proxy_url: Optional[str] = None,
|
||||
token: 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).
|
||||
|
||||
Returns:
|
||||
Dict with catalogue matches, or ``{"error": ...}`` on failure.
|
||||
"""
|
||||
return RegistryProxyTool(proxy_url=proxy_url, token=token).search(query)
|
||||
|
||||
|
||||
@tool
|
||||
def registry_describe(
|
||||
tool_id: str,
|
||||
proxy_url: Optional[str] = None,
|
||||
token: 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).
|
||||
|
||||
Returns:
|
||||
Dict describing the entry, or ``{"error": ...}`` on failure.
|
||||
"""
|
||||
return RegistryProxyTool(proxy_url=proxy_url, token=token).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,
|
||||
) -> 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.
|
||||
|
||||
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.
|
||||
|
||||
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,
|
||||
).call(tool_id, params)
|
||||
@@ -19,10 +19,12 @@ Ready-to-use tools:
|
||||
from praisonai_tools.tools.base import BaseTool, ToolResult, ToolValidationError, validate_tool
|
||||
from praisonai_tools.tools.decorator import tool, FunctionTool, is_tool, get_tool_schema
|
||||
|
||||
# Lazy imports for tools to avoid loading dependencies until needed
|
||||
def __getattr__(name):
|
||||
"""Lazy load tool classes."""
|
||||
tool_map = {
|
||||
# Mapping of every public tool name (classes and functions) to the module that
|
||||
# defines it. This is the single source of truth for lazy loading AND for the
|
||||
# catalogue/discovery contract (see ``praisonai_tools.catalogue``). Keeping it a
|
||||
# module-level constant lets other modules enumerate the shipped tools without
|
||||
# importing any optional dependency.
|
||||
_TOOL_MAP = {
|
||||
# Email
|
||||
"EmailTool": "email_tool",
|
||||
"send_email": "email_tool",
|
||||
@@ -122,6 +124,7 @@ def __getattr__(name):
|
||||
# Firecrawl
|
||||
"FirecrawlTool": "firecrawl_tool",
|
||||
"firecrawl_scrape": "firecrawl_tool",
|
||||
"firecrawl_search": "firecrawl_tool",
|
||||
# Serper
|
||||
"SerperTool": "serper_tool",
|
||||
"serper_search": "serper_tool",
|
||||
@@ -558,10 +561,14 @@ def __getattr__(name):
|
||||
"scan_agent_code": "inkog_tool",
|
||||
"scan_skill_package": "inkog_tool",
|
||||
"scan_mcp_server": "inkog_tool",
|
||||
}
|
||||
}
|
||||
|
||||
if name in tool_map:
|
||||
module_name = tool_map[name]
|
||||
|
||||
# Lazy imports for tools to avoid loading dependencies until needed
|
||||
def __getattr__(name):
|
||||
"""Lazy load tool classes."""
|
||||
if name in _TOOL_MAP:
|
||||
module_name = _TOOL_MAP[name]
|
||||
from importlib import import_module
|
||||
|
||||
# Handle absolute module paths (e.g., praisonai_tools.n8n.n8n_workflow)
|
||||
@@ -685,6 +692,7 @@ __all__ = [
|
||||
# Firecrawl Tool
|
||||
"FirecrawlTool",
|
||||
"firecrawl_scrape",
|
||||
"firecrawl_search",
|
||||
# Serper Tool
|
||||
"SerperTool",
|
||||
"serper_search",
|
||||
|
||||
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
+21
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-tools"
|
||||
version = "0.2.32"
|
||||
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,6 +64,9 @@ agentfolio = [
|
||||
n8n = [
|
||||
"httpx>=0.27.0",
|
||||
]
|
||||
registry-proxy = [
|
||||
"httpx>=0.24.0",
|
||||
]
|
||||
langextract = [
|
||||
"langextract>=0.1.0",
|
||||
]
|
||||
@@ -83,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"]
|
||||
@@ -164,8 +164,11 @@ class TestMotionGraphicsTeam:
|
||||
custom_param="test"
|
||||
)
|
||||
|
||||
# Check that custom parameters are passed through
|
||||
assert team.kwargs == {"custom_param": "test"}
|
||||
# Hierarchical teams forward process + manager_llm alongside
|
||||
# any user-supplied kwargs, so assert individual entries.
|
||||
assert team.kwargs["custom_param"] == "test"
|
||||
assert team.kwargs["process"] == "hierarchical"
|
||||
assert team.kwargs["manager_llm"] == "gpt-4"
|
||||
|
||||
# Check that workspace is set
|
||||
assert team._motion_graphics_workspace == Path(tmpdir)
|
||||
@@ -209,15 +212,20 @@ class TestMotionGraphicsTeam:
|
||||
@patch('praisonai_tools.video.motion_graphics.team.search_web', MockSearch())
|
||||
@patch('praisonai_tools.video.motion_graphics.team.create_motion_graphics_agent')
|
||||
def test_coordinator_is_leader(self, mock_create_agent):
|
||||
"""Test that coordinator is set as team leader."""
|
||||
"""Test that coordinator is the semantic leader (first agent).
|
||||
|
||||
In hierarchical process mode the manager LLM orchestrates workers,
|
||||
so the coordinator is the first agent rather than a static
|
||||
``team.leader`` reference.
|
||||
"""
|
||||
mock_animator = MockAgent(name="animator")
|
||||
mock_create_agent.return_value = mock_animator
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
team = motion_graphics_team(workspace=tmpdir)
|
||||
|
||||
assert team.leader is not None
|
||||
assert team.leader.name == "coordinator"
|
||||
coordinator = team.agents[0]
|
||||
assert coordinator.name == "coordinator"
|
||||
|
||||
@patch('praisonai_tools.video.motion_graphics.team.AgentTeam', MockAgentTeam)
|
||||
@patch('praisonai_tools.video.motion_graphics.team.Agent', MockAgent)
|
||||
@@ -278,7 +286,7 @@ class TestMotionGraphicsTeam:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
team = motion_graphics_team(workspace=tmpdir)
|
||||
|
||||
coordinator = team.leader
|
||||
coordinator = team.agents[0]
|
||||
instructions = coordinator.instructions.lower()
|
||||
|
||||
assert "coordinator" in instructions
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Tests for the tool discovery / catalogue contract."""
|
||||
|
||||
|
||||
def test_list_tools_importable_from_top_level():
|
||||
from praisonai_tools import list_tools, ToolCatalogueEntry, get_tool_names
|
||||
|
||||
assert callable(list_tools)
|
||||
assert callable(get_tool_names)
|
||||
assert ToolCatalogueEntry is not None
|
||||
|
||||
|
||||
def test_list_tools_returns_entries():
|
||||
from praisonai_tools import list_tools
|
||||
from praisonai_tools.catalogue import ToolCatalogueEntry
|
||||
|
||||
entries = list_tools()
|
||||
assert isinstance(entries, list)
|
||||
assert len(entries) > 100 # ships a large catalogue
|
||||
assert all(isinstance(e, ToolCatalogueEntry) for e in entries)
|
||||
|
||||
|
||||
def test_entries_only_expose_tool_classes():
|
||||
from praisonai_tools import list_tools
|
||||
|
||||
for entry in list_tools():
|
||||
assert entry.name.endswith("Tool"), entry.name
|
||||
|
||||
|
||||
def test_entries_are_sorted_and_unique():
|
||||
from praisonai_tools import list_tools
|
||||
|
||||
names = [e.name for e in list_tools()]
|
||||
assert names == sorted(names)
|
||||
assert len(names) == len(set(names))
|
||||
|
||||
|
||||
def test_catalogue_derived_from_tool_map_source_of_truth():
|
||||
from praisonai_tools import list_tools
|
||||
from praisonai_tools.tools import _TOOL_MAP
|
||||
|
||||
expected = {name for name in _TOOL_MAP if name.endswith("Tool")}
|
||||
actual = {e.name for e in list_tools()}
|
||||
# Every class in the map must be catalogued (third-party EPs may add more).
|
||||
assert expected <= actual
|
||||
|
||||
|
||||
def test_previously_missing_optional_tool_is_now_discoverable():
|
||||
# QdrantTool was a valid tool absent from the wrapper's hand-maintained
|
||||
# allowlist; it must now be discoverable via the catalogue.
|
||||
from praisonai_tools import get_tool_names
|
||||
|
||||
names = get_tool_names()
|
||||
assert "QdrantTool" in names
|
||||
|
||||
|
||||
def test_extras_are_populated_for_optional_tools():
|
||||
from praisonai_tools import list_tools
|
||||
|
||||
by_name = {e.name for e in list_tools()}
|
||||
entries = {e.name: e for e in list_tools()}
|
||||
|
||||
assert "SwarmScoreTool" in by_name
|
||||
assert "swarmscore" in entries["SwarmScoreTool"].extras
|
||||
|
||||
|
||||
def test_entries_have_summary_and_module():
|
||||
from praisonai_tools import list_tools
|
||||
|
||||
for entry in list_tools():
|
||||
assert entry.module
|
||||
assert entry.summary
|
||||
|
||||
|
||||
def test_list_tools_does_not_require_optional_deps():
|
||||
# Building the catalogue must not import any tool module / optional dep.
|
||||
from praisonai_tools import list_tools
|
||||
|
||||
entries = list_tools()
|
||||
assert entries # smoke: no ImportError raised
|
||||
@@ -0,0 +1,387 @@
|
||||
"""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 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/seo.backlinks",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
|
||||
class TestCall:
|
||||
def test_call_success(self, mock_httpx):
|
||||
from praisonai_tools.registry_proxy import RegistryProxyTool
|
||||
|
||||
response = Mock()
|
||||
response.json.return_value = {"data": {"backlinks": 42}}
|
||||
response.raise_for_status.return_value = None
|
||||
client = Mock()
|
||||
client.post.return_value = response
|
||||
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.post.assert_called_once_with(
|
||||
"https://r.example.com/call/seo.backlinks",
|
||||
json={"domain": "example.com"},
|
||||
headers={"Content-Type": "application/json", "Authorization": "Bearer t"},
|
||||
)
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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.post.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", {})
|
||||
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.post.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", {})
|
||||
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.post.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.post.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.post.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.post.assert_not_called()
|
||||
|
||||
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.post.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")
|
||||
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,
|
||||
)
|
||||
instance.call.assert_called_once_with("seo.backlinks", {"domain": "x"})
|
||||
|
||||
|
||||
def test_smoke_import():
|
||||
from praisonai_tools.registry_proxy import (
|
||||
RegistryProxyTool,
|
||||
registry_search,
|
||||
registry_describe,
|
||||
registry_call,
|
||||
)
|
||||
|
||||
assert callable(registry_search)
|
||||
assert callable(registry_describe)
|
||||
assert callable(registry_call)
|
||||
assert RegistryProxyTool().name == "registry_proxy"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_live_smoke():
|
||||
"""Gated live smoke test; skipped unless TOOL_PROXY_URL is configured."""
|
||||
proxy_url = os.getenv("TOOL_PROXY_URL")
|
||||
if not proxy_url:
|
||||
pytest.skip("TOOL_PROXY_URL not set")
|
||||
from praisonai_tools.registry_proxy import registry_search
|
||||
|
||||
result = registry_search("backlinks for a domain")
|
||||
assert isinstance(result, dict)
|
||||
assert "error" not in result
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Tier 0: pin that registry-installed skills (./.claude/skills/) are discoverable.
|
||||
|
||||
Registries that expose ``skill install <name>`` install into ``./.claude/skills/``.
|
||||
PraisonAI's skill discovery already scans that directory, so registry-installed
|
||||
skills are discoverable with zero connector code. This test pins that contract.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
|
||||
|
||||
def test_claude_skills_dir_is_scanned(tmp_path, monkeypatch):
|
||||
discovery = pytest.importorskip("praisonaiagents.skills.discovery")
|
||||
|
||||
skills_dir = tmp_path / ".claude" / "skills" / "my-registry-skill"
|
||||
skills_dir.mkdir(parents=True)
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
dirs = discovery.get_default_skill_dirs()
|
||||
|
||||
claude_skills = tmp_path / ".claude" / "skills"
|
||||
assert any(os.path.samefile(str(d), str(claude_skills)) for d in dirs), (
|
||||
"PraisonAI must scan ./.claude/skills/ so registry-installed skills are "
|
||||
"discoverable with zero connector code."
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Pytest configuration for video/motion-graphics tests.
|
||||
|
||||
Most tests in this tree are fully mocked (protocols, dataclasses, mocked
|
||||
backends) and run without the optional ``video-motion`` dependencies. Only a
|
||||
subset actually constructs :class:`HtmlRenderBackend`, which hard-requires
|
||||
``playwright`` and ``imageio-ffmpeg`` at construction time.
|
||||
|
||||
Rather than skipping the whole tree when those packages are absent (for example
|
||||
a minimal ``pip install -e ".[dev]"`` where the wheels could not be resolved),
|
||||
gate *only* the dependency-bound tests. Mark them with
|
||||
``@pytest.mark.requires_video_deps`` and this hook skips just those when the
|
||||
extras are missing, preserving coverage for the dependency-independent tests.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
|
||||
import pytest
|
||||
|
||||
_VIDEO_DEPS = ("playwright", "imageio_ffmpeg")
|
||||
_MISSING_VIDEO_DEPS = [
|
||||
name for name in _VIDEO_DEPS if importlib.util.find_spec(name) is None
|
||||
]
|
||||
VIDEO_DEPS_AVAILABLE = not _MISSING_VIDEO_DEPS
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"requires_video_deps: mark test as requiring the optional "
|
||||
"video-motion extras (playwright, imageio-ffmpeg).",
|
||||
)
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
if VIDEO_DEPS_AVAILABLE:
|
||||
return
|
||||
skip_reason = (
|
||||
"video-motion extra not installed "
|
||||
"(pip install -e '.[dev,video-motion]'); missing: "
|
||||
+ ", ".join(_MISSING_VIDEO_DEPS)
|
||||
)
|
||||
skip_marker = pytest.mark.skip(reason=skip_reason)
|
||||
for item in items:
|
||||
if "requires_video_deps" in item.keywords:
|
||||
item.add_marker(skip_marker)
|
||||
@@ -13,6 +13,7 @@ except ImportError:
|
||||
|
||||
|
||||
@pytest.mark.skipif(not BACKEND_AVAILABLE, reason="Motion graphics backend not available")
|
||||
@pytest.mark.requires_video_deps
|
||||
class TestBackendSafety:
|
||||
"""Test workspace safety features."""
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Unit tests for HTML render backend."""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
import asyncio
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch, AsyncMock
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonai_tools.video.motion_graphics.backend_html import HtmlRenderBackend
|
||||
from praisonai_tools.video.motion_graphics.protocols import RenderOpts
|
||||
@@ -13,22 +14,32 @@ from praisonai_tools.video.motion_graphics.protocols import RenderOpts
|
||||
class TestHtmlRenderBackend:
|
||||
"""Test HTML render backend."""
|
||||
|
||||
@pytest.mark.requires_video_deps
|
||||
def test_import_error_handling(self):
|
||||
"""Test that import errors are handled properly."""
|
||||
with patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', None):
|
||||
with pytest.raises(ImportError, match="Playwright not installed"):
|
||||
HtmlRenderBackend()
|
||||
|
||||
with patch('praisonai_tools.video.motion_graphics.backend_html.imageio_ffmpeg', None):
|
||||
with pytest.raises(ImportError, match="imageio-ffmpeg not installed"):
|
||||
HtmlRenderBackend()
|
||||
with (
|
||||
patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', None),
|
||||
pytest.raises(ImportError, match="Playwright not installed"),
|
||||
):
|
||||
HtmlRenderBackend()
|
||||
|
||||
# Ensure the playwright guard passes so the imageio-ffmpeg guard is the
|
||||
# one under test, regardless of whether the optional deps are installed.
|
||||
with (
|
||||
patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', Mock()),
|
||||
patch('praisonai_tools.video.motion_graphics.backend_html.imageio_ffmpeg', None),
|
||||
pytest.raises(ImportError, match="imageio-ffmpeg not installed"),
|
||||
):
|
||||
HtmlRenderBackend()
|
||||
|
||||
def test_init_success(self):
|
||||
"""Test successful initialization."""
|
||||
with patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', Mock()):
|
||||
with patch('praisonai_tools.video.motion_graphics.backend_html.imageio_ffmpeg', Mock()):
|
||||
backend = HtmlRenderBackend()
|
||||
assert backend is not None
|
||||
with (
|
||||
patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', Mock()),
|
||||
patch('praisonai_tools.video.motion_graphics.backend_html.imageio_ffmpeg', Mock()),
|
||||
):
|
||||
backend = HtmlRenderBackend()
|
||||
assert backend is not None
|
||||
|
||||
|
||||
class TestLinting:
|
||||
@@ -36,9 +47,11 @@ class TestLinting:
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures."""
|
||||
with patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', Mock()):
|
||||
with patch('praisonai_tools.video.motion_graphics.backend_html.imageio_ffmpeg', Mock()):
|
||||
self.backend = HtmlRenderBackend()
|
||||
with (
|
||||
patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', Mock()),
|
||||
patch('praisonai_tools.video.motion_graphics.backend_html.imageio_ffmpeg', Mock()),
|
||||
):
|
||||
self.backend = HtmlRenderBackend()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lint_missing_index_html(self):
|
||||
@@ -198,9 +211,11 @@ class TestRendering:
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures."""
|
||||
with patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', Mock()):
|
||||
with patch('praisonai_tools.video.motion_graphics.backend_html.imageio_ffmpeg', Mock()):
|
||||
self.backend = HtmlRenderBackend()
|
||||
with (
|
||||
patch('praisonai_tools.video.motion_graphics.backend_html.async_playwright', Mock()),
|
||||
patch('praisonai_tools.video.motion_graphics.backend_html.imageio_ffmpeg', Mock()),
|
||||
):
|
||||
self.backend = HtmlRenderBackend()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_missing_index_html(self):
|
||||
@@ -321,8 +336,10 @@ class TestRendering:
|
||||
mock_process.returncode = 0
|
||||
mock_subprocess.return_value = mock_process
|
||||
|
||||
with patch.object(self.backend, '_get_ffmpeg_path', return_value='ffmpeg'):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with (
|
||||
patch.object(self.backend, '_get_ffmpeg_path', return_value='ffmpeg'),
|
||||
tempfile.TemporaryDirectory() as tmpdir,
|
||||
):
|
||||
# Create fake frame files
|
||||
frame_dir = Path(tmpdir)
|
||||
frame_paths = []
|
||||
@@ -357,8 +374,10 @@ class TestRendering:
|
||||
mock_process.returncode = 1
|
||||
mock_subprocess.return_value = mock_process
|
||||
|
||||
with patch.object(self.backend, '_get_ffmpeg_path', return_value='ffmpeg'):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with (
|
||||
patch.object(self.backend, '_get_ffmpeg_path', return_value='ffmpeg'),
|
||||
tempfile.TemporaryDirectory() as tmpdir,
|
||||
):
|
||||
frame_path = Path(tmpdir) / "frame_000000.png"
|
||||
frame_path.write_bytes(b"fake")
|
||||
|
||||
@@ -378,8 +397,10 @@ class TestRendering:
|
||||
mock_process.kill = Mock()
|
||||
mock_subprocess.return_value = mock_process
|
||||
|
||||
with patch.object(self.backend, '_get_ffmpeg_path', return_value='ffmpeg'):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with (
|
||||
patch.object(self.backend, '_get_ffmpeg_path', return_value='ffmpeg'),
|
||||
tempfile.TemporaryDirectory() as tmpdir,
|
||||
):
|
||||
frame_path = Path(tmpdir) / "frame_000000.png"
|
||||
frame_path.write_bytes(b"fake")
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@ from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from praisonai_tools.video.motion_graphics.agent import (
|
||||
create_motion_graphics_agent,
|
||||
RenderTools,
|
||||
_resolve_backend
|
||||
_resolve_backend,
|
||||
create_motion_graphics_agent,
|
||||
)
|
||||
from praisonai_tools.video.motion_graphics.protocols import RenderResult, LintResult
|
||||
from praisonai_tools.video.motion_graphics.protocols import LintResult, RenderResult
|
||||
|
||||
|
||||
class MockAgent:
|
||||
@@ -96,6 +96,7 @@ class TestRenderTools:
|
||||
class TestResolveBackend:
|
||||
"""Test backend resolution."""
|
||||
|
||||
@pytest.mark.requires_video_deps
|
||||
def test_resolve_string_backend(self):
|
||||
"""Test resolving string backend specification."""
|
||||
backend = _resolve_backend("html")
|
||||
@@ -119,6 +120,7 @@ class TestResolveBackend:
|
||||
class TestCreateMotionGraphicsAgent:
|
||||
"""Test motion graphics agent factory."""
|
||||
|
||||
@pytest.mark.requires_video_deps
|
||||
@patch('praisonai_tools.video.motion_graphics.agent.Agent', MockAgent)
|
||||
@patch('praisonai_tools.video.motion_graphics.agent.FileTools', MockFileTools)
|
||||
def test_create_agent_defaults(self):
|
||||
@@ -137,6 +139,7 @@ class TestCreateMotionGraphicsAgent:
|
||||
"lint_composition", "render_composition"} <= tool_names
|
||||
assert "motion graphics specialist" in agent.instructions.lower()
|
||||
|
||||
@pytest.mark.requires_video_deps
|
||||
@patch('praisonai_tools.video.motion_graphics.agent.Agent', MockAgent)
|
||||
@patch('praisonai_tools.video.motion_graphics.agent.FileTools', MockFileTools)
|
||||
def test_create_agent_custom_params(self):
|
||||
@@ -159,6 +162,7 @@ class TestCreateMotionGraphicsAgent:
|
||||
with pytest.raises(ImportError, match="praisonaiagents not available"):
|
||||
create_motion_graphics_agent()
|
||||
|
||||
@pytest.mark.requires_video_deps
|
||||
@patch('praisonai_tools.video.motion_graphics.agent.Agent', MockAgent)
|
||||
@patch('praisonai_tools.video.motion_graphics.agent.FileTools', MockFileTools)
|
||||
def test_create_agent_auto_workspace(self):
|
||||
@@ -183,6 +187,7 @@ class TestCreateMotionGraphicsAgent:
|
||||
|
||||
assert agent._motion_graphics_backend is mock_backend
|
||||
|
||||
@pytest.mark.requires_video_deps
|
||||
@patch('praisonai_tools.video.motion_graphics.agent.Agent', MockAgent)
|
||||
@patch('praisonai_tools.video.motion_graphics.agent.FileTools', MockFileTools)
|
||||
def test_create_agent_workspace_creation(self):
|
||||
@@ -195,6 +200,7 @@ class TestCreateMotionGraphicsAgent:
|
||||
assert workspace_path.exists()
|
||||
assert agent._motion_graphics_workspace == workspace_path
|
||||
|
||||
@pytest.mark.requires_video_deps
|
||||
@patch('praisonai_tools.video.motion_graphics.agent.Agent', MockAgent)
|
||||
@patch('praisonai_tools.video.motion_graphics.agent.FileTools', MockFileTools)
|
||||
def test_create_agent_skill_included(self):
|
||||
@@ -207,6 +213,7 @@ class TestCreateMotionGraphicsAgent:
|
||||
assert "timeline" in agent.instructions
|
||||
assert "window.__timelines" in agent.instructions
|
||||
|
||||
@pytest.mark.requires_video_deps
|
||||
@patch('praisonai_tools.video.motion_graphics.agent.Agent', MockAgent)
|
||||
@patch('praisonai_tools.video.motion_graphics.agent.FileTools', MockFileTools)
|
||||
def test_create_agent_output_validation(self):
|
||||
@@ -219,6 +226,7 @@ class TestCreateMotionGraphicsAgent:
|
||||
assert "Never fabricate file paths" in agent.instructions
|
||||
assert "3 failed attempts" in agent.instructions
|
||||
|
||||
@pytest.mark.requires_video_deps
|
||||
@patch('praisonai_tools.video.motion_graphics.agent.Agent', MockAgent)
|
||||
@patch('praisonai_tools.video.motion_graphics.agent.FileTools', MockFileTools)
|
||||
def test_agent_tools_configuration(self):
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from praisonai_tools.video.motion_graphics.protocols import (
|
||||
LintResult,
|
||||
RenderBackendProtocol,
|
||||
RenderOpts,
|
||||
LintResult,
|
||||
RenderResult,
|
||||
RenderBackendProtocol
|
||||
)
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ class TestRenderResult:
|
||||
class TestRenderBackendProtocol:
|
||||
"""Test RenderBackendProtocol protocol."""
|
||||
|
||||
@pytest.mark.requires_video_deps
|
||||
def test_protocol_runtime_checkable(self):
|
||||
"""Test that protocol is runtime checkable."""
|
||||
from praisonai_tools.video.motion_graphics.backend_html import HtmlRenderBackend
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Tests for render retry functionality."""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from praisonai_tools.video.motion_graphics.agent import RenderTools
|
||||
from praisonai_tools.video.motion_graphics.protocols import RenderResult, RenderOpts
|
||||
from praisonai_tools.video.motion_graphics.protocols import RenderOpts, RenderResult
|
||||
AGENT_AVAILABLE = True
|
||||
except ImportError:
|
||||
AGENT_AVAILABLE = False
|
||||
|
||||
Reference in new issue
Block a user