mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-07 17:33:33 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a72e156c4d | ||
|
|
24385d6487 | ||
|
|
2f8162207f | ||
|
|
516d8b6d88 | ||
|
|
7eb98e895b | ||
|
|
ef79b7a056 | ||
|
|
3ea8376610 | ||
|
|
6360b24c06 | ||
|
|
b0d8f77752 | ||
|
|
179cab02db | ||
|
|
402d7ed9fc | ||
|
|
701e8e6a6a | ||
|
|
ef09341cc0 | ||
|
|
3a4d7e11c8 | ||
|
|
92644518c0 | ||
|
|
cd54574b20 | ||
|
|
68035af76d | ||
|
|
c989f2c31e | ||
|
|
a2c51a0760 | ||
|
|
7a601b5373 | ||
|
|
12f430e29c | ||
|
|
c3c78fbc7b | ||
|
|
bf120c8dca | ||
|
|
13d073b02a |
No files matched your search
@@ -181,12 +181,15 @@ jobs:
|
||||
|
||||
- name: Fetch PR branch and Setup Remote
|
||||
if: github.event.issue.pull_request
|
||||
env:
|
||||
PR_BRANCH: ${{ steps.check_fork.outputs.pr_branch }}
|
||||
IS_FORK: ${{ steps.check_fork.outputs.is_fork }}
|
||||
run: |
|
||||
# Fetch PR head from base repo and put it into local branch
|
||||
git fetch origin pull/${{ github.event.issue.number }}/head:${{ steps.check_fork.outputs.pr_branch }}
|
||||
git fetch origin pull/${{ github.event.issue.number }}/head:"${PR_BRANCH}"
|
||||
|
||||
# If it's a fork, make origin point to local to trick claude-code-action's `git fetch origin <branch>`
|
||||
if [ "${{ steps.check_fork.outputs.is_fork }}" == "true" ]; then
|
||||
if [ "${IS_FORK}" = "true" ]; then
|
||||
git remote set-url origin file://$(pwd)
|
||||
fi
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Security release checklist (maintainer)
|
||||
|
||||
After merging security PRs [#1684](https://github.com/MervinPraison/PraisonAI/pull/1684) and [#1685](https://github.com/MervinPraison/PraisonAI/pull/1685):
|
||||
|
||||
## 1. Publish PyPI (requires `PYPI_TOKEN`)
|
||||
|
||||
```bash
|
||||
cd src/praisonai-agents
|
||||
export PYPI_TOKEN=...
|
||||
praisonai publish pypi
|
||||
|
||||
cd ../praisonai
|
||||
python scripts/bump_and_release.py <WRAPPER_VERSION> --agents <AGENTS_VERSION> --wait
|
||||
```
|
||||
|
||||
## 2. Publish GitHub security advisories
|
||||
|
||||
Set versions to match PyPI, then:
|
||||
|
||||
```bash
|
||||
AGENTS_VERSION=1.6.40 WRAPPER_VERSION=4.6.40 ./scripts/security_publish_advisories.sh
|
||||
```
|
||||
|
||||
Add reporter `credits` per advisory via `gh api` before publishing if not already set.
|
||||
|
||||
## 3. Verify
|
||||
|
||||
```bash
|
||||
pip index versions praisonaiagents | head -1
|
||||
pip index versions praisonai | head -1
|
||||
gh api repos/MervinPraison/PraisonAI/security-advisories --jq '.[] | "\(.ghsa_id) \(.state) \(.cve_id // "pending")"'
|
||||
```
|
||||
|
||||
Resources: https://github.com/MervinPraison/PraisonAI · https://docs.praison.ai · https://praison.ai
|
||||
@@ -0,0 +1,26 @@
|
||||
# Security advisory triage (maintainer)
|
||||
|
||||
| GHSA | Status after batch 1+2 | Notes |
|
||||
|------|------------------------|-------|
|
||||
| GHSA-4mr5-g6f9-cfrh | fixed batch 1 | Sandbox AST |
|
||||
| GHSA-5c6w-wwfq-7qqm | fixed batch 1 | spider_tools SSRF |
|
||||
| GHSA-9cr9-25q5-8prj | fixed batch 1 | MCP yaml paths |
|
||||
| GHSA-86qc-r5v2-v6x6 | fixed batch 1 | call server auth |
|
||||
| GHSA-hvhp-v2gc-268q | fixed batch 1 | write_file cwd |
|
||||
| GHSA-5cxw-77wg-jrf3 | fixed batch 1 | @url mentions |
|
||||
| GHSA-xp85-6wwf-r67c | fixed batch 1 | GHA branch quote |
|
||||
| GHSA-3qg8-5g3r-79v5 | fixed batch 1+2 | JWT + issue guard |
|
||||
| GHSA-xwq8, 7p8g, w388, g8rr | fixed batch 1 | platform RBAC/IDOR partial |
|
||||
| GHSA-943m, 5jx9, 4x6r, 27p4, cp4f | fixed batch 2 | platform IDOR completion |
|
||||
| GHSA-vg22-4gmj-prxw | fixed batch 2 | example eval hardening |
|
||||
| GHSA-8444-4fhq-fxpq | already-fixed | APIConfig.auth_enabled default True |
|
||||
| GHSA-78r8-wwqv-r299 | already-fixed | load_user_module gate |
|
||||
| GHSA-gv23-xxxx-xxxx, GHSA-h8q5-cp56-rr65, GHSA-6h6v-xxxx-xxxx, GHSA-h37g-4h4p-9x97 | fixed batch 3 | service-layer workspace_id on get/update/delete |
|
||||
| GHSA-c2m8-xxxx-xxxx, GHSA-8g2p-xxxx-xxxx | fixed batch 3 | only owner assigns admin/owner |
|
||||
| GHSA-h8q5-cp56-rr65 | fixed batch 3 | bind default 127.0.0.1 (+ PLATFORM_HOST) |
|
||||
| GHSA-6xj3-927j-6pqw | not-applicable | Open WebUI path; not in this repo |
|
||||
| GHSA-gmjg, 9q28 | published | prior release |
|
||||
|
||||
**Code fixed on main; GHSA state still triage until PyPI publish + advisory publish.**
|
||||
|
||||
Resources: https://github.com/MervinPraison/PraisonAI · https://docs.praison.ai · https://praison.ai
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.38" \
|
||||
"praisonai>=4.6.40" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.38" \
|
||||
"praisonai>=4.6.40" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.38" \
|
||||
"praisonai>=4.6.40" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -23,12 +23,39 @@ print(f"[2] Updated to v{managed.agent_version}")
|
||||
result = agent.start("What is 123 + 456?", stream=True)
|
||||
|
||||
# ── 3. Custom tool ──
|
||||
def _safe_calc(expr: str) -> str:
|
||||
import ast
|
||||
allowed = set("0123456789+-*/.() ")
|
||||
if not all(c in allowed for c in expr):
|
||||
return "error"
|
||||
try:
|
||||
tree = ast.parse(expr, mode="eval")
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(
|
||||
node,
|
||||
(
|
||||
ast.Expression,
|
||||
ast.BinOp,
|
||||
ast.UnaryOp,
|
||||
ast.Constant,
|
||||
ast.Add,
|
||||
ast.Sub,
|
||||
ast.Mult,
|
||||
ast.Div,
|
||||
ast.USub,
|
||||
ast.UAdd,
|
||||
),
|
||||
):
|
||||
return "error"
|
||||
val = eval(compile(tree, "<expr>", "eval"), {"__builtins__": {}})
|
||||
return str(val)
|
||||
except Exception:
|
||||
return "error"
|
||||
|
||||
|
||||
def handle_calculator(tool_name, tool_input):
|
||||
expr = tool_input.get("expression", "0")
|
||||
try:
|
||||
val = eval(expr, {"__builtins__": {}})
|
||||
except Exception:
|
||||
val = "error"
|
||||
val = _safe_calc(str(expr))
|
||||
print(f" [Calculator: {expr} = {val}]")
|
||||
return str(val)
|
||||
|
||||
|
||||
@@ -44,12 +44,33 @@ def main():
|
||||
|
||||
def calculate(expression: str) -> str:
|
||||
"""Calculate a math expression safely."""
|
||||
try:
|
||||
# Safe eval for basic math
|
||||
allowed = set("0123456789+-*/.() ")
|
||||
if all(c in allowed for c in expression):
|
||||
return str(eval(expression))
|
||||
import ast
|
||||
|
||||
allowed = set("0123456789+-*/.() ")
|
||||
if not all(c in allowed for c in expression):
|
||||
return "Error: Invalid expression"
|
||||
try:
|
||||
tree = ast.parse(expression, mode="eval")
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(
|
||||
node,
|
||||
(
|
||||
ast.Expression,
|
||||
ast.BinOp,
|
||||
ast.UnaryOp,
|
||||
ast.Constant,
|
||||
ast.Add,
|
||||
ast.Sub,
|
||||
ast.Mult,
|
||||
ast.Div,
|
||||
ast.USub,
|
||||
ast.UAdd,
|
||||
),
|
||||
):
|
||||
return "Error: Invalid expression"
|
||||
return str(
|
||||
eval(compile(tree, "<expr>", "eval"), {"__builtins__": {}})
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
# Publish security advisories after PyPI release.
|
||||
# Usage: AGENTS_VERSION=1.6.40 WRAPPER_VERSION=4.6.40 ./scripts/security_publish_advisories.sh
|
||||
set -euo pipefail
|
||||
|
||||
REPO="MervinPraison/PraisonAI"
|
||||
AGENTS_VERSION="${AGENTS_VERSION:?Set AGENTS_VERSION (e.g. 1.6.40)}"
|
||||
WRAPPER_VERSION="${WRAPPER_VERSION:?Set WRAPPER_VERSION (e.g. 4.6.40)}"
|
||||
LAST_AGENTS="${LAST_AGENTS:-1.6.39}"
|
||||
LAST_WRAPPER="${LAST_WRAPPER:-4.6.39}"
|
||||
|
||||
# Batch 1 + 2 GHSA IDs (update credits login per advisory via gh api first if needed)
|
||||
AGENTS_GHSAS=(
|
||||
GHSA-4mr5-g6f9-cfrh
|
||||
GHSA-5c6w-wwfq-7qqm
|
||||
GHSA-5cxw-77wg-jrf3
|
||||
)
|
||||
|
||||
WRAPPER_GHSAS=(
|
||||
GHSA-9cr9-25q5-8prj
|
||||
GHSA-86qc-r5v2-v6x6
|
||||
GHSA-hvhp-v2gc-268q
|
||||
GHSA-xp85-6wwf-r67c
|
||||
GHSA-8444-4fhq-fxpq
|
||||
GHSA-78r8-wwqv-r299
|
||||
GHSA-vg22-4gmj-prxw
|
||||
GHSA-6xj3-927j-6pqw
|
||||
)
|
||||
|
||||
patch_advisory() {
|
||||
local ghsa="$1" package="$2" last="$3" patched="$4"
|
||||
gh api "repos/${REPO}/security-advisories/${ghsa}" --method PATCH --input - <<EOF
|
||||
{
|
||||
"state": "draft",
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"package": {"ecosystem": "pip", "name": "${package}"},
|
||||
"vulnerable_version_range": "<= ${last}",
|
||||
"patched_versions": ">= ${patched}"
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
gh api "repos/${REPO}/security-advisories/${ghsa}" --method PATCH -f state=published
|
||||
gh api "repos/${REPO}/security-advisories/${ghsa}/cve" --method POST || true
|
||||
echo "Published ${ghsa}"
|
||||
}
|
||||
|
||||
for ghsa in "${AGENTS_GHSAS[@]}"; do
|
||||
patch_advisory "$ghsa" "praisonaiagents" "$LAST_AGENTS" "$AGENTS_VERSION"
|
||||
done
|
||||
|
||||
for ghsa in "${WRAPPER_GHSAS[@]}"; do
|
||||
patch_advisory "$ghsa" "praisonai" "$LAST_WRAPPER" "$WRAPPER_VERSION"
|
||||
done
|
||||
|
||||
echo "Done. Verify: gh api repos/${REPO}/security-advisories --jq '.[] | select(.state==\"published\") | .ghsa_id'"
|
||||
@@ -164,7 +164,8 @@ Follow best practices and coding standards."""
|
||||
messages=messages,
|
||||
**{k: v for k, v in kwargs.items() if k != "model"}
|
||||
)
|
||||
|
||||
if not response.choices or response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
code = response.choices[0].message.content
|
||||
self._log("Code generated successfully", "green")
|
||||
return code
|
||||
@@ -204,7 +205,8 @@ Follow best practices and coding standards."""
|
||||
messages=messages,
|
||||
**{k: v for k, v in kwargs.items() if k != "model"}
|
||||
)
|
||||
|
||||
if not response.choices or response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
code = response.choices[0].message.content
|
||||
self._log("Code generated successfully", "green")
|
||||
return code
|
||||
@@ -365,6 +367,8 @@ Provide clear, actionable feedback."""
|
||||
**{k: v for k, v in kwargs.items() if k != "model"}
|
||||
)
|
||||
|
||||
if not response.choices or response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
review = response.choices[0].message.content
|
||||
self._log("Review completed", "green")
|
||||
return review
|
||||
@@ -405,6 +409,8 @@ Include:
|
||||
**{k: v for k, v in kwargs.items() if k != "model"}
|
||||
)
|
||||
|
||||
if not response.choices or response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
explanation = response.choices[0].message.content
|
||||
self._log("Explanation completed", "green")
|
||||
return explanation
|
||||
@@ -451,6 +457,8 @@ Only output the refactored code."""
|
||||
**{k: v for k, v in kwargs.items() if k != "model"}
|
||||
)
|
||||
|
||||
if not response.choices or response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
refactored = response.choices[0].message.content
|
||||
self._log("Refactoring completed", "green")
|
||||
return refactored
|
||||
@@ -495,6 +503,8 @@ Ensure the fix:
|
||||
**{k: v for k, v in kwargs.items() if k != "model"}
|
||||
)
|
||||
|
||||
if not response.choices or response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
fixed = response.choices[0].message.content
|
||||
self._log("Fix completed", "green")
|
||||
return fixed
|
||||
@@ -907,6 +907,8 @@ class OpenAIClient:
|
||||
final_response = self._responses_to_chat_completion(raw)
|
||||
|
||||
# Display the response text if display_fn provided
|
||||
if not final_response.choices or final_response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
response_text = final_response.choices[0].message.content or ""
|
||||
if display_fn and response_text:
|
||||
from rich.markdown import Markdown
|
||||
@@ -1167,6 +1169,8 @@ class OpenAIClient:
|
||||
final_response = self._responses_to_chat_completion(raw)
|
||||
|
||||
# Display the response text if display_fn provided
|
||||
if not final_response.choices or final_response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
response_text = final_response.choices[0].message.content or ""
|
||||
if display_fn and response_text:
|
||||
from rich.markdown import Markdown
|
||||
@@ -1638,8 +1642,10 @@ class OpenAIClient:
|
||||
)
|
||||
|
||||
# Check for tool calls
|
||||
if not final_response.choices or final_response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None)
|
||||
|
||||
|
||||
# Emit llm_content for intermediate narrative display
|
||||
# (gpt-4.1+ models produce text alongside tool calls)
|
||||
response_content = getattr(final_response.choices[0].message, 'content', None)
|
||||
@@ -1835,10 +1841,12 @@ class OpenAIClient:
|
||||
|
||||
if not final_response:
|
||||
return None
|
||||
|
||||
|
||||
# Check for tool calls
|
||||
if not final_response.choices or final_response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None)
|
||||
|
||||
|
||||
# Emit llm_content for intermediate narrative display
|
||||
# (gpt-4.1+ models produce text alongside tool calls)
|
||||
response_content = getattr(final_response.choices[0].message, 'content', None)
|
||||
@@ -2037,6 +2045,8 @@ class OpenAIClient:
|
||||
return
|
||||
|
||||
# Check for tool calls
|
||||
if not final_response.choices or final_response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None)
|
||||
|
||||
if tool_calls and execute_tool_fn:
|
||||
|
||||
@@ -75,12 +75,12 @@ TOOL_MAPPINGS = {
|
||||
|
||||
|
||||
# Python Tools
|
||||
'execute_code': ('.python_tools', 'PythonTools'),
|
||||
'analyze_code': ('.python_tools', 'PythonTools'),
|
||||
'format_code': ('.python_tools', 'PythonTools'),
|
||||
'lint_code': ('.python_tools', 'PythonTools'),
|
||||
'disassemble_code': ('.python_tools', 'PythonTools'),
|
||||
'python_tools': ('.python_tools', 'PythonTools'),
|
||||
'execute_code': ('.python_tools', None),
|
||||
'analyze_code': ('.python_tools', None),
|
||||
'format_code': ('.python_tools', None),
|
||||
'lint_code': ('.python_tools', None),
|
||||
'disassemble_code': ('.python_tools', None),
|
||||
'python_tools': ('.python_tools', None),
|
||||
|
||||
|
||||
# Chain of Thought Training Tools
|
||||
@@ -326,7 +326,7 @@ def __getattr__(name: str) -> Any:
|
||||
if name in [
|
||||
'duckduckgo', 'internet_search', 'searxng_search', 'searxng',
|
||||
'scrape_page', 'extract_links', 'crawl', 'extract_text',
|
||||
'execute_command', 'list_processes', 'kill_process', 'get_system_info',
|
||||
'execute_command', 'execute_code', 'analyze_code', 'format_code', 'lint_code', 'disassemble_code', 'list_processes', 'kill_process', 'get_system_info',
|
||||
'tavily', 'tavily_search', 'tavily_extract', 'tavily_crawl', 'tavily_map',
|
||||
'tavily_search_async', 'tavily_extract_async',
|
||||
'ydc', 'ydc_search', 'ydc_contents', 'ydc_news', 'ydc_images',
|
||||
|
||||
@@ -268,6 +268,11 @@ class MentionsParser:
|
||||
def _process_url_mention(self, url: str) -> Optional[str]:
|
||||
"""Process @url:https://... mention."""
|
||||
try:
|
||||
from praisonaiagents.tools.spider_tools import SpiderTools
|
||||
|
||||
if not SpiderTools()._validate_url(url):
|
||||
return f"# URL: {url}\n[Blocked: URL is not allowed]"
|
||||
|
||||
import urllib.request
|
||||
|
||||
req = urllib.request.Request(
|
||||
|
||||
@@ -36,6 +36,30 @@ def _safe_getattr(obj, name, *default):
|
||||
return getattr(obj, name, *default) if default else getattr(obj, name)
|
||||
|
||||
|
||||
_SANDBOX_BLOCKED_ATTRS = frozenset({
|
||||
'__subclasses__', '__bases__', '__mro__', '__globals__',
|
||||
'__code__', '__class__', '__dict__', '__builtins__',
|
||||
'__import__', '__loader__', '__spec__', '__init_subclass__',
|
||||
'__set_name__', '__reduce__', '__reduce_ex__',
|
||||
'__traceback__', '__qualname__', '__module__',
|
||||
'__wrapped__', '__closure__', '__annotations__',
|
||||
'__self__', # C builtins leak real builtins module (GHSA-4mr5-g6f9-cfrh)
|
||||
# Frame/code object introspection
|
||||
'gi_frame', 'gi_code', 'cr_frame', 'cr_code',
|
||||
'ag_frame', 'ag_code', 'tb_frame', 'tb_next',
|
||||
'f_globals', 'f_locals', 'f_builtins', 'f_code',
|
||||
'co_consts', 'co_names',
|
||||
'__getattribute__', '__getattr__', '__setattr__', '__delattr__',
|
||||
'__dir__', '__get__', '__set__', '__delete__',
|
||||
})
|
||||
|
||||
_SANDBOX_BLOCKED_CALLS = frozenset({
|
||||
'exec', 'eval', 'compile', '__import__',
|
||||
'open', 'input', 'breakpoint',
|
||||
'setattr', 'delattr', 'dir', 'vars',
|
||||
})
|
||||
|
||||
|
||||
def _validate_code_ast(code: str):
|
||||
"""Validate code using AST — catches attacks that bypass text checks.
|
||||
|
||||
@@ -48,23 +72,6 @@ def _validate_code_ast(code: str):
|
||||
except SyntaxError:
|
||||
return None # let compile() handle syntax errors later
|
||||
|
||||
# Dangerous dunder attributes attackers use for sandbox escape
|
||||
_blocked_attrs = frozenset({
|
||||
'__subclasses__', '__bases__', '__mro__', '__globals__',
|
||||
'__code__', '__class__', '__dict__', '__builtins__',
|
||||
'__import__', '__loader__', '__spec__', '__init_subclass__',
|
||||
'__set_name__', '__reduce__', '__reduce_ex__',
|
||||
'__traceback__', '__qualname__', '__module__',
|
||||
'__wrapped__', '__closure__', '__annotations__',
|
||||
# Frame/code object introspection
|
||||
'gi_frame', 'gi_code', 'cr_frame', 'cr_code',
|
||||
'ag_frame', 'ag_code', 'tb_frame', 'tb_next',
|
||||
'f_globals', 'f_locals', 'f_builtins', 'f_code',
|
||||
'co_consts', 'co_names',
|
||||
'__getattribute__', '__getattr__', '__setattr__', '__delattr__',
|
||||
'__dir__', '__get__', '__set__', '__delete__',
|
||||
})
|
||||
|
||||
for node in ast.walk(tree):
|
||||
# Block import statements
|
||||
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
||||
@@ -72,28 +79,26 @@ def _validate_code_ast(code: str):
|
||||
|
||||
# Block attribute access to dangerous dunders
|
||||
if isinstance(node, ast.Attribute):
|
||||
if node.attr in _blocked_attrs:
|
||||
if node.attr in _SANDBOX_BLOCKED_ATTRS:
|
||||
return (
|
||||
f"Access to attribute '{node.attr}' is restricted"
|
||||
)
|
||||
|
||||
# Block calls to dangerous builtins by name
|
||||
# Block calls to dangerous builtins (bare name or attribute access)
|
||||
if isinstance(node, ast.Call):
|
||||
func = node.func
|
||||
if isinstance(func, ast.Name) and func.id in (
|
||||
'exec', 'eval', 'compile', '__import__',
|
||||
'open', 'input', 'breakpoint',
|
||||
'setattr', 'delattr', 'dir',
|
||||
):
|
||||
if isinstance(func, ast.Name) and func.id in _SANDBOX_BLOCKED_CALLS:
|
||||
return f"Call to '{func.id}' is not allowed"
|
||||
if isinstance(func, ast.Attribute) and func.attr in _SANDBOX_BLOCKED_CALLS:
|
||||
return f"Call to '{func.attr}' is not allowed"
|
||||
|
||||
# Block dangerous constants (strings containing dunders)
|
||||
# Fallback for Python 3.7 ast.Str
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
if any(attr in node.value for attr in _blocked_attrs):
|
||||
if any(attr in node.value for attr in _SANDBOX_BLOCKED_ATTRS):
|
||||
return f"String constant contains restricted attribute name"
|
||||
elif type(node).__name__ == 'Str':
|
||||
if any(attr in getattr(node, 's', '') for attr in _blocked_attrs):
|
||||
if any(attr in getattr(node, 's', '') for attr in _SANDBOX_BLOCKED_ATTRS):
|
||||
return f"String constant contains restricted attribute name"
|
||||
|
||||
return None
|
||||
@@ -112,7 +117,16 @@ def _execute_code_sandboxed(
|
||||
"""
|
||||
if limits is None:
|
||||
limits = ResourceLimits.minimal()
|
||||
|
||||
|
||||
ast_error = _validate_code_ast(code)
|
||||
if ast_error:
|
||||
return {
|
||||
'result': None,
|
||||
'stdout': '',
|
||||
'stderr': f'Security Error: {ast_error}',
|
||||
'success': False,
|
||||
}
|
||||
|
||||
try:
|
||||
# Create temporary file for the code
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
|
||||
@@ -151,21 +165,8 @@ def safe_execute():
|
||||
}}
|
||||
|
||||
# Block dangerous patterns
|
||||
blocked_attrs = {{
|
||||
'__subclasses__', '__bases__', '__mro__', '__globals__',
|
||||
'__code__', '__class__', '__dict__', '__builtins__',
|
||||
'__import__', '__loader__', '__spec__', '__init_subclass__',
|
||||
'__set_name__', '__reduce__', '__reduce_ex__',
|
||||
'__traceback__', '__qualname__', '__module__',
|
||||
'__wrapped__', '__closure__', '__annotations__',
|
||||
# Frame/code object introspection
|
||||
'gi_frame', 'gi_code', 'cr_frame', 'cr_code',
|
||||
'ag_frame', 'ag_code', 'tb_frame', 'tb_next',
|
||||
'f_globals', 'f_locals', 'f_builtins', 'f_code',
|
||||
'co_consts', 'co_names',
|
||||
'__getattribute__', '__getattr__', '__setattr__', '__delattr__',
|
||||
'__dir__', '__get__', '__set__', '__delete__',
|
||||
}}
|
||||
blocked_attrs = {set(_SANDBOX_BLOCKED_ATTRS)!r}
|
||||
blocked_calls = {set(_SANDBOX_BLOCKED_CALLS)!r}
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
||||
@@ -182,14 +183,20 @@ def safe_execute():
|
||||
"stderr": f"Access to attribute '{{node.attr}}' is restricted",
|
||||
"success": False
|
||||
}}
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
|
||||
if node.func.id in ('exec', 'eval', 'compile', '__import__',
|
||||
'open', 'input', 'breakpoint',
|
||||
'setattr', 'delattr', 'dir'):
|
||||
if isinstance(node, ast.Call):
|
||||
func = node.func
|
||||
if isinstance(func, ast.Name) and func.id in blocked_calls:
|
||||
return {{
|
||||
"result": None,
|
||||
"stdout": "",
|
||||
"stderr": f"Call to '{{node.func.id}}' is not allowed",
|
||||
"stderr": f"Call to '{{func.id}}' is not allowed",
|
||||
"success": False
|
||||
}}
|
||||
if isinstance(func, ast.Attribute) and func.attr in blocked_calls:
|
||||
return {{
|
||||
"result": None,
|
||||
"stdout": "",
|
||||
"stderr": f"Call to '{{func.attr}}' is not allowed",
|
||||
"success": False
|
||||
}}
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
@@ -481,7 +488,7 @@ def _execute_code_direct(
|
||||
'__import__', 'import ', 'from ', 'exec', 'eval',
|
||||
'compile', 'open(', 'file(', 'input(', 'raw_input',
|
||||
'__subclasses__', '__bases__', '__globals__', '__code__',
|
||||
'__class__', 'globals(', 'locals(', 'vars('
|
||||
'__class__', '__self__', 'globals(', 'locals(', 'vars('
|
||||
]
|
||||
|
||||
code_lower = code.lower()
|
||||
|
||||
@@ -11,6 +11,8 @@ content = scrape_page("https://example.com")
|
||||
"""
|
||||
|
||||
import logging
|
||||
import ipaddress
|
||||
import socket
|
||||
from typing import List, Dict, Union, Optional, Any
|
||||
from importlib import util
|
||||
import json
|
||||
@@ -20,6 +22,48 @@ import os
|
||||
import hashlib
|
||||
import time
|
||||
|
||||
|
||||
def _host_is_blocked(hostname: str) -> bool:
|
||||
"""Return True when hostname resolves to loopback/private/internal targets."""
|
||||
if not hostname:
|
||||
return True
|
||||
host = hostname.lower().rstrip(".")
|
||||
if host in ("localhost", "0.0.0.0", "::1") or host.endswith(".localhost"):
|
||||
return True
|
||||
if host in ("169.254.169.254", "metadata.google.internal"):
|
||||
return True
|
||||
if any(host.endswith(suffix) for suffix in (".local", ".internal", ".localdomain")):
|
||||
return True
|
||||
|
||||
def _ip_blocked(ip: ipaddress._BaseAddress) -> bool:
|
||||
return bool(
|
||||
ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local
|
||||
)
|
||||
|
||||
if host.isdigit():
|
||||
try:
|
||||
return _ip_blocked(ipaddress.ip_address(int(host)))
|
||||
except (ValueError, OverflowError):
|
||||
return True
|
||||
|
||||
if host.startswith("0x"):
|
||||
try:
|
||||
return _ip_blocked(ipaddress.ip_address(int(host, 16)))
|
||||
except (ValueError, OverflowError):
|
||||
return True
|
||||
|
||||
try:
|
||||
return _ip_blocked(ipaddress.ip_address(host))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
try:
|
||||
return _ip_blocked(ipaddress.ip_address(socket.inet_aton(host)))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
class SpiderTools:
|
||||
"""Tools for web scraping and crawling."""
|
||||
|
||||
@@ -59,31 +103,9 @@ class SpiderTools:
|
||||
if not parsed.hostname:
|
||||
return False
|
||||
|
||||
# Reject local/internal addresses
|
||||
hostname = parsed.hostname.lower()
|
||||
|
||||
# Block localhost and loopback
|
||||
if hostname in ['localhost', '127.0.0.1', '0.0.0.0', '::1']:
|
||||
if _host_is_blocked(parsed.hostname):
|
||||
return False
|
||||
|
||||
# Block private IP ranges
|
||||
import ipaddress
|
||||
try:
|
||||
ip = ipaddress.ip_address(hostname)
|
||||
if ip.is_private or ip.is_reserved or ip.is_loopback or ip.is_link_local:
|
||||
return False
|
||||
except ValueError:
|
||||
# Not an IP address, continue with domain validation
|
||||
pass
|
||||
|
||||
# Block common internal domains
|
||||
if any(hostname.endswith(domain) for domain in ['.local', '.internal', '.localdomain']):
|
||||
return False
|
||||
|
||||
# Block metadata service endpoints
|
||||
if hostname in ['169.254.169.254', 'metadata.google.internal']:
|
||||
return False
|
||||
|
||||
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.38"
|
||||
version = "1.6.40"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -21,6 +21,16 @@ import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
def _unregister_bot_platform(name: str) -> None:
|
||||
"""Remove a runtime-registered bot platform (tests only)."""
|
||||
from praisonai.bots._registry import get_default_bot_registry
|
||||
|
||||
try:
|
||||
get_default_bot_registry().unregister(name.lower())
|
||||
except (KeyError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# Helper: Mock adapter that simulates a platform bot
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
@@ -59,9 +69,10 @@ class TestBotWithMockAdapter:
|
||||
"""Integration tests for Bot class with a mock platform adapter."""
|
||||
|
||||
def setup_method(self):
|
||||
from praisonai.bots._registry import register_platform, _custom_platforms
|
||||
from praisonai.bots._registry import register_platform
|
||||
|
||||
register_platform("mockbot", MockPlatformAdapter)
|
||||
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
|
||||
self._cleanup = lambda: _unregister_bot_platform("mockbot")
|
||||
|
||||
def teardown_method(self):
|
||||
self._cleanup()
|
||||
@@ -148,10 +159,14 @@ class TestBotOSLifecycle:
|
||||
"""Integration tests for BotOS multi-bot orchestration."""
|
||||
|
||||
def setup_method(self):
|
||||
from praisonai.bots._registry import register_platform, _custom_platforms
|
||||
from praisonai.bots._registry import register_platform
|
||||
|
||||
register_platform("mock1", MockPlatformAdapter)
|
||||
register_platform("mock2", MockPlatformAdapter)
|
||||
self._cleanup = lambda: (_custom_platforms.pop("mock1", None), _custom_platforms.pop("mock2", None))
|
||||
self._cleanup = lambda: (
|
||||
_unregister_bot_platform("mock1"),
|
||||
_unregister_bot_platform("mock2"),
|
||||
)
|
||||
|
||||
def teardown_method(self):
|
||||
self._cleanup()
|
||||
@@ -212,9 +227,10 @@ class TestRealAgentInBot:
|
||||
"""Tests with a real praisonaiagents.Agent inside Bot."""
|
||||
|
||||
def setup_method(self):
|
||||
from praisonai.bots._registry import register_platform, _custom_platforms
|
||||
from praisonai.bots._registry import register_platform
|
||||
|
||||
register_platform("mockbot", MockPlatformAdapter)
|
||||
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
|
||||
self._cleanup = lambda: _unregister_bot_platform("mockbot")
|
||||
|
||||
def teardown_method(self):
|
||||
self._cleanup()
|
||||
@@ -246,9 +262,10 @@ class TestAgentTeamInBotOS:
|
||||
"""AgentTeam (multi-agent) inside Bot and BotOS."""
|
||||
|
||||
def setup_method(self):
|
||||
from praisonai.bots._registry import register_platform, _custom_platforms
|
||||
from praisonai.bots._registry import register_platform
|
||||
|
||||
register_platform("mockbot", MockPlatformAdapter)
|
||||
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
|
||||
self._cleanup = lambda: _unregister_bot_platform("mockbot")
|
||||
|
||||
def teardown_method(self):
|
||||
self._cleanup()
|
||||
@@ -300,9 +317,10 @@ class TestAgentFlowInBotOS:
|
||||
"""AgentFlow (deterministic workflow) inside Bot and BotOS."""
|
||||
|
||||
def setup_method(self):
|
||||
from praisonai.bots._registry import register_platform, _custom_platforms
|
||||
from praisonai.bots._registry import register_platform
|
||||
|
||||
register_platform("mockbot", MockPlatformAdapter)
|
||||
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
|
||||
self._cleanup = lambda: _unregister_bot_platform("mockbot")
|
||||
|
||||
def teardown_method(self):
|
||||
self._cleanup()
|
||||
@@ -355,9 +373,8 @@ class TestPlatformExtensibility:
|
||||
"""Third-party platform registration and usage."""
|
||||
|
||||
def teardown_method(self):
|
||||
from praisonai.bots._registry import _custom_platforms
|
||||
_custom_platforms.pop("custom_chat", None)
|
||||
_custom_platforms.pop("my_platform", None)
|
||||
_unregister_bot_platform("custom_chat")
|
||||
_unregister_bot_platform("my_platform")
|
||||
|
||||
def test_register_and_use_custom_platform(self):
|
||||
from praisonai.bots._registry import register_platform, resolve_adapter
|
||||
@@ -389,7 +406,7 @@ class TestPlatformExtensibility:
|
||||
|
||||
def test_resolve_unknown_platform_raises(self):
|
||||
from praisonai.bots._registry import resolve_adapter
|
||||
with pytest.raises(ValueError, match="Unknown platform"):
|
||||
with pytest.raises(ValueError, match=r"Unknown .*plugin"):
|
||||
resolve_adapter("nonexistent_xyz")
|
||||
|
||||
|
||||
@@ -401,9 +418,10 @@ class TestBotOSFromConfig:
|
||||
"""BotOS.from_config() YAML config loading."""
|
||||
|
||||
def setup_method(self):
|
||||
from praisonai.bots._registry import register_platform, _custom_platforms
|
||||
from praisonai.bots._registry import register_platform
|
||||
|
||||
register_platform("mockbot", MockPlatformAdapter)
|
||||
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
|
||||
self._cleanup = lambda: _unregister_bot_platform("mockbot")
|
||||
|
||||
def teardown_method(self):
|
||||
self._cleanup()
|
||||
@@ -459,7 +477,8 @@ platforms:
|
||||
|
||||
def test_from_config_multi_platform(self):
|
||||
from praisonai.bots import BotOS
|
||||
from praisonai.bots._registry import register_platform, _custom_platforms
|
||||
from praisonai.bots._registry import register_platform
|
||||
|
||||
register_platform("mock2", MockPlatformAdapter)
|
||||
|
||||
yaml_content = """\
|
||||
@@ -484,7 +503,7 @@ platforms:
|
||||
assert "mock2" in botos.list_bots()
|
||||
finally:
|
||||
os.unlink(path)
|
||||
_custom_platforms.pop("mock2", None)
|
||||
_unregister_bot_platform("mock2")
|
||||
|
||||
def test_from_config_invalid_raises(self):
|
||||
from praisonai.bots import BotOS
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"""@url mentions must not fetch loopback targets."""
|
||||
|
||||
from praisonaiagents.tools.mentions import MentionsParser
|
||||
|
||||
|
||||
def test_url_mention_blocks_loopback():
|
||||
parser = MentionsParser()
|
||||
result = parser._process_url_mention("http://127.0.0.1:8765/")
|
||||
assert result is not None
|
||||
assert "Blocked" in result
|
||||
@@ -361,3 +361,11 @@ class TestProfileExports:
|
||||
from praisonaiagents.tools import AUTONOMY_PROFILE
|
||||
|
||||
assert AUTONOMY_PROFILE is not None
|
||||
|
||||
def test_import_execute_code_uses_module_function(self):
|
||||
"""execute_code should resolve to python_tools.execute_code function."""
|
||||
from praisonaiagents.tools import execute_code
|
||||
from praisonaiagents.tools.python_tools import execute_code as execute_code_impl
|
||||
|
||||
assert callable(execute_code)
|
||||
assert execute_code is execute_code_impl
|
||||
@@ -112,6 +112,22 @@ class TestSandboxEscapePrevention:
|
||||
result = sandbox.run("__import__('os')")
|
||||
assert result["success"] is False
|
||||
|
||||
def test_print_self_blocked(self, sandbox):
|
||||
"""print.__self__ leaks real builtins module (GHSA-4mr5-g6f9-cfrh)."""
|
||||
result = sandbox.run("b = print.__self__")
|
||||
assert result["success"] is False
|
||||
assert "restricted" in result["stderr"].lower()
|
||||
|
||||
def test_vars_call_blocked(self, sandbox):
|
||||
"""vars() can expose builtins.__dict__ after __self__ leak."""
|
||||
result = sandbox.run("vars({})")
|
||||
assert result["success"] is False
|
||||
|
||||
def test_attribute_dunder_call_blocked(self, sandbox):
|
||||
"""Attribute calls bypass bare-name Call checks."""
|
||||
result = sandbox.run("(1).__class__.__mro__")
|
||||
assert result["success"] is False
|
||||
|
||||
|
||||
# ── Legitimate Code (all must PASS) ─────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -38,6 +38,16 @@ def test_still_blocks_loopback():
|
||||
assert spider._validate_url("http://localhost/") is False
|
||||
|
||||
|
||||
def test_blocks_alternate_loopback_encodings():
|
||||
"""GHSA-5c6w-wwfq-7qqm: non-canonical loopback host forms."""
|
||||
spider = SpiderTools()
|
||||
assert spider._validate_url("http://localhost.:8765/") is False
|
||||
assert spider._validate_url("http://127.1:8765/") is False
|
||||
assert spider._validate_url("http://0177.0.0.1:8765/") is False
|
||||
assert spider._validate_url("http://0x7f000001:8765/") is False
|
||||
assert spider._validate_url("http://2130706433:8765/") is False
|
||||
|
||||
|
||||
def test_rejects_non_string_input():
|
||||
spider = SpiderTools()
|
||||
assert spider._validate_url(None) is False # type: ignore[arg-type]
|
||||
|
||||
Generated
+1
-1
@@ -2992,7 +2992,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.38"
|
||||
version = "1.6.40"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -7,12 +7,18 @@ Usage::
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> None:
|
||||
default_host = os.environ.get("PLATFORM_HOST", "127.0.0.1")
|
||||
parser = argparse.ArgumentParser(description="PraisonAI Platform Server")
|
||||
parser.add_argument("--host", default="0.0.0.0", help="Bind host (default: 0.0.0.0)")
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
default=default_host,
|
||||
help="Bind host (default: 127.0.0.1, or PLATFORM_HOST env)",
|
||||
)
|
||||
parser.add_argument("--port", type=int, default=8000, help="Bind port (default: 8000)")
|
||||
parser.add_argument("--reload", action="store_true", help="Enable auto-reload for development")
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -71,3 +71,57 @@ async def require_workspace_member(
|
||||
)
|
||||
user.workspace_id = workspace_id
|
||||
return user
|
||||
|
||||
|
||||
async def require_workspace_admin(
|
||||
workspace_id: str,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
) -> AuthIdentity:
|
||||
"""Require admin or owner role in the workspace."""
|
||||
return await require_workspace_member(
|
||||
workspace_id, user, session, min_role="admin"
|
||||
)
|
||||
|
||||
|
||||
async def require_workspace_owner(
|
||||
workspace_id: str,
|
||||
user: AuthIdentity = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
) -> AuthIdentity:
|
||||
"""Require owner role in the workspace."""
|
||||
return await require_workspace_member(
|
||||
workspace_id, user, session, min_role="owner"
|
||||
)
|
||||
|
||||
|
||||
def ensure_resource_in_workspace(
|
||||
resource_workspace_id: str | None,
|
||||
workspace_id: str,
|
||||
*,
|
||||
label: str = "Resource",
|
||||
) -> None:
|
||||
"""Reject cross-workspace access (IDOR) with a generic 404."""
|
||||
if resource_workspace_id != workspace_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"{label} not found",
|
||||
)
|
||||
|
||||
|
||||
async def require_issue_in_workspace(
|
||||
workspace_id: str,
|
||||
issue_id: str,
|
||||
session: AsyncSession,
|
||||
):
|
||||
"""Load an issue and verify it belongs to the URL workspace."""
|
||||
from ..db.models import Issue
|
||||
|
||||
issue = await session.get(Issue, issue_id)
|
||||
if issue is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Issue not found",
|
||||
)
|
||||
ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue")
|
||||
return issue
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from praisonaiagents.auth import AuthIdentity
|
||||
|
||||
from ..deps import get_db, require_workspace_member
|
||||
from ..deps import get_db, require_issue_in_workspace, require_workspace_member
|
||||
from ..schemas import ActivityLogResponse
|
||||
from ...services.activity_service import ActivityService
|
||||
|
||||
@@ -38,6 +38,7 @@ async def list_issue_activity(
|
||||
user: AuthIdentity = Depends(require_workspace_member),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_issue_in_workspace(workspace_id, issue_id, session)
|
||||
svc = ActivityService(session)
|
||||
logs = await svc.list_for_issue(issue_id, limit=limit, offset=offset)
|
||||
return [ActivityLogResponse.model_validate(log) for log in logs]
|
||||
@@ -58,7 +58,7 @@ async def get_agent(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = AgentService(session)
|
||||
agent = await svc.get(agent_id)
|
||||
agent = await svc.get(agent_id, workspace_id=workspace_id)
|
||||
if agent is None:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
return AgentResponse.model_validate(agent)
|
||||
@@ -75,6 +75,7 @@ async def update_agent(
|
||||
svc = AgentService(session)
|
||||
agent = await svc.update(
|
||||
agent_id,
|
||||
workspace_id=workspace_id,
|
||||
name=body.name,
|
||||
status=body.status,
|
||||
instructions=body.instructions,
|
||||
@@ -95,6 +96,6 @@ async def delete_agent(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = AgentService(session)
|
||||
deleted = await svc.delete(agent_id)
|
||||
deleted = await svc.delete(agent_id, workspace_id=workspace_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
@@ -9,9 +9,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from praisonaiagents.auth import AuthIdentity
|
||||
|
||||
from ..deps import get_db, require_workspace_member
|
||||
from ..schemas import DependencyCreate, DependencyResponse
|
||||
from ..deps import get_db, require_issue_in_workspace, require_workspace_member
|
||||
from ...services.dependency_service import DependencyService
|
||||
from ..schemas import DependencyCreate, DependencyResponse
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/workspaces/{workspace_id}/issues/{issue_id}/dependencies",
|
||||
@@ -27,6 +27,10 @@ async def create_dependency(
|
||||
user: AuthIdentity = Depends(require_workspace_member),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_issue_in_workspace(workspace_id, issue_id, session)
|
||||
await require_issue_in_workspace(
|
||||
workspace_id, body.depends_on_issue_id, session
|
||||
)
|
||||
svc = DependencyService(session)
|
||||
dep = await svc.create(issue_id, body.depends_on_issue_id, body.type)
|
||||
return DependencyResponse.model_validate(dep)
|
||||
@@ -39,6 +43,7 @@ async def list_dependencies(
|
||||
user: AuthIdentity = Depends(require_workspace_member),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_issue_in_workspace(workspace_id, issue_id, session)
|
||||
svc = DependencyService(session)
|
||||
deps = await svc.list_for_issue(issue_id)
|
||||
return [DependencyResponse.model_validate(d) for d in deps]
|
||||
@@ -52,7 +57,13 @@ async def delete_dependency(
|
||||
user: AuthIdentity = Depends(require_workspace_member),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_issue_in_workspace(workspace_id, issue_id, session)
|
||||
svc = DependencyService(session)
|
||||
dep = await svc.get(dep_id)
|
||||
if dep is None:
|
||||
raise HTTPException(status_code=404, detail="Dependency not found")
|
||||
if dep.issue_id != issue_id and dep.depends_on_issue_id != issue_id:
|
||||
raise HTTPException(status_code=404, detail="Dependency not found")
|
||||
deleted = await svc.delete(dep_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Dependency not found")
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from praisonaiagents.auth import AuthIdentity
|
||||
|
||||
from ..deps import get_db, require_workspace_member
|
||||
from ..deps import ensure_resource_in_workspace, get_db, require_workspace_member
|
||||
from ..schemas import (
|
||||
CommentCreate,
|
||||
CommentResponse,
|
||||
@@ -87,7 +87,7 @@ async def get_issue(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = IssueService(session)
|
||||
issue = await svc.get(issue_id)
|
||||
issue = await svc.get(issue_id, workspace_id=workspace_id)
|
||||
if issue is None:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
return IssueResponse.model_validate(issue)
|
||||
@@ -104,6 +104,7 @@ async def update_issue(
|
||||
svc = IssueService(session)
|
||||
issue = await svc.update(
|
||||
issue_id,
|
||||
workspace_id=workspace_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
@@ -132,7 +133,7 @@ async def delete_issue(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = IssueService(session)
|
||||
deleted = await svc.delete(issue_id)
|
||||
deleted = await svc.delete(issue_id, workspace_id=workspace_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
|
||||
@@ -148,6 +149,11 @@ async def add_comment(
|
||||
user: AuthIdentity = Depends(require_workspace_member),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
issue_svc = IssueService(session)
|
||||
issue = await issue_svc.get(issue_id)
|
||||
if issue is None:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue")
|
||||
svc = CommentService(session)
|
||||
comment = await svc.create(
|
||||
issue_id=issue_id,
|
||||
@@ -166,6 +172,11 @@ async def list_comments(
|
||||
user: AuthIdentity = Depends(require_workspace_member),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
issue_svc = IssueService(session)
|
||||
issue = await issue_svc.get(issue_id)
|
||||
if issue is None:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue")
|
||||
svc = CommentService(session)
|
||||
comments = await svc.list_for_issue(issue_id)
|
||||
return [CommentResponse.model_validate(c) for c in comments]
|
||||
@@ -9,7 +9,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from praisonaiagents.auth import AuthIdentity
|
||||
|
||||
from ..deps import get_db, require_workspace_member
|
||||
from ..deps import (
|
||||
ensure_resource_in_workspace,
|
||||
get_db,
|
||||
require_issue_in_workspace,
|
||||
require_workspace_member,
|
||||
)
|
||||
from ..schemas import LabelCreate, LabelResponse, LabelUpdate
|
||||
from ...services.label_service import LabelService
|
||||
|
||||
@@ -48,6 +53,10 @@ async def update_label(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = LabelService(session)
|
||||
label = await svc.get(label_id)
|
||||
if label is None:
|
||||
raise HTTPException(status_code=404, detail="Label not found")
|
||||
ensure_resource_in_workspace(label.workspace_id, workspace_id, label="Label")
|
||||
label = await svc.update(label_id, body.name, body.color)
|
||||
if label is None:
|
||||
raise HTTPException(status_code=404, detail="Label not found")
|
||||
@@ -62,6 +71,10 @@ async def delete_label(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = LabelService(session)
|
||||
label = await svc.get(label_id)
|
||||
if label is None:
|
||||
raise HTTPException(status_code=404, detail="Label not found")
|
||||
ensure_resource_in_workspace(label.workspace_id, workspace_id, label="Label")
|
||||
deleted = await svc.delete(label_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Label not found")
|
||||
@@ -78,7 +91,12 @@ async def add_label_to_issue(
|
||||
user: AuthIdentity = Depends(require_workspace_member),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_issue_in_workspace(workspace_id, issue_id, session)
|
||||
svc = LabelService(session)
|
||||
label = await svc.get(label_id)
|
||||
if label is None:
|
||||
raise HTTPException(status_code=404, detail="Label not found")
|
||||
ensure_resource_in_workspace(label.workspace_id, workspace_id, label="Label")
|
||||
await svc.add_to_issue(issue_id, label_id)
|
||||
|
||||
|
||||
@@ -90,7 +108,12 @@ async def remove_label_from_issue(
|
||||
user: AuthIdentity = Depends(require_workspace_member),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_issue_in_workspace(workspace_id, issue_id, session)
|
||||
svc = LabelService(session)
|
||||
label = await svc.get(label_id)
|
||||
if label is None:
|
||||
raise HTTPException(status_code=404, detail="Label not found")
|
||||
ensure_resource_in_workspace(label.workspace_id, workspace_id, label="Label")
|
||||
await svc.remove_from_issue(issue_id, label_id)
|
||||
|
||||
|
||||
@@ -101,6 +124,7 @@ async def list_issue_labels(
|
||||
user: AuthIdentity = Depends(require_workspace_member),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_issue_in_workspace(workspace_id, issue_id, session)
|
||||
svc = LabelService(session)
|
||||
labels = await svc.list_for_issue(issue_id)
|
||||
return [LabelResponse.model_validate(l) for l in labels]
|
||||
@@ -56,7 +56,7 @@ async def get_project(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = ProjectService(session)
|
||||
project = await svc.get(project_id)
|
||||
project = await svc.get(project_id, workspace_id=workspace_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return ProjectResponse.model_validate(project)
|
||||
@@ -73,6 +73,7 @@ async def update_project(
|
||||
svc = ProjectService(session)
|
||||
project = await svc.update(
|
||||
project_id,
|
||||
workspace_id=workspace_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
@@ -92,7 +93,7 @@ async def delete_project(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = ProjectService(session)
|
||||
deleted = await svc.delete(project_id)
|
||||
deleted = await svc.delete(project_id, workspace_id=workspace_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
@@ -105,4 +106,7 @@ async def project_stats(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = ProjectService(session)
|
||||
project = await svc.get(project_id, workspace_id=workspace_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return await svc.get_stats(project_id)
|
||||
@@ -9,7 +9,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from praisonaiagents.auth import AuthIdentity
|
||||
|
||||
from ..deps import get_current_user, get_db, require_workspace_member
|
||||
from ..deps import (
|
||||
get_current_user,
|
||||
get_db,
|
||||
require_workspace_admin,
|
||||
require_workspace_member,
|
||||
require_workspace_owner,
|
||||
)
|
||||
from ..schemas import (
|
||||
MemberAdd,
|
||||
MemberResponse,
|
||||
@@ -64,7 +70,7 @@ async def get_workspace(
|
||||
async def update_workspace(
|
||||
workspace_id: str,
|
||||
body: WorkspaceUpdate,
|
||||
user: AuthIdentity = Depends(require_workspace_member),
|
||||
user: AuthIdentity = Depends(require_workspace_admin),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
ws_svc = WorkspaceService(session)
|
||||
@@ -77,7 +83,7 @@ async def update_workspace(
|
||||
@router.delete("/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_workspace(
|
||||
workspace_id: str,
|
||||
user: AuthIdentity = Depends(require_workspace_member),
|
||||
user: AuthIdentity = Depends(require_workspace_owner),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
ws_svc = WorkspaceService(session)
|
||||
@@ -93,10 +99,16 @@ async def delete_workspace(
|
||||
async def add_member(
|
||||
workspace_id: str,
|
||||
body: MemberAdd,
|
||||
user: AuthIdentity = Depends(require_workspace_member),
|
||||
user: AuthIdentity = Depends(require_workspace_admin),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
member_svc = MemberService(session)
|
||||
if body.role in ("owner", "admin"):
|
||||
if not await member_svc.has_role(workspace_id, user.id, "owner"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only owners can add admin or owner roles",
|
||||
)
|
||||
member = await member_svc.add(workspace_id, body.user_id, body.role)
|
||||
return MemberResponse.model_validate(member)
|
||||
|
||||
@@ -117,10 +129,32 @@ async def update_member_role(
|
||||
workspace_id: str,
|
||||
user_id: str,
|
||||
body: MemberUpdate,
|
||||
user: AuthIdentity = Depends(require_workspace_member),
|
||||
user: AuthIdentity = Depends(require_workspace_admin),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
member_svc = MemberService(session)
|
||||
target = await member_svc.get(workspace_id, user_id)
|
||||
if target is None:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
if user_id == user.id and body.role != target.role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot change your own role",
|
||||
)
|
||||
if body.role in ("owner", "admin") and not await member_svc.has_role(
|
||||
workspace_id, user.id, "owner"
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only owners can assign admin or owner roles",
|
||||
)
|
||||
if target.role == "owner" and not await member_svc.has_role(
|
||||
workspace_id, user.id, "owner"
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only owners can change an owner's role",
|
||||
)
|
||||
member = await member_svc.update_role(workspace_id, user_id, body.role)
|
||||
if member is None:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
@@ -131,10 +165,25 @@ async def update_member_role(
|
||||
async def remove_member(
|
||||
workspace_id: str,
|
||||
user_id: str,
|
||||
user: AuthIdentity = Depends(require_workspace_member),
|
||||
user: AuthIdentity = Depends(require_workspace_admin),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
member_svc = MemberService(session)
|
||||
if user_id == user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot remove yourself from the workspace",
|
||||
)
|
||||
target = await member_svc.get(workspace_id, user_id)
|
||||
if target is None:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
if target.role == "owner" and not await member_svc.has_role(
|
||||
workspace_id, user.id, "owner"
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only owners can remove an owner",
|
||||
)
|
||||
removed = await member_svc.remove(workspace_id, user_id)
|
||||
if not removed:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
@@ -50,9 +50,16 @@ class AgentService:
|
||||
await self._session.flush()
|
||||
return agent
|
||||
|
||||
async def get(self, agent_id: str) -> Optional[Agent]:
|
||||
"""Get agent by ID."""
|
||||
return await self._session.get(Agent, agent_id)
|
||||
async def get(
|
||||
self, agent_id: str, *, workspace_id: Optional[str] = None
|
||||
) -> Optional[Agent]:
|
||||
"""Get agent by ID, optionally scoped to a workspace."""
|
||||
agent = await self._session.get(Agent, agent_id)
|
||||
if agent is None:
|
||||
return None
|
||||
if workspace_id is not None and agent.workspace_id != workspace_id:
|
||||
return None
|
||||
return agent
|
||||
|
||||
async def list_for_workspace(
|
||||
self,
|
||||
@@ -72,6 +79,8 @@ class AgentService:
|
||||
async def update(
|
||||
self,
|
||||
agent_id: str,
|
||||
*,
|
||||
workspace_id: Optional[str] = None,
|
||||
name: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
instructions: Optional[str] = None,
|
||||
@@ -80,7 +89,7 @@ class AgentService:
|
||||
max_concurrent_tasks: Optional[int] = None,
|
||||
) -> Optional[Agent]:
|
||||
"""Update agent fields."""
|
||||
agent = await self.get(agent_id)
|
||||
agent = await self.get(agent_id, workspace_id=workspace_id)
|
||||
if agent is None:
|
||||
return None
|
||||
if name is not None:
|
||||
@@ -102,9 +111,11 @@ class AgentService:
|
||||
await self._session.flush()
|
||||
return agent
|
||||
|
||||
async def delete(self, agent_id: str) -> bool:
|
||||
async def delete(
|
||||
self, agent_id: str, *, workspace_id: Optional[str] = None
|
||||
) -> bool:
|
||||
"""Delete an agent."""
|
||||
agent = await self.get(agent_id)
|
||||
agent = await self.get(agent_id, workspace_id=workspace_id)
|
||||
if agent is None:
|
||||
return False
|
||||
await self._session.delete(agent)
|
||||
|
||||
@@ -113,6 +113,10 @@ class AuthService:
|
||||
|
||||
def _issue_token(self, user: User) -> str:
|
||||
"""Issue a JWT for a user."""
|
||||
if JWT_SECRET == _DEFAULT_SECRET and os.environ.get("PLATFORM_ENV", "dev") != "dev":
|
||||
raise RuntimeError(
|
||||
"Refusing to issue JWT with default PLATFORM_JWT_SECRET outside dev"
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": user.id,
|
||||
|
||||
@@ -69,9 +69,16 @@ class IssueService:
|
||||
await self._session.flush()
|
||||
return issue
|
||||
|
||||
async def get(self, issue_id: str) -> Optional[Issue]:
|
||||
"""Get issue by ID."""
|
||||
return await self._session.get(Issue, issue_id)
|
||||
async def get(
|
||||
self, issue_id: str, *, workspace_id: Optional[str] = None
|
||||
) -> Optional[Issue]:
|
||||
"""Get issue by ID, optionally scoped to a workspace."""
|
||||
issue = await self._session.get(Issue, issue_id)
|
||||
if issue is None:
|
||||
return None
|
||||
if workspace_id is not None and issue.workspace_id != workspace_id:
|
||||
return None
|
||||
return issue
|
||||
|
||||
async def list_for_workspace(
|
||||
self,
|
||||
@@ -97,6 +104,8 @@ class IssueService:
|
||||
async def update(
|
||||
self,
|
||||
issue_id: str,
|
||||
*,
|
||||
workspace_id: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
@@ -106,7 +115,7 @@ class IssueService:
|
||||
project_id: Optional[str] = None,
|
||||
) -> Optional[Issue]:
|
||||
"""Update issue fields."""
|
||||
issue = await self.get(issue_id)
|
||||
issue = await self.get(issue_id, workspace_id=workspace_id)
|
||||
if issue is None:
|
||||
return None
|
||||
if title is not None:
|
||||
@@ -135,21 +144,27 @@ class IssueService:
|
||||
issue_id: str,
|
||||
assignee_type: str,
|
||||
assignee_id: str,
|
||||
*,
|
||||
workspace_id: Optional[str] = None,
|
||||
) -> Optional[Issue]:
|
||||
"""Assign an issue to a member or agent."""
|
||||
if assignee_type not in VALID_ASSIGNEE_TYPES:
|
||||
raise ValueError(f"Invalid assignee_type: {assignee_type}")
|
||||
return await self.update(
|
||||
issue_id, assignee_type=assignee_type, assignee_id=assignee_id
|
||||
issue_id, workspace_id=workspace_id, assignee_type=assignee_type, assignee_id=assignee_id
|
||||
)
|
||||
|
||||
async def transition(self, issue_id: str, new_status: str) -> Optional[Issue]:
|
||||
async def transition(
|
||||
self, issue_id: str, new_status: str, *, workspace_id: Optional[str] = None
|
||||
) -> Optional[Issue]:
|
||||
"""Transition an issue to a new status."""
|
||||
return await self.update(issue_id, status=new_status)
|
||||
return await self.update(issue_id, workspace_id=workspace_id, status=new_status)
|
||||
|
||||
async def delete(self, issue_id: str) -> bool:
|
||||
async def delete(
|
||||
self, issue_id: str, *, workspace_id: Optional[str] = None
|
||||
) -> bool:
|
||||
"""Delete an issue."""
|
||||
issue = await self.get(issue_id)
|
||||
issue = await self.get(issue_id, workspace_id=workspace_id)
|
||||
if issue is None:
|
||||
return False
|
||||
await self._session.delete(issue)
|
||||
|
||||
@@ -44,9 +44,16 @@ class ProjectService:
|
||||
await self._session.flush()
|
||||
return project
|
||||
|
||||
async def get(self, project_id: str) -> Optional[Project]:
|
||||
"""Get project by ID."""
|
||||
return await self._session.get(Project, project_id)
|
||||
async def get(
|
||||
self, project_id: str, *, workspace_id: Optional[str] = None
|
||||
) -> Optional[Project]:
|
||||
"""Get project by ID, optionally scoped to a workspace."""
|
||||
project = await self._session.get(Project, project_id)
|
||||
if project is None:
|
||||
return None
|
||||
if workspace_id is not None and project.workspace_id != workspace_id:
|
||||
return None
|
||||
return project
|
||||
|
||||
async def list_for_workspace(
|
||||
self,
|
||||
@@ -62,6 +69,8 @@ class ProjectService:
|
||||
async def update(
|
||||
self,
|
||||
project_id: str,
|
||||
*,
|
||||
workspace_id: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
@@ -69,7 +78,7 @@ class ProjectService:
|
||||
lead_id: Optional[str] = None,
|
||||
) -> Optional[Project]:
|
||||
"""Update project fields."""
|
||||
project = await self.get(project_id)
|
||||
project = await self.get(project_id, workspace_id=workspace_id)
|
||||
if project is None:
|
||||
return None
|
||||
if title is not None:
|
||||
@@ -85,9 +94,11 @@ class ProjectService:
|
||||
await self._session.flush()
|
||||
return project
|
||||
|
||||
async def delete(self, project_id: str) -> bool:
|
||||
async def delete(
|
||||
self, project_id: str, *, workspace_id: Optional[str] = None
|
||||
) -> bool:
|
||||
"""Delete a project."""
|
||||
project = await self.get(project_id)
|
||||
project = await self.get(project_id, workspace_id=workspace_id)
|
||||
if project is None:
|
||||
return False
|
||||
await self._session.delete(project)
|
||||
|
||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..db.models import Member, Workspace
|
||||
@@ -98,10 +98,13 @@ class WorkspaceService:
|
||||
return ws
|
||||
|
||||
async def delete(self, workspace_id: str) -> bool:
|
||||
"""Delete a workspace."""
|
||||
"""Delete a workspace and its memberships."""
|
||||
ws = await self.get(workspace_id)
|
||||
if ws is None:
|
||||
return False
|
||||
await self._session.execute(
|
||||
delete(Member).where(Member.workspace_id == workspace_id)
|
||||
)
|
||||
await self._session.delete(ws)
|
||||
await self._session.flush()
|
||||
return True
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonai-platform"
|
||||
version = "0.1.2"
|
||||
version = "0.1.4"
|
||||
description = "Platform layer for PraisonAI — workspace, auth, issues, projects"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Cross-workspace IDOR guards for platform resources."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from praisonai_platform.api.deps import ensure_resource_in_workspace
|
||||
|
||||
|
||||
def test_project_workspace_mismatch():
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
ensure_resource_in_workspace("ws-other", "ws-mine", label="Project")
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
def test_label_workspace_mismatch():
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
ensure_resource_in_workspace("ws-other", "ws-mine", label="Label")
|
||||
assert exc.value.status_code == 404
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Service-layer workspace scoping for issues and projects."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonai_platform.services.auth_service import AuthService
|
||||
from praisonai_platform.services.issue_service import IssueService
|
||||
from praisonai_platform.services.project_service import ProjectService
|
||||
from praisonai_platform.services.workspace_service import WorkspaceService
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_issue_get_rejects_wrong_workspace(session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
ws_a = await WorkspaceService(session).create("A", user.id)
|
||||
ws_b = await WorkspaceService(session).create("B", user.id)
|
||||
issue_svc = IssueService(session)
|
||||
issue = await issue_svc.create(
|
||||
workspace_id=ws_a.id,
|
||||
title="secret",
|
||||
creator_id=user.id,
|
||||
)
|
||||
assert await issue_svc.get(issue.id, workspace_id=ws_a.id) is not None
|
||||
assert await issue_svc.get(issue.id, workspace_id=ws_b.id) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_delete_scoped_to_workspace(session):
|
||||
auth = AuthService(session)
|
||||
user, _ = await auth.register("[email protected]", "pass")
|
||||
ws_a = await WorkspaceService(session).create("PA", user.id)
|
||||
ws_b = await WorkspaceService(session).create("PB", user.id)
|
||||
proj_svc = ProjectService(session)
|
||||
project = await proj_svc.create(workspace_id=ws_a.id, title="p1")
|
||||
assert await proj_svc.delete(project.id, workspace_id=ws_b.id) is False
|
||||
assert await proj_svc.delete(project.id, workspace_id=ws_a.id) is True
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Workspace member RBAC and cross-workspace IDOR guards."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from praisonai_platform.api.deps import ensure_resource_in_workspace
|
||||
|
||||
|
||||
def test_ensure_resource_in_workspace_rejects_mismatch():
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
ensure_resource_in_workspace("ws-a", "ws-b", label="Issue")
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
def test_ensure_resource_in_workspace_allows_match():
|
||||
ensure_resource_in_workspace("ws-a", "ws-a", label="Issue")
|
||||
Generated
+1
-1
@@ -1208,7 +1208,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonai-platform"
|
||||
version = "0.1.2"
|
||||
version = "0.1.4"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "1.5.87",
|
||||
"lastUpdated": "2026-05-13",
|
||||
"lastUpdated": "2026-05-19",
|
||||
"generatedBy": "praisonai._dev.parity.generator",
|
||||
"sourceOfTruth": "Python SDK (praisonaiagents)",
|
||||
"status": "PARITY_ACHIEVED",
|
||||
@@ -1725,6 +1725,7 @@
|
||||
"planning": [],
|
||||
"plugins": [
|
||||
"PluginRegistry",
|
||||
"ToolDefinition",
|
||||
"disable_plugins",
|
||||
"enable_plugins",
|
||||
"get_plugin_manager",
|
||||
@@ -1749,7 +1750,6 @@
|
||||
"tools": [
|
||||
"FunctionTool",
|
||||
"Tool",
|
||||
"ToolDefinition",
|
||||
"ToolRegistry",
|
||||
"ToolResult"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "1.5.87",
|
||||
"lastUpdated": "2026-05-13",
|
||||
"lastUpdated": "2026-05-19",
|
||||
"generatedBy": "praisonai._dev.parity.generator",
|
||||
"sourceOfTruth": "Python SDK (praisonaiagents)",
|
||||
"summary": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Feature Parity Tracker
|
||||
|
||||
> **Version:** 1.5.87 | **Last Updated:** 2026-05-13
|
||||
> **Version:** 1.5.87 | **Last Updated:** 2026-05-19
|
||||
> **Source of Truth:** Python SDK (praisonaiagents)
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -3,8 +3,8 @@ class Praisonai < Formula
|
||||
|
||||
desc "AI tools for various AI applications"
|
||||
homepage "https://github.com/MervinPraison/PraisonAI"
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.38.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.38.tar.gz | shasum -a 256`.split.first
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.40.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.40.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -36,10 +36,34 @@ __all__ = [
|
||||
_telemetry_lock = threading.Lock()
|
||||
_telemetry_initialized = False
|
||||
|
||||
def _get_telemetry_defaults() -> dict[str, str]:
|
||||
"""Get telemetry environment defaults without mutating os.environ.
|
||||
|
||||
Returns:
|
||||
Dict of environment variable defaults for telemetry
|
||||
"""
|
||||
import os
|
||||
defaults = {}
|
||||
|
||||
# Respect any value the user already set
|
||||
if "OTEL_SDK_DISABLED" not in os.environ:
|
||||
langfuse_configured = bool(
|
||||
os.getenv("LANGFUSE_PUBLIC_KEY")
|
||||
or os.path.exists(os.path.expanduser("~/.praisonai/langfuse.env"))
|
||||
)
|
||||
defaults["OTEL_SDK_DISABLED"] = "false" if langfuse_configured else "true"
|
||||
|
||||
if "EC_TELEMETRY" not in os.environ:
|
||||
defaults["EC_TELEMETRY"] = "false"
|
||||
|
||||
return defaults
|
||||
|
||||
|
||||
def _ensure_telemetry_defaults() -> None:
|
||||
"""Apply telemetry env defaults exactly once, on first observability use.
|
||||
|
||||
Thread-safe implementation using double-checked locking pattern.
|
||||
DEPRECATED: Use _apply_telemetry_defaults() with explicit config instead.
|
||||
"""
|
||||
global _telemetry_initialized
|
||||
if _telemetry_initialized: # fast path, OK without lock
|
||||
@@ -47,18 +71,21 @@ def _ensure_telemetry_defaults() -> None:
|
||||
with _telemetry_lock:
|
||||
if _telemetry_initialized:
|
||||
return
|
||||
import os
|
||||
# Respect any value the user already set
|
||||
if "OTEL_SDK_DISABLED" not in os.environ:
|
||||
langfuse_configured = bool(
|
||||
os.getenv("LANGFUSE_PUBLIC_KEY")
|
||||
or os.path.exists(os.path.expanduser("~/.praisonai/langfuse.env"))
|
||||
)
|
||||
os.environ["OTEL_SDK_DISABLED"] = "false" if langfuse_configured else "true"
|
||||
os.environ.setdefault("EC_TELEMETRY", "false") # respect user overrides
|
||||
_apply_telemetry_defaults(_get_telemetry_defaults())
|
||||
_telemetry_initialized = True
|
||||
|
||||
|
||||
def _apply_telemetry_defaults(env_vars: dict[str, str]) -> None:
|
||||
"""Apply telemetry defaults to os.environ.
|
||||
|
||||
Args:
|
||||
env_vars: Dict of environment variables to set
|
||||
"""
|
||||
import os
|
||||
for key, value in env_vars.items():
|
||||
os.environ.setdefault(key, value)
|
||||
|
||||
|
||||
# Lazy loading for heavy imports
|
||||
def __getattr__(name):
|
||||
"""Lazy load heavy modules to improve import time."""
|
||||
|
||||
@@ -24,6 +24,7 @@ class PluginRegistry(Generic[T]):
|
||||
- Built-in plugins with lazy loading
|
||||
- Entry points discovery
|
||||
- Runtime registration
|
||||
- Alias support for alternative names
|
||||
- Thread-safe operations
|
||||
"""
|
||||
|
||||
@@ -41,6 +42,7 @@ class PluginRegistry(Generic[T]):
|
||||
"""
|
||||
self._entry_point_group = entry_point_group
|
||||
self._items: Dict[str, Type[T]] = {}
|
||||
self._aliases: Dict[str, str] = {} # alias -> canonical name
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# Load built-in plugins with error handling
|
||||
@@ -76,15 +78,29 @@ class PluginRegistry(Generic[T]):
|
||||
# entry_points() might not be available in older Python versions
|
||||
logger.debug("Entry points not available for group %s", self._entry_point_group)
|
||||
|
||||
def register(self, name: str, cls: Type[T]) -> None:
|
||||
def register(
|
||||
self,
|
||||
name: str,
|
||||
cls: Type[T],
|
||||
*,
|
||||
aliases: Optional[list[str]] = None
|
||||
) -> None:
|
||||
"""Register a plugin at runtime.
|
||||
|
||||
Args:
|
||||
name: Plugin name
|
||||
cls: Plugin class
|
||||
aliases: Optional list of alias names for this plugin
|
||||
"""
|
||||
with self._lock:
|
||||
self._items[name.lower()] = cls
|
||||
canonical_name = name.lower()
|
||||
self._items[canonical_name] = cls
|
||||
|
||||
# Register aliases
|
||||
if aliases:
|
||||
for alias in aliases:
|
||||
normalized_alias = alias.lower()
|
||||
self._aliases[normalized_alias] = canonical_name
|
||||
|
||||
def unregister(self, name: str) -> bool:
|
||||
"""Unregister a plugin.
|
||||
@@ -96,7 +112,27 @@ class PluginRegistry(Generic[T]):
|
||||
True if plugin was found and removed, False otherwise
|
||||
"""
|
||||
with self._lock:
|
||||
return self._items.pop(name.lower(), None) is not None
|
||||
normalized_name = name.lower()
|
||||
|
||||
# Check if it's an alias
|
||||
if normalized_name in self._aliases:
|
||||
del self._aliases[normalized_name]
|
||||
return True
|
||||
|
||||
# Check if it's a canonical name
|
||||
if normalized_name in self._items:
|
||||
# Remove all aliases pointing to this plugin
|
||||
aliases_to_remove = [
|
||||
alias for alias, canonical in self._aliases.items()
|
||||
if canonical == normalized_name
|
||||
]
|
||||
for alias in aliases_to_remove:
|
||||
del self._aliases[alias]
|
||||
|
||||
del self._items[normalized_name]
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def resolve(self, name: str) -> Type[T]:
|
||||
"""Resolve a plugin name to its class.
|
||||
@@ -111,10 +147,20 @@ class PluginRegistry(Generic[T]):
|
||||
ValueError: If plugin is not found
|
||||
"""
|
||||
with self._lock:
|
||||
cls = self._items.get(name.lower())
|
||||
normalized_name = name.lower()
|
||||
|
||||
# Resolve alias to canonical name
|
||||
canonical_name = self._aliases.get(normalized_name, normalized_name)
|
||||
cls = self._items.get(canonical_name)
|
||||
|
||||
# Capture available plugins snapshot while holding lock
|
||||
# to avoid race condition between check and error message
|
||||
available_snapshot = sorted(self._items.keys()) if cls is None else None
|
||||
if cls is None:
|
||||
available_plugins = sorted(self._items.keys())
|
||||
available_aliases = sorted(self._aliases.keys())
|
||||
available_snapshot = available_plugins + available_aliases
|
||||
else:
|
||||
available_snapshot = None
|
||||
|
||||
if cls is None:
|
||||
raise ValueError(
|
||||
@@ -161,4 +207,22 @@ class PluginRegistry(Generic[T]):
|
||||
self.resolve(name)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
return False
|
||||
|
||||
def list_aliases(self) -> Dict[str, str]:
|
||||
"""List all aliases and their target plugins.
|
||||
|
||||
Returns:
|
||||
Dict mapping alias -> canonical name
|
||||
"""
|
||||
with self._lock:
|
||||
return dict(self._aliases)
|
||||
|
||||
def list_all_names(self) -> list[str]:
|
||||
"""List all names including aliases.
|
||||
|
||||
Returns:
|
||||
Sorted list of all registered names and aliases
|
||||
"""
|
||||
with self._lock:
|
||||
return sorted(list(self._items.keys()) + list(self._aliases.keys()))
|
||||
@@ -1172,9 +1172,9 @@ class AgentsGenerator:
|
||||
acp_enabled = global_config.get('acp', False)
|
||||
lsp_enabled = global_config.get('lsp', False)
|
||||
interactive_runtime = None
|
||||
interactive_loop = None
|
||||
|
||||
if acp_enabled or lsp_enabled:
|
||||
runtime_started = False
|
||||
try:
|
||||
import asyncio
|
||||
from praisonai.cli.features.interactive_runtime import InteractiveRuntime, RuntimeConfig
|
||||
@@ -1190,11 +1190,12 @@ class AgentsGenerator:
|
||||
interactive_runtime = InteractiveRuntime(runtime_config)
|
||||
self.logger.info(f"Starting InteractiveRuntime (ACP: {acp_enabled}, LSP: {lsp_enabled})")
|
||||
|
||||
# Create a scoped event loop instead of modifying process globals
|
||||
interactive_loop = asyncio.new_event_loop()
|
||||
|
||||
# Start the runtime but keep it alive for agent execution
|
||||
interactive_loop.run_until_complete(interactive_runtime.start())
|
||||
# Runs on the persistent background loop; safe from sync and async callers.
|
||||
# run_sync raises RuntimeError early if called from inside a running loop
|
||||
# so the bug is loud instead of a deadlock.
|
||||
from ._async_bridge import run_sync
|
||||
run_sync(interactive_runtime.start())
|
||||
runtime_started = True
|
||||
|
||||
centric_tools = create_agent_centric_tools(interactive_runtime)
|
||||
self.logger.info(f"Loaded {len(centric_tools)} InteractiveRuntime tools")
|
||||
@@ -1203,13 +1204,20 @@ class AgentsGenerator:
|
||||
except ImportError as e:
|
||||
self.logger.warning(f"Failed to load InteractiveRuntime components: {e}")
|
||||
interactive_runtime = None
|
||||
interactive_loop = None
|
||||
except RuntimeError:
|
||||
# Don't swallow RuntimeError from run_sync - preserve fail-fast semantics
|
||||
raise
|
||||
except Exception as e:
|
||||
if runtime_started and interactive_runtime is not None:
|
||||
try:
|
||||
from ._async_bridge import run_sync
|
||||
run_sync(interactive_runtime.stop())
|
||||
except Exception as stop_error:
|
||||
self.logger.error(
|
||||
f"Error stopping partially started InteractiveRuntime: {stop_error}"
|
||||
)
|
||||
self.logger.error(f"Error starting InteractiveRuntime: {e}")
|
||||
if 'interactive_loop' in locals() and interactive_loop is not None:
|
||||
interactive_loop.close()
|
||||
interactive_runtime = None
|
||||
interactive_loop = None
|
||||
|
||||
# Create agents from config
|
||||
for role, details in config['roles'].items():
|
||||
@@ -1443,14 +1451,13 @@ class AgentsGenerator:
|
||||
self.logger.debug(f"Result: {response}")
|
||||
result = response if response else ""
|
||||
finally:
|
||||
if interactive_runtime and interactive_loop:
|
||||
if interactive_runtime:
|
||||
try:
|
||||
self.logger.info("Stopping InteractiveRuntime...")
|
||||
interactive_loop.run_until_complete(interactive_runtime.stop())
|
||||
from ._async_bridge import run_sync
|
||||
run_sync(interactive_runtime.stop())
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error stopping InteractiveRuntime: {e}")
|
||||
finally:
|
||||
interactive_loop.close()
|
||||
|
||||
if AGENTOPS_AVAILABLE:
|
||||
import agentops
|
||||
|
||||
@@ -29,14 +29,26 @@ logger = logging.getLogger(__name__)
|
||||
# Authentication
|
||||
import os
|
||||
CALL_SERVER_TOKEN = os.getenv('CALL_SERVER_TOKEN')
|
||||
_CALL_AUTH_DISABLED = os.getenv('PRAISONAI_CALL_AUTH', '').lower() == 'disabled'
|
||||
|
||||
|
||||
async def verify_token(
|
||||
request: Request,
|
||||
authorization: Optional[str] = Header(None)
|
||||
) -> None:
|
||||
"""Verify API token for authentication."""
|
||||
if not FASTAPI_AVAILABLE or not CALL_SERVER_TOKEN:
|
||||
return # No authentication if FastAPI unavailable or no token set
|
||||
if not FASTAPI_AVAILABLE:
|
||||
return
|
||||
if _CALL_AUTH_DISABLED:
|
||||
return
|
||||
if not CALL_SERVER_TOKEN:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=(
|
||||
"CALL_SERVER_TOKEN is not configured. Set CALL_SERVER_TOKEN or "
|
||||
"PRAISONAI_CALL_AUTH=disabled to run without authentication."
|
||||
),
|
||||
)
|
||||
|
||||
token = None
|
||||
|
||||
|
||||
@@ -15,9 +15,8 @@ from typing import Any, Dict, List, Optional, Type, TypeVar
|
||||
import os
|
||||
import json
|
||||
import yaml
|
||||
from rich import print
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from rich import print
|
||||
from praisonai._logging import get_logger
|
||||
|
||||
# Type variable for Pydantic models
|
||||
@@ -59,10 +58,6 @@ def _load_optional(key: str, loader):
|
||||
return _optional_cache[key]
|
||||
|
||||
|
||||
# Bounded LRU cache for OpenAI clients (one per (api_key, base_url) tuple)
|
||||
_OPENAI_CLIENT_CACHE_MAX = 8
|
||||
_openai_clients: "OrderedDict[tuple, object]" = OrderedDict()
|
||||
_openai_clients_lock = threading.Lock()
|
||||
|
||||
|
||||
# --- CrewAI lazy loading ---
|
||||
@@ -199,38 +194,6 @@ def _check_openai_available() -> bool:
|
||||
return result is not None
|
||||
|
||||
|
||||
def _get_openai_client(api_key: str = None, base_url: str = None):
|
||||
"""Lazy load OpenAI client with bounded LRU cache (thread-safe, multi-tenant).
|
||||
|
||||
Multi-tenant safe: each (api_key, base_url) tuple gets its own cached client.
|
||||
Bounded by _OPENAI_CLIENT_CACHE_MAX with proper LRU eviction.
|
||||
"""
|
||||
key = (api_key or os.environ.get("OPENAI_API_KEY"), base_url)
|
||||
|
||||
with _openai_clients_lock:
|
||||
# Check if client exists and update LRU position
|
||||
client = _openai_clients.get(key)
|
||||
if client is not None:
|
||||
_openai_clients.move_to_end(key)
|
||||
return client
|
||||
|
||||
# Create new client
|
||||
try:
|
||||
from openai import OpenAI
|
||||
except ImportError as e:
|
||||
raise ImportError("Install with: pip install openai") from e
|
||||
client = OpenAI(api_key=key[0], base_url=key[1])
|
||||
_openai_clients[key] = client
|
||||
|
||||
# Bound the cache; close the LRU victim
|
||||
if len(_openai_clients) > _OPENAI_CLIENT_CACHE_MAX:
|
||||
_, victim = _openai_clients.popitem(last=False)
|
||||
try:
|
||||
victim.close()
|
||||
except Exception:
|
||||
pass # Best-effort cleanup
|
||||
|
||||
return client
|
||||
|
||||
|
||||
# Use namespaced logger; root logger is configured only by the CLI
|
||||
@@ -478,6 +441,38 @@ class BaseAutoGenerator:
|
||||
'api_key': ep.api_key
|
||||
}
|
||||
]
|
||||
self._openai_client = None # lazy, per-instance
|
||||
self._openai_client_lock = threading.Lock()
|
||||
|
||||
def _get_openai_client(self):
|
||||
"""Get or create the OpenAI client for this instance."""
|
||||
if self._openai_client is None:
|
||||
with self._openai_client_lock:
|
||||
if self._openai_client is None:
|
||||
try:
|
||||
from openai import OpenAI
|
||||
except ImportError as e:
|
||||
raise ImportError("Install with: pip install openai") from e
|
||||
cfg = self.config_list[0]
|
||||
self._openai_client = OpenAI(
|
||||
api_key=cfg.get("api_key") or os.environ.get("OPENAI_API_KEY"),
|
||||
base_url=cfg.get("base_url"),
|
||||
)
|
||||
return self._openai_client
|
||||
|
||||
def close(self):
|
||||
"""Close the OpenAI client if it exists."""
|
||||
if not hasattr(self, '_openai_client_lock'):
|
||||
return # Object was never fully initialized
|
||||
with self._openai_client_lock:
|
||||
client = getattr(self, '_openai_client', None)
|
||||
self._openai_client = None
|
||||
if client is not None:
|
||||
client.close()
|
||||
|
||||
def __del__(self):
|
||||
"""Best-effort cleanup, but the canonical path is explicit close()."""
|
||||
self.close()
|
||||
|
||||
def _structured_completion(self, response_model: Type[T], messages: List[Dict], **kwargs) -> T:
|
||||
"""
|
||||
@@ -514,10 +509,7 @@ class BaseAutoGenerator:
|
||||
|
||||
# Fallback to OpenAI SDK (uses beta.chat.completions.parse)
|
||||
if _check_openai_available():
|
||||
client = _get_openai_client(
|
||||
api_key=self.config_list[0].get('api_key'),
|
||||
base_url=self.config_list[0].get('base_url')
|
||||
)
|
||||
client = self._get_openai_client()
|
||||
response = client.beta.chat.completions.parse(
|
||||
model=model_name,
|
||||
messages=messages,
|
||||
@@ -1537,4 +1529,4 @@ Generate a workflow for: {self.topic}
|
||||
with open(full_path, 'w') as f:
|
||||
yaml.dump(workflow_yaml, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
return full_path
|
||||
return full_path
|
||||
@@ -86,6 +86,8 @@ Only respond with the JSON, no other text."""
|
||||
)
|
||||
|
||||
import json
|
||||
if not response.choices or response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
result_text = response.choices[0].message.content
|
||||
result_data = json.loads(result_text)
|
||||
|
||||
@@ -160,6 +162,8 @@ Only respond with the JSON, no other text."""
|
||||
)
|
||||
|
||||
import json
|
||||
if not response.choices or response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
result_text = response.choices[0].message.content
|
||||
result_data = json.loads(result_text)
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ def rag_query(
|
||||
**kwargs
|
||||
)
|
||||
|
||||
answer = response.choices[0].message.content if response.choices else ""
|
||||
answer = response.choices[0].message.content if response.choices and response.choices[0].message is not None else ""
|
||||
|
||||
usage = None
|
||||
if hasattr(response, 'usage') and response.usage:
|
||||
@@ -188,7 +188,7 @@ async def arag_query(
|
||||
**kwargs
|
||||
)
|
||||
|
||||
answer = response.choices[0].message.content if response.choices else ""
|
||||
answer = response.choices[0].message.content if response.choices and response.choices[0].message is not None else ""
|
||||
|
||||
usage = None
|
||||
if hasattr(response, 'usage') and response.usage:
|
||||
|
||||
@@ -8,10 +8,36 @@ Plugin registry for CLI backend implementations following AGENTS.md patterns:
|
||||
|
||||
import threading
|
||||
from typing import Dict, Callable, Any, Optional, Union
|
||||
from .._registry import PluginRegistry
|
||||
|
||||
# Thread-safe registry for CLI backend factories
|
||||
_REGISTRY: Dict[str, Callable[[], Any]] = {}
|
||||
_REGISTRY_LOCK = threading.Lock()
|
||||
# CLI Backend Registry using canonical PluginRegistry
|
||||
def _get_builtin_cli_backend_loaders() -> Dict[str, Callable[[], Any]]:
|
||||
"""Get built-in CLI backend loaders."""
|
||||
def claude_loader():
|
||||
def factory():
|
||||
from .claude import ClaudeCodeBackend
|
||||
return ClaudeCodeBackend()
|
||||
return factory
|
||||
|
||||
return {
|
||||
"claude-code": claude_loader
|
||||
}
|
||||
|
||||
# Global CLI backend registry instance
|
||||
_cli_backend_registry: Optional[PluginRegistry] = None
|
||||
_registry_lock = threading.Lock()
|
||||
|
||||
def _get_cli_backend_registry() -> PluginRegistry:
|
||||
"""Get the CLI backend registry instance."""
|
||||
global _cli_backend_registry
|
||||
if _cli_backend_registry is None:
|
||||
with _registry_lock:
|
||||
if _cli_backend_registry is None:
|
||||
_cli_backend_registry = PluginRegistry(
|
||||
entry_point_group="praisonai.cli_backends",
|
||||
builtins=_get_builtin_cli_backend_loaders()
|
||||
)
|
||||
return _cli_backend_registry
|
||||
|
||||
|
||||
def register_cli_backend(backend_id: str, factory: Callable[[], Any]) -> None:
|
||||
@@ -21,14 +47,18 @@ def register_cli_backend(backend_id: str, factory: Callable[[], Any]) -> None:
|
||||
backend_id: Unique identifier (e.g., "claude-code", "codex-cli")
|
||||
factory: Factory function that returns a CliBackendProtocol instance
|
||||
"""
|
||||
with _REGISTRY_LOCK:
|
||||
_REGISTRY[backend_id] = factory
|
||||
# Wrap factory in a loader function for PluginRegistry
|
||||
def factory_loader():
|
||||
return factory
|
||||
|
||||
registry = _get_cli_backend_registry()
|
||||
registry.register(backend_id, factory_loader)
|
||||
|
||||
|
||||
def list_cli_backends() -> list[str]:
|
||||
"""List all registered CLI backend IDs."""
|
||||
with _REGISTRY_LOCK:
|
||||
return list(_REGISTRY.keys())
|
||||
registry = _get_cli_backend_registry()
|
||||
return registry.list_names()
|
||||
|
||||
|
||||
def resolve_cli_backend(
|
||||
@@ -47,12 +77,15 @@ def resolve_cli_backend(
|
||||
Raises:
|
||||
ValueError: If backend_id is not registered
|
||||
"""
|
||||
with _REGISTRY_LOCK:
|
||||
if backend_id not in _REGISTRY:
|
||||
available = list(_REGISTRY.keys())
|
||||
raise ValueError(f"Unknown CLI backend: {backend_id}. Available: {available}")
|
||||
|
||||
factory = _REGISTRY[backend_id]
|
||||
registry = _get_cli_backend_registry()
|
||||
|
||||
try:
|
||||
# Get the factory loader and create the factory
|
||||
factory_loader = registry.resolve(backend_id)
|
||||
factory = factory_loader()
|
||||
except ValueError:
|
||||
available = registry.list_names()
|
||||
raise ValueError(f"Unknown CLI backend: {backend_id}. Available: {available}")
|
||||
|
||||
# Create instance with factory
|
||||
backend = factory()
|
||||
@@ -77,24 +110,4 @@ def resolve_cli_backend(
|
||||
return backend
|
||||
|
||||
|
||||
def _register_builtin_backends() -> None:
|
||||
"""Register built-in CLI backends (lazy loaded)."""
|
||||
# Only register if not already done
|
||||
with _REGISTRY_LOCK:
|
||||
if "claude-code" in _REGISTRY:
|
||||
return
|
||||
|
||||
def claude_factory():
|
||||
from .claude import ClaudeCodeBackend
|
||||
return ClaudeCodeBackend()
|
||||
|
||||
register_cli_backend("claude-code", claude_factory)
|
||||
|
||||
|
||||
# Auto-register built-in backends on first access
|
||||
def __getattr__(name: str):
|
||||
"""Lazy registration trigger."""
|
||||
_register_builtin_backends()
|
||||
raise AttributeError(f"No attribute {name}")
|
||||
|
||||
# Registration happens lazily via __getattr__ on first attribute access
|
||||
# Built-in backends are now registered via _get_builtin_cli_backend_loaders()
|
||||
@@ -58,9 +58,10 @@ def write_file(
|
||||
>>> if result['success']:
|
||||
... print(f"Wrote to {result['path']}")
|
||||
"""
|
||||
# Resolve path
|
||||
if workspace and not os.path.isabs(path):
|
||||
abs_path = os.path.abspath(os.path.join(workspace, path))
|
||||
# Resolve path — default workspace is cwd so relative paths cannot escape
|
||||
effective_workspace = workspace or os.getcwd()
|
||||
if not os.path.isabs(path):
|
||||
abs_path = os.path.abspath(os.path.join(effective_workspace, path))
|
||||
else:
|
||||
abs_path = os.path.abspath(path)
|
||||
|
||||
@@ -73,14 +74,12 @@ def write_file(
|
||||
'path': path,
|
||||
}
|
||||
|
||||
# Security check - ensure path is within workspace if specified
|
||||
if workspace:
|
||||
if not is_path_within_directory(abs_path, workspace):
|
||||
return {
|
||||
'success': False,
|
||||
'error': f"Path '{path}' is outside the workspace",
|
||||
'path': path,
|
||||
}
|
||||
if not is_path_within_directory(abs_path, effective_workspace):
|
||||
return {
|
||||
'success': False,
|
||||
'error': f"Path '{path}' is outside the workspace",
|
||||
'path': path,
|
||||
}
|
||||
|
||||
# Process content
|
||||
processed_content = content
|
||||
|
||||
@@ -554,6 +554,162 @@ class PraisonAIDB:
|
||||
return traces[: max(limit, 0)]
|
||||
return traces
|
||||
|
||||
# ========================================================================
|
||||
# Async Surface for async-safe agents
|
||||
# ========================================================================
|
||||
|
||||
async def aon_agent_start(
|
||||
self,
|
||||
session_id: str,
|
||||
name: str,
|
||||
agent_id: str = "",
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Async version of on_agent_start."""
|
||||
self._init_stores()
|
||||
|
||||
if self._state_store:
|
||||
if hasattr(self._state_store, "async_set_agent_state"):
|
||||
await self._state_store.async_set_agent_state(
|
||||
session_id,
|
||||
agent_id or name,
|
||||
{"status": "started", "metadata": metadata or {}}
|
||||
)
|
||||
else:
|
||||
import asyncio
|
||||
await asyncio.to_thread(
|
||||
self._state_store.set_agent_state,
|
||||
session_id,
|
||||
agent_id or name,
|
||||
{"status": "started", "metadata": metadata or {}}
|
||||
)
|
||||
|
||||
async def aon_user_message(
|
||||
self,
|
||||
session_id: str,
|
||||
content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Async version of on_user_message."""
|
||||
self._init_stores()
|
||||
|
||||
if self._conversation_store:
|
||||
from ..persistence.conversation.models import ConversationMessage
|
||||
msg = ConversationMessage(
|
||||
role="user",
|
||||
content=content,
|
||||
metadata=metadata or {}
|
||||
)
|
||||
|
||||
if hasattr(self._conversation_store, "async_add_message"):
|
||||
await self._conversation_store.async_add_message(session_id, msg)
|
||||
else:
|
||||
import asyncio
|
||||
await asyncio.to_thread(self._conversation_store.add_message, session_id, msg)
|
||||
|
||||
async def aon_agent_message(
|
||||
self,
|
||||
session_id: str,
|
||||
content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Async version of on_agent_message."""
|
||||
self._init_stores()
|
||||
|
||||
if self._conversation_store:
|
||||
from ..persistence.conversation.models import ConversationMessage
|
||||
msg = ConversationMessage(
|
||||
role="assistant",
|
||||
content=content,
|
||||
metadata=metadata or {}
|
||||
)
|
||||
|
||||
if hasattr(self._conversation_store, "async_add_message"):
|
||||
await self._conversation_store.async_add_message(session_id, msg)
|
||||
else:
|
||||
import asyncio
|
||||
await asyncio.to_thread(self._conversation_store.add_message, session_id, msg)
|
||||
|
||||
async def aon_tool_call(
|
||||
self,
|
||||
session_id: str,
|
||||
tool_name: str,
|
||||
arguments: Dict[str, Any],
|
||||
result: Any = None,
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Async version of on_tool_call."""
|
||||
self._init_stores()
|
||||
|
||||
if self._conversation_store:
|
||||
if hasattr(self._conversation_store, "async_add_tool_call"):
|
||||
await self._conversation_store.async_add_tool_call(
|
||||
session_id, tool_name, arguments, result, metadata
|
||||
)
|
||||
elif hasattr(self._conversation_store, "add_tool_call"):
|
||||
import asyncio
|
||||
await asyncio.to_thread(
|
||||
self._conversation_store.add_tool_call,
|
||||
session_id, tool_name, arguments, result, metadata
|
||||
)
|
||||
|
||||
async def aon_agent_end(
|
||||
self,
|
||||
session_id: str,
|
||||
name: str,
|
||||
agent_id: str = "",
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Async version of on_agent_end."""
|
||||
self._init_stores()
|
||||
|
||||
if self._state_store:
|
||||
if hasattr(self._state_store, "async_set_agent_state"):
|
||||
await self._state_store.async_set_agent_state(
|
||||
session_id,
|
||||
agent_id or name,
|
||||
{"status": "ended", "metadata": metadata or {}}
|
||||
)
|
||||
else:
|
||||
import asyncio
|
||||
await asyncio.to_thread(
|
||||
self._state_store.set_agent_state,
|
||||
session_id,
|
||||
agent_id or name,
|
||||
{"status": "ended", "metadata": metadata or {}}
|
||||
)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Async version of close."""
|
||||
stores = [self._conversation_store, self._state_store, self._knowledge_store]
|
||||
|
||||
for store in stores:
|
||||
if store is None:
|
||||
continue
|
||||
if hasattr(store, "async_close"):
|
||||
await store.async_close()
|
||||
else:
|
||||
import asyncio
|
||||
await asyncio.to_thread(store.close)
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Async context manager entry."""
|
||||
self._init_stores()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Async context manager exit."""
|
||||
await self.aclose()
|
||||
|
||||
def __enter__(self):
|
||||
"""Sync context manager entry."""
|
||||
self._init_stores()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Sync context manager exit."""
|
||||
self.close()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close all database connections."""
|
||||
if self._conversation_store:
|
||||
|
||||
@@ -57,7 +57,7 @@ class CloudDeployer:
|
||||
file.write("FROM python:3.11-slim\n")
|
||||
file.write("WORKDIR /app\n")
|
||||
file.write("COPY . .\n")
|
||||
file.write("RUN pip install flask praisonai==4.6.38 gunicorn markdown\n")
|
||||
file.write("RUN pip install flask praisonai==4.6.40 gunicorn markdown\n")
|
||||
file.write("EXPOSE 8080\n")
|
||||
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
|
||||
|
||||
@@ -76,7 +76,8 @@ class CloudDeployer:
|
||||
with open("api.py", "w") as file:
|
||||
file.write("from flask import Flask\n")
|
||||
file.write("from praisonai import PraisonAI\n")
|
||||
file.write("import markdown\n\n")
|
||||
file.write("import markdown\n")
|
||||
file.write("import bleach\n\n")
|
||||
file.write("app = Flask(__name__)\n\n")
|
||||
file.write("def basic():\n")
|
||||
file.write(" praisonai = PraisonAI(agent_file=\"agents.yaml\")\n")
|
||||
@@ -84,8 +85,9 @@ class CloudDeployer:
|
||||
file.write("@app.route('/')\n")
|
||||
file.write("def home():\n")
|
||||
file.write(" output = basic()\n")
|
||||
file.write(" html_output = markdown.markdown(output)\n")
|
||||
file.write(" return f'<html><body>{html_output}</body></html>'\n\n")
|
||||
file.write(" rendered = markdown.markdown(str(output))\n")
|
||||
file.write(" safe_html = bleach.clean(rendered, tags=bleach.sanitizer.ALLOWED_TAGS, attributes=bleach.sanitizer.ALLOWED_ATTRIBUTES)\n")
|
||||
file.write(" return f'<html><body>{safe_html}</body></html>'\n\n")
|
||||
file.write("if __name__ == \"__main__\":\n")
|
||||
file.write(" import os\n")
|
||||
file.write(" app.run(debug=os.environ.get('DEBUG', 'false').lower() == 'true')\n")
|
||||
|
||||
@@ -256,17 +256,42 @@ class ExecApprovalManager:
|
||||
logger.debug("Expired approval request: %s", rid)
|
||||
|
||||
|
||||
# ── Module-level singleton accessor ──────────────────────────────────
|
||||
# ── Manager accessor (non-singleton) ─────────────────────────────────
|
||||
|
||||
_global_manager: Optional[ExecApprovalManager] = None
|
||||
# Default manager for CLI/backward compatibility
|
||||
_default_manager: Optional[ExecApprovalManager] = None
|
||||
_manager_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_exec_approval_manager(ttl: float = 300.0) -> ExecApprovalManager:
|
||||
"""Get or create the global :class:`ExecApprovalManager` singleton."""
|
||||
global _global_manager
|
||||
if _global_manager is None:
|
||||
def get_default_exec_approval_manager(ttl: float = 300.0) -> ExecApprovalManager:
|
||||
"""Get the default exec approval manager (for CLI and backward compatibility).
|
||||
|
||||
Args:
|
||||
ttl: Seconds before approval requests auto-expire (default 5 min).
|
||||
|
||||
Returns:
|
||||
The default ExecApprovalManager instance.
|
||||
"""
|
||||
global _default_manager
|
||||
if _default_manager is None:
|
||||
with _manager_lock:
|
||||
if _global_manager is None:
|
||||
_global_manager = ExecApprovalManager(ttl=ttl)
|
||||
return _global_manager
|
||||
if _default_manager is None:
|
||||
_default_manager = ExecApprovalManager(ttl=ttl)
|
||||
return _default_manager
|
||||
|
||||
|
||||
def get_exec_approval_manager(ttl: float = 300.0) -> ExecApprovalManager:
|
||||
"""Backward compatibility alias for get_default_exec_approval_manager."""
|
||||
return get_default_exec_approval_manager(ttl)
|
||||
|
||||
|
||||
def create_exec_approval_manager(ttl: float = 300.0) -> ExecApprovalManager:
|
||||
"""Create a new exec approval manager instance (for per-agent use).
|
||||
|
||||
Args:
|
||||
ttl: Seconds before approval requests auto-expire (default 5 min).
|
||||
|
||||
Returns:
|
||||
A new ExecApprovalManager instance.
|
||||
"""
|
||||
return ExecApprovalManager(ttl=ttl)
|
||||
@@ -1,49 +1,33 @@
|
||||
# Lazy availability checks using find_spec (no actual import)
|
||||
# This avoids the ~3200ms crewai import at CLI startup
|
||||
"""Lazy access to autogen_tools, with `find_spec` startup probes for crewai/autogen."""
|
||||
import importlib.util
|
||||
from praisonai.auto import _load_optional
|
||||
|
||||
CREWAI_AVAILABLE = importlib.util.find_spec("crewai") is not None
|
||||
AUTOGEN_AVAILABLE = importlib.util.find_spec("autogen") is not None
|
||||
PRAISONAI_TOOLS_AVAILABLE = False
|
||||
PRAISONAI_TOOLS_PACKAGE_AVAILABLE = importlib.util.find_spec("praisonai_tools") is not None
|
||||
|
||||
|
||||
def _load_autogen_tools():
|
||||
# Load the autogen_tools module unconditionally since it can work with just praisonai_tools
|
||||
import importlib
|
||||
return importlib.import_module(__name__ + ".autogen_tools")
|
||||
|
||||
# Guard against recursive imports
|
||||
_importing_autogen_tools = False
|
||||
_autogen_tools_module = None
|
||||
|
||||
# Lazy import helper - only imports when actually needed
|
||||
def _get_autogen_tools():
|
||||
"""Lazy import autogen_tools only when needed."""
|
||||
global PRAISONAI_TOOLS_AVAILABLE, _importing_autogen_tools, _autogen_tools_module
|
||||
|
||||
# Return cached module if already imported
|
||||
if _autogen_tools_module is not None:
|
||||
return _autogen_tools_module
|
||||
|
||||
# Prevent recursive import
|
||||
if _importing_autogen_tools:
|
||||
return None
|
||||
|
||||
if CREWAI_AVAILABLE or AUTOGEN_AVAILABLE:
|
||||
try:
|
||||
_importing_autogen_tools = True
|
||||
from . import autogen_tools
|
||||
_autogen_tools_module = autogen_tools
|
||||
PRAISONAI_TOOLS_AVAILABLE = True
|
||||
return autogen_tools
|
||||
except ImportError:
|
||||
pass
|
||||
finally:
|
||||
_importing_autogen_tools = False
|
||||
return None
|
||||
"""Thread-safe, single-shot lazy import. Negative result is cached too."""
|
||||
return _load_optional("inbuilt_autogen_tools", _load_autogen_tools)
|
||||
|
||||
# For backward compatibility, provide __getattr__ for lazy access
|
||||
|
||||
def _praisonai_tools_available() -> bool:
|
||||
"""Read-only accessor — never mutate this from inside a function."""
|
||||
return PRAISONAI_TOOLS_PACKAGE_AVAILABLE or _get_autogen_tools() is not None
|
||||
|
||||
|
||||
# Backward-compat: keep the constant, computed lazily on attribute access.
|
||||
def __getattr__(name):
|
||||
"""Lazy load autogen_tools exports on demand."""
|
||||
# Avoid recursion during import
|
||||
if _importing_autogen_tools:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
if name == "PRAISONAI_TOOLS_AVAILABLE":
|
||||
return _praisonai_tools_available()
|
||||
tools_module = _get_autogen_tools()
|
||||
if tools_module and hasattr(tools_module, name):
|
||||
if tools_module is not None and hasattr(tools_module, name):
|
||||
return getattr(tools_module, name)
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -83,8 +83,9 @@ class InMemoryJobStore(JobStore):
|
||||
def _get_lock(self) -> asyncio.Lock:
|
||||
"""Get the asyncio lock, creating it lazily if needed.
|
||||
|
||||
This deferred creation is required for Python 3.9 compatibility
|
||||
where asyncio.Lock() calls get_event_loop() at creation time.
|
||||
Deferred creation avoids touching the event loop at __init__ time,
|
||||
which keeps the store safe to instantiate from sync contexts on
|
||||
Python 3.9 (where asyncio.Lock() implicitly calls get_event_loop()).
|
||||
"""
|
||||
if self.__lock is None:
|
||||
self.__lock = asyncio.Lock()
|
||||
@@ -125,14 +126,14 @@ class InMemoryJobStore(JobStore):
|
||||
|
||||
async def get(self, job_id: str) -> Optional[Job]:
|
||||
"""Get a job by ID."""
|
||||
return self._jobs.get(job_id)
|
||||
async with self._get_lock():
|
||||
return self._jobs.get(job_id)
|
||||
|
||||
async def get_by_idempotency_key(self, key: str) -> Optional[Job]:
|
||||
"""Get a job by idempotency key."""
|
||||
job_id = self._idempotency_keys.get(key)
|
||||
if job_id:
|
||||
return self._jobs.get(job_id)
|
||||
return None
|
||||
async with self._get_lock():
|
||||
job_id = self._idempotency_keys.get(key)
|
||||
return self._jobs.get(job_id) if job_id else None
|
||||
|
||||
async def list_jobs(
|
||||
self,
|
||||
@@ -142,9 +143,10 @@ class InMemoryJobStore(JobStore):
|
||||
offset: int = 0
|
||||
) -> List[Job]:
|
||||
"""List jobs with optional filters."""
|
||||
jobs = list(self._jobs.values())
|
||||
async with self._get_lock():
|
||||
jobs = list(self._jobs.values())
|
||||
|
||||
# Apply filters
|
||||
# Apply filters outside the lock - values() snapshot is already taken
|
||||
if status:
|
||||
jobs = [j for j in jobs if j.status == status]
|
||||
if session_id:
|
||||
@@ -162,7 +164,8 @@ class InMemoryJobStore(JobStore):
|
||||
session_id: Optional[str] = None
|
||||
) -> int:
|
||||
"""Count jobs matching filters."""
|
||||
jobs = list(self._jobs.values())
|
||||
async with self._get_lock():
|
||||
jobs = list(self._jobs.values())
|
||||
|
||||
if status:
|
||||
jobs = [j for j in jobs if j.status == status]
|
||||
@@ -201,15 +204,20 @@ class InMemoryJobStore(JobStore):
|
||||
|
||||
return len(to_remove)
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
async def get_stats(self) -> Dict[str, Any]:
|
||||
"""Get store statistics."""
|
||||
async with self._get_lock():
|
||||
# Take a snapshot to avoid RuntimeError if the dict is mutated concurrently
|
||||
jobs_snapshot = list(self._jobs.values())
|
||||
idempotency_count = len(self._idempotency_keys)
|
||||
|
||||
status_counts = {}
|
||||
for job in self._jobs.values():
|
||||
for job in jobs_snapshot:
|
||||
status_counts[job.status.value] = status_counts.get(job.status.value, 0) + 1
|
||||
|
||||
return {
|
||||
"total_jobs": len(self._jobs),
|
||||
"idempotency_keys": len(self._idempotency_keys),
|
||||
"total_jobs": len(jobs_snapshot),
|
||||
"idempotency_keys": idempotency_count,
|
||||
"max_jobs": self._max_jobs,
|
||||
"status_counts": status_counts
|
||||
}
|
||||
@@ -22,6 +22,7 @@ Example:
|
||||
|
||||
from typing import Any, Callable, Dict, List, Optional, Type, Union
|
||||
import threading
|
||||
from .._registry import PluginRegistry
|
||||
|
||||
|
||||
# Type aliases
|
||||
@@ -30,171 +31,46 @@ ProviderFactory = Callable[[str, Optional[Dict[str, Any]]], Any]
|
||||
ProviderType = Union[ProviderClass, ProviderFactory]
|
||||
|
||||
|
||||
class LLMProviderRegistry:
|
||||
class LLMProviderRegistry(PluginRegistry[ProviderType]):
|
||||
"""
|
||||
Registry for LLM providers.
|
||||
|
||||
Manages registration and resolution of LLM providers by name.
|
||||
Supports lazy loading, aliases, and isolated instances.
|
||||
Thread-safe for concurrent operations.
|
||||
|
||||
Now inherits from PluginRegistry to eliminate duplication.
|
||||
"""
|
||||
|
||||
_instance: Optional["LLMProviderRegistry"] = None
|
||||
_instance_lock = threading.Lock()
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize an empty registry."""
|
||||
self._providers: Dict[str, ProviderType] = {}
|
||||
self._aliases: Dict[str, str] = {} # alias -> canonical name
|
||||
self._lock = threading.RLock() # RLock for re-entrant calls
|
||||
# Register built-in providers during initialization
|
||||
_register_builtin_providers(self)
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls) -> "LLMProviderRegistry":
|
||||
"""
|
||||
Get the singleton registry instance.
|
||||
"""Initialize LLM provider registry."""
|
||||
# Get built-in provider loaders
|
||||
builtins = _get_builtin_provider_loaders()
|
||||
|
||||
Returns:
|
||||
LLMProviderRegistry: The singleton registry instance
|
||||
"""
|
||||
if cls._instance is None:
|
||||
with cls._instance_lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
# Initialize parent with entry points and builtins
|
||||
super().__init__(
|
||||
entry_point_group="praisonai.llm_providers",
|
||||
builtins=builtins
|
||||
)
|
||||
|
||||
# Register aliases for built-in providers
|
||||
self._register_builtin_aliases()
|
||||
|
||||
def register(
|
||||
self,
|
||||
name: str,
|
||||
provider: ProviderType,
|
||||
*,
|
||||
override: bool = False,
|
||||
aliases: Optional[List[str]] = None
|
||||
) -> None:
|
||||
"""
|
||||
Register a provider by name.
|
||||
def _register_builtin_aliases(self) -> None:
|
||||
"""Register aliases for built-in providers."""
|
||||
aliases_map = [
|
||||
("openai", ("oai",)),
|
||||
("anthropic", ("claude",)),
|
||||
("google", ("gemini", "google_genai")),
|
||||
]
|
||||
|
||||
Args:
|
||||
name: Provider name (e.g., 'cloudflare', 'ollama')
|
||||
provider: Provider class or factory function
|
||||
override: Allow overwriting existing registration
|
||||
aliases: Additional names that resolve to this provider
|
||||
|
||||
Raises:
|
||||
ValueError: If name is already registered (unless override=True)
|
||||
"""
|
||||
with self._lock:
|
||||
normalized_name = name.lower()
|
||||
|
||||
# Check for existing registration
|
||||
if normalized_name in self._providers and not override:
|
||||
raise ValueError(
|
||||
f"Provider '{name}' is already registered. "
|
||||
f"Use override=True to replace it."
|
||||
)
|
||||
|
||||
# Check if this canonical name conflicts with existing alias
|
||||
if normalized_name in self._aliases and not override:
|
||||
existing_target = self._aliases[normalized_name]
|
||||
raise ValueError(
|
||||
f"Cannot register provider '{name}' - it conflicts with existing alias "
|
||||
f"(currently points to '{existing_target}'). Use override=True to replace it."
|
||||
)
|
||||
|
||||
# If override=True, clean up any existing alias before registering canonical name
|
||||
if override and normalized_name in self._aliases:
|
||||
del self._aliases[normalized_name]
|
||||
|
||||
self._providers[normalized_name] = provider
|
||||
|
||||
# Register aliases
|
||||
if aliases:
|
||||
for alias in aliases:
|
||||
normalized_alias = alias.lower()
|
||||
# Check collision with existing provider name
|
||||
if normalized_alias in self._providers and not override:
|
||||
raise ValueError(
|
||||
f"Alias '{alias}' conflicts with existing provider name. "
|
||||
f"Use override=True to replace it."
|
||||
)
|
||||
# Check collision with existing alias
|
||||
if normalized_alias in self._aliases and not override:
|
||||
existing_target = self._aliases[normalized_alias]
|
||||
raise ValueError(
|
||||
f"Alias '{alias}' is already registered (points to '{existing_target}'). "
|
||||
f"Use override=True to replace it."
|
||||
)
|
||||
self._aliases[normalized_alias] = normalized_name
|
||||
for name, aliases in aliases_map:
|
||||
if name in self._items: # Only add aliases if the provider loaded
|
||||
for alias in aliases:
|
||||
self._aliases[alias.lower()] = name.lower()
|
||||
|
||||
def unregister(self, name: str) -> bool:
|
||||
"""
|
||||
Unregister a provider by name.
|
||||
|
||||
Args:
|
||||
name: Provider name to unregister
|
||||
|
||||
Returns:
|
||||
True if provider was unregistered, False if not found
|
||||
"""
|
||||
with self._lock:
|
||||
normalized_name = name.lower()
|
||||
|
||||
# Check if it's an alias
|
||||
if normalized_name in self._aliases:
|
||||
del self._aliases[normalized_name]
|
||||
return True
|
||||
|
||||
# Check if it's a canonical name
|
||||
if normalized_name in self._providers:
|
||||
# Remove all aliases pointing to this provider
|
||||
aliases_to_remove = [
|
||||
alias for alias, canonical in self._aliases.items()
|
||||
if canonical == normalized_name
|
||||
]
|
||||
for alias in aliases_to_remove:
|
||||
del self._aliases[alias]
|
||||
|
||||
del self._providers[normalized_name]
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def has(self, name: str) -> bool:
|
||||
"""
|
||||
Check if a provider is registered.
|
||||
|
||||
Args:
|
||||
name: Provider name to check
|
||||
|
||||
Returns:
|
||||
True if provider is registered
|
||||
"""
|
||||
with self._lock:
|
||||
normalized_name = name.lower()
|
||||
return normalized_name in self._providers or normalized_name in self._aliases
|
||||
|
||||
def list(self) -> List[str]:
|
||||
"""
|
||||
List all registered provider names (canonical names only).
|
||||
|
||||
Returns:
|
||||
List of provider names
|
||||
"""
|
||||
with self._lock:
|
||||
return list(self._providers.keys())
|
||||
|
||||
def list_all(self) -> List[str]:
|
||||
"""
|
||||
List all names including aliases.
|
||||
|
||||
Returns:
|
||||
List of all registered names and aliases
|
||||
"""
|
||||
with self._lock:
|
||||
return list(self._providers.keys()) + list(self._aliases.keys())
|
||||
|
||||
def resolve(
|
||||
def resolve_provider(
|
||||
self,
|
||||
name: str,
|
||||
model_id: str,
|
||||
@@ -214,23 +90,69 @@ class LLMProviderRegistry:
|
||||
Raises:
|
||||
ValueError: If provider not found
|
||||
"""
|
||||
with self._lock:
|
||||
normalized_name = name.lower()
|
||||
|
||||
# Resolve alias to canonical name
|
||||
canonical_name = self._aliases.get(normalized_name, normalized_name)
|
||||
|
||||
provider = self._providers.get(canonical_name)
|
||||
if provider is None:
|
||||
available = list(self._providers.keys()) # Don't call self.list() to avoid double-locking
|
||||
raise ValueError(
|
||||
f"Unknown provider: '{name}'. "
|
||||
f"Available providers: {', '.join(available) if available else 'none'}. "
|
||||
f"Register a custom provider with register_llm_provider('{name}', YourProviderClass)."
|
||||
)
|
||||
|
||||
provider_cls = self.resolve(name)
|
||||
# Create instance outside the lock
|
||||
return provider(model_id, config)
|
||||
return provider_cls(model_id, config)
|
||||
|
||||
def register_provider(
|
||||
self,
|
||||
name: str,
|
||||
provider: ProviderType,
|
||||
*,
|
||||
override: bool = False,
|
||||
aliases: Optional[List[str]] = None
|
||||
) -> None:
|
||||
"""
|
||||
Register a provider by name with override validation.
|
||||
|
||||
Args:
|
||||
name: Provider name (e.g., 'cloudflare', 'ollama')
|
||||
provider: Provider class or factory function
|
||||
override: Allow overwriting existing registration
|
||||
aliases: Additional names that resolve to this provider
|
||||
|
||||
Raises:
|
||||
ValueError: If name is already registered (unless override=True)
|
||||
"""
|
||||
# Check for existing registration if override is False
|
||||
if not override and self.is_available(name):
|
||||
raise ValueError(
|
||||
f"Provider '{name}' is already registered. "
|
||||
f"Use override=True to replace it."
|
||||
)
|
||||
|
||||
# Check alias conflicts if override is False
|
||||
if not override and aliases:
|
||||
for alias in aliases:
|
||||
if self.is_available(alias):
|
||||
raise ValueError(
|
||||
f"Alias '{alias}' is already registered. "
|
||||
f"Use override=True to replace it."
|
||||
)
|
||||
|
||||
# If override=True, unregister existing entries first
|
||||
if override:
|
||||
self.unregister(name)
|
||||
if aliases:
|
||||
for alias in aliases:
|
||||
self.unregister(alias)
|
||||
|
||||
# Use parent class registration
|
||||
self.register(name, provider, aliases=aliases)
|
||||
|
||||
# Inherit unregister, is_available, list_names from parent
|
||||
# Add backwards compatibility aliases
|
||||
def has(self, name: str) -> bool:
|
||||
"""Check if a provider is registered (alias for is_available)."""
|
||||
return self.is_available(name)
|
||||
|
||||
def list(self) -> List[str]:
|
||||
"""List provider names (alias for list_names)."""
|
||||
return self.list_names()
|
||||
|
||||
def list_all(self) -> List[str]:
|
||||
"""List all names including aliases (alias for list_all_names)."""
|
||||
return self.list_all_names()
|
||||
|
||||
def get(self, name: str) -> Optional[ProviderType]:
|
||||
"""
|
||||
@@ -242,30 +164,40 @@ class LLMProviderRegistry:
|
||||
Returns:
|
||||
Provider class/factory or None
|
||||
"""
|
||||
with self._lock:
|
||||
normalized_name = name.lower()
|
||||
canonical_name = self._aliases.get(normalized_name, normalized_name)
|
||||
return self._providers.get(canonical_name)
|
||||
try:
|
||||
return self.resolve(name)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Default Registry Singleton
|
||||
# Default Registry Instance (No Singleton)
|
||||
# ============================================================================
|
||||
|
||||
# Default module-level registry instance
|
||||
_default_llm_registry: Optional[LLMProviderRegistry] = None
|
||||
_default_llm_lock = threading.Lock()
|
||||
|
||||
def get_default_llm_registry() -> LLMProviderRegistry:
|
||||
"""
|
||||
Get the default global LLM provider registry.
|
||||
|
||||
This is the registry used by create_llm_provider() when no custom registry
|
||||
is specified. Uses the thread-safe singleton pattern.
|
||||
is specified. Uses lazy initialization pattern.
|
||||
"""
|
||||
return LLMProviderRegistry.get_instance()
|
||||
global _default_llm_registry
|
||||
if _default_llm_registry is None:
|
||||
with _default_llm_lock:
|
||||
if _default_llm_registry is None:
|
||||
_default_llm_registry = LLMProviderRegistry()
|
||||
return _default_llm_registry
|
||||
|
||||
|
||||
def _register_builtin_providers(registry: LLMProviderRegistry) -> None:
|
||||
def _get_builtin_provider_loaders() -> Dict[str, Callable[[], ProviderType]]:
|
||||
"""
|
||||
Register built-in providers to a registry.
|
||||
Get built-in provider loaders.
|
||||
|
||||
Returns a dict of name -> loader function that creates provider factories.
|
||||
Uses lazy loading to avoid importing heavy dependencies at module load time.
|
||||
"""
|
||||
# Built-in adapter that wraps LiteLLM so create_llm_provider works out of the box.
|
||||
@@ -324,13 +256,22 @@ def _register_builtin_providers(registry: LLMProviderRegistry) -> None:
|
||||
return _LiteLLMProvider(model_id, cfg)
|
||||
return factory
|
||||
|
||||
# Build loader dict for the canonical PluginRegistry
|
||||
loaders: Dict[str, Callable[[], ProviderType]] = {}
|
||||
|
||||
# Cover the providers parse_model_string() already special-cases.
|
||||
for name, aliases in [
|
||||
("openai", ("oai",)),
|
||||
("anthropic", ("claude",)),
|
||||
("google", ("gemini", "google_genai")),
|
||||
]:
|
||||
registry.register(name, _make_litellm_factory(name), aliases=list(aliases))
|
||||
# Use local function to capture name correctly in closure
|
||||
def make_loader(provider_name):
|
||||
return lambda: _make_litellm_factory(provider_name)
|
||||
loaders[name] = make_loader(name)
|
||||
# Note: aliases will be registered separately via PluginRegistry.register()
|
||||
|
||||
return loaders
|
||||
|
||||
|
||||
def register_llm_provider(
|
||||
@@ -354,7 +295,7 @@ def register_llm_provider(
|
||||
|
||||
register_llm_provider("cloudflare", CloudflareProvider)
|
||||
"""
|
||||
get_default_llm_registry().register(name, provider, override=override, aliases=aliases)
|
||||
get_default_llm_registry().register_provider(name, provider, override=override, aliases=aliases)
|
||||
|
||||
|
||||
def unregister_llm_provider(name: str) -> bool:
|
||||
@@ -456,12 +397,12 @@ def create_llm_provider(
|
||||
name = input_value["name"]
|
||||
model_id = input_value.get("model_id", "default")
|
||||
provider_config = input_value.get("config") or config
|
||||
return reg.resolve(name, model_id, provider_config)
|
||||
return reg.resolve_provider(name, model_id, provider_config)
|
||||
|
||||
# Case 3: String - parse and resolve
|
||||
if isinstance(input_value, str):
|
||||
parsed = parse_model_string(input_value)
|
||||
return reg.resolve(parsed["provider_id"], parsed["model_id"], config)
|
||||
return reg.resolve_provider(parsed["provider_id"], parsed["model_id"], config)
|
||||
|
||||
raise ValueError(
|
||||
f"Invalid provider input. Expected string, provider instance, or spec dict. "
|
||||
@@ -471,5 +412,6 @@ def create_llm_provider(
|
||||
|
||||
def _reset_default_registry() -> None:
|
||||
"""Reset the default registry (mainly for testing)."""
|
||||
with LLMProviderRegistry._instance_lock:
|
||||
LLMProviderRegistry._instance = None
|
||||
global _default_llm_registry
|
||||
with _default_llm_lock:
|
||||
_default_llm_registry = None
|
||||
@@ -21,6 +21,31 @@ from ..registry import register_tool
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_cwd_yaml_path(file_path: str) -> "Path":
|
||||
"""Resolve a YAML path strictly inside the current working directory."""
|
||||
from pathlib import Path
|
||||
|
||||
if not isinstance(file_path, str) or not file_path:
|
||||
raise ValueError("file_path must be a non-empty string")
|
||||
if (
|
||||
"/" in file_path
|
||||
or "\\" in file_path
|
||||
or "\x00" in file_path
|
||||
or file_path.startswith(".")
|
||||
or file_path in ("..", ".")
|
||||
):
|
||||
raise ValueError(f"invalid file_path: {file_path!r}")
|
||||
if not file_path.endswith((".yaml", ".yml")):
|
||||
raise ValueError("file_path must be a .yaml or .yml file")
|
||||
base = Path.cwd().resolve()
|
||||
candidate = (base / file_path).resolve()
|
||||
try:
|
||||
candidate.relative_to(base)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"invalid file_path: {file_path!r}") from exc
|
||||
return candidate
|
||||
|
||||
|
||||
def register_cli_tools() -> None:
|
||||
"""Register CLI-based MCP tools."""
|
||||
|
||||
@@ -44,7 +69,8 @@ def register_cli_tools() -> None:
|
||||
"""Validate a workflow YAML file."""
|
||||
try:
|
||||
import yaml
|
||||
with open(file_path, 'r') as f:
|
||||
yaml_path = _resolve_cwd_yaml_path(file_path)
|
||||
with open(yaml_path, 'r') as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
required = ["framework", "topic"]
|
||||
@@ -64,9 +90,10 @@ def register_cli_tools() -> None:
|
||||
def workflow_show(file_path: str) -> str:
|
||||
"""Show workflow configuration."""
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
content = f.read()
|
||||
return content
|
||||
yaml_path = _resolve_cwd_yaml_path(file_path)
|
||||
return yaml_path.read_text()
|
||||
except ValueError as e:
|
||||
return f"Error: {e}"
|
||||
except FileNotFoundError:
|
||||
return f"File not found: {file_path}"
|
||||
except Exception as e:
|
||||
@@ -417,7 +444,8 @@ def register_cli_tools() -> None:
|
||||
"""Validate deployment configuration."""
|
||||
try:
|
||||
import yaml
|
||||
with open(config_path, 'r') as f:
|
||||
yaml_path = _resolve_cwd_yaml_path(config_path)
|
||||
with open(yaml_path, 'r') as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
required = ["name", "type"]
|
||||
|
||||
@@ -14,11 +14,12 @@ except ImportError:
|
||||
from importlib_metadata import entry_points
|
||||
|
||||
import logging
|
||||
from .._registry import PluginRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StoreRegistry:
|
||||
class StoreRegistry(PluginRegistry[Callable[..., Any]]):
|
||||
"""Registry for persistence store factories."""
|
||||
|
||||
def __init__(self, kind: str, entry_point_group: str):
|
||||
@@ -30,12 +31,14 @@ class StoreRegistry:
|
||||
entry_point_group: The entry point group for external registrations
|
||||
"""
|
||||
self._kind = kind
|
||||
self._factories: Dict[str, Callable[..., Any]] = {}
|
||||
self._aliases: Dict[str, str] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._register_entry_points(entry_point_group)
|
||||
|
||||
# Initialize parent with entry points
|
||||
super().__init__(entry_point_group=entry_point_group)
|
||||
|
||||
# Load built-in stores based on kind
|
||||
self._register_builtin_stores()
|
||||
|
||||
def register(self, name: str, factory: Callable, *, aliases=()) -> None:
|
||||
def register_store(self, name: str, factory: Callable, *, aliases=()) -> None:
|
||||
"""
|
||||
Register a store factory.
|
||||
|
||||
@@ -44,12 +47,16 @@ class StoreRegistry:
|
||||
factory: Factory function that creates store instances
|
||||
aliases: Optional list of alias names for this backend
|
||||
"""
|
||||
with self._lock:
|
||||
normalized_name = name.lower()
|
||||
self._factories[normalized_name] = factory
|
||||
for alias in aliases:
|
||||
normalized_alias = alias.lower()
|
||||
self._aliases[normalized_alias] = normalized_name
|
||||
# Wrap the factory in a loader function for PluginRegistry
|
||||
def factory_loader():
|
||||
return factory
|
||||
|
||||
# Use parent's register method with aliases
|
||||
super().register(name, factory_loader, aliases=list(aliases))
|
||||
|
||||
def register(self, name: str, factory: Callable, *, aliases=()) -> None:
|
||||
"""Backwards compatibility alias for register_store."""
|
||||
self.register_store(name, factory, aliases=aliases)
|
||||
|
||||
def create(self, name: str, **kwargs) -> Any:
|
||||
"""
|
||||
@@ -65,15 +72,11 @@ class StoreRegistry:
|
||||
Raises:
|
||||
ValueError: If backend is not registered
|
||||
"""
|
||||
normalized_name = name.lower()
|
||||
with self._lock:
|
||||
canonical = self._aliases.get(normalized_name, normalized_name)
|
||||
factory = self._factories.get(canonical)
|
||||
if factory is None:
|
||||
# Get available backends for error message inside lock
|
||||
available_backends = sorted(self._factories.keys())
|
||||
|
||||
if factory is None:
|
||||
try:
|
||||
factory_loader = self.resolve(name)
|
||||
factory = factory_loader()
|
||||
except ValueError:
|
||||
available_backends = self.list_names()
|
||||
raise ValueError(
|
||||
f"Unknown {self._kind} backend: {name}. "
|
||||
f"Registered: {available_backends}"
|
||||
@@ -82,32 +85,20 @@ class StoreRegistry:
|
||||
return factory(**kwargs)
|
||||
|
||||
def list_registered(self) -> list[str]:
|
||||
"""List all registered backend names."""
|
||||
with self._lock:
|
||||
return sorted(self._factories.keys())
|
||||
"""List all registered backend names (alias for list_names)."""
|
||||
return self.list_names()
|
||||
|
||||
def list_aliases(self) -> Dict[str, str]:
|
||||
"""List all aliases and their target backends."""
|
||||
with self._lock:
|
||||
return dict(self._aliases)
|
||||
|
||||
def _register_entry_points(self, group: str) -> None:
|
||||
"""Register backends from entry points."""
|
||||
try:
|
||||
for ep in entry_points(group=group):
|
||||
try:
|
||||
self.register(ep.name, ep.load())
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to load %s backend %r from entry point",
|
||||
self._kind, ep.name, exc_info=True
|
||||
)
|
||||
except Exception:
|
||||
# entry_points() may fail in some environments
|
||||
pass
|
||||
def _register_builtin_stores(self) -> None:
|
||||
"""Register built-in stores based on kind."""
|
||||
if self._kind == "conversation":
|
||||
_register_builtin_conversation_stores(self)
|
||||
elif self._kind == "knowledge":
|
||||
_register_builtin_knowledge_stores(self)
|
||||
elif self._kind == "state":
|
||||
_register_builtin_state_stores(self)
|
||||
|
||||
|
||||
def _register_builtin_conversation_stores():
|
||||
def _register_builtin_conversation_stores(registry: StoreRegistry):
|
||||
"""Register built-in conversation stores with lazy imports."""
|
||||
|
||||
def _postgres(url=None, **kwargs):
|
||||
@@ -155,24 +146,24 @@ def _register_builtin_conversation_stores():
|
||||
return TursoConversationStore(url=url, **kwargs)
|
||||
|
||||
# Register all backends with aliases
|
||||
CONVERSATION_STORES.register("postgres", _postgres,
|
||||
registry.register("postgres", _postgres,
|
||||
aliases=("neon", "cockroachdb", "crdb", "cockroach", "xata"))
|
||||
CONVERSATION_STORES.register("async_postgres", _async_postgres,
|
||||
registry.register("async_postgres", _async_postgres,
|
||||
aliases=("asyncpg", "postgres_async"))
|
||||
CONVERSATION_STORES.register("mysql", _mysql)
|
||||
CONVERSATION_STORES.register("async_mysql", _async_mysql,
|
||||
registry.register("mysql", _mysql)
|
||||
registry.register("async_mysql", _async_mysql,
|
||||
aliases=("aiomysql", "mysql_async"))
|
||||
CONVERSATION_STORES.register("sqlite", _sqlite)
|
||||
CONVERSATION_STORES.register("async_sqlite", _async_sqlite,
|
||||
registry.register("sqlite", _sqlite)
|
||||
registry.register("async_sqlite", _async_sqlite,
|
||||
aliases=("aiosqlite", "sqlite_async"))
|
||||
CONVERSATION_STORES.register("json", _json)
|
||||
CONVERSATION_STORES.register("singlestore", _singlestore)
|
||||
CONVERSATION_STORES.register("supabase", _supabase)
|
||||
CONVERSATION_STORES.register("surrealdb", _surrealdb)
|
||||
CONVERSATION_STORES.register("turso", _turso, aliases=("libsql",))
|
||||
registry.register("json", _json)
|
||||
registry.register("singlestore", _singlestore)
|
||||
registry.register("supabase", _supabase)
|
||||
registry.register("surrealdb", _surrealdb)
|
||||
registry.register("turso", _turso, aliases=("libsql",))
|
||||
|
||||
|
||||
def _register_builtin_knowledge_stores():
|
||||
def _register_builtin_knowledge_stores(registry: StoreRegistry):
|
||||
"""Register built-in knowledge stores with lazy imports."""
|
||||
|
||||
def _chroma(url=None, path=None, **kwargs):
|
||||
@@ -259,37 +250,37 @@ def _register_builtin_knowledge_stores():
|
||||
from .knowledge.cosmosdb_vector import CosmosDBVectorKnowledgeStore
|
||||
return CosmosDBVectorKnowledgeStore(url=url, **kwargs)
|
||||
|
||||
KNOWLEDGE_STORES.register("chroma", _chroma, aliases=("chromadb",))
|
||||
KNOWLEDGE_STORES.register("qdrant", _qdrant)
|
||||
KNOWLEDGE_STORES.register("pinecone", _pinecone)
|
||||
KNOWLEDGE_STORES.register("weaviate", _weaviate)
|
||||
KNOWLEDGE_STORES.register("lancedb", _lancedb)
|
||||
KNOWLEDGE_STORES.register("milvus", _milvus)
|
||||
KNOWLEDGE_STORES.register("pgvector", _pgvector)
|
||||
KNOWLEDGE_STORES.register("redis", _redis_vector)
|
||||
KNOWLEDGE_STORES.register("cassandra", _cassandra)
|
||||
KNOWLEDGE_STORES.register("clickhouse", _clickhouse)
|
||||
KNOWLEDGE_STORES.register("mongodb_vector", _mongodb_vector,
|
||||
registry.register("chroma", _chroma, aliases=("chromadb",))
|
||||
registry.register("qdrant", _qdrant)
|
||||
registry.register("pinecone", _pinecone)
|
||||
registry.register("weaviate", _weaviate)
|
||||
registry.register("lancedb", _lancedb)
|
||||
registry.register("milvus", _milvus)
|
||||
registry.register("pgvector", _pgvector)
|
||||
registry.register("redis", _redis_vector)
|
||||
registry.register("cassandra", _cassandra)
|
||||
registry.register("clickhouse", _clickhouse)
|
||||
registry.register("mongodb_vector", _mongodb_vector,
|
||||
aliases=("mongodb_atlas", "mongo_vector"))
|
||||
|
||||
# Register missing backends that were supported in old factory
|
||||
KNOWLEDGE_STORES.register("couchbase", _couchbase)
|
||||
KNOWLEDGE_STORES.register("singlestore_vector", _singlestore_vector,
|
||||
registry.register("couchbase", _couchbase)
|
||||
registry.register("singlestore_vector", _singlestore_vector,
|
||||
aliases=("singlestore_v",))
|
||||
KNOWLEDGE_STORES.register("surrealdb_vector", _surrealdb_vector,
|
||||
registry.register("surrealdb_vector", _surrealdb_vector,
|
||||
aliases=("surrealdb_v",))
|
||||
KNOWLEDGE_STORES.register("upstash_vector", _upstash_vector,
|
||||
registry.register("upstash_vector", _upstash_vector,
|
||||
aliases=("upstash_v",))
|
||||
KNOWLEDGE_STORES.register("lightrag", _lightrag)
|
||||
KNOWLEDGE_STORES.register("langchain", _langchain_adapter,
|
||||
registry.register("lightrag", _lightrag)
|
||||
registry.register("langchain", _langchain_adapter,
|
||||
aliases=("langchain_adapter",))
|
||||
KNOWLEDGE_STORES.register("llamaindex", _llamaindex_adapter,
|
||||
registry.register("llamaindex", _llamaindex_adapter,
|
||||
aliases=("llama_index", "llamaindex_adapter"))
|
||||
KNOWLEDGE_STORES.register("cosmosdb", _cosmosdb_vector,
|
||||
registry.register("cosmosdb", _cosmosdb_vector,
|
||||
aliases=("cosmos", "azure_cosmos", "cosmosdb_vector"))
|
||||
|
||||
|
||||
def _register_builtin_state_stores():
|
||||
def _register_builtin_state_stores(registry: StoreRegistry):
|
||||
"""Register built-in state stores with lazy imports."""
|
||||
|
||||
def _redis(url=None, **kwargs):
|
||||
@@ -330,23 +321,18 @@ def _register_builtin_state_stores():
|
||||
raise ValueError("GCS state store requires 'bucket_name' option")
|
||||
return GCSStateStore(bucket_name=bucket, **kwargs)
|
||||
|
||||
STATE_STORES.register("redis", _redis)
|
||||
STATE_STORES.register("dynamodb", _dynamodb)
|
||||
STATE_STORES.register("firestore", _firestore)
|
||||
STATE_STORES.register("mongodb", _mongodb)
|
||||
STATE_STORES.register("async_mongodb", _async_mongodb,
|
||||
registry.register("redis", _redis)
|
||||
registry.register("dynamodb", _dynamodb)
|
||||
registry.register("firestore", _firestore)
|
||||
registry.register("mongodb", _mongodb)
|
||||
registry.register("async_mongodb", _async_mongodb,
|
||||
aliases=("motor", "mongodb_async"))
|
||||
STATE_STORES.register("upstash", _upstash)
|
||||
STATE_STORES.register("memory", _memory)
|
||||
STATE_STORES.register("gcs", _gcs)
|
||||
registry.register("upstash", _upstash)
|
||||
registry.register("memory", _memory)
|
||||
registry.register("gcs", _gcs)
|
||||
|
||||
|
||||
# Create global registry instances
|
||||
# Create global registry instances - built-in stores are registered automatically via __init__
|
||||
CONVERSATION_STORES = StoreRegistry("conversation", "praisonai.conversation_stores")
|
||||
KNOWLEDGE_STORES = StoreRegistry("knowledge", "praisonai.knowledge_stores")
|
||||
STATE_STORES = StoreRegistry("state", "praisonai.state_stores")
|
||||
|
||||
# Register built-in stores
|
||||
_register_builtin_conversation_stores()
|
||||
_register_builtin_knowledge_stores()
|
||||
_register_builtin_state_stores()
|
||||
STATE_STORES = StoreRegistry("state", "praisonai.state_stores")
|
||||
+413
-524
File diff suppressed because it is too large.
Load diff
@@ -6,12 +6,12 @@ at regular intervals, enabling 24/7 autonomous agent operations.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, Callable
|
||||
|
||||
from .base import ScheduleParser, PraisonAgentExecutor
|
||||
from .shared import backoff_delay
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -292,9 +292,11 @@ class AgentScheduler:
|
||||
logger.error(f"Agent execution failed on attempt {attempt + 1}: {e}")
|
||||
|
||||
if attempt < max_retries - 1:
|
||||
wait_time = 30 * (attempt + 1) # Exponential backoff
|
||||
wait_time = backoff_delay(attempt, initial=30.0, cap=300.0)
|
||||
logger.debug(f"Waiting {wait_time}s before retry...")
|
||||
time.sleep(wait_time)
|
||||
if self._stop_event.wait(wait_time):
|
||||
# stop() was called during backoff — exit immediately
|
||||
return
|
||||
|
||||
if not success:
|
||||
self._failure_count += 1
|
||||
|
||||
@@ -7,6 +7,7 @@ for both builtin and user tools.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import Dict, Callable, List, Optional, Any
|
||||
import inspect
|
||||
|
||||
@@ -19,45 +20,54 @@ class ToolRegistry:
|
||||
def __init__(self):
|
||||
self._functions: Dict[str, Callable] = {}
|
||||
self._autogen_adapters: Dict[str, Callable] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def register_function(self, name: str, func: Callable) -> None:
|
||||
"""Register a function tool."""
|
||||
if not callable(func):
|
||||
raise ValueError(f"Tool {name} must be callable")
|
||||
self._functions[name] = func
|
||||
with self._lock:
|
||||
self._functions[name] = func
|
||||
logger.debug(f"Registered function tool: {name}")
|
||||
|
||||
def register_autogen_adapter(self, tool_type_name: str, adapter: Callable) -> None:
|
||||
"""Register an AutoGen-specific tool adapter."""
|
||||
if not callable(adapter):
|
||||
raise ValueError(f"AutoGen adapter for {tool_type_name} must be callable")
|
||||
self._autogen_adapters[tool_type_name] = adapter
|
||||
with self._lock:
|
||||
self._autogen_adapters[tool_type_name] = adapter
|
||||
logger.debug(f"Registered AutoGen adapter: {tool_type_name}")
|
||||
|
||||
def get_function(self, name: str) -> Optional[Callable]:
|
||||
"""Get a function tool by name."""
|
||||
return self._functions.get(name)
|
||||
with self._lock:
|
||||
return self._functions.get(name)
|
||||
|
||||
def get_autogen_adapter(self, tool_type_name: str) -> Optional[Callable]:
|
||||
"""Get an AutoGen adapter by tool type name."""
|
||||
return self._autogen_adapters.get(tool_type_name)
|
||||
with self._lock:
|
||||
return self._autogen_adapters.get(tool_type_name)
|
||||
|
||||
def list_functions(self) -> List[str]:
|
||||
"""List all registered function tool names."""
|
||||
return list(self._functions.keys())
|
||||
with self._lock:
|
||||
return list(self._functions.keys())
|
||||
|
||||
def list_autogen_adapters(self) -> List[str]:
|
||||
"""List all registered AutoGen adapter names."""
|
||||
return list(self._autogen_adapters.keys())
|
||||
with self._lock:
|
||||
return list(self._autogen_adapters.keys())
|
||||
|
||||
def get_functions_dict(self) -> Dict[str, Callable]:
|
||||
"""Get a copy of all registered functions."""
|
||||
return dict(self._functions)
|
||||
with self._lock:
|
||||
return dict(self._functions)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all registered tools."""
|
||||
self._functions.clear()
|
||||
self._autogen_adapters.clear()
|
||||
with self._lock:
|
||||
self._functions.clear()
|
||||
self._autogen_adapters.clear()
|
||||
logger.debug("Cleared tool registry")
|
||||
|
||||
def register_from_module(self, module: Any) -> List[str]:
|
||||
@@ -75,7 +85,7 @@ class ToolRegistry:
|
||||
if (not name.startswith('_') and
|
||||
callable(obj) and
|
||||
not inspect.isclass(obj)):
|
||||
self.register_function(name, obj)
|
||||
self.register_function(name, obj) # This already acquires the lock
|
||||
registered.append(name)
|
||||
|
||||
logger.debug(f"Registered {len(registered)} functions from module: {registered}")
|
||||
@@ -107,8 +117,10 @@ class ToolRegistry:
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Return total number of registered tools."""
|
||||
return len(self._functions) + len(self._autogen_adapters)
|
||||
with self._lock:
|
||||
return len(self._functions) + len(self._autogen_adapters)
|
||||
|
||||
def __contains__(self, name: str) -> bool:
|
||||
"""Check if a tool function is registered."""
|
||||
return name in self._functions
|
||||
with self._lock:
|
||||
return name in self._functions
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.6.38"
|
||||
__version__ = "4.6.40"
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"rich>=13.7",
|
||||
"markdown>=3.5",
|
||||
"pyparsing>=3.0.0",
|
||||
"praisonaiagents>=1.6.38",
|
||||
"praisonaiagents>=1.6.40",
|
||||
"python-dotenv>=0.19.0",
|
||||
"litellm>=1.83.14,<2",
|
||||
"PyYAML>=6.0",
|
||||
|
||||
@@ -129,7 +129,12 @@ def temp_directory(tmp_path):
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_test_environment(request):
|
||||
"""Setup test environment before each test."""
|
||||
"""Setup test environment before each test.
|
||||
|
||||
For non-live unit/mock tests, fills missing provider API keys with placeholders
|
||||
so imports and guardrails do not crash. Keys already present in the environment
|
||||
(e.g. from ~/.bashrc or CI secrets) are never overwritten.
|
||||
"""
|
||||
# Only set test API keys for non-real tests
|
||||
# Real tests (marked with @pytest.mark.real) should use actual environment variables
|
||||
is_real_test = False
|
||||
@@ -155,19 +160,22 @@ def setup_test_environment(request):
|
||||
original_values = {}
|
||||
|
||||
if not is_real_test:
|
||||
# Set test environment variables only for mock tests
|
||||
# Placeholder keys only where unset — preserves keys from the parent shell
|
||||
# (e.g. ~/.bashrc / ~/.zshrc) so local agentic unit tests can call real APIs.
|
||||
test_keys = {
|
||||
'OPENAI_API_KEY': 'test-key',
|
||||
'ANTHROPIC_API_KEY': 'test-key',
|
||||
'ANTHROPIC_API_KEY': 'test-key',
|
||||
'GOOGLE_API_KEY': 'test-key',
|
||||
'XAI_API_KEY': 'test-key',
|
||||
'GROQ_API_KEY': 'test-key',
|
||||
'COHERE_API_KEY': 'test-key',
|
||||
}
|
||||
|
||||
|
||||
for key, value in test_keys.items():
|
||||
original_values[key] = os.environ.get(key)
|
||||
os.environ[key] = value
|
||||
# Only set placeholder when the key is absent (preserves explicit empty strings)
|
||||
if key not in os.environ:
|
||||
os.environ[key] = value
|
||||
|
||||
yield
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
"""N4 — Real agentic smoke test for inbound DLQ.
|
||||
|
||||
This is not a pytest test file - it's an integration smoke test.
|
||||
|
||||
Scenario:
|
||||
1. Real Agent (anthropic/claude-haiku-4-5 or gemini/2.5-flash).
|
||||
2. First chat attempt fails (we patch agent.chat to raise once).
|
||||
@@ -11,11 +15,12 @@ Scenario:
|
||||
|
||||
Run::
|
||||
|
||||
cd /Users/praison/worktrees/n4-dlq
|
||||
PYTHONPATH=src/praisonai-agents:src/praisonai python scripts/smoke_n4_real.py
|
||||
cd /path/to/PraisonAI
|
||||
PYTHONPATH=src/praisonai-agents:src/praisonai python src/praisonai/tests/integration/smoke_n4_real.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
# Prevent pytest from treating this as a test file
|
||||
__test__ = False
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
+7
-2
@@ -1,6 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
"""W1 real agentic smoke test (high-level BotOS API, no manual mgr).
|
||||
|
||||
This is not a pytest test file - it's an integration smoke test.
|
||||
|
||||
Uses the user-facing API only — ``Bot``, ``BotOS``, ``InMemoryIdentityResolver`` —
|
||||
and exercises the same cross-platform-continuity scenario as
|
||||
``smoke_w1_real.py`` but without touching ``BotSessionManager`` directly.
|
||||
@@ -13,10 +17,11 @@ wire-up from ``BotOS(identity_resolver=...)`` → ``Bot._identity_resolver``
|
||||
Run::
|
||||
|
||||
# From the repository root:
|
||||
PYTHONPATH=src/praisonai-agents:src/praisonai python scripts/smoke_w1_botos_real.py
|
||||
PYTHONPATH=src/praisonai-agents:src/praisonai python src/praisonai/tests/integration/smoke_w1_botos_real.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
# Prevent pytest from treating this as a test file
|
||||
__test__ = False
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
@@ -1,13 +1,18 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
"""W1 real agentic smoke test (bypasses pytest conftest gating).
|
||||
|
||||
This is not a pytest test file - it's an integration smoke test.
|
||||
|
||||
Runs::
|
||||
|
||||
cd src/praisonai
|
||||
python ../../scripts/smoke_w1_real.py
|
||||
cd /path/to/PraisonAI
|
||||
PYTHONPATH=src/praisonai-agents:src/praisonai python src/praisonai/tests/integration/smoke_w1_real.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
# Prevent pytest from treating this as a test file
|
||||
__test__ = False
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
@@ -1,6 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
"""W1 deep-validation smoke test.
|
||||
|
||||
This is not a pytest test file - it's an integration smoke test.
|
||||
|
||||
Exercises every robustness dimension of W1 with REAL LLM calls:
|
||||
|
||||
T1. 3-platform unification (telegram → discord → slack, one user)
|
||||
@@ -12,11 +16,12 @@ Exercises every robustness dimension of W1 with REAL LLM calls:
|
||||
|
||||
Run::
|
||||
|
||||
cd /Users/praison/worktrees/hermes-parity
|
||||
PYTHONPATH=src/praisonai-agents:src/praisonai python scripts/smoke_w1_robust.py
|
||||
cd /path/to/PraisonAI
|
||||
PYTHONPATH=src/praisonai-agents:src/praisonai python src/praisonai/tests/integration/smoke_w1_robust.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
# Prevent pytest from treating this as a test file
|
||||
__test__ = False
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
@@ -22,14 +22,17 @@ class TestAgentMailPlatformRegistry:
|
||||
from praisonai.bots._registry import _BUILTIN_PLATFORMS
|
||||
|
||||
assert "agentmail" in _BUILTIN_PLATFORMS
|
||||
assert _BUILTIN_PLATFORMS["agentmail"] == ("praisonai.bots.agentmail", "AgentMailBot")
|
||||
loader = _BUILTIN_PLATFORMS["agentmail"]
|
||||
assert callable(loader)
|
||||
assert loader.__name__ == "_agentmail_loader"
|
||||
|
||||
def test_resolve_adapter_returns_agentmail_bot(self):
|
||||
"""resolve_adapter('agentmail') should return AgentMailBot class."""
|
||||
from praisonai.bots import AgentMailBot
|
||||
from praisonai.bots._registry import resolve_adapter
|
||||
|
||||
cls = resolve_adapter("agentmail")
|
||||
assert cls.__name__ == "AgentMailBot"
|
||||
assert cls is AgentMailBot
|
||||
|
||||
def test_list_platforms_includes_agentmail(self):
|
||||
"""list_platforms() should include 'agentmail'."""
|
||||
|
||||
@@ -24,14 +24,17 @@ class TestEmailPlatformRegistry:
|
||||
from praisonai.bots._registry import _BUILTIN_PLATFORMS
|
||||
|
||||
assert "email" in _BUILTIN_PLATFORMS
|
||||
assert _BUILTIN_PLATFORMS["email"] == ("praisonai.bots.email", "EmailBot")
|
||||
loader = _BUILTIN_PLATFORMS["email"]
|
||||
assert callable(loader)
|
||||
assert loader.__name__ == "_email_loader"
|
||||
|
||||
def test_resolve_adapter_returns_email_bot(self):
|
||||
"""Verify resolve_adapter('email') returns EmailBot class."""
|
||||
from praisonai.bots import EmailBot
|
||||
from praisonai.bots._registry import resolve_adapter
|
||||
|
||||
EmailBot = resolve_adapter("email")
|
||||
assert EmailBot.__name__ == "EmailBot"
|
||||
cls = resolve_adapter("email")
|
||||
assert cls is EmailBot
|
||||
|
||||
def test_list_platforms_includes_email(self):
|
||||
"""Verify list_platforms() includes email."""
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""write_file must constrain paths when workspace is omitted."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from praisonai.code.tools.write_file import write_file
|
||||
|
||||
|
||||
def test_write_file_without_workspace_stays_in_cwd(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
ok = write_file("out.txt", "hello", workspace=None)
|
||||
assert ok["success"] is True
|
||||
assert (tmp_path / "out.txt").read_text() == "hello"
|
||||
|
||||
|
||||
def test_write_file_without_workspace_blocks_escape(tmp_path, monkeypatch):
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
monkeypatch.chdir(project)
|
||||
result = write_file("../outside.txt", "nope", workspace=None)
|
||||
assert result["success"] is False
|
||||
assert "outside" in result["error"].lower()
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Generated deploy API servers must enable auth by default."""
|
||||
|
||||
from praisonai.deploy.api import generate_api_server_code
|
||||
from praisonai.deploy.models import APIConfig
|
||||
|
||||
|
||||
def test_generate_api_server_auth_enabled_by_default():
|
||||
code = generate_api_server_code("agents.yaml", APIConfig())
|
||||
assert "AUTH_ENABLED" in code
|
||||
assert "'enabled'" in code or '"enabled"' in code
|
||||
assert "check_auth" in code
|
||||
assert "compare_digest" in code
|
||||
|
||||
|
||||
def test_generate_api_server_respects_disabled_config():
|
||||
code = generate_api_server_code(
|
||||
"agents.yaml", APIConfig(auth_enabled=False)
|
||||
)
|
||||
assert "'disabled'" in code or '"disabled"' in code
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Unit tests for jobs store."""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import pytest
|
||||
|
||||
from praisonai.jobs.models import Job, JobStatus
|
||||
@@ -237,3 +239,74 @@ class TestInMemoryJobStore:
|
||||
assert "status_counts" in stats
|
||||
assert stats["status_counts"]["queued"] == 1
|
||||
assert stats["status_counts"]["running"] == 1
|
||||
|
||||
|
||||
class TestInMemoryJobStoreConcurrency:
|
||||
"""Concurrency tests for InMemoryJobStore."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_saves_no_data_loss(self):
|
||||
"""Concurrent save() calls must not lose jobs."""
|
||||
store = InMemoryJobStore(max_jobs=1000)
|
||||
jobs = [Job(prompt=f"Job {i}") for i in range(50)]
|
||||
|
||||
await asyncio.gather(*[store.save(job) for job in jobs])
|
||||
|
||||
count = await store.count()
|
||||
assert count == 50
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_reads_while_writing(self):
|
||||
"""list_jobs() and count() must not raise while saves are in flight."""
|
||||
store = InMemoryJobStore(max_jobs=1000)
|
||||
errors = []
|
||||
|
||||
async def writer():
|
||||
for i in range(20):
|
||||
await store.save(Job(prompt=f"job {i}"))
|
||||
|
||||
async def reader():
|
||||
for _ in range(30):
|
||||
try:
|
||||
await store.list_jobs()
|
||||
await store.count()
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
await asyncio.gather(writer(), reader(), reader())
|
||||
assert errors == [], f"Concurrent read/write raised: {errors}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_stats_consistent_snapshot(self):
|
||||
"""get_stats() must not raise RuntimeError with concurrent mutations."""
|
||||
store = InMemoryJobStore(max_jobs=1000)
|
||||
errors = []
|
||||
|
||||
async def writer():
|
||||
for i in range(30):
|
||||
await store.save(Job(prompt=f"job {i}"))
|
||||
|
||||
def stats_reader():
|
||||
for _ in range(50):
|
||||
try:
|
||||
store.get_stats()
|
||||
except RuntimeError as e:
|
||||
errors.append(e)
|
||||
|
||||
# Run stats reader in a thread while async writer runs
|
||||
t = threading.Thread(target=stats_reader)
|
||||
t.start()
|
||||
await writer()
|
||||
t.join()
|
||||
|
||||
assert errors == [], f"get_stats raised RuntimeError: {errors}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_stats_returns_correct_total(self):
|
||||
"""get_stats() total_jobs must match actual job count."""
|
||||
store = InMemoryJobStore(max_jobs=100)
|
||||
for i in range(5):
|
||||
await store.save(Job(prompt=f"Job {i}"))
|
||||
|
||||
stats = store.get_stats()
|
||||
assert stats["total_jobs"] == 5
|
||||
@@ -0,0 +1,21 @@
|
||||
"""MCP CLI tools must not read arbitrary filesystem paths."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_resolve_cwd_yaml_rejects_traversal(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
from praisonai.mcp_server.adapters import cli_tools
|
||||
|
||||
(tmp_path / "workflow.yaml").write_text("framework: test\ntopic: t\n")
|
||||
|
||||
path = cli_tools._resolve_cwd_yaml_path("workflow.yaml")
|
||||
assert path.name == "workflow.yaml"
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
cli_tools._resolve_cwd_yaml_path("../../etc/passwd")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
cli_tools._resolve_cwd_yaml_path("/etc/passwd")
|
||||
@@ -169,8 +169,7 @@ class TestAgentSchedulerExecution:
|
||||
with pytest.raises(Exception, match="Agent error"):
|
||||
scheduler.execute_once()
|
||||
|
||||
@patch('time.sleep')
|
||||
def test_execute_with_retry_success_first_try(self, mock_sleep):
|
||||
def test_execute_with_retry_success_first_try(self):
|
||||
"""Test _execute_with_retry() succeeds on first try."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.start = Mock(return_value="Success")
|
||||
@@ -180,10 +179,8 @@ class TestAgentSchedulerExecution:
|
||||
|
||||
assert scheduler._success_count == 1
|
||||
assert scheduler._failure_count == 0
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
@patch('time.sleep')
|
||||
def test_execute_with_retry_success_on_retry(self, mock_sleep):
|
||||
def test_execute_with_retry_success_on_retry(self):
|
||||
"""Test _execute_with_retry() succeeds on retry."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.start = Mock(side_effect=[
|
||||
@@ -192,37 +189,56 @@ class TestAgentSchedulerExecution:
|
||||
])
|
||||
|
||||
scheduler = AgentScheduler(mock_agent, "Test task")
|
||||
# Mock Event.wait() to return False immediately (not stopped, backoff skipped)
|
||||
scheduler._stop_event.wait = Mock(return_value=False)
|
||||
scheduler._execute_with_retry(max_retries=3)
|
||||
|
||||
assert scheduler._success_count == 1
|
||||
assert scheduler._failure_count == 0
|
||||
assert mock_agent.start.call_count == 2
|
||||
|
||||
@patch('time.sleep')
|
||||
def test_execute_with_retry_all_fail(self, mock_sleep):
|
||||
def test_execute_with_retry_all_fail(self):
|
||||
"""Test _execute_with_retry() when all retries fail."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.start = Mock(side_effect=Exception("Always fails"))
|
||||
|
||||
scheduler = AgentScheduler(mock_agent, "Test task")
|
||||
# Mock Event.wait() to return False immediately (not stopped, backoff skipped)
|
||||
scheduler._stop_event.wait = Mock(return_value=False)
|
||||
scheduler._execute_with_retry(max_retries=3)
|
||||
|
||||
assert scheduler._success_count == 0
|
||||
assert scheduler._failure_count == 1
|
||||
assert mock_agent.start.call_count == 3
|
||||
|
||||
@patch('time.sleep')
|
||||
def test_execute_with_retry_exponential_backoff(self, mock_sleep):
|
||||
"""Test _execute_with_retry() uses exponential backoff."""
|
||||
def test_execute_with_retry_backoff_uses_stop_event(self):
|
||||
"""Test _execute_with_retry() uses Event.wait() for backoff, not time.sleep()."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.start = Mock(side_effect=Exception("Always fails"))
|
||||
|
||||
scheduler = AgentScheduler(mock_agent, "Test task")
|
||||
scheduler._stop_event.wait = Mock(return_value=False)
|
||||
scheduler._execute_with_retry(max_retries=3)
|
||||
|
||||
# Should sleep 30s, then 60s (exponential backoff)
|
||||
assert mock_sleep.call_count == 2
|
||||
mock_sleep.assert_has_calls([call(30), call(60)])
|
||||
# Should call Event.wait() twice (between attempt 1->2 and 2->3)
|
||||
assert scheduler._stop_event.wait.call_count == 2
|
||||
# Each wait should be called with a positive backoff delay
|
||||
for call_args in scheduler._stop_event.wait.call_args_list:
|
||||
wait_time = call_args[0][0]
|
||||
assert wait_time > 0, "backoff delay must be positive"
|
||||
|
||||
def test_execute_with_retry_stops_immediately_on_stop_event(self):
|
||||
"""Test that _execute_with_retry() exits when stop() is called during backoff."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.start = Mock(side_effect=Exception("Always fails"))
|
||||
|
||||
scheduler = AgentScheduler(mock_agent, "Test task")
|
||||
# Mock Event.wait() to return True (stop was signaled)
|
||||
scheduler._stop_event.wait = Mock(return_value=True)
|
||||
scheduler._execute_with_retry(max_retries=3)
|
||||
|
||||
# Should stop after first failure + backoff signal, not complete all retries
|
||||
assert mock_agent.start.call_count == 1
|
||||
|
||||
|
||||
class TestAgentSchedulerCallbacks:
|
||||
@@ -239,14 +255,15 @@ class TestAgentSchedulerCallbacks:
|
||||
|
||||
on_success.assert_called_once_with("Success result")
|
||||
|
||||
@patch('time.sleep')
|
||||
def test_on_failure_callback_invoked(self, mock_sleep):
|
||||
def test_on_failure_callback_invoked(self):
|
||||
"""Test on_failure callback is invoked on failure."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.start = Mock(side_effect=Exception("Error"))
|
||||
on_failure = Mock()
|
||||
|
||||
scheduler = AgentScheduler(mock_agent, "Test task", on_failure=on_failure)
|
||||
# Mock Event.wait() to return False immediately (not stopped, backoff skipped)
|
||||
scheduler._stop_event.wait = Mock(return_value=False)
|
||||
scheduler._execute_with_retry(max_retries=2)
|
||||
|
||||
on_failure.assert_called_once()
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"""load_tools_from_module must use gated safe loader."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_load_tools_from_module_returns_empty_when_blocked():
|
||||
from praisonai.agents_generator import AgentsGenerator
|
||||
|
||||
gen = object.__new__(AgentsGenerator)
|
||||
with patch(
|
||||
"praisonai._safe_loader.load_user_module", return_value=None
|
||||
):
|
||||
assert gen.load_tools_from_module("/tmp/evil_tools.py") == {}
|
||||
@@ -10,10 +10,13 @@ class TestPraisonAISessionDataStore:
|
||||
|
||||
def test_init_with_default_store(self):
|
||||
"""Test initialization with default hierarchical store."""
|
||||
with patch('praisonai.ui._aiui_datastore.get_hierarchical_session_store') as mock_get_store:
|
||||
import praisonai.ui._aiui_datastore as _ds
|
||||
|
||||
with patch("praisonaiagents.session.get_hierarchical_session_store") as mock_get_store, \
|
||||
patch.object(_ds, "_impl_cls", None):
|
||||
mock_store = Mock()
|
||||
mock_get_store.return_value = mock_store
|
||||
|
||||
|
||||
adapter = PraisonAISessionDataStore()
|
||||
|
||||
assert adapter._store == mock_store
|
||||
@@ -231,4 +234,4 @@ class TestPraisonAISessionDataStore:
|
||||
|
||||
|
||||
# Removed test_import_fallback - was a no-op test that passed regardless of behavior
|
||||
# After Blocker 1 fix, imports now fail loudly instead of falling back silently
|
||||
# After Blocker 1 fix, imports now fail loudly instead of falling back silently
|
||||
@@ -114,46 +114,70 @@ class TestSafeStringSubstitution:
|
||||
class TestThreadSafety:
|
||||
"""Test thread-safe patterns added in the wrapper layer."""
|
||||
|
||||
def test_get_openai_client_returns_same_instance_for_same_key(self):
|
||||
"""Multiple threads requesting the same key must get the same client object."""
|
||||
def test_get_openai_client_returns_same_instance_per_generator(self):
|
||||
"""Multiple threads on one generator must get the same client object."""
|
||||
import threading
|
||||
import types
|
||||
import sys
|
||||
import praisonai.auto as auto
|
||||
import unittest.mock as mock
|
||||
|
||||
auto._openai_client = None
|
||||
auto._openai_client_key = None
|
||||
class DummyOpenAI:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
generator = auto.BaseAutoGenerator(config_list=[{
|
||||
"model": "gpt-4o-mini",
|
||||
"api_key": "test-thread-key",
|
||||
"base_url": None,
|
||||
}])
|
||||
|
||||
results = []
|
||||
errors = []
|
||||
|
||||
def call_client():
|
||||
try:
|
||||
client = auto._get_openai_client(api_key="test-thread-key")
|
||||
client = generator._get_openai_client()
|
||||
results.append(id(client))
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
|
||||
threads = [threading.Thread(target=call_client) for _ in range(8)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
with mock.patch.dict(sys.modules, {"openai": types.SimpleNamespace(OpenAI=DummyOpenAI)}):
|
||||
threads = [threading.Thread(target=call_client) for _ in range(8)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert not errors, f"Unexpected errors: {errors}"
|
||||
assert len(results) == 8
|
||||
# All threads must receive the exact same cached instance
|
||||
assert len(set(results)) == 1, "Got different client instances across threads"
|
||||
|
||||
def test_get_openai_client_rebuilds_on_key_change(self):
|
||||
"""Changing the (api_key, base_url) pair must produce a new client."""
|
||||
def test_get_openai_client_is_not_shared_across_generators(self):
|
||||
"""Different generator instances must not share a client."""
|
||||
import types
|
||||
import sys
|
||||
import praisonai.auto as auto
|
||||
import unittest.mock as mock
|
||||
|
||||
auto._openai_client = None
|
||||
auto._openai_client_key = None
|
||||
class DummyOpenAI:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
client_a = auto._get_openai_client(api_key="key-a")
|
||||
client_b = auto._get_openai_client(api_key="key-b")
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
assert id(client_a) != id(client_b), "Different keys must yield different clients"
|
||||
generator_a = auto.BaseAutoGenerator(config_list=[{"model": "gpt-4o-mini", "api_key": "key-a", "base_url": None}])
|
||||
generator_b = auto.BaseAutoGenerator(config_list=[{"model": "gpt-4o-mini", "api_key": "key-b", "base_url": None}])
|
||||
|
||||
with mock.patch.dict(sys.modules, {"openai": types.SimpleNamespace(OpenAI=DummyOpenAI)}):
|
||||
client_a = generator_a._get_openai_client()
|
||||
client_b = generator_b._get_openai_client()
|
||||
|
||||
assert id(client_a) != id(client_b), "Different generators must own distinct clients"
|
||||
|
||||
def test_get_openai_client_no_root_logger_mutation(self):
|
||||
"""Importing auto must not add any new handlers to the root logger."""
|
||||
@@ -220,4 +244,3 @@ class TestThreadSafety:
|
||||
assert main_mod._typer_commands_cache is None, (
|
||||
"Cache must remain None after failure to allow retries"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Call server agent API must not fail open without CALL_SERVER_TOKEN."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("fastapi")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_token_requires_token_by_default(monkeypatch):
|
||||
monkeypatch.delenv("CALL_SERVER_TOKEN", raising=False)
|
||||
monkeypatch.delenv("PRAISONAI_CALL_AUTH", raising=False)
|
||||
|
||||
mod = importlib.import_module("praisonai.api.agent_invoke")
|
||||
importlib.reload(mod)
|
||||
|
||||
class _Req:
|
||||
query_params = {}
|
||||
|
||||
with pytest.raises(Exception) as exc:
|
||||
await mod.verify_token(_Req(), authorization=None)
|
||||
assert "CALL_SERVER_TOKEN" in str(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_token_optout(monkeypatch):
|
||||
monkeypatch.delenv("CALL_SERVER_TOKEN", raising=False)
|
||||
monkeypatch.setenv("PRAISONAI_CALL_AUTH", "disabled")
|
||||
|
||||
mod = importlib.import_module("praisonai.api.agent_invoke")
|
||||
importlib.reload(mod)
|
||||
|
||||
class _Req:
|
||||
query_params = {}
|
||||
|
||||
await mod.verify_token(_Req(), authorization=None)
|
||||
@@ -13,7 +13,6 @@ from unittest.mock import patch, MagicMock
|
||||
|
||||
try:
|
||||
from praisonai.framework_adapters.validators import assert_framework_available
|
||||
from praisonai.framework_adapters.registry import FrameworkAdapterRegistry
|
||||
except ImportError as e:
|
||||
pytest.skip(f"Could not import framework_adapters: {e}", allow_module_level=True)
|
||||
|
||||
@@ -22,7 +21,7 @@ class TestAssertFrameworkAvailableRaises:
|
||||
"""Tests that ImportError is raised for unavailable frameworks."""
|
||||
|
||||
def test_raises_import_error_for_missing_framework(self):
|
||||
with patch.object(FrameworkAdapterRegistry, "get_instance") as mock_get:
|
||||
with patch("praisonai.framework_adapters.validators.get_default_registry") as mock_get:
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.is_available.return_value = False
|
||||
mock_get.return_value = mock_registry
|
||||
@@ -31,7 +30,7 @@ class TestAssertFrameworkAvailableRaises:
|
||||
assert_framework_available("crewai")
|
||||
|
||||
def test_error_message_contains_framework_name(self):
|
||||
with patch.object(FrameworkAdapterRegistry, "get_instance") as mock_get:
|
||||
with patch("praisonai.framework_adapters.validators.get_default_registry") as mock_get:
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.is_available.return_value = False
|
||||
mock_get.return_value = mock_registry
|
||||
@@ -40,7 +39,7 @@ class TestAssertFrameworkAvailableRaises:
|
||||
assert_framework_available("crewai")
|
||||
|
||||
def test_error_message_contains_install_hint(self):
|
||||
with patch.object(FrameworkAdapterRegistry, "get_instance") as mock_get:
|
||||
with patch("praisonai.framework_adapters.validators.get_default_registry") as mock_get:
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.is_available.return_value = False
|
||||
mock_get.return_value = mock_registry
|
||||
@@ -49,7 +48,7 @@ class TestAssertFrameworkAvailableRaises:
|
||||
assert_framework_available("crewai")
|
||||
|
||||
def test_crewai_hint_mentions_praisonai_extra(self):
|
||||
with patch.object(FrameworkAdapterRegistry, "get_instance") as mock_get:
|
||||
with patch("praisonai.framework_adapters.validators.get_default_registry") as mock_get:
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.is_available.return_value = False
|
||||
mock_get.return_value = mock_registry
|
||||
@@ -60,7 +59,7 @@ class TestAssertFrameworkAvailableRaises:
|
||||
assert "praisonai[crewai]" in error_msg or "pip install crewai" in error_msg
|
||||
|
||||
def test_autogen_hint_mentions_pyautogen(self):
|
||||
with patch.object(FrameworkAdapterRegistry, "get_instance") as mock_get:
|
||||
with patch("praisonai.framework_adapters.validators.get_default_registry") as mock_get:
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.is_available.return_value = False
|
||||
mock_get.return_value = mock_registry
|
||||
@@ -70,7 +69,7 @@ class TestAssertFrameworkAvailableRaises:
|
||||
assert "autogen" in str(exc_info.value).lower()
|
||||
|
||||
def test_unknown_framework_generic_hint(self):
|
||||
with patch.object(FrameworkAdapterRegistry, "get_instance") as mock_get:
|
||||
with patch("praisonai.framework_adapters.validators.get_default_registry") as mock_get:
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.is_available.return_value = False
|
||||
mock_get.return_value = mock_registry
|
||||
@@ -84,7 +83,7 @@ class TestAssertFrameworkAvailableSucceeds:
|
||||
"""Tests that no exception is raised for available frameworks."""
|
||||
|
||||
def test_no_error_when_framework_available(self):
|
||||
with patch.object(FrameworkAdapterRegistry, "get_instance") as mock_get:
|
||||
with patch("praisonai.framework_adapters.validators.get_default_registry") as mock_get:
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.is_available.return_value = True
|
||||
mock_get.return_value = mock_registry
|
||||
@@ -93,7 +92,7 @@ class TestAssertFrameworkAvailableSucceeds:
|
||||
assert_framework_available("crewai")
|
||||
|
||||
def test_returns_none_for_available_framework(self):
|
||||
with patch.object(FrameworkAdapterRegistry, "get_instance") as mock_get:
|
||||
with patch("praisonai.framework_adapters.validators.get_default_registry") as mock_get:
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.is_available.return_value = True
|
||||
mock_get.return_value = mock_registry
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from tests import conftest as test_conftest
|
||||
|
||||
|
||||
PROVIDER_API_KEYS = (
|
||||
"OPENAI_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"GOOGLE_API_KEY",
|
||||
"XAI_API_KEY",
|
||||
"GROQ_API_KEY",
|
||||
"COHERE_API_KEY",
|
||||
)
|
||||
|
||||
|
||||
class MockRequest:
|
||||
def __init__(self, markers=None, fspath=__file__):
|
||||
markers = markers or []
|
||||
self.node = SimpleNamespace(iter_markers=lambda: markers)
|
||||
self.fspath = fspath
|
||||
|
||||
|
||||
def _run_fixture(request):
|
||||
"""Execute the fixture generator directly with a synthetic request object."""
|
||||
fixture_impl = test_conftest.setup_test_environment.__wrapped__
|
||||
fixture_gen = fixture_impl(request)
|
||||
next(fixture_gen)
|
||||
return fixture_gen
|
||||
|
||||
|
||||
def _teardown_fixture(fixture_gen):
|
||||
with pytest.raises(StopIteration):
|
||||
next(fixture_gen)
|
||||
|
||||
|
||||
def test_setup_environment_preserves_existing_provider_key_and_restores_missing_keys():
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
mp.setenv("OPENAI_API_KEY", "real-openai-key")
|
||||
mp.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
|
||||
fixture_gen = _run_fixture(MockRequest())
|
||||
|
||||
assert os.environ["OPENAI_API_KEY"] == "real-openai-key"
|
||||
assert os.environ["ANTHROPIC_API_KEY"] == "test-key"
|
||||
|
||||
_teardown_fixture(fixture_gen)
|
||||
|
||||
assert os.environ["OPENAI_API_KEY"] == "real-openai-key"
|
||||
assert "ANTHROPIC_API_KEY" not in os.environ
|
||||
|
||||
|
||||
def test_setup_environment_fills_placeholders_only_when_keys_are_missing():
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
for key in PROVIDER_API_KEYS:
|
||||
mp.delenv(key, raising=False)
|
||||
|
||||
fixture_gen = _run_fixture(MockRequest())
|
||||
|
||||
for key in PROVIDER_API_KEYS:
|
||||
assert os.environ[key] == "test-key"
|
||||
|
||||
_teardown_fixture(fixture_gen)
|
||||
|
||||
for key in PROVIDER_API_KEYS:
|
||||
assert key not in os.environ
|
||||
|
||||
|
||||
def test_setup_environment_skips_placeholder_keys_for_provider_marked_tests():
|
||||
marker = SimpleNamespace(name="provider_openai")
|
||||
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
mp.delenv("OPENAI_API_KEY", raising=False)
|
||||
|
||||
fixture_gen = _run_fixture(MockRequest(markers=[marker]))
|
||||
|
||||
assert "OPENAI_API_KEY" not in os.environ
|
||||
|
||||
_teardown_fixture(fixture_gen)
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Unit tests for ToolRegistry, focusing on thread-safety."""
|
||||
|
||||
import threading
|
||||
import pytest
|
||||
from praisonai.tool_registry import ToolRegistry
|
||||
|
||||
|
||||
class TestToolRegistryBasic:
|
||||
"""Basic functionality tests for ToolRegistry."""
|
||||
|
||||
def test_register_and_get_function(self):
|
||||
registry = ToolRegistry()
|
||||
func = lambda x: x
|
||||
registry.register_function("my_func", func)
|
||||
assert registry.get_function("my_func") is func
|
||||
|
||||
def test_register_non_callable_raises(self):
|
||||
registry = ToolRegistry()
|
||||
with pytest.raises(ValueError):
|
||||
registry.register_function("bad", "not_callable")
|
||||
|
||||
def test_get_missing_function_returns_none(self):
|
||||
registry = ToolRegistry()
|
||||
assert registry.get_function("missing") is None
|
||||
|
||||
def test_register_and_get_autogen_adapter(self):
|
||||
registry = ToolRegistry()
|
||||
adapter = lambda: None
|
||||
registry.register_autogen_adapter("MyTool", adapter)
|
||||
assert registry.get_autogen_adapter("MyTool") is adapter
|
||||
|
||||
def test_list_functions(self):
|
||||
registry = ToolRegistry()
|
||||
registry.register_function("a", lambda: None)
|
||||
registry.register_function("b", lambda: None)
|
||||
names = registry.list_functions()
|
||||
assert sorted(names) == ["a", "b"]
|
||||
|
||||
def test_list_autogen_adapters(self):
|
||||
registry = ToolRegistry()
|
||||
registry.register_autogen_adapter("T1", lambda: None)
|
||||
registry.register_autogen_adapter("T2", lambda: None)
|
||||
names = registry.list_autogen_adapters()
|
||||
assert sorted(names) == ["T1", "T2"]
|
||||
|
||||
def test_get_functions_dict_returns_copy(self):
|
||||
registry = ToolRegistry()
|
||||
f = lambda: None
|
||||
registry.register_function("f", f)
|
||||
d = registry.get_functions_dict()
|
||||
assert d == {"f": f}
|
||||
# Mutating the returned dict does not affect the registry
|
||||
d["extra"] = lambda: None
|
||||
assert "extra" not in registry.get_functions_dict()
|
||||
|
||||
def test_clear(self):
|
||||
registry = ToolRegistry()
|
||||
registry.register_function("f", lambda: None)
|
||||
registry.register_autogen_adapter("A", lambda: None)
|
||||
registry.clear()
|
||||
assert len(registry) == 0
|
||||
|
||||
def test_len(self):
|
||||
registry = ToolRegistry()
|
||||
assert len(registry) == 0
|
||||
registry.register_function("f", lambda: None)
|
||||
assert len(registry) == 1
|
||||
registry.register_autogen_adapter("A", lambda: None)
|
||||
assert len(registry) == 2
|
||||
|
||||
def test_contains(self):
|
||||
registry = ToolRegistry()
|
||||
registry.register_function("f", lambda: None)
|
||||
assert "f" in registry
|
||||
assert "missing" not in registry
|
||||
|
||||
|
||||
class TestToolRegistryThreadSafety:
|
||||
"""Thread-safety tests for ToolRegistry."""
|
||||
|
||||
def test_concurrent_writes_no_error(self):
|
||||
"""Concurrent register_function calls must not raise."""
|
||||
registry = ToolRegistry()
|
||||
errors = []
|
||||
|
||||
def writer(n):
|
||||
try:
|
||||
for i in range(50):
|
||||
registry.register_function(f"func_{n}_{i}", lambda: None)
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=writer, args=(t,)) for t in range(5)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert errors == [], f"Concurrent writes raised: {errors}"
|
||||
assert len(registry) == 250 # 5 threads * 50 registrations
|
||||
|
||||
def test_concurrent_reads_writes_no_error(self):
|
||||
"""Concurrent reads and writes must not raise RuntimeError."""
|
||||
registry = ToolRegistry()
|
||||
# Pre-populate
|
||||
for i in range(20):
|
||||
registry.register_function(f"pre_{i}", lambda: None)
|
||||
|
||||
errors = []
|
||||
|
||||
def writer():
|
||||
try:
|
||||
for i in range(30):
|
||||
registry.register_function(f"w_{i}", lambda: None)
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
def reader():
|
||||
try:
|
||||
for _ in range(100):
|
||||
registry.list_functions()
|
||||
registry.get_functions_dict()
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=writer) for _ in range(3)]
|
||||
threads += [threading.Thread(target=reader) for _ in range(3)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert errors == [], f"Concurrent read/write raised: {errors}"
|
||||
|
||||
def test_concurrent_clear_and_register(self):
|
||||
"""clear() racing with register_function() must not corrupt state."""
|
||||
registry = ToolRegistry()
|
||||
errors = []
|
||||
|
||||
def writer():
|
||||
try:
|
||||
for i in range(50):
|
||||
registry.register_function(f"w_{i}", lambda: None)
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
def clearer():
|
||||
try:
|
||||
for _ in range(20):
|
||||
registry.clear()
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=writer) for _ in range(3)]
|
||||
threads += [threading.Thread(target=clearer) for _ in range(2)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert errors == [], f"Concurrent clear/register raised: {errors}"
|
||||
Generated
+1
-1
@@ -4841,7 +4841,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.38"
|
||||
version = "1.6.40"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
Reference in new issue
Block a user