mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-10 03:31:28 -10:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a8ce9eb7d | ||
|
|
9fcac3a951 | ||
|
|
6878780000 | ||
|
|
e55789e8cf | ||
|
|
e0fb8e7dd1 | ||
|
|
bc73dd1db9 | ||
|
|
5216274924 | ||
|
|
2e5eca1db6 | ||
|
|
f51fe294e7 | ||
|
|
a1b7ed5c91 | ||
|
|
aaed51eee8 | ||
|
|
48407be580 | ||
|
|
0ad42ff2c5 | ||
|
|
2943c3b77e | ||
|
|
84627f593c | ||
|
|
927020cea8 | ||
|
|
23ca2a80d6 | ||
|
|
f931173e60 | ||
|
|
ba12269d5d | ||
|
|
7839196391 | ||
|
|
e958968307 | ||
|
|
1c26a5bf14 | ||
|
|
ba8cd6b3c1 | ||
|
|
8327f8e8b8 | ||
|
|
f78ba6348c | ||
|
|
4f3daa8628 | ||
|
|
d7f68a0870 | ||
|
|
1bf62b0416 |
No files matched your search
@@ -382,7 +382,7 @@ praisonai flow
|
||||
|
||||
Open **http://localhost:7861** — use the **Agent** and **Agent Team** components to create sequential or parallel workflows. Connect Chat Input → Agent Team → Chat Output for instant multi-agent pipelines.
|
||||
|
||||
> 📖 [Full Flow docs](https://docs.praison.ai/docs/concepts/flow) — visual agent building, component reference, and deployment
|
||||
> 📖 [Full Flow docs](https://docs.praison.ai/docs/concepts/agentflow) — visual agent building, component reference, and deployment
|
||||
|
||||
### 8. PraisonAI UI 🤖 (Clean Chat)
|
||||
|
||||
|
||||
@@ -911,6 +911,18 @@ Methods:
|
||||
* <code title="cli">praisonai hooks add <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
|
||||
* <code title="cli">praisonai hooks list <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
|
||||
* <code title="cli">praisonai hooks remove <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban block <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban boards <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban comment <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban complete <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban create <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban dispatch <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban link <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban list <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban move <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban reclaim <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban show <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban unblock <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai knowledge add <a href="./src/praisonai/praisonai/cli/commands/knowledge.py">--help</a></code>
|
||||
* <code title="cli">praisonai knowledge index <a href="./src/praisonai/praisonai/cli/commands/knowledge.py">--help</a></code>
|
||||
* <code title="cli">praisonai knowledge list <a href="./src/praisonai/praisonai/cli/commands/knowledge.py">--help</a></code>
|
||||
|
||||
@@ -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.48" \
|
||||
"praisonai>=4.6.52" \
|
||||
"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.48" \
|
||||
"praisonai>=4.6.52" \
|
||||
"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.48" \
|
||||
"praisonai>=4.6.52" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -16,8 +16,19 @@ def search_web(query: str) -> str:
|
||||
|
||||
|
||||
def calculate(expression: str) -> str:
|
||||
"""Calculate a math expression."""
|
||||
return str(eval(expression))
|
||||
"""Calculate a math expression safely."""
|
||||
import ast, operator
|
||||
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
|
||||
ast.Mod: operator.mod, ast.Pow: operator.pow,
|
||||
ast.USub: operator.neg, ast.UAdd: operator.pos}
|
||||
def _ev(n):
|
||||
if isinstance(n, ast.Expression): return _ev(n.body)
|
||||
if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): return n.value
|
||||
if isinstance(n, ast.UnaryOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.operand))
|
||||
if isinstance(n, ast.BinOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.left), _ev(n.right))
|
||||
raise ValueError(f"Unsupported: {ast.dump(n)}")
|
||||
return str(_ev(ast.parse(expression, mode="eval")))
|
||||
|
||||
|
||||
# Check if we have an API key
|
||||
|
||||
@@ -19,20 +19,43 @@ import os
|
||||
|
||||
def calculate(expression: str) -> str:
|
||||
"""
|
||||
A simple calculator tool that evaluates mathematical expressions.
|
||||
|
||||
A simple calculator tool that evaluates mathematical expressions safely.
|
||||
|
||||
Args:
|
||||
expression: A mathematical expression to evaluate (e.g., "2 + 2")
|
||||
|
||||
|
||||
Returns:
|
||||
The result of the calculation
|
||||
"""
|
||||
import ast
|
||||
import operator
|
||||
|
||||
_OPS = {
|
||||
ast.Add: operator.add,
|
||||
ast.Sub: operator.sub,
|
||||
ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv,
|
||||
ast.FloorDiv: operator.floordiv,
|
||||
ast.Mod: operator.mod,
|
||||
ast.Pow: operator.pow,
|
||||
ast.USub: operator.neg,
|
||||
ast.UAdd: operator.pos,
|
||||
}
|
||||
|
||||
def _safe_eval(node):
|
||||
if isinstance(node, ast.Expression):
|
||||
return _safe_eval(node.body)
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
|
||||
return node.value
|
||||
if isinstance(node, ast.UnaryOp) and type(node.op) in _OPS:
|
||||
return _OPS[type(node.op)](_safe_eval(node.operand))
|
||||
if isinstance(node, ast.BinOp) and type(node.op) in _OPS:
|
||||
return _OPS[type(node.op)](_safe_eval(node.left), _safe_eval(node.right))
|
||||
raise ValueError(f"Unsupported expression: {ast.dump(node)}")
|
||||
|
||||
try:
|
||||
# Safe evaluation of mathematical expressions
|
||||
allowed_chars = set("0123456789+-*/.() ")
|
||||
if not all(c in allowed_chars for c in expression):
|
||||
return "Error: Invalid characters in expression"
|
||||
result = eval(expression)
|
||||
tree = ast.parse(expression, mode="eval")
|
||||
result = _safe_eval(tree)
|
||||
return f"Result: {result}"
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
@@ -24,12 +24,18 @@ def main():
|
||||
def calculate(expression: str) -> str:
|
||||
"""Safely evaluate a mathematical expression."""
|
||||
try:
|
||||
# Only allow safe math operations
|
||||
allowed = set("0123456789+-*/.(). ")
|
||||
if not all(c in allowed for c in expression):
|
||||
return "Error: Invalid characters in expression"
|
||||
result = eval(expression) # Safe due to character filtering
|
||||
return f"Result: {result}"
|
||||
import ast, operator
|
||||
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
|
||||
ast.Mod: operator.mod, ast.Pow: operator.pow,
|
||||
ast.USub: operator.neg, ast.UAdd: operator.pos}
|
||||
def _ev(n):
|
||||
if isinstance(n, ast.Expression): return _ev(n.body)
|
||||
if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): return n.value
|
||||
if isinstance(n, ast.UnaryOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.operand))
|
||||
if isinstance(n, ast.BinOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.left), _ev(n.right))
|
||||
raise ValueError(f"Unsupported: {ast.dump(n)}")
|
||||
return f"Result: {_ev(ast.parse(expression, mode='eval'))}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@@ -21,9 +21,36 @@ def search_web(query: str) -> str:
|
||||
return f"Search results for: {query}"
|
||||
|
||||
def calculate(expression: str) -> str:
|
||||
"""Calculate a mathematical expression."""
|
||||
"""Calculate a mathematical expression safely."""
|
||||
import ast
|
||||
import operator
|
||||
|
||||
_OPS = {
|
||||
ast.Add: operator.add,
|
||||
ast.Sub: operator.sub,
|
||||
ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv,
|
||||
ast.FloorDiv: operator.floordiv,
|
||||
ast.Mod: operator.mod,
|
||||
ast.Pow: operator.pow,
|
||||
ast.USub: operator.neg,
|
||||
ast.UAdd: operator.pos,
|
||||
}
|
||||
|
||||
def _eval(node):
|
||||
if isinstance(node, ast.Expression):
|
||||
return _eval(node.body)
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
|
||||
return node.value
|
||||
if isinstance(node, ast.UnaryOp) and type(node.op) in _OPS:
|
||||
return _OPS[type(node.op)](_eval(node.operand))
|
||||
if isinstance(node, ast.BinOp) and type(node.op) in _OPS:
|
||||
return _OPS[type(node.op)](_eval(node.left), _eval(node.right))
|
||||
raise ValueError(f"Unsupported expression: {ast.dump(node)}")
|
||||
|
||||
try:
|
||||
return f"Result: {eval(expression)}"
|
||||
tree = ast.parse(expression, mode="eval")
|
||||
return f"Result: {_eval(tree)}"
|
||||
except Exception:
|
||||
return "Invalid expression"
|
||||
|
||||
|
||||
@@ -49,7 +49,18 @@ def calculate_with_tracking(expression: str) -> str:
|
||||
time.sleep(0.05)
|
||||
|
||||
try:
|
||||
result = eval(expression)
|
||||
import ast, operator
|
||||
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
|
||||
ast.Mod: operator.mod, ast.Pow: operator.pow,
|
||||
ast.USub: operator.neg, ast.UAdd: operator.pos}
|
||||
def _ev(n):
|
||||
if isinstance(n, ast.Expression): return _ev(n.body)
|
||||
if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): return n.value
|
||||
if isinstance(n, ast.UnaryOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.operand))
|
||||
if isinstance(n, ast.BinOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.left), _ev(n.right))
|
||||
raise ValueError(f"Unsupported: {ast.dump(n)}")
|
||||
result = _ev(ast.parse(expression, mode="eval"))
|
||||
return f"Result: {result}"
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
@@ -36,10 +36,18 @@ def calculate(expression: str) -> dict:
|
||||
expression: Math expression (e.g., "2 + 2 * 3")
|
||||
"""
|
||||
try:
|
||||
allowed = set("0123456789+-*/.() ")
|
||||
if not all(c in allowed for c in expression):
|
||||
return {"error": "Invalid characters"}
|
||||
return {"expression": expression, "result": eval(expression)}
|
||||
import ast, operator
|
||||
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
|
||||
ast.Mod: operator.mod, ast.Pow: operator.pow,
|
||||
ast.USub: operator.neg, ast.UAdd: operator.pos}
|
||||
def _ev(n):
|
||||
if isinstance(n, ast.Expression): return _ev(n.body)
|
||||
if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): return n.value
|
||||
if isinstance(n, ast.UnaryOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.operand))
|
||||
if isinstance(n, ast.BinOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.left), _ev(n.right))
|
||||
raise ValueError(f"Unsupported: {ast.dump(n)}")
|
||||
return {"expression": expression, "result": _ev(ast.parse(expression, mode="eval"))}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@@ -47,12 +47,18 @@ def calculate(expression: str) -> Dict[str, Any]:
|
||||
The result of the calculation
|
||||
"""
|
||||
try:
|
||||
# Safe evaluation of mathematical expressions
|
||||
allowed_chars = set("0123456789+-*/.() ")
|
||||
if not all(c in allowed_chars for c in expression):
|
||||
return {"error": "Invalid characters in expression"}
|
||||
|
||||
result = eval(expression) # Use safer alternative in production
|
||||
import ast, operator
|
||||
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
|
||||
ast.Mod: operator.mod, ast.Pow: operator.pow,
|
||||
ast.USub: operator.neg, ast.UAdd: operator.pos}
|
||||
def _ev(n):
|
||||
if isinstance(n, ast.Expression): return _ev(n.body)
|
||||
if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): return n.value
|
||||
if isinstance(n, ast.UnaryOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.operand))
|
||||
if isinstance(n, ast.BinOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.left), _ev(n.right))
|
||||
raise ValueError(f"Unsupported: {ast.dump(n)}")
|
||||
result = _ev(ast.parse(expression, mode="eval"))
|
||||
return {"expression": expression, "result": result}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@@ -49,13 +49,18 @@ def calculate(expression: str) -> Dict[str, Any]:
|
||||
The result of the calculation
|
||||
"""
|
||||
try:
|
||||
# Safe evaluation of mathematical expressions
|
||||
# In production, use a proper math parser
|
||||
allowed_chars = set("0123456789+-*/.() ")
|
||||
if not all(c in allowed_chars for c in expression):
|
||||
return {"error": "Invalid characters in expression"}
|
||||
|
||||
result = eval(expression) # Note: Use safer alternative in production
|
||||
import ast, operator
|
||||
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
|
||||
ast.Mod: operator.mod, ast.Pow: operator.pow,
|
||||
ast.USub: operator.neg, ast.UAdd: operator.pos}
|
||||
def _ev(n):
|
||||
if isinstance(n, ast.Expression): return _ev(n.body)
|
||||
if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): return n.value
|
||||
if isinstance(n, ast.UnaryOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.operand))
|
||||
if isinstance(n, ast.BinOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.left), _ev(n.right))
|
||||
raise ValueError(f"Unsupported: {ast.dump(n)}")
|
||||
result = _ev(ast.parse(expression, mode="eval"))
|
||||
return {"expression": expression, "result": result}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@@ -5,7 +5,7 @@ Demonstrates using should_run to conditionally execute agent steps
|
||||
based on input or previous results.
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, Workflow, Task
|
||||
from praisonaiagents import Agent, AgentFlow, Task
|
||||
|
||||
# Condition functions
|
||||
def is_technical(ctx) -> bool:
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Example: Agent with MCP Tool Integration
|
||||
#
|
||||
# This example shows how to configure an agent with MCP (Model Context Protocol)
|
||||
# servers for additional tool capabilities.
|
||||
#
|
||||
# Usage:
|
||||
# praisonai workflow run --file examples/yaml/agent-with-mcp.yaml
|
||||
|
||||
agent:
|
||||
name: mcp_assistant
|
||||
instructions: |
|
||||
You are a helpful assistant with access to filesystem and time tools.
|
||||
Use the MCP tools when needed to help users with file operations or time queries.
|
||||
llm: gpt-4o-mini
|
||||
|
||||
# MCP server configuration
|
||||
mcp:
|
||||
servers:
|
||||
filesystem:
|
||||
# Filesystem access for the agent
|
||||
command: npx
|
||||
args:
|
||||
- "-y"
|
||||
- "@modelcontextprotocol/server-filesystem"
|
||||
- "."
|
||||
env: {}
|
||||
enabled: true
|
||||
|
||||
time:
|
||||
# Time and date utilities
|
||||
command: npx
|
||||
args:
|
||||
- "-y"
|
||||
- "@modelcontextprotocol/server-time"
|
||||
env: {}
|
||||
enabled: true
|
||||
|
||||
# NOTE: Per-server tool filtering (tools.include/exclude) is not yet supported
|
||||
# by the MCP server config schema. Tool filtering should be applied after
|
||||
# calling mcp.get_tools() in your application code.
|
||||
@@ -95,4 +95,5 @@ chroma_db/
|
||||
.windsurf
|
||||
.ruff_cache
|
||||
*.sfdx
|
||||
.sfdx/
|
||||
.sfdx/
|
||||
.cursor/
|
||||
@@ -79,10 +79,20 @@ def get_time(timezone: Literal["est", "pst"]) -> str:
|
||||
|
||||
|
||||
def calculate(expression: str) -> str:
|
||||
"""Calculate a math expression."""
|
||||
"""Calculate a math expression safely."""
|
||||
try:
|
||||
result = eval(expression)
|
||||
return f"Result: {result}"
|
||||
import ast, operator
|
||||
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
|
||||
ast.Mod: operator.mod, ast.Pow: operator.pow,
|
||||
ast.USub: operator.neg, ast.UAdd: operator.pos}
|
||||
def _ev(n):
|
||||
if isinstance(n, ast.Expression): return _ev(n.body)
|
||||
if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): return n.value
|
||||
if isinstance(n, ast.UnaryOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.operand))
|
||||
if isinstance(n, ast.BinOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.left), _ev(n.right))
|
||||
raise ValueError(f"Unsupported: {ast.dump(n)}")
|
||||
return f"Result: {_ev(ast.parse(expression, mode='eval'))}"
|
||||
except Exception:
|
||||
return "Error: Invalid expression"
|
||||
|
||||
|
||||
@@ -384,6 +384,7 @@ _LAZY_IMPORTS = {
|
||||
'MultiAgentExecutionConfig': ('praisonaiagents.config.feature_configs', 'MultiAgentExecutionConfig'),
|
||||
'MultiAgentPlanningConfig': ('praisonaiagents.config.feature_configs', 'MultiAgentPlanningConfig'),
|
||||
'MultiAgentMemoryConfig': ('praisonaiagents.config.feature_configs', 'MultiAgentMemoryConfig'),
|
||||
'ToolSearchConfig': ('praisonaiagents.config.feature_configs', 'ToolSearchConfig'),
|
||||
|
||||
# Parameter resolver
|
||||
'resolve': ('praisonaiagents.config.param_resolver', 'resolve'),
|
||||
|
||||
@@ -556,6 +556,7 @@ class Agent(SandboxMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandler
|
||||
backend: Optional[Any] = None, # External managed agent backend (e.g., ManagedAgentIntegration)
|
||||
cli_backend: Optional[Union[str, Any]] = None, # CLI backend for delegating turns (e.g., "claude-code")
|
||||
interrupt_controller: Optional['InterruptController'] = None, # G2: Cooperative cancellation
|
||||
tool_search: Optional[Union[bool, str, Dict[str, Any], 'ToolSearchConfig']] = False, # Progressive tool disclosure
|
||||
sandbox: Optional[Union[bool, 'SandboxConfig']] = None, # Sandbox for safe code execution
|
||||
):
|
||||
"""Initialize an Agent instance.
|
||||
@@ -660,6 +661,14 @@ class Agent(SandboxMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandler
|
||||
When provided, agent delegates entire conversation turns to the CLI tool
|
||||
instead of using the built-in LLM. Enables session continuity and
|
||||
tool integration through external AI coding assistants.
|
||||
tool_search: Progressive tool disclosure configuration. Accepts:
|
||||
- bool: False=disabled (default), True=auto mode
|
||||
- str: Mode ("auto", "on", "off")
|
||||
- Dict[str, Any]: Config overrides (e.g. {"threshold_pct": 15})
|
||||
- ToolSearchConfig: Custom configuration
|
||||
When enabled, replaces large tool schemas with bridge tools (tool_search,
|
||||
tool_describe, tool_call) to save context. Core SDK tools never defer.
|
||||
Auto mode activates based on token threshold. Opt-in feature.
|
||||
|
||||
Raises:
|
||||
ValueError: If all of name, role, goal, backstory, and instructions are None.
|
||||
@@ -1430,6 +1439,34 @@ class Agent(SandboxMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandler
|
||||
web_search = False
|
||||
web_fetch = False
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Resolve TOOL_SEARCH param
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Fast path: None/False -> disabled (zero overhead)
|
||||
if tool_search is None or tool_search is False:
|
||||
self._tool_search_config = None
|
||||
elif tool_search is True:
|
||||
# True -> auto mode with defaults
|
||||
from ..tools.tool_search import ToolSearchConfig as _ToolSearchConfig
|
||||
self._tool_search_config = _ToolSearchConfig(enabled="auto")
|
||||
elif isinstance(tool_search, str):
|
||||
# String mode ("auto", "on", "off")
|
||||
from ..tools.tool_search import ToolSearchConfig as _ToolSearchConfig
|
||||
self._tool_search_config = _ToolSearchConfig.from_raw(tool_search)
|
||||
elif isinstance(tool_search, dict):
|
||||
# Dict -> config overrides
|
||||
from ..tools.tool_search import ToolSearchConfig as _ToolSearchConfig
|
||||
self._tool_search_config = _ToolSearchConfig(**tool_search)
|
||||
else:
|
||||
from ..tools.tool_search import ToolSearchConfig as _ToolSearchConfig
|
||||
if isinstance(tool_search, _ToolSearchConfig):
|
||||
self._tool_search_config = tool_search
|
||||
else:
|
||||
raise TypeError(
|
||||
"tool_search must be False/None, True, a mode string, "
|
||||
"a dict of ToolSearchConfig fields, or ToolSearchConfig"
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# END CONSOLIDATED PARAMS EXTRACTION
|
||||
# ============================================================
|
||||
@@ -2026,6 +2063,7 @@ Your Goal: {self.goal}
|
||||
'skills': getattr(self, '_skills_config', None),
|
||||
'approval': getattr(self, '_approval_config', None),
|
||||
'learn': getattr(self, '_learn_config', None),
|
||||
'tool_search': getattr(self, '_tool_search_config', None),
|
||||
|
||||
# Tool configuration
|
||||
'tool_timeout': getattr(self, '_tool_timeout', None),
|
||||
|
||||
@@ -331,6 +331,7 @@ Your Goal: {self.goal}"""
|
||||
- Callable functions
|
||||
- String function names
|
||||
- Objects with to_openai_tool() method
|
||||
- Tool Search progressive disclosure (if enabled)
|
||||
|
||||
Args:
|
||||
tools: List of tools in various formats or None to use self.tools
|
||||
@@ -344,10 +345,14 @@ Your Goal: {self.goal}"""
|
||||
if not tools:
|
||||
return []
|
||||
|
||||
# Check cache first
|
||||
# Check cache first - include tool_search config in cache key for safety
|
||||
tools_key = self._get_tools_cache_key(tools)
|
||||
cached_tools = self._cache_get(self._formatted_tools_cache, tools_key)
|
||||
if cached_tools is not None:
|
||||
tool_search_enabled = getattr(self, '_tool_search_config', None) is not None
|
||||
cache_key = f"{tools_key}:tool_search={tool_search_enabled}"
|
||||
cached_entry = self._cache_get(self._formatted_tools_cache, cache_key)
|
||||
if cached_entry is not None:
|
||||
cached_tools, cached_metadata = cached_entry
|
||||
self._tool_search_metadata = cached_metadata
|
||||
return cached_tools
|
||||
|
||||
formatted_tools = []
|
||||
@@ -399,9 +404,46 @@ Your Goal: {self.goal}"""
|
||||
logging.error(f"Tools are not JSON serializable: {e}")
|
||||
return []
|
||||
|
||||
# Cache the formatted tools with LRU eviction
|
||||
self._cache_put(self._formatted_tools_cache, tools_key, formatted_tools)
|
||||
return formatted_tools
|
||||
# Apply tool search assembly if enabled (after formatting, before caching)
|
||||
if hasattr(self, '_tool_search_config') and self._tool_search_config is not None:
|
||||
try:
|
||||
from ..tools.tool_search import assemble_tool_defs
|
||||
# Get context length from LLM config if available
|
||||
context_length = getattr(self, '_context_window_size', None)
|
||||
|
||||
# Assemble tools with bridge mode check
|
||||
assembled_tools, metadata = assemble_tool_defs(
|
||||
tool_defs=formatted_tools,
|
||||
config=self._tool_search_config,
|
||||
context_length=context_length
|
||||
)
|
||||
|
||||
# Store metadata for bridge tool dispatch
|
||||
self._tool_search_metadata = metadata
|
||||
formatted_tools = assembled_tools
|
||||
|
||||
except ImportError:
|
||||
# Tool search module not available, continue with original tools
|
||||
logging.warning("Tool search requested but tool_search module not available")
|
||||
|
||||
# Strip __praisonai_deferrable__ from provider-facing tool payloads
|
||||
# Keep the marker only for internal tool classification
|
||||
cleaned_tools = []
|
||||
for tool in formatted_tools:
|
||||
if isinstance(tool, dict) and "__praisonai_deferrable__" in tool:
|
||||
tool_copy = tool.copy()
|
||||
tool_copy.pop("__praisonai_deferrable__", None)
|
||||
cleaned_tools.append(tool_copy)
|
||||
else:
|
||||
cleaned_tools.append(tool)
|
||||
|
||||
# Cache the formatted tools with LRU eviction, including tool search metadata
|
||||
self._cache_put(
|
||||
self._formatted_tools_cache,
|
||||
cache_key,
|
||||
(cleaned_tools, getattr(self, "_tool_search_metadata", None)),
|
||||
)
|
||||
return cleaned_tools
|
||||
|
||||
def _build_multimodal_prompt(
|
||||
self,
|
||||
@@ -507,7 +549,7 @@ Your Goal: {self.goal}"""
|
||||
emit_events=True
|
||||
)
|
||||
|
||||
def _chat_completion(self, messages, temperature=1.0, tools=None, stream=True, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, _retry_depth=0):
|
||||
def _chat_completion(self, messages, temperature=1.0, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, _retry_depth=0):
|
||||
start_time = time.time()
|
||||
|
||||
# --- Context compaction (opt-in via ExecutionConfig.context_compaction) ---
|
||||
@@ -577,6 +619,39 @@ Your Goal: {self.goal}"""
|
||||
# Use the new _format_tools_for_completion helper method
|
||||
formatted_tools = self._format_tools_for_completion(tools)
|
||||
|
||||
# Smart fallback for streaming: try streaming first, fall back to non-streaming if unsupported
|
||||
if stream is None:
|
||||
# Auto-detect: prefer streaming for better UX, fallback if adapter doesn't support it
|
||||
try:
|
||||
# First attempt: try with streaming enabled for better user experience
|
||||
stream_callback = self.stream_emitter.emit if hasattr(self, 'stream_emitter') else None
|
||||
final_response = self._execute_unified_chat_completion(
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
tools=formatted_tools,
|
||||
stream=True, # Try streaming first
|
||||
reasoning_steps=reasoning_steps,
|
||||
task_name=task_name,
|
||||
task_description=task_description,
|
||||
task_id=task_id,
|
||||
response_format=response_format,
|
||||
stream_callback=stream_callback,
|
||||
emit_events=True
|
||||
)
|
||||
return final_response
|
||||
except ValueError as e:
|
||||
if "Streaming is not supported" in str(e):
|
||||
# Fallback: retry with non-streaming for sync adapters
|
||||
logging.debug(f"{self.name}: Streaming not supported by adapter, falling back to non-streaming")
|
||||
stream = False # Set for the main execution below
|
||||
else:
|
||||
raise # Re-raise if it's a different ValueError
|
||||
except Exception:
|
||||
# For any other exception, fall back to non-streaming
|
||||
logging.debug(f"{self.name}: Streaming attempt failed, falling back to non-streaming")
|
||||
stream = False # Set for the main execution below
|
||||
|
||||
# If stream was explicitly set or fallback occurred, use the specified/fallback value
|
||||
try:
|
||||
# NEW: Unified protocol dispatch path (Issue #1304, #1362)
|
||||
# UNIFIED: Single protocol-driven dispatch path (fixes DRY violation)
|
||||
@@ -774,7 +849,7 @@ Your Goal: {self.goal}"""
|
||||
messages,
|
||||
temperature=1.0,
|
||||
tools=None,
|
||||
stream=True,
|
||||
stream=None,
|
||||
reasoning_steps=False,
|
||||
task_name=None,
|
||||
task_description=None,
|
||||
@@ -806,6 +881,47 @@ Your Goal: {self.goal}"""
|
||||
# Cache the dispatcher
|
||||
self._unified_dispatcher = dispatcher
|
||||
|
||||
# Smart fallback for streaming: try streaming first, fall back to non-streaming if unsupported
|
||||
if stream is None:
|
||||
# Auto-detect: prefer streaming for better UX, fallback if adapter doesn't support it
|
||||
try:
|
||||
# First attempt: try with streaming enabled for better user experience
|
||||
if stream_callback is None and hasattr(self, 'stream_emitter'):
|
||||
stream_callback = getattr(self.stream_emitter, 'emit', None)
|
||||
final_response = self._unified_dispatcher.chat_completion(
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
tool_choice=getattr(self, 'tool_choice', None),
|
||||
temperature=temperature,
|
||||
max_tokens=getattr(self, 'max_tokens', None),
|
||||
stream=True, # Try streaming first
|
||||
response_format=response_format,
|
||||
execute_tool_fn=getattr(self, 'execute_tool', None),
|
||||
console=self.console if (self.verbose or True) else None, # Enable console for streaming
|
||||
display_fn=self._display_generating if self.verbose else None,
|
||||
stream_callback=stream_callback,
|
||||
emit_events=emit_events,
|
||||
verbose=self.verbose,
|
||||
max_iterations=10,
|
||||
reasoning_steps=reasoning_steps,
|
||||
task_name=task_name,
|
||||
task_description=task_description,
|
||||
task_id=task_id,
|
||||
agent_name=getattr(self, 'name', 'assistant')
|
||||
)
|
||||
return final_response
|
||||
except ValueError as e:
|
||||
if "Streaming is not supported" in str(e):
|
||||
# Fallback: retry with non-streaming for sync adapters
|
||||
logging.debug(f"Agent: Streaming not supported by adapter, falling back to non-streaming")
|
||||
stream = False # Set for the main execution below
|
||||
else:
|
||||
raise # Re-raise if it's a different ValueError
|
||||
except Exception:
|
||||
# For any other exception, fall back to non-streaming
|
||||
logging.debug(f"Agent: Streaming attempt failed, falling back to non-streaming")
|
||||
stream = False # Set for the main execution below
|
||||
|
||||
# Execute unified dispatch with all necessary parameters
|
||||
# Includes all parameters from both legacy paths to ensure full compatibility
|
||||
try:
|
||||
@@ -857,7 +973,7 @@ Your Goal: {self.goal}"""
|
||||
messages,
|
||||
temperature=1.0,
|
||||
tools=None,
|
||||
stream=True,
|
||||
stream=True, # Async methods keep stream=True default (async adapters support streaming vs sync smart fallback)
|
||||
reasoning_steps=False,
|
||||
task_name=None,
|
||||
task_description=None,
|
||||
|
||||
@@ -135,6 +135,12 @@ class ToolExecutionMixin:
|
||||
"""
|
||||
logging.debug(f"{self.name} executing tool {function_name} with arguments: {arguments}")
|
||||
|
||||
# Handle bridge tool unwrapping BEFORE trace/stream/hooks (design invariant #6)
|
||||
# Only intercept when tool_search is active; otherwise fall through to real tool execution
|
||||
if (getattr(self, '_tool_search_config', None) is not None and
|
||||
function_name in ("tool_search", "tool_describe", "tool_call")):
|
||||
return self._handle_bridge_tool_call(function_name, arguments, tool_call_id)
|
||||
|
||||
# NOTE: tool_call callback is triggered by display_tool_call in openai_client.py
|
||||
# Do NOT call it here to avoid duplicate output
|
||||
|
||||
@@ -954,3 +960,97 @@ class ToolExecutionMixin:
|
||||
def pending_approval_count(self) -> int:
|
||||
"""Number of approval requests still waiting."""
|
||||
return len(self._pending_approvals)
|
||||
|
||||
def _handle_bridge_tool_call(self, function_name: str, arguments: Dict[str, Any], tool_call_id: Optional[str] = None) -> Any:
|
||||
"""
|
||||
Handle bridge tool calls (tool_search, tool_describe, tool_call).
|
||||
|
||||
This implements the tool search unwrapping logic before trace/stream/hooks
|
||||
as required by design invariant #6.
|
||||
|
||||
Args:
|
||||
function_name: Bridge tool name
|
||||
arguments: Arguments passed to bridge tool
|
||||
tool_call_id: Optional tool call ID
|
||||
|
||||
Returns:
|
||||
Result of bridge tool execution or unwrapped real tool call
|
||||
"""
|
||||
# Ensure tool search metadata is available
|
||||
if not hasattr(self, '_tool_search_metadata') or self._tool_search_metadata is None:
|
||||
return "Tool search not available or not in bridge mode"
|
||||
|
||||
metadata = self._tool_search_metadata
|
||||
|
||||
# Check if we're in bridge mode
|
||||
if not metadata.get("bridge_mode", False):
|
||||
return "Tool search not in bridge mode"
|
||||
|
||||
# Get deferrable tools from metadata
|
||||
deferrable_tools = metadata.get("deferrable_tools", [])
|
||||
|
||||
if function_name == "tool_search":
|
||||
# Handle tool_search bridge call
|
||||
try:
|
||||
from ..tools.tool_search import dispatch_tool_search
|
||||
query = arguments.get("query", "")
|
||||
limit = arguments.get("limit", None)
|
||||
|
||||
result = dispatch_tool_search(
|
||||
query=query,
|
||||
limit=limit,
|
||||
deferrable_tools=deferrable_tools,
|
||||
config=self._tool_search_config
|
||||
)
|
||||
return json.dumps(result, indent=2)
|
||||
except ImportError:
|
||||
return "Tool search module not available"
|
||||
except Exception as e:
|
||||
logging.error(f"Error in tool_search: {e}")
|
||||
return f"Error searching tools: {e}"
|
||||
|
||||
elif function_name == "tool_describe":
|
||||
# Handle tool_describe bridge call
|
||||
try:
|
||||
from ..tools.tool_search import dispatch_tool_describe
|
||||
tool_name = arguments.get("tool_name", "")
|
||||
|
||||
result = dispatch_tool_describe(
|
||||
tool_name=tool_name,
|
||||
deferrable_tools=deferrable_tools
|
||||
)
|
||||
return json.dumps(result, indent=2)
|
||||
except ImportError:
|
||||
return "Tool search module not available"
|
||||
except Exception as e:
|
||||
logging.error(f"Error in tool_describe: {e}")
|
||||
return f"Error describing tool: {e}"
|
||||
|
||||
elif function_name == "tool_call":
|
||||
# Handle tool_call bridge - unwrap and recurse with real tool
|
||||
try:
|
||||
from ..tools.tool_search import resolve_underlying_call
|
||||
|
||||
# Unwrap the real tool call
|
||||
real_function_name, real_arguments = resolve_underlying_call(function_name, arguments)
|
||||
|
||||
# Validate that the real tool is in our deferrable set (security check)
|
||||
deferrable_names = {
|
||||
tool_def.get("function", {}).get("name", "")
|
||||
for tool_def in deferrable_tools
|
||||
}
|
||||
|
||||
if real_function_name not in deferrable_names:
|
||||
return f"Tool '{real_function_name}' is not available for execution"
|
||||
|
||||
# Recursively execute the real tool (this will go through normal execution path)
|
||||
return self.execute_tool(real_function_name, real_arguments, tool_call_id)
|
||||
|
||||
except ImportError:
|
||||
return "Tool search module not available"
|
||||
except Exception as e:
|
||||
logging.error(f"Error in tool_call unwrap: {e}")
|
||||
return f"Error executing tool: {e}"
|
||||
|
||||
else:
|
||||
return f"Unknown bridge tool: {function_name}"
|
||||
@@ -39,6 +39,7 @@ __all__ = [
|
||||
"CachingConfig",
|
||||
"HooksConfig",
|
||||
"SkillsConfig",
|
||||
"ToolSearchConfig",
|
||||
# Type aliases
|
||||
"MemoryParam",
|
||||
"KnowledgeParam",
|
||||
@@ -122,6 +123,7 @@ _MODULE_MAP = {
|
||||
"CachingConfig": "feature_configs",
|
||||
"HooksConfig": "feature_configs",
|
||||
"SkillsConfig": "feature_configs",
|
||||
"ToolSearchConfig": "feature_configs",
|
||||
"MemoryParam": "feature_configs",
|
||||
"KnowledgeParam": "feature_configs",
|
||||
"PlanningParam": "feature_configs",
|
||||
|
||||
@@ -8,6 +8,7 @@ Provides dataclasses for consolidated feature configuration:
|
||||
- ReflectionConfig: Self-reflection settings
|
||||
- GuardrailConfig: Safety and validation
|
||||
- WebConfig: Web search and fetch
|
||||
- ToolSearchConfig: Progressive tool disclosure
|
||||
|
||||
All configs follow the agent-centric pattern:
|
||||
- False: Feature disabled (zero overhead)
|
||||
@@ -16,21 +17,22 @@ All configs follow the agent-centric pattern:
|
||||
- Instance: Pre-configured manager/engine
|
||||
|
||||
Usage:
|
||||
from praisonaiagents import Agent, MemoryConfig, KnowledgeConfig
|
||||
from praisonaiagents import Agent, MemoryConfig, KnowledgeConfig, ToolSearchConfig
|
||||
|
||||
# Simple enable
|
||||
agent = Agent(instructions="...", memory=True)
|
||||
agent = Agent(instructions="...", memory=True, tool_search=True)
|
||||
|
||||
# With config
|
||||
agent = Agent(
|
||||
instructions="...",
|
||||
memory=MemoryConfig(backend="redis", user_id="user123"),
|
||||
knowledge=KnowledgeConfig(sources=["docs/"], rerank=True),
|
||||
tool_search=ToolSearchConfig(enabled="auto", threshold_pct=15),
|
||||
)
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Any, Optional, Callable, Tuple, Union
|
||||
from typing import Dict, List, Any, Optional, Callable, Tuple, Union, FrozenSet
|
||||
from enum import Enum
|
||||
|
||||
# Import AutonomyConfig from canonical location (no circular dep)
|
||||
@@ -1072,6 +1074,25 @@ class MultiAgentMemoryConfig:
|
||||
}
|
||||
|
||||
|
||||
# Import ToolSearchConfig from tools module to avoid duplication
|
||||
def __get_tool_search_config():
|
||||
try:
|
||||
from ..tools.tool_search import ToolSearchConfig as _ToolSearchConfig
|
||||
return _ToolSearchConfig
|
||||
except ImportError:
|
||||
# Fallback minimal config if tools module not available
|
||||
@dataclass
|
||||
class FallbackToolSearchConfig:
|
||||
enabled: Union[bool, str] = "auto"
|
||||
threshold_pct: float = 10.0
|
||||
search_default_limit: int = 5
|
||||
max_search_limit: int = 20
|
||||
core_tools: Optional[FrozenSet[str]] = None
|
||||
return FallbackToolSearchConfig
|
||||
|
||||
ToolSearchConfig = __get_tool_search_config()
|
||||
|
||||
|
||||
class AutonomyLevel(str, Enum):
|
||||
"""Autonomy levels for agent behavior."""
|
||||
SUGGEST = "suggest"
|
||||
@@ -1092,6 +1113,7 @@ CachingParam = Union[bool, CachingConfig]
|
||||
HooksParam = Union[List[Any], HooksConfig]
|
||||
SkillsParam = Union[List[str], SkillsConfig]
|
||||
AutonomyParam = Union[bool, Dict[str, Any], "AutonomyConfig"]
|
||||
ToolSearchParam = Union[bool, str, Dict[str, Any], ToolSearchConfig]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -1343,6 +1365,21 @@ def resolve_autonomy(value: AutonomyParam) -> Optional[AutonomyConfig]:
|
||||
return value
|
||||
|
||||
|
||||
def resolve_tool_search(value: ToolSearchParam) -> Optional[ToolSearchConfig]:
|
||||
"""Resolve tool_search= parameter following precedence ladder."""
|
||||
if value is None or value is False:
|
||||
return None
|
||||
if value is True:
|
||||
return ToolSearchConfig()
|
||||
if isinstance(value, str):
|
||||
return ToolSearchConfig(enabled=value)
|
||||
if isinstance(value, dict):
|
||||
return ToolSearchConfig(**value)
|
||||
if isinstance(value, ToolSearchConfig):
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
__all__ = [
|
||||
# Enums
|
||||
"MemoryBackend",
|
||||
@@ -1368,6 +1405,7 @@ __all__ = [
|
||||
"HooksConfig",
|
||||
"SkillsConfig",
|
||||
"AutonomyConfig",
|
||||
"ToolSearchConfig",
|
||||
# Config classes (Multi-Agent)
|
||||
"MultiAgentHooksConfig",
|
||||
"MultiAgentOutputConfig",
|
||||
@@ -1388,6 +1426,7 @@ __all__ = [
|
||||
"HooksParam",
|
||||
"SkillsParam",
|
||||
"AutonomyParam",
|
||||
"ToolSearchParam",
|
||||
# Precedence ladder resolvers
|
||||
"resolve_memory",
|
||||
"resolve_knowledge",
|
||||
@@ -1399,4 +1438,5 @@ __all__ = [
|
||||
"resolve_execution",
|
||||
"resolve_caching",
|
||||
"resolve_autonomy",
|
||||
"resolve_tool_search",
|
||||
]
|
||||
@@ -83,11 +83,23 @@ def __getattr__(name):
|
||||
elif name == "OAUTH_CALLBACK_PATH":
|
||||
from .mcp_oauth_callback import OAUTH_CALLBACK_PATH
|
||||
return OAUTH_CALLBACK_PATH
|
||||
# Protocols (lazy loaded)
|
||||
elif name == "MCPClientProtocol":
|
||||
from .protocols import MCPClientProtocol
|
||||
return MCPClientProtocol
|
||||
# Loader (lazy loaded)
|
||||
elif name == "load_mcp_tools":
|
||||
from .loader import load_mcp_tools
|
||||
return load_mcp_tools
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
__all__ = [
|
||||
# Client
|
||||
"MCP",
|
||||
# Protocols (lazy loaded)
|
||||
"MCPClientProtocol",
|
||||
# Loader (lazy loaded)
|
||||
"load_mcp_tools",
|
||||
# Server (lazy loaded)
|
||||
"ToolsMCPServer",
|
||||
"launch_tools_mcp_server",
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
MCP Tools Loader for PraisonAI Agents.
|
||||
|
||||
Provides a thin helper function to load MCP tools for agents
|
||||
following the agent-centric design principles in AGENTS.md.
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from ..memory.mcp_config import MCPConfig, MCPConfigManager
|
||||
from .mcp import MCP
|
||||
|
||||
|
||||
def load_mcp_tools(
|
||||
names: Optional[List[str]] = None,
|
||||
*,
|
||||
configs: Optional[List[MCPConfig]] = None,
|
||||
prefix_tools: bool = True,
|
||||
) -> List[MCP]:
|
||||
"""
|
||||
Load MCP tools for use with agents.
|
||||
|
||||
This is a thin helper that bridges the gap between configuration
|
||||
and agent tool setup, following the "few lines to run an agent"
|
||||
principle from AGENTS.md.
|
||||
|
||||
Args:
|
||||
names: Specific config names to load (None = all enabled)
|
||||
configs: Optional injected configs from wrapper TOML loader
|
||||
prefix_tools: Whether to prefix tool names when multiple servers
|
||||
are loaded to avoid collisions (default: True).
|
||||
NOTE: Currently not implemented - collisions possible with multiple servers.
|
||||
|
||||
Returns:
|
||||
List of MCP client instances ready for agent use
|
||||
|
||||
Examples:
|
||||
# Load all enabled MCP servers from JSON config
|
||||
>>> from praisonaiagents import Agent
|
||||
>>> from praisonaiagents.mcp import load_mcp_tools
|
||||
>>> agent = Agent(name="assistant", tools=load_mcp_tools())
|
||||
|
||||
# Load specific servers only
|
||||
>>> tools = load_mcp_tools(["filesystem", "github"])
|
||||
>>> agent = Agent(name="coder", tools=tools)
|
||||
|
||||
# Inject configs from wrapper TOML
|
||||
>>> from praisonai.cli.configuration import get_config_loader
|
||||
>>> loader = get_config_loader()
|
||||
>>> config = loader.load()
|
||||
>>> toml_configs = [MCPConfig(...) for server in config.mcp.servers]
|
||||
>>> tools = load_mcp_tools(configs=toml_configs)
|
||||
"""
|
||||
mcp_clients: List[MCP] = []
|
||||
|
||||
if configs is not None:
|
||||
# Use injected configs from wrapper (TOML loading)
|
||||
target_configs = configs
|
||||
if names:
|
||||
# Filter by names if specified
|
||||
target_configs = [c for c in configs if c.name in names]
|
||||
else:
|
||||
# Load from MCPConfigManager (JSON files)
|
||||
manager = MCPConfigManager()
|
||||
if names:
|
||||
target_configs = [manager.get_config(name) for name in names]
|
||||
target_configs = [c for c in target_configs if c and c.enabled]
|
||||
else:
|
||||
target_configs = manager.get_enabled_configs()
|
||||
|
||||
for config in target_configs:
|
||||
if not config.enabled:
|
||||
continue
|
||||
|
||||
# Convert config to MCP instance
|
||||
mcp = config.to_mcp_instance()
|
||||
if mcp:
|
||||
# Apply tool filtering if configured (B3)
|
||||
# TODO: Wire include/exclude filters when B3 is implemented
|
||||
|
||||
# Apply tool prefix if multiple servers (B4)
|
||||
if prefix_tools and len(target_configs) > 1:
|
||||
# Sanitize server name for use as prefix
|
||||
server_name = config.name.replace('-', '_').replace(' ', '_')
|
||||
# TODO: Tool name prefixing not yet implemented
|
||||
# Need to modify tool names in MCP instance to avoid collisions
|
||||
# e.g., "read_file" -> "filesystem_read_file"
|
||||
pass
|
||||
|
||||
mcp_clients.append(mcp)
|
||||
|
||||
return mcp_clients
|
||||
@@ -187,7 +187,8 @@ class MCP:
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, command_or_string=None, args=None, *, command=None, timeout=60, debug=False, **kwargs):
|
||||
def __init__(self, command_or_string=None, args=None, *, command=None, timeout=60, debug=False,
|
||||
allowed_tools: Optional[List[str]] = None, disabled_tools: Optional[List[str]] = None, **kwargs):
|
||||
"""
|
||||
Initialize the MCP connection and get tools.
|
||||
|
||||
@@ -201,7 +202,13 @@ class MCP:
|
||||
command: Alternative parameter name for backward compatibility
|
||||
timeout: Timeout in seconds for MCP server initialization and tool calls (default: 60)
|
||||
debug: Enable debug logging for MCP operations (default: False)
|
||||
allowed_tools: Include whitelist - only these tools will be available (default: None = all tools)
|
||||
disabled_tools: Exclude blacklist - these tools will be filtered out (default: None = no exclusions)
|
||||
**kwargs: Additional parameters for StdioServerParameters
|
||||
|
||||
Note:
|
||||
If both allowed_tools and disabled_tools are specified, allowed_tools takes precedence
|
||||
(include wins over exclude).
|
||||
"""
|
||||
# Check if MCP is available
|
||||
if not MCP_AVAILABLE:
|
||||
@@ -240,6 +247,8 @@ class MCP:
|
||||
# Store additional parameters
|
||||
self.timeout = timeout
|
||||
self.debug = debug
|
||||
self.allowed_tools = allowed_tools
|
||||
self.disabled_tools = disabled_tools
|
||||
|
||||
# Check if this is a WebSocket URL (ws:// or wss://)
|
||||
if isinstance(command_or_string, str) and re.match(r'^wss?://', command_or_string):
|
||||
@@ -254,7 +263,7 @@ class MCP:
|
||||
auth_token=auth_token,
|
||||
options=kwargs
|
||||
)
|
||||
self._tools = list(self.websocket_client.tools)
|
||||
self._tools = self._apply_tool_filters(list(self.websocket_client.tools))
|
||||
self.is_sse = False
|
||||
self.is_http_stream = False
|
||||
self.is_websocket = True
|
||||
@@ -268,7 +277,7 @@ class MCP:
|
||||
# Legacy SSE URL - use SSE transport for backward compatibility
|
||||
from .mcp_sse import SSEMCPClient
|
||||
self.sse_client = SSEMCPClient(command_or_string, debug=debug, timeout=timeout)
|
||||
self._tools = list(self.sse_client.tools)
|
||||
self._tools = self._apply_tool_filters(list(self.sse_client.tools))
|
||||
self.is_sse = True
|
||||
self.is_http_stream = False
|
||||
self.is_npx = False
|
||||
@@ -295,7 +304,7 @@ class MCP:
|
||||
timeout=timeout,
|
||||
options=transport_options
|
||||
)
|
||||
self._tools = list(self.http_stream_client.tools)
|
||||
self._tools = self._apply_tool_filters(list(self.http_stream_client.tools))
|
||||
self.is_sse = False
|
||||
self.is_http_stream = True
|
||||
self.is_npx = False
|
||||
@@ -325,20 +334,10 @@ class MCP:
|
||||
self.is_sse = False
|
||||
self.is_http_stream = False
|
||||
|
||||
# Ensure UTF-8 encoding in environment for Docker compatibility
|
||||
env = kwargs.get('env', {})
|
||||
if not env:
|
||||
env = os.environ.copy()
|
||||
|
||||
# Always set Python encoding
|
||||
env['PYTHONIOENCODING'] = 'utf-8'
|
||||
|
||||
# Only set locale variables on Unix systems
|
||||
if platform.system() != 'Windows':
|
||||
env.update({
|
||||
'LC_ALL': 'C.UTF-8',
|
||||
'LANG': 'C.UTF-8'
|
||||
})
|
||||
# Build safe environment for stdio MCP servers
|
||||
# Use safe baseline + explicit env from config (B5 security policy)
|
||||
custom_env = kwargs.get('env', {})
|
||||
env = self._build_safe_env(custom_env)
|
||||
|
||||
kwargs['env'] = env
|
||||
|
||||
@@ -369,7 +368,7 @@ class MCP:
|
||||
self._initialize_npx_mcp_tools(cmd, arguments)
|
||||
else:
|
||||
# Generate tool functions immediately and store them
|
||||
self._tools = self._generate_tool_functions()
|
||||
self._tools = self._apply_tool_filters(self._generate_tool_functions())
|
||||
|
||||
def _generate_tool_functions(self) -> List[Callable]:
|
||||
"""
|
||||
@@ -489,7 +488,7 @@ class MCP:
|
||||
logging.debug(f"Initializing NPX MCP tools with command: {cmd} {' '.join(arguments)}")
|
||||
|
||||
# Generate tool functions using the regular MCP approach
|
||||
self._tools = self._generate_tool_functions()
|
||||
self._tools = self._apply_tool_filters(self._generate_tool_functions())
|
||||
|
||||
if self.debug:
|
||||
logging.debug(f"Generated {len(self._tools)} NPX MCP tools")
|
||||
@@ -499,6 +498,100 @@ class MCP:
|
||||
logging.error(f"Failed to initialize NPX MCP tools: {e}")
|
||||
raise RuntimeError(f"Failed to initialize NPX MCP tools: {e}")
|
||||
|
||||
def _apply_tool_filters(self, raw_tools: List[Any]) -> List[Any]:
|
||||
"""
|
||||
Apply tool filtering based on allowed_tools and disabled_tools.
|
||||
|
||||
Args:
|
||||
raw_tools: Unfiltered tool list
|
||||
|
||||
Returns:
|
||||
Filtered tool list
|
||||
"""
|
||||
if not self.allowed_tools and not self.disabled_tools:
|
||||
return raw_tools
|
||||
|
||||
# Import filter functions
|
||||
from .mcp_utils import filter_tools_by_allowlist, filter_disabled_tools
|
||||
|
||||
# Convert tools to the format expected by filter functions (list of dicts with 'name')
|
||||
# Raw tools might be function objects, so we need to extract names
|
||||
tool_defs = []
|
||||
for tool in raw_tools:
|
||||
if hasattr(tool, '__name__'):
|
||||
tool_defs.append({"name": tool.__name__, "_tool_obj": tool})
|
||||
elif isinstance(tool, dict) and 'name' in tool:
|
||||
tool_defs.append(tool)
|
||||
else:
|
||||
# Skip tools that don't have a clear name
|
||||
continue
|
||||
|
||||
# Include wins over exclude - apply allowlist exclusively if provided
|
||||
if self.allowed_tools:
|
||||
tool_defs = filter_tools_by_allowlist(tool_defs, self.allowed_tools)
|
||||
elif self.disabled_tools:
|
||||
tool_defs = filter_disabled_tools(tool_defs, self.disabled_tools)
|
||||
|
||||
# Convert back to original format
|
||||
filtered_tools = []
|
||||
for tool_def in tool_defs:
|
||||
if "_tool_obj" in tool_def:
|
||||
filtered_tools.append(tool_def["_tool_obj"])
|
||||
else:
|
||||
filtered_tools.append(tool_def)
|
||||
|
||||
return filtered_tools
|
||||
|
||||
def _build_safe_env(self, custom_env: Optional[dict] = None) -> dict:
|
||||
"""
|
||||
Build a safe environment for stdio MCP servers.
|
||||
|
||||
Merges only safe baseline environment variables with explicit custom ones,
|
||||
following the security policy outlined in AGENTS.md.
|
||||
|
||||
Args:
|
||||
custom_env: Optional custom environment variables from config
|
||||
|
||||
Returns:
|
||||
Safe environment dictionary
|
||||
"""
|
||||
# Safe baseline environment variables
|
||||
safe_baseline = {
|
||||
'PATH': os.environ.get('PATH', ''),
|
||||
'HOME': os.environ.get('HOME', os.path.expanduser('~')),
|
||||
'USER': os.environ.get('USER', ''),
|
||||
'LANG': os.environ.get('LANG', 'C.UTF-8'),
|
||||
'LC_ALL': os.environ.get('LC_ALL', 'C.UTF-8'),
|
||||
'PYTHONIOENCODING': 'utf-8',
|
||||
}
|
||||
|
||||
# Platform-specific safe variables
|
||||
if platform.system() == 'Windows':
|
||||
safe_baseline.update({
|
||||
'SYSTEMROOT': os.environ.get('SYSTEMROOT', ''),
|
||||
'COMSPEC': os.environ.get('COMSPEC', ''),
|
||||
'USERNAME': os.environ.get('USERNAME', ''),
|
||||
'USERPROFILE': os.environ.get('USERPROFILE', ''),
|
||||
'APPDATA': os.environ.get('APPDATA', ''),
|
||||
'LOCALAPPDATA': os.environ.get('LOCALAPPDATA', ''),
|
||||
'TEMP': os.environ.get('TEMP', ''),
|
||||
'TMP': os.environ.get('TMP', ''),
|
||||
})
|
||||
else:
|
||||
safe_baseline.update({
|
||||
'TMPDIR': os.environ.get('TMPDIR', '/tmp'),
|
||||
'SHELL': os.environ.get('SHELL', '/bin/sh'),
|
||||
})
|
||||
|
||||
# Start with safe baseline
|
||||
env = {k: v for k, v in safe_baseline.items() if v} # Only include non-empty values
|
||||
|
||||
# Add explicit custom environment variables from config
|
||||
if custom_env:
|
||||
env.update(custom_env)
|
||||
|
||||
return env
|
||||
|
||||
def __iter__(self) -> Iterable[Callable]:
|
||||
"""
|
||||
Allow the MCP instance to be used directly as an iterable of tools.
|
||||
@@ -609,7 +702,8 @@ class MCP:
|
||||
"name": tool.name,
|
||||
"description": tool.description if hasattr(tool, 'description') else f"Call the {tool.name} tool",
|
||||
"parameters": parameters
|
||||
}
|
||||
},
|
||||
"__praisonai_deferrable__": True # Mark MCP tools as deferrable for tool search
|
||||
})
|
||||
|
||||
return openai_tools
|
||||
|
||||
@@ -156,7 +156,8 @@ class HTTPStreamMCPTool:
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"parameters": fixed_schema
|
||||
}
|
||||
},
|
||||
"__praisonai_deferrable__": True # Mark MCP tools as deferrable for tool search
|
||||
}
|
||||
|
||||
class HTTPStreamTransport:
|
||||
|
||||
@@ -121,7 +121,8 @@ class SSEMCPTool:
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"parameters": fixed_schema
|
||||
}
|
||||
},
|
||||
"__praisonai_deferrable__": True # Mark MCP tools as deferrable for tool search
|
||||
}
|
||||
|
||||
class SSEMCPClient:
|
||||
|
||||
@@ -363,7 +363,8 @@ class WebSocketMCPTool:
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"parameters": fixed_schema
|
||||
}
|
||||
},
|
||||
"__praisonai_deferrable__": True # Mark MCP tools as deferrable for tool search
|
||||
}
|
||||
|
||||
class WebSocketMCPClient:
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
Model Context Protocol (MCP) client protocols for PraisonAI Agents.
|
||||
|
||||
Defines protocol interfaces for MCP client extensibility following
|
||||
the protocol-driven design pattern outlined in AGENTS.md.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class MCPClientProtocol(Protocol):
|
||||
"""
|
||||
Protocol interface for MCP client implementations.
|
||||
|
||||
Defines the minimal contract that all MCP clients must follow,
|
||||
enabling extensibility through different transport mechanisms
|
||||
(stdio, SSE, HTTP stream, WebSocket) while maintaining a
|
||||
consistent interface for agents.
|
||||
"""
|
||||
|
||||
def list_tools(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List available tools from the MCP server.
|
||||
|
||||
Returns:
|
||||
List of tool definitions from the server
|
||||
"""
|
||||
...
|
||||
|
||||
def get_tools(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get available tools from the MCP server.
|
||||
|
||||
Alias for list_tools() to maintain backward compatibility.
|
||||
|
||||
Returns:
|
||||
List of tool definitions from the server
|
||||
"""
|
||||
...
|
||||
|
||||
def call_tool(self, name: str, args: Dict[str, Any]) -> Any:
|
||||
"""
|
||||
Call a tool on the MCP server.
|
||||
|
||||
Args:
|
||||
name: Tool name to call
|
||||
args: Tool arguments
|
||||
|
||||
Returns:
|
||||
Tool execution result
|
||||
|
||||
Raises:
|
||||
Exception: If tool call fails
|
||||
"""
|
||||
...
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""
|
||||
Shutdown the MCP client and clean up resources.
|
||||
|
||||
This should gracefully close connections, terminate
|
||||
subprocesses, and release any held resources.
|
||||
"""
|
||||
...
|
||||
|
||||
async def async_list_tools(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Asynchronously list available tools from the MCP server.
|
||||
|
||||
Returns:
|
||||
List of tool definitions from the server
|
||||
"""
|
||||
...
|
||||
|
||||
async def async_get_tools(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Asynchronously get available tools from the MCP server.
|
||||
|
||||
Alias for async_list_tools() to maintain backward compatibility.
|
||||
|
||||
Returns:
|
||||
List of tool definitions from the server
|
||||
"""
|
||||
...
|
||||
|
||||
async def async_call_tool(self, name: str, args: Dict[str, Any]) -> Any:
|
||||
"""
|
||||
Asynchronously call a tool on the MCP server.
|
||||
|
||||
Args:
|
||||
name: Tool name to call
|
||||
args: Tool arguments
|
||||
|
||||
Returns:
|
||||
Tool execution result
|
||||
|
||||
Raises:
|
||||
Exception: If tool call fails
|
||||
"""
|
||||
...
|
||||
|
||||
async def async_shutdown(self) -> None:
|
||||
"""
|
||||
Asynchronously shutdown the MCP client and clean up resources.
|
||||
|
||||
This should gracefully close connections, terminate
|
||||
subprocesses, and release any held resources.
|
||||
"""
|
||||
...
|
||||
@@ -81,7 +81,7 @@ class MCPConfig:
|
||||
MCP instance ready for use with agents
|
||||
"""
|
||||
try:
|
||||
from praisonaiagents.tools.mcp import MCP
|
||||
from praisonaiagents.mcp import MCP
|
||||
|
||||
# Resolve environment variables
|
||||
resolved_env = {}
|
||||
|
||||
@@ -144,6 +144,8 @@ class HierarchicalSessionStore(DefaultSessionStore):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._extended_cache: Dict[str, ExtendedSessionData] = {}
|
||||
self._cache_mtimes: Dict[str, float] = {} # Track file modification times
|
||||
|
||||
|
||||
def _load_session_from_disk(self, session_id: str, filepath: str) -> ExtendedSessionData:
|
||||
"""Load extended session JSON from disk (caller must hold FileLock)."""
|
||||
@@ -173,6 +175,43 @@ class HierarchicalSessionStore(DefaultSessionStore):
|
||||
if isinstance(cached, ExtendedSessionData):
|
||||
self._extended_cache[session_id] = cached
|
||||
return result
|
||||
|
||||
def _is_cache_valid(self, session_id: str) -> bool:
|
||||
"""Check if cached session is still valid based on file mtime."""
|
||||
if session_id not in self._extended_cache:
|
||||
return False
|
||||
|
||||
filepath = self._get_session_path(session_id)
|
||||
if not os.path.exists(filepath):
|
||||
return False
|
||||
|
||||
try:
|
||||
current_mtime = os.path.getmtime(filepath)
|
||||
cached_mtime = self._cache_mtimes.get(session_id, 0)
|
||||
return current_mtime <= cached_mtime
|
||||
except (OSError, IOError):
|
||||
return False
|
||||
|
||||
def _read_session_fresh(self, session_id: str) -> ExtendedSessionData:
|
||||
"""Reload from disk and keep _cache and _extended_cache in sync."""
|
||||
session = super()._read_session_fresh(session_id)
|
||||
if not isinstance(session, ExtendedSessionData):
|
||||
session = ExtendedSessionData.from_session_data(session)
|
||||
with self._lock:
|
||||
self._cache[session_id] = session
|
||||
|
||||
# Update cache with fresh file mtime
|
||||
filepath = self._get_session_path(session_id)
|
||||
try:
|
||||
mtime = os.path.getmtime(filepath) if os.path.exists(filepath) else time.time()
|
||||
except (OSError, IOError):
|
||||
mtime = time.time()
|
||||
|
||||
with self._lock:
|
||||
self._extended_cache[session_id] = session
|
||||
self._cache_mtimes[session_id] = mtime
|
||||
|
||||
return session
|
||||
|
||||
def add_message(
|
||||
self,
|
||||
@@ -204,35 +243,14 @@ class HierarchicalSessionStore(DefaultSessionStore):
|
||||
)
|
||||
|
||||
def _load_extended_session(self, session_id: str, force_reload: bool = False) -> ExtendedSessionData:
|
||||
"""Load extended session from disk."""
|
||||
filepath = self._get_session_path(session_id)
|
||||
|
||||
# Check cache first (unless force reload)
|
||||
if not force_reload:
|
||||
with self._lock:
|
||||
if session_id in self._extended_cache:
|
||||
return self._extended_cache[session_id]
|
||||
|
||||
# Load from disk
|
||||
if not os.path.exists(filepath):
|
||||
session = ExtendedSessionData(session_id=session_id)
|
||||
with self._lock:
|
||||
self._extended_cache[session_id] = session
|
||||
return session
|
||||
|
||||
with FileLock(filepath, self.lock_timeout):
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
session = ExtendedSessionData.from_dict(data)
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
logger.warning(f"Failed to load session {session_id}: {e}")
|
||||
session = ExtendedSessionData(session_id=session_id)
|
||||
"""Load extended session with smart caching based on file modification time."""
|
||||
# Force reload bypasses cache validation
|
||||
if force_reload or not self._is_cache_valid(session_id):
|
||||
return self._read_session_fresh(session_id)
|
||||
|
||||
# Cache is valid, return cached version
|
||||
with self._lock:
|
||||
self._extended_cache[session_id] = session
|
||||
|
||||
return session
|
||||
return self._extended_cache[session_id]
|
||||
|
||||
def _save_extended_session(self, session: ExtendedSessionData) -> bool:
|
||||
"""Save extended session to disk."""
|
||||
@@ -260,8 +278,15 @@ class HierarchicalSessionStore(DefaultSessionStore):
|
||||
|
||||
os.replace(temp_path, filepath)
|
||||
|
||||
# Update cache with current file mtime after successful write
|
||||
try:
|
||||
mtime = os.path.getmtime(filepath)
|
||||
except (OSError, IOError):
|
||||
mtime = time.time()
|
||||
|
||||
with self._lock:
|
||||
self._extended_cache[session.session_id] = session
|
||||
self._cache_mtimes[session.session_id] = mtime
|
||||
|
||||
return True
|
||||
except (IOError, OSError) as e:
|
||||
@@ -304,12 +329,13 @@ class HierarchicalSessionStore(DefaultSessionStore):
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
# Update parent's children list
|
||||
# Update parent's children list without clobbering concurrent message writes
|
||||
if parent_id:
|
||||
parent = self._load_extended_session(parent_id)
|
||||
if sid not in parent.children_ids:
|
||||
parent.children_ids.append(sid)
|
||||
self._save_extended_session(parent)
|
||||
def _apply(parent_session: SessionData) -> None:
|
||||
assert isinstance(parent_session, ExtendedSessionData)
|
||||
if sid not in parent_session.children_ids:
|
||||
parent_session.children_ids.append(sid)
|
||||
self._modify_session_locked(parent_id, _apply, error_label="update parent children")
|
||||
|
||||
self._save_extended_session(session)
|
||||
return sid
|
||||
@@ -356,11 +382,15 @@ class HierarchicalSessionStore(DefaultSessionStore):
|
||||
metadata=copy.deepcopy(parent.metadata),
|
||||
)
|
||||
|
||||
# Update parent's children list
|
||||
parent.children_ids.append(new_id)
|
||||
|
||||
self._save_extended_session(forked)
|
||||
self._save_extended_session(parent)
|
||||
|
||||
def _register_fork(parent: SessionData) -> None:
|
||||
if new_id not in parent.children_ids:
|
||||
parent.children_ids.append(new_id)
|
||||
|
||||
self._modify_session_locked(
|
||||
session_id, _register_fork, error_label="register forked session"
|
||||
)
|
||||
|
||||
return new_id
|
||||
|
||||
@@ -411,18 +441,22 @@ class HierarchicalSessionStore(DefaultSessionStore):
|
||||
Returns:
|
||||
The snapshot ID
|
||||
"""
|
||||
# Force reload to get latest messages
|
||||
session = self._load_extended_session(session_id, force_reload=True)
|
||||
|
||||
snapshot = SessionSnapshot(
|
||||
session_id=session_id,
|
||||
message_index=len(session.messages) - 1 if session.messages else -1,
|
||||
message_index=-1, # Set from fresh session inside locked write
|
||||
label=label,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
session.snapshots.append(snapshot)
|
||||
self._save_extended_session(session)
|
||||
|
||||
def _record_snapshot(session: SessionData) -> None:
|
||||
snapshot.message_index = (
|
||||
len(session.messages) - 1 if session.messages else -1
|
||||
)
|
||||
session.snapshots.append(snapshot)
|
||||
|
||||
self._modify_session_locked(
|
||||
session_id, _record_snapshot, error_label="create snapshot"
|
||||
)
|
||||
|
||||
return snapshot.id
|
||||
|
||||
@@ -442,26 +476,30 @@ class HierarchicalSessionStore(DefaultSessionStore):
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
session = self._load_extended_session(session_id)
|
||||
|
||||
# Find the snapshot
|
||||
snapshot = None
|
||||
for s in session.snapshots:
|
||||
if s.id == snapshot_id:
|
||||
snapshot = s
|
||||
break
|
||||
|
||||
if snapshot is None:
|
||||
logger.warning(f"Snapshot {snapshot_id} not found")
|
||||
def _apply(session: SessionData) -> None:
|
||||
assert isinstance(session, ExtendedSessionData)
|
||||
|
||||
# Find the snapshot
|
||||
snapshot = None
|
||||
for s in session.snapshots:
|
||||
if s.id == snapshot_id:
|
||||
snapshot = s
|
||||
break
|
||||
|
||||
if snapshot is None:
|
||||
logger.warning(f"Snapshot {snapshot_id} not found")
|
||||
raise ValueError(f"Snapshot {snapshot_id} not found")
|
||||
|
||||
# Revert messages
|
||||
if snapshot.message_index >= 0:
|
||||
session.messages = session.messages[:snapshot.message_index + 1]
|
||||
else:
|
||||
session.messages = []
|
||||
|
||||
try:
|
||||
return self._modify_session_locked(session_id, _apply, error_label="revert to snapshot")
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
# Revert messages
|
||||
if snapshot.message_index >= 0:
|
||||
session.messages = session.messages[:snapshot.message_index + 1]
|
||||
else:
|
||||
session.messages = []
|
||||
|
||||
return self._save_extended_session(session)
|
||||
|
||||
def revert_to_message(self, session_id: str, message_index: int) -> bool:
|
||||
"""
|
||||
@@ -474,27 +512,33 @@ class HierarchicalSessionStore(DefaultSessionStore):
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
# Force reload to get latest messages
|
||||
session = self._load_extended_session(session_id, force_reload=True)
|
||||
|
||||
if message_index < 0 or message_index >= len(session.messages):
|
||||
logger.warning(f"Invalid message index {message_index}")
|
||||
def _apply(session: SessionData) -> None:
|
||||
assert isinstance(session, ExtendedSessionData)
|
||||
|
||||
if message_index < 0 or message_index >= len(session.messages):
|
||||
logger.warning(f"Invalid message index {message_index}")
|
||||
raise ValueError(f"Invalid message index {message_index}")
|
||||
|
||||
session.messages = session.messages[:message_index + 1]
|
||||
|
||||
try:
|
||||
return self._modify_session_locked(session_id, _apply, error_label="revert to message")
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
session.messages = session.messages[:message_index + 1]
|
||||
return self._save_extended_session(session)
|
||||
|
||||
def share_session(self, session_id: str) -> bool:
|
||||
"""Mark a session as shared."""
|
||||
session = self._load_extended_session(session_id)
|
||||
session.is_shared = True
|
||||
return self._save_extended_session(session)
|
||||
def _apply(session: SessionData) -> None:
|
||||
assert isinstance(session, ExtendedSessionData)
|
||||
session.is_shared = True
|
||||
return self._modify_session_locked(session_id, _apply, error_label="share session")
|
||||
|
||||
def unshare_session(self, session_id: str) -> bool:
|
||||
"""Mark a session as not shared."""
|
||||
session = self._load_extended_session(session_id)
|
||||
session.is_shared = False
|
||||
return self._save_extended_session(session)
|
||||
def _apply(session: SessionData) -> None:
|
||||
assert isinstance(session, ExtendedSessionData)
|
||||
session.is_shared = False
|
||||
return self._modify_session_locked(session_id, _apply, error_label="unshare session")
|
||||
|
||||
def is_shared(self, session_id: str) -> bool:
|
||||
"""Check if a session is shared."""
|
||||
@@ -503,9 +547,10 @@ class HierarchicalSessionStore(DefaultSessionStore):
|
||||
|
||||
def set_title(self, session_id: str, title: str) -> bool:
|
||||
"""Set session title."""
|
||||
session = self._load_extended_session(session_id)
|
||||
session.title = title
|
||||
return self._save_extended_session(session)
|
||||
def _apply(session: SessionData) -> None:
|
||||
assert isinstance(session, ExtendedSessionData)
|
||||
session.title = title
|
||||
return self._modify_session_locked(session_id, _apply, error_label="set session title")
|
||||
|
||||
async def auto_title(self, session_id: str) -> bool:
|
||||
"""Generate and set title automatically from first exchange.
|
||||
@@ -560,12 +605,19 @@ class HierarchicalSessionStore(DefaultSessionStore):
|
||||
title = await generate_title_async(user_msg, assistant_msg)
|
||||
|
||||
if title and title.strip():
|
||||
# Reload session to avoid overwriting concurrent updates
|
||||
fresh_session = await asyncio.to_thread(self._load_extended_session, session_id)
|
||||
# Only set title if it's still empty
|
||||
if not fresh_session.title or not fresh_session.title.strip():
|
||||
fresh_session.title = title.strip()
|
||||
return await asyncio.to_thread(self._save_extended_session, fresh_session)
|
||||
# Use locked read-modify-write to avoid overwriting concurrent updates
|
||||
def _apply(fresh_session: SessionData) -> None:
|
||||
assert isinstance(fresh_session, ExtendedSessionData)
|
||||
# Only set title if it's still empty
|
||||
if not fresh_session.title or not fresh_session.title.strip():
|
||||
fresh_session.title = title.strip()
|
||||
|
||||
return await asyncio.to_thread(
|
||||
self._modify_session_locked,
|
||||
session_id,
|
||||
_apply,
|
||||
error_label="auto-title session"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Title generation failed - log with context instead of silent failure
|
||||
@@ -577,7 +629,17 @@ class HierarchicalSessionStore(DefaultSessionStore):
|
||||
|
||||
def get_extended_session(self, session_id: str) -> ExtendedSessionData:
|
||||
"""Get extended session data."""
|
||||
return self._load_extended_session(session_id)
|
||||
return self._read_session_fresh(session_id)
|
||||
|
||||
def invalidate_cache(self, session_id: Optional[str] = None) -> None:
|
||||
"""Invalidate base and extended in-memory caches atomically."""
|
||||
with self._lock:
|
||||
if session_id:
|
||||
self._cache.pop(session_id, None)
|
||||
self._extended_cache.pop(session_id, None)
|
||||
else:
|
||||
self._cache.clear()
|
||||
self._extended_cache.clear()
|
||||
|
||||
def export_session(self, session_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
|
||||
@@ -15,10 +15,10 @@ import threading
|
||||
import time
|
||||
|
||||
# fcntl is Unix-only; on Windows, use msvcrt for file locking
|
||||
if sys.platform != 'win32':
|
||||
try:
|
||||
import fcntl
|
||||
_HAS_FCNTL = True
|
||||
else:
|
||||
except ImportError:
|
||||
_HAS_FCNTL = False
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
@@ -167,11 +167,11 @@ class FileLock:
|
||||
if _HAS_FCNTL:
|
||||
fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
else:
|
||||
# Warn once about degraded locking (should never happen with current platform detection)
|
||||
# Warn once about degraded locking on non-Windows platforms without fcntl
|
||||
global _WARNED_NO_FCNTL
|
||||
if not _WARNED_NO_FCNTL:
|
||||
logger.warning(
|
||||
"File locking unavailable on this platform (no fcntl/msvcrt); "
|
||||
"File locking unavailable on this platform (fcntl not available); "
|
||||
"concurrent writers may corrupt session files."
|
||||
)
|
||||
_WARNED_NO_FCNTL = True
|
||||
@@ -265,32 +265,22 @@ class DefaultSessionStore:
|
||||
def _load_session(self, session_id: str) -> SessionData:
|
||||
"""Load session from disk with file locking."""
|
||||
filepath = self._get_session_path(session_id)
|
||||
|
||||
# Check cache first
|
||||
with self._lock:
|
||||
if session_id in self._cache:
|
||||
return self._cache[session_id]
|
||||
|
||||
# Load from disk
|
||||
if not os.path.exists(filepath):
|
||||
session = SessionData(session_id=session_id)
|
||||
|
||||
# When a session file exists, always reload under FileLock so reads
|
||||
# from another DefaultSessionStore instance (or process) are visible.
|
||||
if os.path.exists(filepath):
|
||||
with FileLock(filepath, self.lock_timeout):
|
||||
session = self._load_session_from_disk(session_id, filepath)
|
||||
with self._lock:
|
||||
self._cache[session_id] = session
|
||||
return session
|
||||
|
||||
with FileLock(filepath, self.lock_timeout):
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
session = SessionData.from_dict(data)
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
logger.warning(f"Failed to load session {session_id}: {e}")
|
||||
session = SessionData(session_id=session_id)
|
||||
|
||||
|
||||
with self._lock:
|
||||
if session_id in self._cache:
|
||||
return self._cache[session_id]
|
||||
session = SessionData(session_id=session_id)
|
||||
self._cache[session_id] = session
|
||||
|
||||
return session
|
||||
return session
|
||||
|
||||
def _load_session_from_disk(self, session_id: str, filepath: str) -> SessionData:
|
||||
"""Load session JSON from disk (caller must hold FileLock)."""
|
||||
@@ -540,6 +530,29 @@ class DefaultSessionStore:
|
||||
error_label="clear session",
|
||||
)
|
||||
|
||||
def set_chat_history(
|
||||
self,
|
||||
session_id: str,
|
||||
messages: List[Dict[str, str]],
|
||||
) -> bool:
|
||||
"""Replace session messages atomically (file-locked read-modify-write)."""
|
||||
|
||||
def _apply(session: SessionData) -> None:
|
||||
session.messages.clear()
|
||||
for msg in messages:
|
||||
session.messages.append(
|
||||
SessionMessage(
|
||||
role=msg.get("role", "user"),
|
||||
content=msg.get("content", ""),
|
||||
timestamp=msg.get("timestamp", time.time()),
|
||||
metadata=msg.get("metadata", {}),
|
||||
)
|
||||
)
|
||||
|
||||
return self._modify_session_locked(
|
||||
session_id, _apply, error_label="set chat history"
|
||||
)
|
||||
|
||||
def update_session_metadata(self, session_id: str, **fields: Any) -> bool:
|
||||
"""Merge run stats / metadata fields into a persisted session."""
|
||||
if not fields:
|
||||
|
||||
@@ -0,0 +1,584 @@
|
||||
"""
|
||||
Tool Search - Progressive disclosure layer for MCP and non-core plugin tools.
|
||||
|
||||
Implements Hermes-parity bridge tools with lazy-loaded implementation:
|
||||
- tool_search(query) → matches
|
||||
- tool_describe(name) → full schema
|
||||
- tool_call(name, args) → unwrap → execute_tool(real_name)
|
||||
|
||||
Architecture invariants (ported from Hermes):
|
||||
1. Core tools never defer - PRAISONAI_CORE_TOOLS
|
||||
2. Unknown tools stay visible - never silently dropped
|
||||
3. Stateless catalog - rebuilt every assembly from current tool-defs
|
||||
4. skip_assembly for bridge dispatch - handlers read pre-assembly deferrable set
|
||||
5. Session scoping - catalog = deferrable subset of agent's own tool list
|
||||
6. Unwrap before trace/stream - hooks, approval, AG-UI show real tool name
|
||||
7. Auto threshold - deferrable schema tokens ≥ threshold_pct * context_length
|
||||
8. BM25 inlined - no new dependency; substring fallback on tool name
|
||||
"""
|
||||
|
||||
import re
|
||||
import math
|
||||
from typing import Dict, List, Optional, Union, Protocol, Set, FrozenSet, Tuple, Any
|
||||
from dataclasses import dataclass, field
|
||||
from collections import defaultdict
|
||||
|
||||
# Type definitions matching Hermes patterns
|
||||
ToolDef = Dict[str, Any] # OpenAI function schema
|
||||
ToolDefList = List[ToolDef]
|
||||
|
||||
# Core tools that never defer (mirrors Hermes _HERMES_CORE_TOOLS)
|
||||
PRAISONAI_CORE_TOOLS: FrozenSet[str] = frozenset({
|
||||
# File operations
|
||||
"read_file", "write_file", "list_files", "get_file_info",
|
||||
"copy_file", "move_file", "delete_file",
|
||||
|
||||
# Shell operations
|
||||
"execute_command", "list_processes", "kill_process", "get_system_info",
|
||||
|
||||
# Web operations
|
||||
"search_web", "web_search", "internet_search", "web_crawl", "crawl_web",
|
||||
|
||||
# Schedule operations (agent-centric scheduling)
|
||||
"schedule_add", "schedule_list", "schedule_remove",
|
||||
|
||||
# Memory operations (active memory)
|
||||
"store_memory", "search_memory",
|
||||
|
||||
# Clarify tool (human-in-the-loop clarification)
|
||||
"clarify",
|
||||
})
|
||||
|
||||
@dataclass
|
||||
class ToolSearchConfig:
|
||||
"""Configuration for Tool Search feature."""
|
||||
enabled: Union[bool, str] = "auto" # auto | on | off | True | False
|
||||
threshold_pct: float = 10.0 # Percentage of context window for deferral threshold
|
||||
search_default_limit: int = 5 # Default number of search results
|
||||
max_search_limit: int = 20 # Maximum search results allowed
|
||||
core_tools: Optional[FrozenSet[str]] = None # Override core tools set
|
||||
|
||||
@classmethod
|
||||
def from_raw(cls, raw_config: Any) -> "ToolSearchConfig":
|
||||
"""Create config from various input formats (bool, dict, ToolSearchConfig)."""
|
||||
if isinstance(raw_config, cls):
|
||||
return raw_config
|
||||
elif isinstance(raw_config, bool):
|
||||
return cls(enabled="on" if raw_config else "off")
|
||||
elif isinstance(raw_config, str):
|
||||
if raw_config.lower() in ("true", "on", "yes", "1"):
|
||||
return cls(enabled="on")
|
||||
elif raw_config.lower() in ("false", "off", "no", "0"):
|
||||
return cls(enabled="off")
|
||||
elif raw_config.lower() == "auto":
|
||||
return cls(enabled="auto")
|
||||
else:
|
||||
raise ValueError(f"Invalid tool_search string value: {raw_config}")
|
||||
elif isinstance(raw_config, dict):
|
||||
return cls(**raw_config)
|
||||
else:
|
||||
raise TypeError(f"Invalid tool_search type: {type(raw_config)}")
|
||||
|
||||
class ToolSearchProtocol(Protocol):
|
||||
"""Protocol for tool search implementations."""
|
||||
|
||||
def search_catalog(self, query: str, limit: int = 5) -> List[Dict[str, str]]:
|
||||
"""Search for tools matching the query."""
|
||||
...
|
||||
|
||||
def describe_tool(self, tool_name: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get full schema for a specific tool."""
|
||||
...
|
||||
|
||||
def resolve_underlying_call(self, tool_name: str, tool_args: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
|
||||
"""Unwrap bridge call to real tool name and args."""
|
||||
...
|
||||
|
||||
def classify_tools(tool_defs: ToolDefList, config: ToolSearchConfig) -> Tuple[ToolDefList, ToolDefList]:
|
||||
"""
|
||||
Classify tools into core (never defer) vs deferrable.
|
||||
|
||||
Args:
|
||||
tool_defs: List of OpenAI function schemas
|
||||
config: Tool search configuration
|
||||
|
||||
Returns:
|
||||
Tuple of (core_tools, deferrable_tools)
|
||||
"""
|
||||
core_tools = []
|
||||
deferrable_tools = []
|
||||
|
||||
# Use configured core tools or default
|
||||
core_tool_names = config.core_tools or PRAISONAI_CORE_TOOLS
|
||||
|
||||
for tool_def in tool_defs:
|
||||
tool_name = tool_def.get("function", {}).get("name", "")
|
||||
|
||||
# Check if it's a core tool
|
||||
if tool_name in core_tool_names:
|
||||
core_tools.append(tool_def)
|
||||
continue
|
||||
|
||||
# Check for deferrable marker from MCP or custom tools
|
||||
if _is_tool_deferrable(tool_def):
|
||||
deferrable_tools.append(tool_def)
|
||||
else:
|
||||
# Unknown tools stay visible (invariant #2)
|
||||
core_tools.append(tool_def)
|
||||
|
||||
return core_tools, deferrable_tools
|
||||
|
||||
def _is_tool_deferrable(tool_def: ToolDef) -> bool:
|
||||
"""Check if a tool should be deferred based on markers."""
|
||||
# Check for MCP deferrable marker
|
||||
if tool_def.get("__praisonai_deferrable__", False):
|
||||
return True
|
||||
|
||||
# Check function metadata for deferrable flag
|
||||
function_def = tool_def.get("function", {})
|
||||
if function_def.get("deferrable", False):
|
||||
return True
|
||||
|
||||
# Check tool name patterns for MCP tools (prefix only, to avoid false-positives)
|
||||
tool_name = function_def.get("name", "")
|
||||
if tool_name.startswith("mcp_"):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def estimate_tool_schema_tokens(tool_defs: ToolDefList) -> int:
|
||||
"""
|
||||
Estimate token count for tool schemas.
|
||||
|
||||
Rough approximation: 1 token ≈ 4 characters for English text.
|
||||
Function schemas are structured JSON so may be more dense.
|
||||
"""
|
||||
if not tool_defs:
|
||||
return 0
|
||||
|
||||
# Convert to JSON and estimate
|
||||
import json
|
||||
total_chars = 0
|
||||
for tool_def in tool_defs:
|
||||
try:
|
||||
json_str = json.dumps(tool_def, separators=(',', ':'))
|
||||
total_chars += len(json_str)
|
||||
except (TypeError, ValueError):
|
||||
# Fallback estimation
|
||||
total_chars += len(str(tool_def))
|
||||
|
||||
# Conservative estimate: 1 token per 3.5 characters for JSON
|
||||
return int(total_chars / 3.5)
|
||||
|
||||
def should_defer_tools(
|
||||
deferrable_tools: ToolDefList,
|
||||
config: ToolSearchConfig,
|
||||
context_length: Optional[int] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Determine if tools should be deferred based on auto threshold.
|
||||
|
||||
Args:
|
||||
deferrable_tools: List of deferrable tool schemas
|
||||
config: Tool search configuration
|
||||
context_length: Model context window size
|
||||
|
||||
Returns:
|
||||
True if tools should be deferred
|
||||
"""
|
||||
if config.enabled == "off" or config.enabled is False:
|
||||
return False
|
||||
elif config.enabled == "on" or config.enabled is True:
|
||||
return True
|
||||
elif config.enabled == "auto":
|
||||
# Auto mode: check threshold
|
||||
if not deferrable_tools:
|
||||
return False
|
||||
|
||||
deferrable_tokens = estimate_tool_schema_tokens(deferrable_tools)
|
||||
|
||||
# Use fallback if context length unknown
|
||||
context_limit = context_length or 20000
|
||||
threshold_tokens = int(context_limit * (config.threshold_pct / 100.0))
|
||||
|
||||
return deferrable_tokens >= threshold_tokens
|
||||
else:
|
||||
return False
|
||||
|
||||
class BM25ToolSearcher:
|
||||
"""
|
||||
Simple BM25 implementation for tool search.
|
||||
No external dependencies - inlined from Hermes approach.
|
||||
"""
|
||||
|
||||
def __init__(self, tool_catalog: List[Dict[str, str]]):
|
||||
"""
|
||||
Initialize with tool catalog.
|
||||
|
||||
Args:
|
||||
tool_catalog: List of dicts with 'name', 'description' keys
|
||||
"""
|
||||
self.catalog = tool_catalog
|
||||
self.term_frequencies = []
|
||||
self.doc_frequencies = defaultdict(int)
|
||||
self.total_docs = len(tool_catalog)
|
||||
|
||||
# Preprocess documents
|
||||
self._build_index()
|
||||
|
||||
def _tokenize(self, text: str) -> List[str]:
|
||||
"""Simple tokenization - lowercase, alphanumeric + underscores."""
|
||||
if not text:
|
||||
return []
|
||||
return re.findall(r'\b[a-zA-Z0-9_]+\b', text.lower())
|
||||
|
||||
def _build_index(self):
|
||||
"""Build BM25 index from catalog."""
|
||||
total_length = 0
|
||||
# Calculate term frequencies for each document
|
||||
for item in self.catalog:
|
||||
doc_text = f"{item['name']} {item['description']}"
|
||||
tokens = self._tokenize(doc_text)
|
||||
|
||||
# Term frequency for this doc
|
||||
tf = defaultdict(int)
|
||||
for token in tokens:
|
||||
tf[token] += 1
|
||||
|
||||
self.term_frequencies.append(dict(tf))
|
||||
total_length += len(tokens)
|
||||
|
||||
# Document frequency (how many docs contain each term)
|
||||
unique_tokens = set(tokens)
|
||||
for token in unique_tokens:
|
||||
self.doc_frequencies[token] += 1
|
||||
|
||||
# Cache average document length to avoid O(n) recomputation in hot path
|
||||
self._cached_avg_doc_length = total_length / len(self.catalog) if self.catalog else 0.0
|
||||
|
||||
def search(self, query: str, limit: int = 5) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Search using BM25 scoring.
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
limit: Maximum results to return
|
||||
|
||||
Returns:
|
||||
List of matching tool items, sorted by relevance
|
||||
"""
|
||||
query_tokens = self._tokenize(query)
|
||||
if not query_tokens:
|
||||
return []
|
||||
|
||||
scores = []
|
||||
k1, b = 1.5, 0.75 # BM25 parameters
|
||||
|
||||
for item, tf in zip(self.catalog, self.term_frequencies, strict=True):
|
||||
score = 0.0
|
||||
doc_length = sum(tf.values())
|
||||
|
||||
for token in query_tokens:
|
||||
if token in tf:
|
||||
# BM25 formula
|
||||
idf = math.log((self.total_docs - self.doc_frequencies[token] + 0.5) /
|
||||
(self.doc_frequencies[token] + 0.5))
|
||||
term_freq = tf[token]
|
||||
score += idf * (term_freq * (k1 + 1)) / (
|
||||
term_freq + k1 * (1 - b + b * (doc_length / self._cached_avg_doc_length))
|
||||
)
|
||||
|
||||
if score > 0:
|
||||
scores.append((score, item))
|
||||
|
||||
# Sort by score descending and return top results
|
||||
scores.sort(key=lambda x: x[0], reverse=True)
|
||||
return [item for _, item in scores[:limit]]
|
||||
|
||||
|
||||
def search_catalog(
|
||||
deferrable_tools: ToolDefList,
|
||||
query: str,
|
||||
limit: int = 5
|
||||
) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Search deferrable tools catalog using BM25.
|
||||
|
||||
Args:
|
||||
deferrable_tools: List of deferrable tool schemas
|
||||
query: Search query
|
||||
limit: Maximum results
|
||||
|
||||
Returns:
|
||||
List of matching tools with name/description
|
||||
"""
|
||||
if not deferrable_tools or not query.strip():
|
||||
return []
|
||||
|
||||
# Build catalog for searching
|
||||
catalog = []
|
||||
for tool_def in deferrable_tools:
|
||||
function_def = tool_def.get("function", {})
|
||||
name = function_def.get("name", "")
|
||||
description = function_def.get("description", "")
|
||||
|
||||
if name: # Only include tools with names
|
||||
catalog.append({
|
||||
"name": name,
|
||||
"description": description
|
||||
})
|
||||
|
||||
if not catalog:
|
||||
return []
|
||||
|
||||
# Use BM25 search if query is substantial, otherwise substring fallback
|
||||
if len(query.strip()) >= 3:
|
||||
searcher = BM25ToolSearcher(catalog)
|
||||
results = searcher.search(query, limit)
|
||||
|
||||
# If BM25 finds results, use them
|
||||
if results:
|
||||
return results
|
||||
|
||||
# Fallback: simple substring matching on tool names
|
||||
query_lower = query.lower()
|
||||
matches = []
|
||||
for item in catalog:
|
||||
if query_lower in item["name"].lower() or query_lower in item["description"].lower():
|
||||
matches.append(item)
|
||||
if len(matches) >= limit:
|
||||
break
|
||||
|
||||
return matches
|
||||
|
||||
def bridge_tool_schemas() -> ToolDefList:
|
||||
"""
|
||||
Generate the three bridge tool schemas.
|
||||
|
||||
Returns:
|
||||
List of OpenAI function schemas for bridge tools
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "tool_search",
|
||||
"description": "Search for available tools by name or functionality. Use this to discover what tools are available before using them.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query describing the tool functionality you need"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of results to return",
|
||||
"default": 5,
|
||||
"minimum": 1,
|
||||
"maximum": 20
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "tool_describe",
|
||||
"description": "Get the full schema and documentation for a specific tool. Use this after tool_search to understand how to use a tool.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tool_name": {
|
||||
"type": "string",
|
||||
"description": "Exact name of the tool to describe"
|
||||
}
|
||||
},
|
||||
"required": ["tool_name"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "tool_call",
|
||||
"description": "Execute a tool with the given arguments. Use this after tool_describe to actually run the tool.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tool_name": {
|
||||
"type": "string",
|
||||
"description": "Exact name of the tool to execute"
|
||||
},
|
||||
"tool_args": {
|
||||
"type": "object",
|
||||
"description": "Arguments to pass to the tool",
|
||||
"additionalProperties": True
|
||||
}
|
||||
},
|
||||
"required": ["tool_name", "tool_args"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
def assemble_tool_defs(
|
||||
tool_defs: ToolDefList,
|
||||
config: ToolSearchConfig,
|
||||
context_length: Optional[int] = None
|
||||
) -> Tuple[ToolDefList, Dict[str, Any]]:
|
||||
"""
|
||||
Main assembly function - decide whether to use bridge mode or pass-through.
|
||||
|
||||
Args:
|
||||
tool_defs: Original list of tool schemas
|
||||
config: Tool search configuration
|
||||
context_length: Model context window size
|
||||
|
||||
Returns:
|
||||
Tuple of (assembled_tool_defs, metadata)
|
||||
metadata contains: {"bridge_mode": bool, "deferred_count": int, "catalog": List}
|
||||
"""
|
||||
# Always return original tools if disabled
|
||||
if config.enabled == "off" or config.enabled is False:
|
||||
return tool_defs, {"bridge_mode": False, "deferred_count": 0, "catalog": []}
|
||||
|
||||
# Classify tools
|
||||
core_tools, deferrable_tools = classify_tools(tool_defs, config)
|
||||
|
||||
# Check if we should defer
|
||||
should_defer = should_defer_tools(deferrable_tools, config, context_length)
|
||||
|
||||
if not should_defer or not deferrable_tools:
|
||||
# Pass-through mode - return original tools
|
||||
return tool_defs, {"bridge_mode": False, "deferred_count": 0, "catalog": []}
|
||||
|
||||
# Bridge mode - replace deferrable tools with bridge schemas
|
||||
bridge_schemas = bridge_tool_schemas()
|
||||
assembled_tools = core_tools + bridge_schemas
|
||||
|
||||
# Build catalog for search
|
||||
catalog = []
|
||||
for tool_def in deferrable_tools:
|
||||
function_def = tool_def.get("function", {})
|
||||
name = function_def.get("name", "")
|
||||
description = function_def.get("description", "")
|
||||
if name:
|
||||
catalog.append({"name": name, "description": description})
|
||||
|
||||
metadata = {
|
||||
"bridge_mode": True,
|
||||
"deferred_count": len(deferrable_tools),
|
||||
"catalog": catalog,
|
||||
"deferrable_tools": deferrable_tools # Keep full schemas for describe/call
|
||||
}
|
||||
|
||||
return assembled_tools, metadata
|
||||
|
||||
def dispatch_tool_search(
|
||||
query: str,
|
||||
limit: Optional[int],
|
||||
deferrable_tools: ToolDefList,
|
||||
config: ToolSearchConfig
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Handle tool_search bridge call.
|
||||
|
||||
Returns:
|
||||
JSON response with search results
|
||||
"""
|
||||
# Validate and clamp limit
|
||||
search_limit = limit or config.search_default_limit
|
||||
search_limit = min(search_limit, config.max_search_limit)
|
||||
search_limit = max(search_limit, 1)
|
||||
|
||||
# Perform search
|
||||
results = search_catalog(deferrable_tools, query, search_limit)
|
||||
|
||||
return {
|
||||
"query": query,
|
||||
"results": results,
|
||||
"total_available": len(deferrable_tools)
|
||||
}
|
||||
|
||||
def dispatch_tool_describe(
|
||||
tool_name: str,
|
||||
deferrable_tools: ToolDefList
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Handle tool_describe bridge call.
|
||||
|
||||
Returns:
|
||||
JSON response with full tool schema or error
|
||||
"""
|
||||
# Find the tool
|
||||
for tool_def in deferrable_tools:
|
||||
function_def = tool_def.get("function", {})
|
||||
if function_def.get("name") == tool_name:
|
||||
return {
|
||||
"tool_name": tool_name,
|
||||
"schema": tool_def,
|
||||
"found": True
|
||||
}
|
||||
|
||||
return {
|
||||
"tool_name": tool_name,
|
||||
"error": f"Tool '{tool_name}' not found in available tools",
|
||||
"found": False
|
||||
}
|
||||
|
||||
def resolve_underlying_call(tool_name: str, tool_args: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
|
||||
"""
|
||||
Unwrap tool_call bridge to get real tool name and args.
|
||||
|
||||
Args:
|
||||
tool_name: Should be "tool_call"
|
||||
tool_args: Dict with "tool_name" and "tool_args" keys
|
||||
|
||||
Returns:
|
||||
Tuple of (real_tool_name, real_args)
|
||||
"""
|
||||
if tool_name != "tool_call":
|
||||
# Not a bridge call, return as-is
|
||||
return tool_name, tool_args
|
||||
|
||||
# Validate tool_args is a dict
|
||||
if not isinstance(tool_args, dict):
|
||||
raise TypeError(
|
||||
f"tool_call expects a dictionary for tool_args, got {type(tool_args).__name__}. "
|
||||
"Ensure the LLM output is properly formatted."
|
||||
)
|
||||
|
||||
# Extract real tool call from bridge args
|
||||
real_tool_name = tool_args.get("tool_name", "")
|
||||
real_args = tool_args.get("tool_args", {})
|
||||
|
||||
if not real_tool_name:
|
||||
raise ValueError("tool_call requires 'tool_name' parameter")
|
||||
|
||||
return real_tool_name, real_args
|
||||
|
||||
def scoped_deferrable_names(agent_tool_list: List[str], deferrable_tools: ToolDefList) -> Set[str]:
|
||||
"""
|
||||
Get names of deferrable tools that are in the agent's enabled tool list.
|
||||
Implements session scoping (invariant #5).
|
||||
|
||||
Args:
|
||||
agent_tool_list: List of tool names available to this agent
|
||||
deferrable_tools: Full list of deferrable tool schemas
|
||||
|
||||
Returns:
|
||||
Set of tool names that are both deferrable and enabled for this agent
|
||||
"""
|
||||
agent_tool_set = set(agent_tool_list)
|
||||
deferrable_names = set()
|
||||
|
||||
for tool_def in deferrable_tools:
|
||||
function_def = tool_def.get("function", {})
|
||||
tool_name = function_def.get("name", "")
|
||||
if tool_name and tool_name in agent_tool_set:
|
||||
deferrable_names.add(tool_name)
|
||||
|
||||
return deferrable_names
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.48"
|
||||
version = "1.6.52"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -349,6 +349,20 @@ class TestDbSessionAdapter:
|
||||
assert meta["agent_id"] == "abc"
|
||||
assert meta["total_input_tokens"] == 42
|
||||
|
||||
def test_update_session_metadata_accepts_keyword_fields(self):
|
||||
"""update_session_metadata matches DefaultSessionStore **fields API."""
|
||||
from praisonai.integrations.db_session_adapter import DbSessionAdapter
|
||||
|
||||
mock_db = MagicMock()
|
||||
adapter = DbSessionAdapter(mock_db)
|
||||
adapter.update_session_metadata(
|
||||
"sess1", model="gpt-4o-mini", total_tokens=99, cost=0.01
|
||||
)
|
||||
meta = adapter.get_metadata("sess1")
|
||||
assert meta["model"] == "gpt-4o-mini"
|
||||
assert meta["total_tokens"] == 99
|
||||
assert meta["cost"] == 0.01
|
||||
|
||||
def test_clear_session_purges_db_history(self):
|
||||
"""clear_session must not leave stale messages reloadable from the DB."""
|
||||
from praisonai.integrations.db_session_adapter import DbSessionAdapter
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _chat_mixin_path() -> Path:
|
||||
for candidate in Path(__file__).resolve().parents:
|
||||
chat_mixin = candidate / "praisonaiagents" / "agent" / "chat_mixin.py"
|
||||
if chat_mixin.exists():
|
||||
return chat_mixin
|
||||
raise FileNotFoundError("Could not locate praisonaiagents/agent/chat_mixin.py")
|
||||
|
||||
|
||||
def get_chat_mixin_stream_parameter_default(function_name: str):
|
||||
chat_mixin = _chat_mixin_path()
|
||||
tree = ast.parse(chat_mixin.read_text(encoding="utf-8"))
|
||||
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.ClassDef) and node.name == "ChatMixin":
|
||||
for item in node.body:
|
||||
if isinstance(item, ast.FunctionDef) and item.name == function_name:
|
||||
args = item.args.args
|
||||
defaults = item.args.defaults
|
||||
first_default_index = len(args) - len(defaults)
|
||||
for idx, arg in enumerate(args):
|
||||
if arg.arg == "stream":
|
||||
default_idx = idx - first_default_index
|
||||
if default_idx < 0:
|
||||
raise AssertionError(
|
||||
f"Function '{function_name}' has no default for stream"
|
||||
)
|
||||
default = defaults[default_idx]
|
||||
if isinstance(default, ast.Constant):
|
||||
return default.value
|
||||
raise AssertionError(
|
||||
f"Function '{function_name}' stream default is non-constant"
|
||||
)
|
||||
raise AssertionError(f"Function '{function_name}' or stream parameter not found")
|
||||
|
||||
|
||||
def test_chat_completion_stream_default_is_none():
|
||||
assert get_chat_mixin_stream_parameter_default("_chat_completion") is None
|
||||
|
||||
|
||||
def test_execute_unified_chat_completion_stream_default_is_none():
|
||||
assert (
|
||||
get_chat_mixin_stream_parameter_default("_execute_unified_chat_completion")
|
||||
is None
|
||||
)
|
||||
@@ -117,6 +117,44 @@ class TestHierarchicalSessionStore:
|
||||
assert len(history) == 2
|
||||
assert history[1]["content"] == "second"
|
||||
|
||||
def test_get_extended_session_sees_writes_from_other_store(self):
|
||||
"""Extended reads must reload from disk, not stale _extended_cache."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
writer = HierarchicalSessionStore(session_dir=tmpdir)
|
||||
reader = HierarchicalSessionStore(session_dir=tmpdir)
|
||||
|
||||
writer.add_user_message("session-1", "first")
|
||||
reader._load_extended_session("session-1")
|
||||
writer.add_user_message("session-1", "second")
|
||||
|
||||
session = reader.get_extended_session("session-1")
|
||||
assert len(session.messages) == 2
|
||||
assert session.messages[1].content == "second"
|
||||
|
||||
def test_stale_cache_write_preserves_concurrent_updates(self):
|
||||
"""Metadata writes must not truncate messages written by other processes."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
writer = HierarchicalSessionStore(session_dir=tmpdir)
|
||||
reader = HierarchicalSessionStore(session_dir=tmpdir)
|
||||
|
||||
# Create session and warm reader's cache
|
||||
writer.create_session("session-1", title="Original")
|
||||
reader.get_extended_session("session-1") # Warms cache
|
||||
|
||||
# Writer adds messages, reader has stale cache
|
||||
writer.add_user_message("session-1", "first message")
|
||||
writer.add_assistant_message("session-1", "first response")
|
||||
|
||||
# Reader performs metadata-only write with stale cache
|
||||
reader.set_title("session-1", "Updated Title")
|
||||
|
||||
# Verify messages are preserved
|
||||
session = writer.get_extended_session("session-1")
|
||||
assert session.title == "Updated Title"
|
||||
assert len(session.messages) == 2
|
||||
assert session.messages[0].content == "first message"
|
||||
assert session.messages[1].content == "first response"
|
||||
|
||||
def test_update_session_metadata_preserves_extended_fields(self):
|
||||
"""Metadata updates must not strip parent_id, snapshots, etc."""
|
||||
session_id = self.store.create_session(title="Parent")
|
||||
@@ -324,6 +362,94 @@ class TestHierarchicalSessionStore:
|
||||
new_id = self.store.import_session(exported, new_session_id=custom_id)
|
||||
|
||||
assert new_id == custom_id
|
||||
|
||||
def test_set_title_does_not_drop_messages_after_external_write(self):
|
||||
"""
|
||||
Regression test for the stale cache bug.
|
||||
|
||||
Reproduces the scenario where:
|
||||
1. Process A loads session (warms cache)
|
||||
2. Process B writes new messages
|
||||
3. Process A calls set_title() → should NOT drop Process B's messages
|
||||
"""
|
||||
import json
|
||||
|
||||
# Create session with initial messages
|
||||
session_id = self.store.create_session(title="Test Session")
|
||||
self.store.add_message(session_id, "user", "Message 1")
|
||||
self.store.add_message(session_id, "assistant", "Response 1")
|
||||
|
||||
# Process A: Load session (warms cache)
|
||||
session_a = self.store.get_extended_session(session_id)
|
||||
assert len(session_a.messages) == 2
|
||||
|
||||
# Process B: Simulate external write by directly modifying file
|
||||
# This mimics another process/store instance writing to the same session
|
||||
filepath = self.store._get_session_path(session_id)
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Add messages from "Process B"
|
||||
data["messages"].extend([
|
||||
{"role": "user", "content": "Message 2", "timestamp": time.time(), "metadata": {}},
|
||||
{"role": "assistant", "content": "Response 2", "timestamp": time.time(), "metadata": {}}
|
||||
])
|
||||
data["updated_at"] = time.time()
|
||||
|
||||
# Write the updated data (simulating external process write)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
# Brief sleep to ensure file mtime is different
|
||||
time.sleep(0.1)
|
||||
|
||||
# Process A: Call set_title() - this should detect the external write
|
||||
# and reload fresh data instead of using stale cache
|
||||
result = self.store.set_title(session_id, "Updated Title")
|
||||
assert result is True
|
||||
|
||||
# Verify no messages were lost - should have all 4 messages
|
||||
final_session = self.store.get_extended_session(session_id)
|
||||
assert len(final_session.messages) == 4, f"Expected 4 messages, got {len(final_session.messages)}"
|
||||
assert final_session.title == "Updated Title"
|
||||
assert final_session.messages[0].content == "Message 1"
|
||||
assert final_session.messages[1].content == "Response 1"
|
||||
assert final_session.messages[2].content == "Message 2"
|
||||
assert final_session.messages[3].content == "Response 2"
|
||||
|
||||
def test_cache_performance_with_unchanged_files(self):
|
||||
"""
|
||||
Test that performance optimization works - reads from cache when file hasn't changed.
|
||||
"""
|
||||
session_id = self.store.create_session(title="Cache Test")
|
||||
self.store.add_message(session_id, "user", "Test message")
|
||||
|
||||
# First read - loads from disk and caches
|
||||
session1 = self.store.get_extended_session(session_id)
|
||||
assert len(session1.messages) == 1
|
||||
|
||||
# Second read should use cache (file hasn't changed)
|
||||
# We can't easily test this directly, but we can verify the cache is valid
|
||||
assert self.store._is_cache_valid(session_id) is True
|
||||
|
||||
session2 = self.store.get_extended_session(session_id)
|
||||
assert len(session2.messages) == 1
|
||||
assert session2 is session1 # Should be same cached object
|
||||
|
||||
def test_force_reload_bypasses_cache(self):
|
||||
"""Test that force_reload=True always loads from disk."""
|
||||
session_id = self.store.create_session(title="Force Reload Test")
|
||||
self.store.add_message(session_id, "user", "Message 1")
|
||||
|
||||
# Load and cache
|
||||
session1 = self.store._load_extended_session(session_id, force_reload=False)
|
||||
|
||||
# Force reload should bypass cache
|
||||
session2 = self.store._load_extended_session(session_id, force_reload=True)
|
||||
|
||||
# Both should have same data but force_reload ensures fresh read
|
||||
assert len(session1.messages) == len(session2.messages)
|
||||
assert session1.session_id == session2.session_id
|
||||
|
||||
|
||||
class TestGlobalHierarchicalStore:
|
||||
|
||||
@@ -9,8 +9,11 @@ import os
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import builtins
|
||||
import importlib
|
||||
import pytest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from unittest.mock import patch
|
||||
|
||||
from praisonaiagents.session.store import (
|
||||
DefaultSessionStore,
|
||||
@@ -160,6 +163,26 @@ class TestFileLock:
|
||||
if start_idx < other_start < end_idx:
|
||||
pytest.fail(f"Lock interleaving detected: {results}")
|
||||
|
||||
def test_import_without_fcntl(self):
|
||||
"""Test module import succeeds when fcntl is unavailable."""
|
||||
import praisonaiagents.session.store as store_module
|
||||
import importlib.util
|
||||
original_import = builtins.__import__
|
||||
|
||||
def import_without_fcntl(name, *args, **kwargs):
|
||||
if name == "fcntl":
|
||||
raise ImportError("fcntl unavailable")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
try:
|
||||
with patch("builtins.__import__", side_effect=import_without_fcntl):
|
||||
reloaded_module = importlib.reload(store_module)
|
||||
assert reloaded_module._HAS_FCNTL is False
|
||||
finally:
|
||||
restored_module = importlib.reload(store_module)
|
||||
|
||||
assert restored_module._HAS_FCNTL is (importlib.util.find_spec("fcntl") is not None)
|
||||
|
||||
|
||||
class TestDefaultSessionStore:
|
||||
"""Tests for DefaultSessionStore."""
|
||||
@@ -327,6 +350,21 @@ class TestDefaultSessionStore:
|
||||
assert session.agent_name == "TestBot"
|
||||
assert session.user_id == "u-1"
|
||||
|
||||
def test_set_chat_history_replaces_under_lock(self, temp_store):
|
||||
"""History replace is a single locked write (not clear + N adds)."""
|
||||
temp_store.add_user_message("session-1", "old")
|
||||
assert temp_store.set_chat_history(
|
||||
"session-1",
|
||||
[
|
||||
{"role": "user", "content": "new"},
|
||||
{"role": "assistant", "content": "reply"},
|
||||
],
|
||||
)
|
||||
assert temp_store.get_chat_history("session-1") == [
|
||||
{"role": "user", "content": "new"},
|
||||
{"role": "assistant", "content": "reply"},
|
||||
]
|
||||
|
||||
def test_get_chat_history_sees_writes_from_other_store(self, temp_store):
|
||||
"""Reads must reload from disk, not a stale in-memory cache."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
@@ -336,10 +374,10 @@ class TestDefaultSessionStore:
|
||||
writer.add_user_message("session-1", "first")
|
||||
reader._load_session("session-1")
|
||||
writer.add_user_message("session-1", "second")
|
||||
|
||||
history = reader.get_chat_history("session-1")
|
||||
assert len(history) == 2
|
||||
assert history[1]["content"] == "second"
|
||||
assert history[1]["content"] == "second"
|
||||
|
||||
def test_clear_session_preserves_new_messages(self, temp_store):
|
||||
"""Clear must reload from disk so concurrent adds are not lost."""
|
||||
@@ -421,12 +459,26 @@ class TestDefaultSessionStore:
|
||||
|
||||
# Reads always reload from disk (no stale cache)
|
||||
history = temp_store.get_chat_history("session-1")
|
||||
assert len(history) == 2 # Fresh from disk
|
||||
|
||||
# invalidate_cache still works but is now essentially a no-op for reads
|
||||
assert len(history) == 2
|
||||
|
||||
# invalidate_cache still clears in-memory state when a file is missing
|
||||
temp_store.invalidate_cache("session-1")
|
||||
history = temp_store.get_chat_history("session-1")
|
||||
assert len(history) == 2
|
||||
|
||||
def test_get_chat_history_sees_other_store_instance(self):
|
||||
"""Another store on the same session_dir must see new messages without invalidate_cache."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
writer = DefaultSessionStore(session_dir=tmpdir)
|
||||
reader = DefaultSessionStore(session_dir=tmpdir)
|
||||
|
||||
writer.add_user_message("session-1", "first")
|
||||
reader._load_session("session-1")
|
||||
writer.add_user_message("session-1", "second")
|
||||
|
||||
history = reader.get_chat_history("session-1")
|
||||
assert len(history) == 2
|
||||
assert history[1]["content"] == "second"
|
||||
|
||||
def test_list_sessions_with_none_updated_at(self, temp_store):
|
||||
"""Test list_sessions handles None updated_at values without crashing.
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
Unit tests for MCP loader functionality.
|
||||
|
||||
Tests the smoke functionality for the new MCP client lifecycle
|
||||
features implemented in this issue.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, MagicMock
|
||||
from praisonaiagents.memory.mcp_config import MCPConfig
|
||||
|
||||
|
||||
def test_mcp_config_to_mcp_instance_import_fix():
|
||||
"""Test that MCPConfig.to_mcp_instance() imports MCP correctly (A1)."""
|
||||
# Create a minimal config
|
||||
config = MCPConfig(
|
||||
name="test",
|
||||
command="echo",
|
||||
args=["hello"],
|
||||
enabled=True
|
||||
)
|
||||
|
||||
# This should not raise ImportError anymore
|
||||
# Note: It might return None if MCP package is not installed, but the import should work
|
||||
try:
|
||||
result = config.to_mcp_instance()
|
||||
# If we get here without ImportError from wrong path, the fix worked
|
||||
assert True
|
||||
except ImportError as e:
|
||||
if "praisonaiagents.tools.mcp" in str(e):
|
||||
pytest.fail("Import still using wrong path - A1 fix failed")
|
||||
else:
|
||||
# This is expected if mcp package is not installed
|
||||
assert "praisonaiagents[mcp]" in str(e)
|
||||
|
||||
|
||||
def test_load_mcp_tools_import():
|
||||
"""Test that load_mcp_tools can be imported (B2)."""
|
||||
from praisonaiagents.mcp import load_mcp_tools
|
||||
assert callable(load_mcp_tools)
|
||||
|
||||
|
||||
def test_mcp_client_protocol_import():
|
||||
"""Test that MCPClientProtocol can be imported (B1)."""
|
||||
from praisonaiagents.mcp import MCPClientProtocol
|
||||
# Should be a protocol/type
|
||||
assert MCPClientProtocol is not None
|
||||
|
||||
|
||||
def test_mcp_filter_parameters():
|
||||
"""Test that MCP class accepts filter parameters (B3)."""
|
||||
try:
|
||||
from praisonaiagents.mcp import MCP
|
||||
# This should not raise TypeError for unknown parameters
|
||||
# Note: May raise ImportError if mcp package not installed
|
||||
mcp = MCP("echo hello", allowed_tools=["test"], disabled_tools=["bad"])
|
||||
assert mcp.allowed_tools == ["test"]
|
||||
assert mcp.disabled_tools == ["bad"]
|
||||
except ImportError as e:
|
||||
if "praisonaiagents[mcp]" in str(e):
|
||||
# Expected if MCP package not installed
|
||||
pytest.skip("MCP package not installed")
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
def test_filter_tool_functions_exist():
|
||||
"""Test that filter functions exist and work (B3)."""
|
||||
from praisonaiagents.mcp.mcp_utils import filter_tools_by_allowlist, filter_disabled_tools
|
||||
|
||||
tools = [
|
||||
{"name": "tool1", "description": "First tool"},
|
||||
{"name": "tool2", "description": "Second tool"},
|
||||
{"name": "tool3", "description": "Third tool"},
|
||||
]
|
||||
|
||||
# Test allowlist filtering
|
||||
filtered = filter_tools_by_allowlist(tools, ["tool1", "tool3"])
|
||||
assert len(filtered) == 2
|
||||
assert filtered[0]["name"] == "tool1"
|
||||
assert filtered[1]["name"] == "tool3"
|
||||
|
||||
# Test disabled filtering
|
||||
filtered = filter_disabled_tools(tools, ["tool2"])
|
||||
assert len(filtered) == 2
|
||||
assert all(t["name"] != "tool2" for t in filtered)
|
||||
|
||||
# Test precedence: include wins over exclude
|
||||
# This is handled at MCP class level, but filters should work independently
|
||||
|
||||
|
||||
def test_prefix_collision_handling():
|
||||
"""Test tool name prefix handling (B4)."""
|
||||
# The prefix logic is in the loader
|
||||
from praisonaiagents.mcp.loader import load_mcp_tools
|
||||
|
||||
# Test that sanitization logic exists (basic check)
|
||||
# Full test would require mock MCP configs
|
||||
assert callable(load_mcp_tools)
|
||||
|
||||
|
||||
def test_safe_env_build():
|
||||
"""Test safe environment building (B5)."""
|
||||
try:
|
||||
from praisonaiagents.mcp import MCP
|
||||
|
||||
# Create MCP instance to test env building
|
||||
# This tests that _build_safe_env method exists
|
||||
mcp = MCP("echo test")
|
||||
assert hasattr(mcp, '_build_safe_env')
|
||||
|
||||
# Test safe env function
|
||||
safe_env = mcp._build_safe_env({"CUSTOM_VAR": "test"})
|
||||
assert isinstance(safe_env, dict)
|
||||
assert "CUSTOM_VAR" in safe_env
|
||||
assert safe_env["CUSTOM_VAR"] == "test"
|
||||
# Should have safe baseline
|
||||
assert "PATH" in safe_env
|
||||
assert "HOME" in safe_env
|
||||
|
||||
except ImportError as e:
|
||||
if "praisonaiagents[mcp]" in str(e):
|
||||
pytest.skip("MCP package not installed")
|
||||
else:
|
||||
raise
|
||||
@@ -0,0 +1,394 @@
|
||||
"""
|
||||
Unit tests for Tool Search functionality.
|
||||
|
||||
Tests core tool search components including:
|
||||
- ToolSearchConfig
|
||||
- Tool classification
|
||||
- BM25 search
|
||||
- Bridge tool schemas
|
||||
- Assembly logic
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from praisonaiagents.tools.tool_search import (
|
||||
ToolSearchConfig, classify_tools, search_catalog, bridge_tool_schemas,
|
||||
assemble_tool_defs, dispatch_tool_search, dispatch_tool_describe,
|
||||
resolve_underlying_call, PRAISONAI_CORE_TOOLS,
|
||||
estimate_tool_schema_tokens, should_defer_tools
|
||||
)
|
||||
|
||||
class TestToolSearchConfig:
|
||||
"""Test ToolSearchConfig creation and validation."""
|
||||
|
||||
def test_default_config(self):
|
||||
"""Test default configuration."""
|
||||
config = ToolSearchConfig()
|
||||
assert config.enabled == "auto"
|
||||
assert config.threshold_pct == 10.0
|
||||
assert config.search_default_limit == 5
|
||||
assert config.max_search_limit == 20
|
||||
assert config.core_tools is None
|
||||
|
||||
def test_from_raw_bool(self):
|
||||
"""Test creating config from boolean values."""
|
||||
config_true = ToolSearchConfig.from_raw(True)
|
||||
assert config_true.enabled == "on"
|
||||
|
||||
config_false = ToolSearchConfig.from_raw(False)
|
||||
assert config_false.enabled == "off"
|
||||
|
||||
def test_from_raw_string(self):
|
||||
"""Test creating config from string values."""
|
||||
config_auto = ToolSearchConfig.from_raw("auto")
|
||||
assert config_auto.enabled == "auto"
|
||||
|
||||
config_on = ToolSearchConfig.from_raw("on")
|
||||
assert config_on.enabled == "on"
|
||||
|
||||
config_off = ToolSearchConfig.from_raw("off")
|
||||
assert config_off.enabled == "off"
|
||||
|
||||
def test_from_raw_dict(self):
|
||||
"""Test creating config from dictionary."""
|
||||
config = ToolSearchConfig.from_raw({
|
||||
"enabled": "on",
|
||||
"threshold_pct": 15.0,
|
||||
"search_default_limit": 10
|
||||
})
|
||||
assert config.enabled == "on"
|
||||
assert config.threshold_pct == 15.0
|
||||
assert config.search_default_limit == 10
|
||||
|
||||
def test_from_raw_invalid(self):
|
||||
"""Test error handling for invalid inputs."""
|
||||
with pytest.raises(ValueError):
|
||||
ToolSearchConfig.from_raw("invalid")
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
ToolSearchConfig.from_raw(123)
|
||||
|
||||
class TestToolClassification:
|
||||
"""Test tool classification into core vs deferrable."""
|
||||
|
||||
def test_core_tool_classification(self):
|
||||
"""Test that core tools are never deferred."""
|
||||
tool_defs = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {"name": "read_file", "description": "Read a file"}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {"name": "search_web", "description": "Search the web"}
|
||||
}
|
||||
]
|
||||
|
||||
config = ToolSearchConfig()
|
||||
core_tools, deferrable_tools = classify_tools(tool_defs, config)
|
||||
|
||||
assert len(core_tools) == 2
|
||||
assert len(deferrable_tools) == 0
|
||||
assert core_tools == tool_defs
|
||||
|
||||
def test_mcp_tool_classification(self):
|
||||
"""Test that MCP tools are marked as deferrable."""
|
||||
tool_defs = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {"name": "mcp_weather", "description": "Get weather"},
|
||||
"__praisonai_deferrable__": True
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {"name": "read_file", "description": "Read a file"}
|
||||
}
|
||||
]
|
||||
|
||||
config = ToolSearchConfig()
|
||||
core_tools, deferrable_tools = classify_tools(tool_defs, config)
|
||||
|
||||
assert len(core_tools) == 1
|
||||
assert len(deferrable_tools) == 1
|
||||
assert core_tools[0]["function"]["name"] == "read_file"
|
||||
assert deferrable_tools[0]["function"]["name"] == "mcp_weather"
|
||||
|
||||
def test_unknown_tool_stays_visible(self):
|
||||
"""Test that unknown tools stay visible (design invariant #2)."""
|
||||
tool_defs = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {"name": "unknown_tool", "description": "Unknown tool"}
|
||||
}
|
||||
]
|
||||
|
||||
config = ToolSearchConfig()
|
||||
core_tools, deferrable_tools = classify_tools(tool_defs, config)
|
||||
|
||||
# Unknown tools should stay in core (visible)
|
||||
assert len(core_tools) == 1
|
||||
assert len(deferrable_tools) == 0
|
||||
assert core_tools[0]["function"]["name"] == "unknown_tool"
|
||||
|
||||
class TestBM25Search:
|
||||
"""Test BM25 search functionality."""
|
||||
|
||||
def test_empty_search(self):
|
||||
"""Test search with empty inputs."""
|
||||
result = search_catalog([], "test query")
|
||||
assert result == []
|
||||
|
||||
result = search_catalog([{"type": "function", "function": {"name": "test"}}], "")
|
||||
assert result == []
|
||||
|
||||
def test_simple_search(self):
|
||||
"""Test basic search functionality."""
|
||||
deferrable_tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "weather_tool",
|
||||
"description": "Get current weather information"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "stock_tool",
|
||||
"description": "Get stock market data"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
results = search_catalog(deferrable_tools, "weather", limit=10)
|
||||
assert len(results) >= 1
|
||||
assert any("weather_tool" in r["name"] for r in results)
|
||||
|
||||
results = search_catalog(deferrable_tools, "stock", limit=10)
|
||||
assert len(results) >= 1
|
||||
assert any("stock_tool" in r["name"] for r in results)
|
||||
|
||||
class TestBridgeTools:
|
||||
"""Test bridge tool schemas and assembly."""
|
||||
|
||||
def test_bridge_schemas(self):
|
||||
"""Test bridge tool schema generation."""
|
||||
schemas = bridge_tool_schemas()
|
||||
assert len(schemas) == 3
|
||||
|
||||
tool_names = {schema["function"]["name"] for schema in schemas}
|
||||
assert tool_names == {"tool_search", "tool_describe", "tool_call"}
|
||||
|
||||
# Validate schema structure
|
||||
for schema in schemas:
|
||||
assert schema["type"] == "function"
|
||||
assert "function" in schema
|
||||
assert "name" in schema["function"]
|
||||
assert "description" in schema["function"]
|
||||
assert "parameters" in schema["function"]
|
||||
|
||||
def test_assemble_disabled(self):
|
||||
"""Test assembly when tool search is disabled."""
|
||||
tool_defs = [
|
||||
{"type": "function", "function": {"name": "test_tool", "description": "Test"}}
|
||||
]
|
||||
|
||||
config = ToolSearchConfig(enabled="off")
|
||||
assembled, metadata = assemble_tool_defs(tool_defs, config)
|
||||
|
||||
assert assembled == tool_defs
|
||||
assert metadata["bridge_mode"] is False
|
||||
assert metadata["deferred_count"] == 0
|
||||
|
||||
def test_assemble_no_deferrable_tools(self):
|
||||
"""Test assembly when no tools are deferrable."""
|
||||
tool_defs = [
|
||||
{"type": "function", "function": {"name": "read_file", "description": "Read file"}}
|
||||
]
|
||||
|
||||
config = ToolSearchConfig(enabled="on")
|
||||
assembled, metadata = assemble_tool_defs(tool_defs, config)
|
||||
|
||||
# Should return original tools since nothing to defer
|
||||
assert assembled == tool_defs
|
||||
assert metadata["bridge_mode"] is False
|
||||
|
||||
def test_assemble_with_deferrable_tools(self):
|
||||
"""Test assembly with deferrable tools."""
|
||||
tool_defs = [
|
||||
{"type": "function", "function": {"name": "read_file", "description": "Read file"}},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {"name": "mcp_weather", "description": "Get weather"},
|
||||
"__praisonai_deferrable__": True
|
||||
}
|
||||
]
|
||||
|
||||
config = ToolSearchConfig(enabled="on")
|
||||
assembled, metadata = assemble_tool_defs(tool_defs, config)
|
||||
|
||||
# Should have core tool + 3 bridge tools
|
||||
assert len(assembled) == 4
|
||||
assert metadata["bridge_mode"] is True
|
||||
assert metadata["deferred_count"] == 1
|
||||
assert len(metadata["catalog"]) == 1
|
||||
|
||||
# Verify core tool remains
|
||||
core_tool_names = {t["function"]["name"] for t in assembled if t["function"]["name"] != "tool_search" and t["function"]["name"] != "tool_describe" and t["function"]["name"] != "tool_call"}
|
||||
assert "read_file" in core_tool_names
|
||||
|
||||
class TestBridgeDispatchers:
|
||||
"""Test bridge tool dispatch handlers."""
|
||||
|
||||
def test_tool_search_dispatch(self):
|
||||
"""Test tool_search bridge dispatch."""
|
||||
deferrable_tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "weather_tool",
|
||||
"description": "Get weather information"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
config = ToolSearchConfig()
|
||||
result = dispatch_tool_search("weather", 5, deferrable_tools, config)
|
||||
|
||||
assert "query" in result
|
||||
assert "results" in result
|
||||
assert "total_available" in result
|
||||
assert result["query"] == "weather"
|
||||
assert result["total_available"] == 1
|
||||
|
||||
def test_tool_describe_dispatch(self):
|
||||
"""Test tool_describe bridge dispatch."""
|
||||
deferrable_tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "weather_tool",
|
||||
"description": "Get weather information"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
result = dispatch_tool_describe("weather_tool", deferrable_tools)
|
||||
|
||||
assert "tool_name" in result
|
||||
assert "found" in result
|
||||
assert result["found"] is True
|
||||
assert result["tool_name"] == "weather_tool"
|
||||
assert "schema" in result
|
||||
|
||||
def test_tool_describe_not_found(self):
|
||||
"""Test tool_describe for non-existent tool."""
|
||||
result = dispatch_tool_describe("nonexistent_tool", [])
|
||||
|
||||
assert "tool_name" in result
|
||||
assert "found" in result
|
||||
assert result["found"] is False
|
||||
assert "error" in result
|
||||
|
||||
class TestToolCallUnwrapping:
|
||||
"""Test tool_call bridge unwrapping."""
|
||||
|
||||
def test_resolve_underlying_call(self):
|
||||
"""Test unwrapping tool_call bridge."""
|
||||
# Normal tool call - should pass through
|
||||
name, args = resolve_underlying_call("normal_tool", {"arg1": "value1"})
|
||||
assert name == "normal_tool"
|
||||
assert args == {"arg1": "value1"}
|
||||
|
||||
# Bridge tool call - should unwrap
|
||||
bridge_args = {
|
||||
"tool_name": "real_tool",
|
||||
"tool_args": {"param1": "test"}
|
||||
}
|
||||
name, args = resolve_underlying_call("tool_call", bridge_args)
|
||||
assert name == "real_tool"
|
||||
assert args == {"param1": "test"}
|
||||
|
||||
def test_resolve_missing_tool_name(self):
|
||||
"""Test error handling for missing tool_name."""
|
||||
with pytest.raises(ValueError, match="tool_call requires 'tool_name' parameter"):
|
||||
resolve_underlying_call("tool_call", {"tool_args": {}})
|
||||
|
||||
class TestTokenEstimation:
|
||||
"""Test token estimation and threshold logic."""
|
||||
|
||||
def test_estimate_tool_schema_tokens(self):
|
||||
"""Test token estimation for tool schemas."""
|
||||
tool_defs = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "test_tool",
|
||||
"description": "A test tool with parameters",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"param1": {"type": "string"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
tokens = estimate_tool_schema_tokens(tool_defs)
|
||||
assert tokens > 0
|
||||
assert isinstance(tokens, int)
|
||||
|
||||
def test_should_defer_tools(self):
|
||||
"""Test deferral threshold logic."""
|
||||
# Create some dummy deferrable tools
|
||||
deferrable_tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {"name": f"tool_{i}", "description": "Test tool"}
|
||||
}
|
||||
for i in range(10)
|
||||
]
|
||||
|
||||
# Test with disabled config
|
||||
config_off = ToolSearchConfig(enabled="off")
|
||||
assert should_defer_tools(deferrable_tools, config_off, 20000) is False
|
||||
|
||||
# Test with on config
|
||||
config_on = ToolSearchConfig(enabled="on")
|
||||
assert should_defer_tools(deferrable_tools, config_on, 20000) is True
|
||||
|
||||
# Test auto mode with high threshold (shouldn't defer)
|
||||
config_auto_high = ToolSearchConfig(enabled="auto", threshold_pct=90)
|
||||
assert should_defer_tools(deferrable_tools, config_auto_high, 20000) is False
|
||||
|
||||
# Test auto mode with low threshold (should defer)
|
||||
config_auto_low = ToolSearchConfig(enabled="auto", threshold_pct=1)
|
||||
assert should_defer_tools(deferrable_tools, config_auto_low, 20000) is True
|
||||
|
||||
class TestCoreToolsInvariant:
|
||||
"""Test that core tools never defer."""
|
||||
|
||||
def test_core_tools_constant(self):
|
||||
"""Test that PRAISONAI_CORE_TOOLS is properly defined."""
|
||||
assert isinstance(PRAISONAI_CORE_TOOLS, frozenset)
|
||||
assert len(PRAISONAI_CORE_TOOLS) > 0
|
||||
|
||||
# Check some expected core tools
|
||||
expected_core = {"read_file", "execute_command", "search_web", "clarify"}
|
||||
assert expected_core.issubset(PRAISONAI_CORE_TOOLS)
|
||||
|
||||
def test_core_tools_never_deferred(self):
|
||||
"""Test that core tools are never put in deferrable category."""
|
||||
# Create tools that are marked as deferrable but are actually core tools
|
||||
tool_defs = []
|
||||
for tool_name in list(PRAISONAI_CORE_TOOLS)[:5]: # Test first 5 core tools
|
||||
tool_defs.append({
|
||||
"type": "function",
|
||||
"function": {"name": tool_name, "description": f"Test {tool_name}"},
|
||||
"__praisonai_deferrable__": True # Try to mark as deferrable
|
||||
})
|
||||
|
||||
config = ToolSearchConfig()
|
||||
core_tools, deferrable_tools = classify_tools(tool_defs, config)
|
||||
|
||||
# All should end up in core tools despite the deferrable marker
|
||||
assert len(core_tools) == 5
|
||||
assert len(deferrable_tools) == 0
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple test script to verify tool search implementation.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
def test_tool_search_basic():
|
||||
"""Test basic tool search functionality."""
|
||||
print("Testing Tool Search implementation...")
|
||||
|
||||
try:
|
||||
# Test config creation
|
||||
from praisonaiagents.tools.tool_search import ToolSearchConfig
|
||||
config = ToolSearchConfig()
|
||||
print(f"✓ ToolSearchConfig created: enabled={config.enabled}, threshold={config.threshold_pct}%")
|
||||
|
||||
# Test config from raw values
|
||||
config_true = ToolSearchConfig.from_raw(True)
|
||||
config_false = ToolSearchConfig.from_raw(False)
|
||||
print(f"✓ Config from bool: True→{config_true.enabled}, False→{config_false.enabled}")
|
||||
|
||||
# Test tool classification
|
||||
from praisonaiagents.tools.tool_search import PRAISONAI_CORE_TOOLS
|
||||
print(f"✓ Core tools defined: {len(PRAISONAI_CORE_TOOLS)} tools")
|
||||
print(f" Sample core tools: {list(PRAISONAI_CORE_TOOLS)[:5]}")
|
||||
|
||||
# Test bridge tools
|
||||
from praisonaiagents.tools.tool_search import bridge_tool_schemas
|
||||
bridge_schemas = bridge_tool_schemas()
|
||||
bridge_names = [schema["function"]["name"] for schema in bridge_schemas]
|
||||
print(f"✓ Bridge tools created: {bridge_names}")
|
||||
|
||||
# Test assembly logic
|
||||
from praisonaiagents.tools.tool_search import assemble_tool_defs
|
||||
|
||||
# Test with disabled config
|
||||
tool_defs = [
|
||||
{"type": "function", "function": {"name": "test_tool", "description": "Test"}}
|
||||
]
|
||||
assembled, metadata = assemble_tool_defs(tool_defs, ToolSearchConfig(enabled="off"))
|
||||
print(f"✓ Disabled mode: bridge={metadata['bridge_mode']}, deferred={metadata['deferred_count']}")
|
||||
|
||||
# Test with enabled but no deferrable tools
|
||||
assembled, metadata = assemble_tool_defs(tool_defs, ToolSearchConfig(enabled="on"))
|
||||
print(f"✓ No deferrable tools: bridge={metadata['bridge_mode']}, deferred={metadata['deferred_count']}")
|
||||
|
||||
# Test with deferrable tools
|
||||
deferrable_tool_defs = [
|
||||
{"type": "function", "function": {"name": "read_file", "description": "Read file"}},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {"name": "mcp_weather", "description": "Get weather"},
|
||||
"__praisonai_deferrable__": True
|
||||
}
|
||||
]
|
||||
assembled, metadata = assemble_tool_defs(deferrable_tool_defs, ToolSearchConfig(enabled="on"))
|
||||
print(f"✓ With deferrable tools: bridge={metadata['bridge_mode']}, deferred={metadata['deferred_count']}")
|
||||
print(f" Assembled tools: {len(assembled)}, catalog: {len(metadata['catalog'])}")
|
||||
|
||||
# Test search functionality
|
||||
from praisonaiagents.tools.tool_search import search_catalog
|
||||
search_results = search_catalog(metadata["deferrable_tools"], "weather", limit=5)
|
||||
print(f"✓ Search 'weather': {len(search_results)} results")
|
||||
if search_results:
|
||||
print(f" First result: {search_results[0]}")
|
||||
|
||||
print("\n🎉 All basic tests passed! Tool Search implementation is working.")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Test failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
def test_agent_integration():
|
||||
"""Test Agent class integration."""
|
||||
print("\nTesting Agent integration...")
|
||||
|
||||
try:
|
||||
# Test config classes are exported
|
||||
from praisonaiagents.config import ToolSearchConfig
|
||||
print("✓ ToolSearchConfig available in config module")
|
||||
|
||||
# Test Agent parameter (basic import check)
|
||||
from praisonaiagents.agent.agent import Agent
|
||||
print("✓ Agent class imports successfully")
|
||||
|
||||
print("✓ Agent integration looks good")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Agent integration test failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Tool Search Implementation Test")
|
||||
print("=" * 50)
|
||||
|
||||
success1 = test_tool_search_basic()
|
||||
success2 = test_agent_integration()
|
||||
|
||||
if success1 and success2:
|
||||
print("\n✅ All tests passed!")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("\n❌ Some tests failed!")
|
||||
sys.exit(1)
|
||||
Generated
+1
-1
@@ -3125,7 +3125,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.48"
|
||||
version = "1.6.52"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -23,15 +23,37 @@ from ..db.models import Member, User
|
||||
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
_DEFAULT_SECRET = "dev-secret-change-me"
|
||||
JWT_SECRET = os.environ.get("PLATFORM_JWT_SECRET", _DEFAULT_SECRET)
|
||||
JWT_SECRET = os.environ.get("PLATFORM_JWT_SECRET")
|
||||
JWT_ALGORITHM = "HS256"
|
||||
JWT_TTL_SECONDS = int(os.environ.get("PLATFORM_JWT_TTL", str(30 * 24 * 3600)))
|
||||
|
||||
if JWT_SECRET == _DEFAULT_SECRET and os.environ.get("PLATFORM_ENV", "dev") != "dev":
|
||||
raise RuntimeError(
|
||||
"PLATFORM_JWT_SECRET must be set to a strong random value in production. "
|
||||
"Set PLATFORM_ENV=dev to suppress this check during development."
|
||||
)
|
||||
if JWT_SECRET is None or JWT_SECRET == _DEFAULT_SECRET:
|
||||
# Determine if we are in an explicit dev environment.
|
||||
# Only trust PLATFORM_ENV when it is explicitly set — an unset value
|
||||
# must NOT silently fall back to "dev" because production deployments
|
||||
# that forget the variable would run with a well-known signing key.
|
||||
_explicit_env = os.environ.get("PLATFORM_ENV")
|
||||
if _explicit_env == "dev":
|
||||
# Developer explicitly opted in to insecure defaults — allow it.
|
||||
JWT_SECRET = _DEFAULT_SECRET
|
||||
else:
|
||||
# Either production or environment not specified.
|
||||
# Auto-generate an ephemeral secret so the service can start, but
|
||||
# warn loudly because tokens will not survive a restart.
|
||||
import secrets as _secrets
|
||||
import warnings as _warnings
|
||||
|
||||
JWT_SECRET = _secrets.token_urlsafe(32)
|
||||
_warnings.warn(
|
||||
"PLATFORM_JWT_SECRET is not set. Auto-generated an ephemeral "
|
||||
"signing key — JWT tokens will be invalidated on restart. "
|
||||
"Set PLATFORM_JWT_SECRET to a stable random value for production "
|
||||
"use, or set PLATFORM_ENV=dev to use the development default.",
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
# Type-narrow: at this point JWT_SECRET is always a non-None str.
|
||||
assert JWT_SECRET is not None # noqa: S105
|
||||
|
||||
|
||||
class AuthService:
|
||||
@@ -113,10 +135,6 @@ 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,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"version": "1.5.87",
|
||||
"lastUpdated": "2026-05-30",
|
||||
"lastUpdated": "2026-06-02",
|
||||
"generatedBy": "praisonai._dev.parity.generator",
|
||||
"sourceOfTruth": "Python SDK (praisonaiagents)",
|
||||
"status": "PARITY_ACHIEVED",
|
||||
"summary": {
|
||||
"pythonCoreFeatures": 335,
|
||||
"pythonCoreFeatures": 336,
|
||||
"rustFeatures": 667,
|
||||
"gapCount": 56,
|
||||
"parityPercentage": 199.1
|
||||
"gapCount": 57,
|
||||
"parityPercentage": 198.5
|
||||
},
|
||||
"pythonCoreSDK": {
|
||||
"exports": [
|
||||
@@ -245,6 +245,7 @@
|
||||
"ToolPluginProtocol",
|
||||
"ToolRegistry",
|
||||
"ToolResult",
|
||||
"ToolSearchConfig",
|
||||
"ToolUseEvent",
|
||||
"ToolValidationError",
|
||||
"Tools",
|
||||
@@ -4161,6 +4162,15 @@
|
||||
"status": "DONE",
|
||||
"category": "plugins"
|
||||
},
|
||||
{
|
||||
"feature": "ToolSearchConfig",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "config"
|
||||
},
|
||||
{
|
||||
"feature": "ToolUseEvent",
|
||||
"python": true,
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# Rust Feature Parity Tracker
|
||||
|
||||
> **Python Features:** 335 | **Rust Features:** 667 | **Parity:** 85.1%
|
||||
> **Python Features:** 336 | **Rust Features:** 667 | **Parity:** 84.8%
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Python Core Features | 335 |
|
||||
| Python Core Features | 336 |
|
||||
| Rust Features | 667 |
|
||||
| **Actual Gap Count** | **50** |
|
||||
| **Actual Gap Count** | **51** |
|
||||
| Language Limitations (N/A) | 5 |
|
||||
| **Parity** | **85.1%** |
|
||||
| **Parity** | **84.8%** |
|
||||
|
||||
## Implemented Features
|
||||
|
||||
@@ -735,6 +735,7 @@ These Python features cannot be directly implemented in Rust due to reserved key
|
||||
- ❌ `StopReason`
|
||||
- ❌ `StructuredFormatter`
|
||||
- ❌ `ToolExecutionError`
|
||||
- ❌ `ToolSearchConfig`
|
||||
- ❌ `ToolUseEvent`
|
||||
- ❌ `ToolValidationError`
|
||||
- ❌ `configure_structured_logging`
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"version": "1.5.87",
|
||||
"lastUpdated": "2026-05-30",
|
||||
"lastUpdated": "2026-06-02",
|
||||
"generatedBy": "praisonai._dev.parity.generator",
|
||||
"sourceOfTruth": "Python SDK (praisonaiagents)",
|
||||
"summary": {
|
||||
"pythonCoreFeatures": 335,
|
||||
"pythonCoreFeatures": 336,
|
||||
"pythonWrapperFeatures": 99,
|
||||
"typescriptFeatures": 1192,
|
||||
"gapCount": 43,
|
||||
"gapCount": 44,
|
||||
"priorityP0": 1,
|
||||
"priorityP1": 0,
|
||||
"priorityP2": 0,
|
||||
"priorityP3": 42
|
||||
"priorityP3": 43
|
||||
},
|
||||
"pythonCoreSDK": {
|
||||
"path": "/home/runner/work/PraisonAI/PraisonAI/src/praisonai-agents/praisonaiagents",
|
||||
@@ -249,6 +249,7 @@
|
||||
"ToolPluginProtocol",
|
||||
"ToolRegistry",
|
||||
"ToolResult",
|
||||
"ToolSearchConfig",
|
||||
"ToolUseEvent",
|
||||
"ToolValidationError",
|
||||
"Tools",
|
||||
@@ -475,6 +476,7 @@
|
||||
"ReflectionConfig",
|
||||
"SkillsConfig",
|
||||
"TemplateConfig",
|
||||
"ToolSearchConfig",
|
||||
"WEB_PRESETS",
|
||||
"WebConfig",
|
||||
"WebSearchProvider",
|
||||
@@ -5254,6 +5256,15 @@
|
||||
"status": "DONE",
|
||||
"category": "plugins"
|
||||
},
|
||||
{
|
||||
"feature": "ToolSearchConfig",
|
||||
"python": true,
|
||||
"typescript": false,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "config"
|
||||
},
|
||||
{
|
||||
"feature": "ToolUseEvent",
|
||||
"python": true,
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
# Feature Parity Tracker
|
||||
|
||||
> **Version:** 1.5.87 | **Last Updated:** 2026-05-30
|
||||
> **Version:** 1.5.87 | **Last Updated:** 2026-06-02
|
||||
> **Source of Truth:** Python SDK (praisonaiagents)
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Python Core Features | 335 |
|
||||
| Python Core Features | 336 |
|
||||
| Python Wrapper Features | 99 |
|
||||
| TypeScript Features | 1192 |
|
||||
| **Gap Count** | **43** |
|
||||
| **Gap Count** | **44** |
|
||||
| P0 (Critical) | 1 |
|
||||
| P1 (High) | 0 |
|
||||
| P2 (Medium) | 0 |
|
||||
| P3 (Low) | 42 |
|
||||
| P3 (Low) | 43 |
|
||||
|
||||
## Gap Matrix
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
| `enable\_telemetry` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `get\_telemetry` | ✅ | ✅ | low | ✅ DONE |
|
||||
|
||||
### P3_Advanced (179 done, 42 todo)
|
||||
### P3_Advanced (179 done, 43 todo)
|
||||
|
||||
| Feature | Python | TypeScript | Effort | Status |
|
||||
|---------|--------|------------|--------|--------|
|
||||
@@ -189,6 +189,7 @@
|
||||
| `StopReason` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `StructuredFormatter` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `ToolExecutionError` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `ToolSearchConfig` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `ToolUseEvent` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `ValidationError` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `configure\_structured\_logging` | ✅ | ❌ | low | ⏳ TODO |
|
||||
@@ -423,7 +424,7 @@ from praisonaiagents import ConditionProtocol, DictCondition, ExpressionConditio
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>config</strong> (75 exports)</summary>
|
||||
<summary><strong>config</strong> (76 exports)</summary>
|
||||
|
||||
```python
|
||||
from praisonaiagents import AUTONOMY_PRESETS, ArrayMode, AutonomyLevel, CACHING_PRESETS, CONTEXT_PRESETS, CachingConfig, ChunkingStrategy, ConfigValidationError, DefaultsConfig, EXECUTION_PRESETS...
|
||||
|
||||
@@ -382,7 +382,7 @@ praisonai flow
|
||||
|
||||
Open **http://localhost:7861** — use the **Agent** and **Agent Team** components to create sequential or parallel workflows. Connect Chat Input → Agent Team → Chat Output for instant multi-agent pipelines.
|
||||
|
||||
> 📖 [Full Flow docs](https://docs.praison.ai/docs/concepts/flow) — visual agent building, component reference, and deployment
|
||||
> 📖 [Full Flow docs](https://docs.praison.ai/docs/concepts/agentflow) — visual agent building, component reference, and deployment
|
||||
|
||||
### 8. PraisonAI UI 🤖 (Clean Chat)
|
||||
|
||||
|
||||
@@ -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.48.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.48.tar.gz | shasum -a 256`.split.first
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.52.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.52.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -31,12 +31,8 @@ class _BackgroundLoop:
|
||||
server processes should call :func:`shutdown` explicitly to cancel
|
||||
in-flight tasks before process exit.
|
||||
"""
|
||||
# Enforce the documented invariant. ``threading.Lock.locked()``
|
||||
# is process-wide, not thread-local, but combined with the fact
|
||||
# that the only callers are :py:meth:`get` and :py:meth:`get_unlocked`
|
||||
# (the latter only invoked from inside ``run_sync`` while holding
|
||||
# the lock), this catches the "forgot to hold the lock" mistake.
|
||||
assert self._lock.locked(), "_spawn_locked() requires self._lock to be held"
|
||||
# Create the loop+thread; caller must hold self._lock.
|
||||
# The lock contract is now enforced structurally by submit() method.
|
||||
if self._loop is None or self._loop.is_closed():
|
||||
self._loop = asyncio.new_event_loop()
|
||||
self._thread = threading.Thread(
|
||||
@@ -51,9 +47,11 @@ class _BackgroundLoop:
|
||||
with self._lock:
|
||||
return self._spawn_locked()
|
||||
|
||||
def get_unlocked(self) -> asyncio.AbstractEventLoop:
|
||||
"""Get loop assuming caller holds _lock. For run_sync() use only."""
|
||||
return self._spawn_locked()
|
||||
def submit(self, coro):
|
||||
"""Atomically (re)spawn loop if needed and submit coro. Returns concurrent.futures.Future."""
|
||||
with self._lock:
|
||||
loop = self._spawn_locked()
|
||||
return asyncio.run_coroutine_threadsafe(coro, loop)
|
||||
|
||||
def shutdown(self, timeout: float = 5.0) -> None:
|
||||
with self._lock:
|
||||
@@ -77,7 +75,16 @@ class _BackgroundLoop:
|
||||
self._loop = None
|
||||
self._thread = None
|
||||
|
||||
_BG = _BackgroundLoop()
|
||||
_BG: "_BackgroundLoop | None" = None
|
||||
_BG_LOCK = threading.Lock()
|
||||
|
||||
def _get_bg() -> "_BackgroundLoop":
|
||||
global _BG
|
||||
if _BG is None:
|
||||
with _BG_LOCK:
|
||||
if _BG is None:
|
||||
_BG = _BackgroundLoop()
|
||||
return _BG
|
||||
|
||||
|
||||
def run_sync(coro: Awaitable[T], *, timeout: float | None = _DEFAULT_TIMEOUT) -> T:
|
||||
@@ -109,10 +116,8 @@ def run_sync(coro: Awaitable[T], *, timeout: float | None = _DEFAULT_TIMEOUT) ->
|
||||
"await the coroutine directly instead."
|
||||
)
|
||||
|
||||
# Submit the coroutine inside the lock to prevent shutdown races
|
||||
with _BG._lock:
|
||||
loop = _BG.get_unlocked()
|
||||
fut: Future = asyncio.run_coroutine_threadsafe(coro, loop)
|
||||
# Submit the coroutine atomically
|
||||
fut = _get_bg().submit(coro)
|
||||
|
||||
try:
|
||||
return fut.result(timeout=timeout)
|
||||
@@ -139,4 +144,6 @@ def run_sync(coro: Awaitable[T], *, timeout: float | None = _DEFAULT_TIMEOUT) ->
|
||||
|
||||
def shutdown() -> None:
|
||||
"""Public hook for long-running server processes to stop the bridge cleanly."""
|
||||
_BG.shutdown()
|
||||
bg = _BG
|
||||
if bg is not None:
|
||||
bg.shutdown()
|
||||
@@ -41,39 +41,28 @@ class PluginRegistry(Generic[T]):
|
||||
builtins: Dict of name -> loader function for built-in plugins
|
||||
"""
|
||||
self._entry_point_group = entry_point_group
|
||||
self._items: Dict[str, Type[T]] = {}
|
||||
self._loaders: Dict[str, Callable[[], Type[T]]] = {} # lazy loaders
|
||||
self._items: Dict[str, Type[T]] = {} # resolved cache
|
||||
self._aliases: Dict[str, str] = {} # alias -> canonical name
|
||||
self._lock = threading.Lock()
|
||||
self._lock = threading.RLock() # Use RLock for re-entrant access
|
||||
|
||||
# Load built-in plugins with error handling
|
||||
# Store built-in loaders (normalized) without calling them
|
||||
if builtins:
|
||||
for name, loader in builtins.items():
|
||||
try:
|
||||
self._items[name] = loader()
|
||||
except ImportError:
|
||||
# Built-in plugin dependencies not available, skip
|
||||
pass
|
||||
except Exception:
|
||||
# Log other errors but don't crash initialization
|
||||
logger.warning("Failed to load built-in plugin %r", name, exc_info=True)
|
||||
self._add_loader(name, loader)
|
||||
|
||||
self._load_entry_points()
|
||||
self._discover_entry_points()
|
||||
|
||||
def _load_entry_points(self) -> None:
|
||||
"""Load plugins from entry points."""
|
||||
def _add_loader(self, name: str, loader: Callable[[], Type[T]]) -> None:
|
||||
"""Add a loader with normalized name."""
|
||||
normalized_name = name.lower()
|
||||
self._loaders[normalized_name] = loader
|
||||
|
||||
def _discover_entry_points(self) -> None:
|
||||
"""Discover entry point loaders without loading them."""
|
||||
try:
|
||||
for ep in entry_points(group=self._entry_point_group):
|
||||
try:
|
||||
plugin_class = ep.load()
|
||||
with self._lock:
|
||||
self._items[ep.name] = plugin_class
|
||||
except Exception:
|
||||
# Do not break plugin dispatch because one plugin is broken
|
||||
logger.warning(
|
||||
"Failed to load plugin %r from entry point",
|
||||
ep.name,
|
||||
exc_info=True,
|
||||
)
|
||||
self._add_loader(ep.name, ep.load)
|
||||
except Exception:
|
||||
# entry_points() might not be available in older Python versions
|
||||
logger.debug("Entry points not available for group %s", self._entry_point_group)
|
||||
@@ -94,6 +83,8 @@ class PluginRegistry(Generic[T]):
|
||||
"""
|
||||
with self._lock:
|
||||
canonical_name = name.lower()
|
||||
# Store both in loaders (for consistency with discovery) and items (for immediate access)
|
||||
self._loaders[canonical_name] = lambda: cls
|
||||
self._items[canonical_name] = cls
|
||||
|
||||
# Register aliases
|
||||
@@ -120,7 +111,7 @@ class PluginRegistry(Generic[T]):
|
||||
return True
|
||||
|
||||
# Check if it's a canonical name
|
||||
if normalized_name in self._items:
|
||||
if normalized_name in self._loaders:
|
||||
# Remove all aliases pointing to this plugin
|
||||
aliases_to_remove = [
|
||||
alias for alias, canonical in self._aliases.items()
|
||||
@@ -129,7 +120,9 @@ class PluginRegistry(Generic[T]):
|
||||
for alias in aliases_to_remove:
|
||||
del self._aliases[alias]
|
||||
|
||||
del self._items[normalized_name]
|
||||
# Remove from both loaders and items cache
|
||||
del self._loaders[normalized_name]
|
||||
self._items.pop(normalized_name, None)
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -146,28 +139,44 @@ class PluginRegistry(Generic[T]):
|
||||
Raises:
|
||||
ValueError: If plugin is not found
|
||||
"""
|
||||
normalized_name = name.lower()
|
||||
|
||||
with self._lock:
|
||||
normalized_name = name.lower()
|
||||
|
||||
# Resolve alias to canonical name
|
||||
# Check cache first
|
||||
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
|
||||
if cls is None:
|
||||
available_plugins = sorted(self._items.keys())
|
||||
if cls is not None:
|
||||
return cls
|
||||
|
||||
# Try to load from loaders
|
||||
loader = self._loaders.get(canonical_name)
|
||||
if loader is None:
|
||||
# Capture available plugins snapshot while holding lock
|
||||
available_loaders = sorted(self._loaders.keys())
|
||||
available_aliases = sorted(self._aliases.keys())
|
||||
available_snapshot = available_plugins + available_aliases
|
||||
else:
|
||||
available_snapshot = None
|
||||
available_snapshot = available_loaders + available_aliases
|
||||
raise ValueError(
|
||||
f"Unknown {self._entry_point_group} plugin: {name!r}. "
|
||||
f"Available: {available_snapshot}"
|
||||
)
|
||||
|
||||
# Load and cache the plugin (release lock during loading to prevent deadlock)
|
||||
pass
|
||||
|
||||
if cls is None:
|
||||
# Load outside the lock to prevent deadlock if loader calls back into registry
|
||||
try:
|
||||
cls = loader()
|
||||
except ImportError as e:
|
||||
raise ValueError(
|
||||
f"Unknown {self._entry_point_group} plugin: {name!r}. "
|
||||
f"Available: {available_snapshot}"
|
||||
)
|
||||
return cls
|
||||
f"Plugin {name!r} is registered but its dependencies "
|
||||
f"are not installed: {e}"
|
||||
) from e
|
||||
|
||||
# Re-acquire lock to cache the result
|
||||
with self._lock:
|
||||
self._items[canonical_name] = cls
|
||||
return cls
|
||||
|
||||
def create(self, name: str, *args, **kwargs) -> T:
|
||||
"""Create an instance of the specified plugin.
|
||||
@@ -192,7 +201,7 @@ class PluginRegistry(Generic[T]):
|
||||
Sorted list of plugin names
|
||||
"""
|
||||
with self._lock:
|
||||
return sorted(self._items.keys())
|
||||
return sorted(self._loaders.keys())
|
||||
|
||||
def is_available(self, name: str) -> bool:
|
||||
"""Check if a plugin is available.
|
||||
@@ -225,4 +234,59 @@ class PluginRegistry(Generic[T]):
|
||||
Sorted list of all registered names and aliases
|
||||
"""
|
||||
with self._lock:
|
||||
return sorted(list(self._items.keys()) + list(self._aliases.keys()))
|
||||
return sorted(list(self._loaders.keys()) + list(self._aliases.keys()))
|
||||
|
||||
def get_by_attr(self, module_name: str, attr_name: str) -> Type[T]:
|
||||
"""Get a plugin by attribute name for __getattr__ dispatch.
|
||||
|
||||
Args:
|
||||
module_name: Module name requesting the attribute (for error messages)
|
||||
attr_name: Attribute name to resolve
|
||||
|
||||
Returns:
|
||||
Plugin class
|
||||
|
||||
Raises:
|
||||
AttributeError: If plugin is not found
|
||||
"""
|
||||
try:
|
||||
return self.resolve(attr_name)
|
||||
except ValueError:
|
||||
raise AttributeError(f"module {module_name!r} has no attribute {attr_name!r}")
|
||||
|
||||
|
||||
def create_lazy_getattr(registry: PluginRegistry[T]) -> Callable[[str], T]:
|
||||
"""Create a __getattr__ function backed by a PluginRegistry.
|
||||
|
||||
This replaces manual if/elif ladders in __init__.py files with a data-driven
|
||||
approach using the plugin registry.
|
||||
|
||||
Args:
|
||||
registry: The plugin registry to use for resolution
|
||||
|
||||
Returns:
|
||||
Function that can be used as __getattr__ in a module
|
||||
|
||||
Example:
|
||||
# In __init__.py:
|
||||
from ._registry import create_lazy_getattr
|
||||
|
||||
# Assuming you have a registry instance
|
||||
__getattr__ = create_lazy_getattr(my_registry)
|
||||
"""
|
||||
def __getattr__(name: str) -> T:
|
||||
try:
|
||||
plugin_class = registry.resolve(name)
|
||||
return plugin_class
|
||||
except ValueError:
|
||||
# Get the calling module name for error context
|
||||
import inspect
|
||||
frame = inspect.currentframe()
|
||||
if frame and frame.f_back:
|
||||
module_name = frame.f_back.f_globals.get('__name__', 'unknown')
|
||||
else:
|
||||
module_name = 'unknown'
|
||||
|
||||
raise AttributeError(f"module {module_name!r} has no attribute {name!r}")
|
||||
|
||||
return __getattr__
|
||||
@@ -28,14 +28,33 @@ from .tool_registry import ToolRegistry
|
||||
|
||||
# BaseTool import is now handled centrally by ToolResolver
|
||||
|
||||
# Check for additional framework availability using centralized detection
|
||||
# Framework availability detection (lazy via __getattr__)
|
||||
from ._framework_availability import is_available
|
||||
PRAISONAI_TOOLS_AVAILABLE = is_available("praisonai_tools")
|
||||
CREWAI_AVAILABLE = is_available("crewai")
|
||||
AUTOGEN_AVAILABLE = is_available("autogen")
|
||||
AG2_AVAILABLE = is_available("ag2")
|
||||
PRAISONAI_AVAILABLE = is_available("praisonaiagents")
|
||||
AGENTOPS_AVAILABLE = is_available("agentops")
|
||||
|
||||
# Lazy constants mapping for backward compatibility
|
||||
_AVAIL = {
|
||||
"PRAISONAI_TOOLS_AVAILABLE": "praisonai_tools",
|
||||
"CREWAI_AVAILABLE": "crewai",
|
||||
"AUTOGEN_AVAILABLE": "autogen",
|
||||
"AG2_AVAILABLE": "ag2",
|
||||
"PRAISONAI_AVAILABLE": "praisonaiagents",
|
||||
"AGENTOPS_AVAILABLE": "agentops",
|
||||
}
|
||||
|
||||
__all__ = list(_AVAIL.keys())
|
||||
|
||||
def __getattr__(name):
|
||||
"""Lazy attribute access for framework availability constants.
|
||||
|
||||
This allows backward compatibility while avoiding import-time probing.
|
||||
Only probes the framework when the constant is actually accessed.
|
||||
"""
|
||||
if name in _AVAIL:
|
||||
return is_available(_AVAIL[name])
|
||||
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
||||
|
||||
def __dir__():
|
||||
return sorted(set(globals()) | set(_AVAIL))
|
||||
|
||||
# Framework adapter registry - now uses proper registry pattern
|
||||
# This replaces the hardcoded FRAMEWORK_ADAPTERS dict
|
||||
@@ -282,7 +301,7 @@ class AgentsGenerator:
|
||||
|
||||
# Keep tool registry for backward compatibility with autogen adapters
|
||||
self.tool_registry = ToolRegistry()
|
||||
self.tool_registry.register_builtin_autogen_adapters()
|
||||
self.tool_registry.register_builtin_autogen_adapters(_suppress_deprecation_warning=True)
|
||||
|
||||
# Initialize tool resolver with the registry wired in (single source of truth for tool resolution)
|
||||
from .tool_resolver import ToolResolver
|
||||
@@ -576,7 +595,7 @@ class AgentsGenerator:
|
||||
tools_dict = {}
|
||||
|
||||
# Demand-driven tool resolution - only resolve tools actually used in YAML
|
||||
if CREWAI_AVAILABLE or AUTOGEN_AVAILABLE or PRAISONAI_AVAILABLE or AG2_AVAILABLE:
|
||||
if is_available("crewai") or is_available("autogen") or is_available("praisonaiagents") or is_available("ag2"):
|
||||
try:
|
||||
# Collect all tool names mentioned in the YAML config
|
||||
needed_tools: set[str] = set()
|
||||
@@ -634,14 +653,163 @@ class AgentsGenerator:
|
||||
self.logger.debug("tools folder exists in the root directory")
|
||||
|
||||
framework = self.framework or config.get('framework', 'crewai')
|
||||
|
||||
# Get initial adapter and resolve to concrete variant
|
||||
initial_adapter = self._get_framework_adapter(framework)
|
||||
adapter = initial_adapter.resolve()
|
||||
|
||||
# Validate framework availability early
|
||||
from .framework_adapters.validators import assert_framework_available
|
||||
assert_framework_available(adapter.name)
|
||||
|
||||
# Initialize observability hooks
|
||||
from .observability.hooks import init_observability
|
||||
init_observability(adapter.name)
|
||||
|
||||
# Run adapter setup hooks
|
||||
adapter.setup(framework_tag=adapter.name)
|
||||
|
||||
# Update framework reference if resolution changed it
|
||||
self.framework = adapter.name
|
||||
self.framework_adapter = adapter
|
||||
|
||||
self.logger.info(f"Using framework: {adapter.name}")
|
||||
return adapter.run(
|
||||
config,
|
||||
self.config_list,
|
||||
topic,
|
||||
tools_dict=tools_dict,
|
||||
agent_callback=getattr(self, 'agent_callback', None),
|
||||
task_callback=getattr(self, 'task_callback', None),
|
||||
cli_config=getattr(self, 'cli_config', None),
|
||||
)
|
||||
|
||||
# Determine AutoGen version if needed (keeping compatibility logic)
|
||||
async def agenerate_crew_and_kickoff(self):
|
||||
"""
|
||||
Async version of generate_crew_and_kickoff.
|
||||
Generates a crew of agents and initiates tasks based on the provided configuration.
|
||||
"""
|
||||
if self.agent_yaml:
|
||||
config = yaml.safe_load(self.agent_yaml)
|
||||
else:
|
||||
if self.agent_file == '/app/api:app' or self.agent_file == 'api:app':
|
||||
self.agent_file = 'agents.yaml'
|
||||
try:
|
||||
with open(self.agent_file, 'r') as f:
|
||||
config = yaml.safe_load(f)
|
||||
except FileNotFoundError:
|
||||
print(f"File not found: {self.agent_file}")
|
||||
return
|
||||
|
||||
# Apply CLI configuration overrides to YAML config
|
||||
if self.cli_config:
|
||||
# Merge CLI configuration with YAML config
|
||||
self._merge_cli_config(config, self.cli_config)
|
||||
|
||||
# Check if this is a workflow-mode YAML (process: workflow or has steps section)
|
||||
process_type = config.get('process', 'sequential')
|
||||
has_steps = 'steps' in config
|
||||
has_workflow_config = 'workflow' in config
|
||||
|
||||
if process_type == 'workflow' or (has_steps and has_workflow_config):
|
||||
return await self._arun_yaml_workflow(config)
|
||||
else:
|
||||
return await self._arun_framework(config)
|
||||
|
||||
async def _arun_framework(self, config):
|
||||
"""Async version of _run_framework with shared preparation logic."""
|
||||
# Canonical format conversion: 'agents' -> 'roles', 'instructions' -> 'backstory'
|
||||
if 'agents' in config and 'roles' not in config:
|
||||
config['roles'] = {}
|
||||
for agent_name, agent_config in config['agents'].items():
|
||||
role_config = dict(agent_config) if agent_config else {}
|
||||
if 'instructions' in role_config and 'backstory' not in role_config:
|
||||
role_config['backstory'] = role_config['instructions']
|
||||
if 'role' not in role_config:
|
||||
role_config['role'] = agent_name.replace('_', ' ').title()
|
||||
if 'goal' not in role_config:
|
||||
role_config['goal'] = role_config.get('backstory', 'Complete the assigned task')
|
||||
if 'backstory' not in role_config:
|
||||
role_config['backstory'] = f'You are a {role_config["role"]}'
|
||||
config['roles'][agent_name] = role_config
|
||||
|
||||
# Get workflow input: 'input' is canonical, 'topic' is alias for backward compatibility
|
||||
topic = config.get('input', config.get('topic', ''))
|
||||
|
||||
# Validate agents configuration for typos in field names
|
||||
self._validate_agents_config(config)
|
||||
|
||||
tools_dict = {}
|
||||
|
||||
# Demand-driven tool resolution - only resolve tools actually used in YAML
|
||||
if is_available("crewai") or is_available("autogen") or is_available("praisonaiagents") or is_available("ag2"):
|
||||
try:
|
||||
# Collect all tool names mentioned in the YAML config
|
||||
needed_tools: set[str] = set()
|
||||
for role_cfg in config.get('roles', {}).values():
|
||||
for t in role_cfg.get('tools') or []:
|
||||
if isinstance(t, str) and t.strip():
|
||||
needed_tools.add(t.strip())
|
||||
for task_cfg in (role_cfg.get('tasks') or {}).values():
|
||||
if not isinstance(task_cfg, dict):
|
||||
continue
|
||||
for t in task_cfg.get('tools') or []:
|
||||
if isinstance(t, str) and t.strip():
|
||||
needed_tools.add(t.strip())
|
||||
|
||||
# Resolve only the tools actually referenced in YAML
|
||||
for tool_name in needed_tools:
|
||||
try:
|
||||
resolved_tool = self.tool_resolver.resolve(tool_name)
|
||||
if resolved_tool is None:
|
||||
self.logger.warning(f"Tool '{tool_name}' not found")
|
||||
continue
|
||||
tools_dict[tool_name] = (
|
||||
resolved_tool() if inspect.isclass(resolved_tool) else resolved_tool
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to initialize tool '{tool_name}': {e}")
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error collecting YAML tool references: {e}")
|
||||
|
||||
# Add tools from class names - use tool_resolver to check tool validity
|
||||
for tool_class in self.tools:
|
||||
if isinstance(tool_class, type):
|
||||
try:
|
||||
tool_instance = tool_class()
|
||||
tool_name = tool_class.__name__
|
||||
tools_dict[tool_name] = tool_instance
|
||||
self.logger.debug(f"Added tool: {tool_name}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to instantiate tool class {tool_class.__name__}: {e}")
|
||||
|
||||
root_directory = os.getcwd()
|
||||
tools_py_path = os.path.join(root_directory, 'tools.py')
|
||||
tools_dir_path = Path(root_directory) / 'tools'
|
||||
|
||||
# Use consolidated ToolResolver for tools.py loading
|
||||
tools_dict.update(self.tool_resolver.get_local_tool_classes())
|
||||
if os.path.isfile(tools_py_path):
|
||||
self.logger.debug("tools.py exists in the root directory. Loading tools.py and skipping tools folder.")
|
||||
elif tools_dir_path.is_dir():
|
||||
tools_dict.update(self.tool_resolver.get_local_tool_classes_from_dir(tools_dir_path))
|
||||
if tools_dict:
|
||||
self.logger.debug("tools folder exists in the root directory")
|
||||
|
||||
framework = self.framework or config.get('framework', 'crewai')
|
||||
|
||||
# AutoGen version selection logic
|
||||
if framework == "autogen":
|
||||
autogen_version = os.environ.get("AUTOGEN_VERSION", "auto").lower()
|
||||
autogen_v4_adapter = self._get_framework_adapter("autogen_v4")
|
||||
autogen_v2_adapter = self._get_framework_adapter("autogen")
|
||||
|
||||
autogen_version = str(
|
||||
config.get('autogen_version', os.environ.get("AUTOGEN_VERSION", "auto"))
|
||||
).lower()
|
||||
use_v4 = False
|
||||
|
||||
if autogen_version == "v0.4" and autogen_v4_adapter.is_available():
|
||||
use_v4 = True
|
||||
elif autogen_version == "v0.2" and autogen_v2_adapter.is_available():
|
||||
@@ -652,27 +820,27 @@ class AgentsGenerator:
|
||||
use_v4 = autogen_v4_adapter.is_available() and not autogen_v2_adapter.is_available()
|
||||
|
||||
framework = "autogen_v4" if use_v4 else "autogen"
|
||||
|
||||
# Initialize AgentOps if available
|
||||
try:
|
||||
import agentops
|
||||
agentops_api_key = os.getenv("AGENTOPS_API_KEY")
|
||||
if agentops_api_key:
|
||||
|
||||
# Initialize AgentOps if configured
|
||||
agentops_api_key = os.getenv("AGENTOPS_API_KEY")
|
||||
if agentops_api_key:
|
||||
try:
|
||||
import agentops
|
||||
agentops.init(agentops_api_key, default_tags=[framework])
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Update framework adapter if framework changed (e.g., AutoGen version selection)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Update framework adapter if framework changed
|
||||
if framework != self.framework:
|
||||
self.framework = framework
|
||||
self.framework_adapter = self._get_framework_adapter(framework)
|
||||
|
||||
# Validate framework availability for non-CLI callers
|
||||
# Validate framework availability
|
||||
from .framework_adapters.validators import assert_framework_available
|
||||
assert_framework_available(framework)
|
||||
|
||||
self.logger.info(f"Using framework: {framework}")
|
||||
return self.framework_adapter.run(
|
||||
return await self.framework_adapter.arun(
|
||||
config,
|
||||
self.config_list,
|
||||
topic,
|
||||
@@ -682,6 +850,12 @@ class AgentsGenerator:
|
||||
cli_config=getattr(self, 'cli_config', None),
|
||||
)
|
||||
|
||||
async def _arun_yaml_workflow(self, config):
|
||||
"""Async placeholder for workflow mode - currently delegates to sync version."""
|
||||
# For now, delegate to sync version using asyncio.to_thread
|
||||
import asyncio
|
||||
return await asyncio.to_thread(self._run_yaml_workflow, config)
|
||||
|
||||
def _run_yaml_workflow(self, config):
|
||||
"""
|
||||
Run a YAML workflow using the YAMLWorkflowParser.
|
||||
@@ -696,7 +870,7 @@ class AgentsGenerator:
|
||||
Returns:
|
||||
str: Result of the workflow execution
|
||||
"""
|
||||
if not PRAISONAI_AVAILABLE:
|
||||
if not is_available("praisonaiagents"):
|
||||
raise ImportError("PraisonAI is not installed. Please install it with 'pip install praisonaiagents'")
|
||||
|
||||
try:
|
||||
|
||||
@@ -65,37 +65,7 @@ def _load_optional(key: str, loader):
|
||||
# Availability checks now delegated to centralized module
|
||||
from ._framework_availability import is_available
|
||||
|
||||
def _check_crewai_available() -> bool:
|
||||
"""Check if crewai is available (cached, thread-safe)."""
|
||||
return is_available("crewai")
|
||||
|
||||
def _check_autogen_available() -> bool:
|
||||
"""Check if autogen v0.2 is available (cached, thread-safe)."""
|
||||
return is_available("autogen")
|
||||
|
||||
def _check_autogen_v4_available() -> bool:
|
||||
"""Check if autogen v0.4 is available (cached, thread-safe)."""
|
||||
return is_available("autogen_v4")
|
||||
|
||||
def _check_ag2_available() -> bool:
|
||||
"""Check if AG2 (community fork of AutoGen) is available (cached, thread-safe)."""
|
||||
return is_available("ag2")
|
||||
|
||||
def _check_praisonai_available() -> bool:
|
||||
"""Check if praisonaiagents is available (cached, thread-safe)."""
|
||||
return is_available("praisonaiagents")
|
||||
|
||||
def _check_praisonai_tools_available() -> bool:
|
||||
"""Check if praisonai_tools is available (cached, thread-safe)."""
|
||||
return is_available("praisonai_tools")
|
||||
|
||||
def _check_litellm_available() -> bool:
|
||||
"""Check if litellm is available (cached, thread-safe)."""
|
||||
return is_available("litellm")
|
||||
|
||||
def _check_openai_available() -> bool:
|
||||
"""Check if openai is available (cached, thread-safe)."""
|
||||
return is_available("openai")
|
||||
# Redundant wrapper functions removed - use is_available() directly
|
||||
|
||||
|
||||
def _get_crewai():
|
||||
@@ -504,7 +474,7 @@ class BaseAutoGenerator:
|
||||
model_name = self.config_list[0]['model']
|
||||
|
||||
# Try LiteLLM first (preferred - supports 100+ providers)
|
||||
if _check_litellm_available():
|
||||
if is_available("litellm"):
|
||||
litellm = _get_litellm()
|
||||
response = litellm.completion(
|
||||
model=model_name,
|
||||
@@ -516,7 +486,7 @@ class BaseAutoGenerator:
|
||||
return response_model.model_validate_json(content)
|
||||
|
||||
# Fallback to OpenAI SDK (uses beta.chat.completions.parse)
|
||||
if _check_openai_available():
|
||||
if is_available("openai"):
|
||||
client = self._get_openai_client()
|
||||
response = client.beta.chat.completions.parse(
|
||||
model=model_name,
|
||||
@@ -554,7 +524,7 @@ class BaseAutoGenerator:
|
||||
model_name = self.config_list[0]['model']
|
||||
|
||||
# Try LiteLLM async first (preferred - supports 100+ providers)
|
||||
if _check_litellm_available():
|
||||
if is_available("litellm"):
|
||||
litellm = _get_litellm()
|
||||
response = await litellm.acompletion(
|
||||
model=model_name,
|
||||
@@ -566,7 +536,7 @@ class BaseAutoGenerator:
|
||||
return response_model.model_validate_json(content)
|
||||
|
||||
# Fallback to OpenAI AsyncSDK (uses beta.chat.completions.parse)
|
||||
if _check_openai_available():
|
||||
if is_available("openai"):
|
||||
if self._async_openai_client is None:
|
||||
with self._client_lock:
|
||||
if self._async_openai_client is None:
|
||||
@@ -728,7 +698,7 @@ class AutoGenerator(BaseAutoGenerator):
|
||||
raise ImportError(f"{adapter.name} is not installed. Please install with:\n {install_hint}")
|
||||
|
||||
# Check tools availability if required by this framework
|
||||
if requires_tools_extra and not _check_praisonai_tools_available():
|
||||
if requires_tools_extra and not is_available("praisonai_tools"):
|
||||
logger.warning(f"Tools are not available for {framework}. To use tools, install:\n {install_hint}")
|
||||
|
||||
self.topic = topic
|
||||
|
||||
@@ -23,11 +23,14 @@ class AgentConfigSchema(BaseModel):
|
||||
|
||||
class ChannelConfigSchema(BaseModel):
|
||||
"""Schema for a single channel configuration."""
|
||||
platform: Optional[str] = None
|
||||
token: str = ""
|
||||
mode: str = "poll" # poll | ws | webhook | hybrid
|
||||
group_policy: str = "respond_all" # respond_all, mention_only, command_only
|
||||
allowlist: List[str] = Field(default_factory=list)
|
||||
blocklist: List[str] = Field(default_factory=list)
|
||||
allowed_users: Optional[str] = None
|
||||
routes: Dict[str, str] = Field(default_factory=dict)
|
||||
webhook_url: Optional[str] = None
|
||||
webhook_port: int = 8080
|
||||
|
||||
@@ -102,12 +105,15 @@ class BotYamlSchema(BaseModel):
|
||||
"No channels configured. Add at least one channel "
|
||||
"(telegram, discord, slack, whatsapp) to your bot.yaml"
|
||||
)
|
||||
# Validate channel names
|
||||
# Channel keys may be platform names (telegram) or custom ids (telegram_cfo)
|
||||
# when ``platform`` is set on the channel block (gateway / multi-bot YAML).
|
||||
valid_platforms = {"telegram", "discord", "slack", "whatsapp", "email", "agentmail"}
|
||||
for name in self.channels:
|
||||
if name not in valid_platforms:
|
||||
for name, channel in self.channels.items():
|
||||
platform = (channel.platform or name).lower()
|
||||
if platform not in valid_platforms:
|
||||
raise ValueError(
|
||||
f"Unknown channel '{name}'. Supported: {', '.join(sorted(valid_platforms))}"
|
||||
f"Unknown channel '{name}' (platform '{platform}'). "
|
||||
f"Supported platforms: {', '.join(sorted(valid_platforms))}"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
@@ -149,15 +149,17 @@ class BotSessionManager:
|
||||
if self._store is not None:
|
||||
key = self._session_key(user_id)
|
||||
try:
|
||||
# Clear existing and re-write (atomic update)
|
||||
self._store.clear_session(key)
|
||||
for msg in history:
|
||||
self._store.add_message(
|
||||
key,
|
||||
msg.get("role", "user"),
|
||||
msg.get("content", ""),
|
||||
msg.get("metadata"),
|
||||
)
|
||||
if hasattr(self._store, "set_chat_history"):
|
||||
self._store.set_chat_history(key, history)
|
||||
else:
|
||||
self._store.clear_session(key)
|
||||
for msg in history:
|
||||
self._store.add_message(
|
||||
key,
|
||||
msg.get("role", "user"),
|
||||
msg.get("content", ""),
|
||||
msg.get("metadata"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to persist session to store: %s", e)
|
||||
|
||||
|
||||
@@ -181,37 +181,10 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
|
||||
)
|
||||
|
||||
async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
# Handle text OR audio messages
|
||||
message_text = None
|
||||
|
||||
if update.message:
|
||||
# Check for voice/audio first
|
||||
if update.message.voice or update.message.audio:
|
||||
message_text = await self._transcribe_audio(update)
|
||||
elif update.message.text:
|
||||
message_text = update.message.text
|
||||
|
||||
if not update.message or not message_text:
|
||||
return
|
||||
|
||||
message = self._convert_update_to_message(update, override_text=message_text)
|
||||
|
||||
# Add channel type for pairing system
|
||||
message._channel_type = "telegram"
|
||||
|
||||
self.fire_message_received(message)
|
||||
|
||||
# Check if channel is allowed
|
||||
if not self.config.is_channel_allowed(message.channel.channel_id if message.channel else ""):
|
||||
return
|
||||
|
||||
# Handle unknown users with pairing system
|
||||
user_id = message.sender.user_id if message.sender else ""
|
||||
is_explicitly_allowed = bool(self.config.allowed_users) and self.config.is_user_allowed(user_id)
|
||||
if not is_explicitly_allowed:
|
||||
user_allowed = await UnknownUserHandler.handle(message, self._bot_context)
|
||||
if not user_allowed:
|
||||
return
|
||||
# Use shared security pipeline for consistent enforcement
|
||||
message = await process_inbound_telegram_message(update, self)
|
||||
if not message:
|
||||
return # Message was dropped by security checks
|
||||
|
||||
for handler in self._message_handlers:
|
||||
try:
|
||||
@@ -252,7 +225,7 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
|
||||
update.message.from_user.username or update.message.from_user.first_name or ""
|
||||
) if update.message.from_user else ""
|
||||
try:
|
||||
message_text = await self._debouncer.debounce(user_id, message_text)
|
||||
message_text = await self._debouncer.debounce(user_id, message.content)
|
||||
|
||||
# Show typing indicator with renewal during long operation
|
||||
if self.config.typing_indicator:
|
||||
@@ -848,3 +821,114 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send reply: {e}")
|
||||
|
||||
|
||||
async def process_inbound_telegram_message(
|
||||
update, # Telegram Update
|
||||
bot: TelegramBot,
|
||||
gateway_context: Optional[Dict] = None
|
||||
) -> Optional[BotMessage]:
|
||||
"""
|
||||
Shared security pipeline for processing inbound Telegram messages.
|
||||
|
||||
Used by both standalone bot (TelegramBot.handle_message) and
|
||||
gateway polling (_start_telegram_bot_polling) to ensure consistent
|
||||
access control enforcement.
|
||||
|
||||
Args:
|
||||
update: Telegram Update object
|
||||
bot: TelegramBot instance with config and security settings
|
||||
gateway_context: Optional dict with gateway-specific context
|
||||
|
||||
Returns:
|
||||
BotMessage if message passes all security checks, None if dropped
|
||||
"""
|
||||
if not update.message:
|
||||
return None
|
||||
|
||||
# Extract message text (including audio transcription)
|
||||
message_text = None
|
||||
if update.message.voice or update.message.audio:
|
||||
message_text = await bot._transcribe_audio(update)
|
||||
elif update.message.text:
|
||||
message_text = update.message.text
|
||||
|
||||
if not message_text:
|
||||
return None
|
||||
|
||||
# Convert to BotMessage for consistent processing
|
||||
message = bot._convert_update_to_message(update, override_text=message_text)
|
||||
|
||||
# Set channel type for pairing system
|
||||
message._channel_type = "telegram"
|
||||
|
||||
# Fire message received event
|
||||
bot.fire_message_received(message)
|
||||
|
||||
# 1. Channel allowlist check
|
||||
channel_id = message.channel.channel_id if message.channel else ""
|
||||
if not bot.config.is_channel_allowed(channel_id):
|
||||
logger.debug(f"Message dropped: channel {channel_id} not in allowed_channels")
|
||||
return None
|
||||
|
||||
# 2. User allowlist and pairing check
|
||||
user_id = message.sender.user_id if message.sender else ""
|
||||
is_explicitly_allowed = bot.config.is_user_allowed(user_id)
|
||||
|
||||
if not is_explicitly_allowed:
|
||||
# Check if bot context is available for pairing system
|
||||
if not hasattr(bot, '_bot_context') or bot._bot_context is None:
|
||||
# For gateway mode, we need to create bot context on demand
|
||||
if not hasattr(bot, '_pairing_store'):
|
||||
from ..gateway.pairing import PairingStore
|
||||
bot._pairing_store = PairingStore()
|
||||
|
||||
bot._bot_context = BotContext(
|
||||
config=bot.config,
|
||||
pairing_store=bot._pairing_store,
|
||||
adapter=bot
|
||||
)
|
||||
|
||||
user_allowed = await UnknownUserHandler.handle(message, bot._bot_context)
|
||||
if not user_allowed:
|
||||
logger.debug(f"Message dropped: user {user_id} not allowed by pairing system")
|
||||
return None
|
||||
|
||||
# 3. Group policy enforcement
|
||||
if message.channel and message.channel.channel_type not in ("dm", "private"):
|
||||
# This is a group/channel message, check group policies
|
||||
group_policy = getattr(bot.config, 'group_policy', 'mention_only')
|
||||
mention_required = getattr(bot.config, 'mention_required', True)
|
||||
|
||||
if group_policy == "command_only":
|
||||
if message.message_type != MessageType.COMMAND:
|
||||
logger.debug(f"Message dropped: non-command in command_only group {channel_id}")
|
||||
return None
|
||||
elif group_policy == "mention_only":
|
||||
# Check if bot was mentioned in the message
|
||||
bot_username = bot._bot_user.username.lower() if bot._bot_user and bot._bot_user.username else ""
|
||||
mention_handle = f"@{bot_username}" if bot_username else ""
|
||||
bot_mentioned = (
|
||||
mention_handle and mention_handle in message.content.lower()
|
||||
) or message.message_type == MessageType.COMMAND # Commands are always allowed
|
||||
|
||||
if not bot_mentioned:
|
||||
logger.debug(f"Message dropped: bot not mentioned in group {channel_id}")
|
||||
return None
|
||||
elif group_policy == "respond_all":
|
||||
# Allow all group messages
|
||||
pass
|
||||
elif mention_required:
|
||||
# Fallback for backward compatibility when group_policy is not set
|
||||
bot_username = bot._bot_user.username.lower() if bot._bot_user and bot._bot_user.username else ""
|
||||
mention_handle = f"@{bot_username}" if bot_username else ""
|
||||
bot_mentioned = (
|
||||
mention_handle and mention_handle in message.content.lower()
|
||||
) or message.message_type == MessageType.COMMAND # Commands are always allowed
|
||||
|
||||
if not bot_mentioned:
|
||||
logger.debug(f"Message dropped: bot not mentioned in group {channel_id}")
|
||||
return None
|
||||
|
||||
# All security checks passed
|
||||
logger.debug(f"Message security checks passed for user {user_id} in channel {channel_id}")
|
||||
return message
|
||||
@@ -72,6 +72,8 @@ configure_host(
|
||||
"guardrails",
|
||||
"sessions",
|
||||
"usage",
|
||||
"kanban", # ADD
|
||||
"jobs-board", # ADD
|
||||
"config",
|
||||
"logs",
|
||||
"debug",
|
||||
|
||||
@@ -305,6 +305,7 @@ def register_commands():
|
||||
from .commands.mcp import app as mcp_app
|
||||
from .commands.serve import app as serve_app
|
||||
from .commands.schedule import app as schedule_app
|
||||
from .commands.kanban import app as kanban_app
|
||||
from .commands.run import app as run_app
|
||||
from .commands.profile import app as profile_app
|
||||
from .commands.benchmark import app as benchmark_app
|
||||
@@ -387,6 +388,7 @@ def register_commands():
|
||||
app.add_typer(mcp_app, name="mcp", help="MCP server management")
|
||||
app.add_typer(serve_app, name="serve", help="API server management")
|
||||
app.add_typer(schedule_app, name="schedule", help="Scheduler management")
|
||||
app.add_typer(kanban_app, name="kanban", help="Kanban task management")
|
||||
app.add_typer(run_app, name="run", help="Run agents")
|
||||
app.add_typer(profile_app, name="profile", help="Performance profiling and diagnostics")
|
||||
app.add_typer(benchmark_app, name="benchmark", help="Comprehensive performance benchmarking")
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
"""
|
||||
Kanban command group for PraisonAI CLI.
|
||||
|
||||
Provides kanban task management for multi-agent coordination.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Optional, List
|
||||
|
||||
import typer
|
||||
|
||||
from ..output.console import get_output_controller
|
||||
|
||||
app = typer.Typer(help="Kanban task management")
|
||||
|
||||
|
||||
def _get_kanban_store():
|
||||
"""Get kanban store instance."""
|
||||
try:
|
||||
from praisonai.kanban.sqlite_store import SQLiteKanbanStore
|
||||
return SQLiteKanbanStore()
|
||||
except ImportError as e:
|
||||
output = get_output_controller()
|
||||
output.print_error(f"Kanban module not available: {e}")
|
||||
raise typer.Exit(4)
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def kanban_list_cmd(
|
||||
status: Optional[str] = typer.Option(None, "--status", "-s", help="Filter by status (todo, ready, running, blocked, done)"),
|
||||
assignee: Optional[str] = typer.Option(None, "--assignee", "-a", help="Filter by assignee"),
|
||||
board: str = typer.Option("default", "--board", "-b", help="Board name"),
|
||||
limit: int = typer.Option(50, "--limit", "-l", help="Maximum number of tasks to show"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
||||
):
|
||||
"""List kanban tasks with optional filtering."""
|
||||
output = get_output_controller()
|
||||
|
||||
try:
|
||||
store = _get_kanban_store()
|
||||
|
||||
# Build filters
|
||||
filters = {'board': board}
|
||||
if status:
|
||||
filters['status'] = status
|
||||
if assignee:
|
||||
filters['assignee'] = assignee
|
||||
|
||||
tasks = store.list_tasks(filters)
|
||||
|
||||
if len(tasks) > limit:
|
||||
tasks = tasks[:limit]
|
||||
|
||||
if json_output:
|
||||
result = {
|
||||
'tasks': [task.to_dict() for task in tasks],
|
||||
'count': len(tasks),
|
||||
'filters': filters,
|
||||
'limited': len(tasks) == limit
|
||||
}
|
||||
output.print_json(result)
|
||||
else:
|
||||
# Pretty print table
|
||||
if not tasks:
|
||||
output.print_info("No tasks found")
|
||||
return
|
||||
|
||||
output.print_info(f"Found {len(tasks)} tasks{' (limited)' if len(tasks) == limit else ''}")
|
||||
output.print("")
|
||||
|
||||
# Table header
|
||||
print(f"{'ID':<15} {'Status':<10} {'Title':<40} {'Assignee':<15} {'Priority':<8}")
|
||||
print("-" * 90)
|
||||
|
||||
# Task rows
|
||||
for task in tasks:
|
||||
title = task.title[:37] + "..." if len(task.title) > 40 else task.title
|
||||
assignee = task.assignee[:12] + "..." if len(task.assignee) > 15 else task.assignee
|
||||
print(f"{task.id:<15} {task.status.value:<10} {title:<40} {assignee:<15} {task.priority:<8}")
|
||||
|
||||
except Exception as e:
|
||||
output.print_error(f"Failed to list tasks: {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command("create")
|
||||
def kanban_create_cmd(
|
||||
title: str = typer.Argument(..., help="Task title"),
|
||||
body: str = typer.Option("", "--body", "-b", help="Task description"),
|
||||
assignee: str = typer.Option("", "--assignee", "-a", help="Username to assign to"),
|
||||
status: str = typer.Option("todo", "--status", "-s", help="Initial status"),
|
||||
priority: int = typer.Option(0, "--priority", "-p", help="Task priority (higher = more important)"),
|
||||
board: str = typer.Option("default", "--board", help="Board name"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
||||
):
|
||||
"""Create a new kanban task."""
|
||||
output = get_output_controller()
|
||||
|
||||
try:
|
||||
store = _get_kanban_store()
|
||||
|
||||
task_data = {
|
||||
'title': title,
|
||||
'body': body,
|
||||
'assignee': assignee,
|
||||
'status': status,
|
||||
'priority': priority,
|
||||
'board': board
|
||||
}
|
||||
|
||||
task = store.create_task(task_data)
|
||||
|
||||
if json_output:
|
||||
output.print_json(task.to_dict())
|
||||
else:
|
||||
output.print_success(f"Created task {task.id}")
|
||||
output.print_info(f"Title: {task.title}")
|
||||
output.print_info(f"Status: {task.status.value}")
|
||||
if assignee:
|
||||
output.print_info(f"Assignee: {task.assignee}")
|
||||
|
||||
except Exception as e:
|
||||
output.print_error(f"Failed to create task: {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command("show")
|
||||
def kanban_show_cmd(
|
||||
task_id: str = typer.Argument(..., help="Task ID to show"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
||||
):
|
||||
"""Show detailed task information."""
|
||||
output = get_output_controller()
|
||||
|
||||
try:
|
||||
store = _get_kanban_store()
|
||||
|
||||
task = store.get_task(task_id)
|
||||
if not task:
|
||||
output.print_error(f"Task {task_id} not found")
|
||||
raise typer.Exit(1)
|
||||
|
||||
comments = store.get_comments(task_id)
|
||||
|
||||
if json_output:
|
||||
result = {
|
||||
'task': task.to_dict(),
|
||||
'comments': [comment.to_dict() for comment in comments],
|
||||
'comment_count': len(comments)
|
||||
}
|
||||
output.print_json(result)
|
||||
else:
|
||||
# Pretty print task details
|
||||
output.print_info(f"Task: {task.id}")
|
||||
output.print("")
|
||||
output.print_info(f"Title: {task.title}")
|
||||
output.print_info(f"Status: {task.status.value}")
|
||||
output.print_info(f"Assignee: {task.assignee or '(unassigned)'}")
|
||||
output.print_info(f"Priority: {task.priority}")
|
||||
output.print_info(f"Board: {task.board}")
|
||||
output.print_info(f"Created: {task.created_at.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
output.print_info(f"Updated: {task.updated_at.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
|
||||
if task.body:
|
||||
output.print("")
|
||||
output.print_info("Description:")
|
||||
output.print(task.body)
|
||||
|
||||
if comments:
|
||||
output.print("")
|
||||
output.print_info(f"Comments ({len(comments)}):")
|
||||
for comment in comments:
|
||||
output.print(f" [{comment.created_at.strftime('%m-%d %H:%M')}] {comment.author}: {comment.text}")
|
||||
|
||||
except Exception as e:
|
||||
output.print_error(f"Failed to show task: {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command("move")
|
||||
def kanban_move_cmd(
|
||||
task_id: str = typer.Argument(..., help="Task ID to move"),
|
||||
status: str = typer.Argument(..., help="New status (todo, ready, running, blocked, done)"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
||||
):
|
||||
"""Move task to a new status."""
|
||||
output = get_output_controller()
|
||||
|
||||
try:
|
||||
store = _get_kanban_store()
|
||||
|
||||
task = store.move_task(task_id, status)
|
||||
|
||||
if json_output:
|
||||
output.print_json(task.to_dict())
|
||||
else:
|
||||
output.print_success(f"Moved task {task_id} to {status}")
|
||||
|
||||
except Exception as e:
|
||||
output.print_error(f"Failed to move task: {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command("comment")
|
||||
def kanban_comment_cmd(
|
||||
task_id: str = typer.Argument(..., help="Task ID to comment on"),
|
||||
text: str = typer.Argument(..., help="Comment text"),
|
||||
author: str = typer.Option("cli", "--author", "-a", help="Comment author"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
||||
):
|
||||
"""Add a comment to a task."""
|
||||
output = get_output_controller()
|
||||
|
||||
try:
|
||||
store = _get_kanban_store()
|
||||
|
||||
comment = store.add_comment(task_id, author, text)
|
||||
|
||||
if json_output:
|
||||
output.print_json(comment.to_dict())
|
||||
else:
|
||||
output.print_success(f"Added comment to task {task_id}")
|
||||
|
||||
except Exception as e:
|
||||
output.print_error(f"Failed to add comment: {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command("link")
|
||||
def kanban_link_cmd(
|
||||
parent_id: str = typer.Argument(..., help="Parent task ID"),
|
||||
child_id: str = typer.Argument(..., help="Child task ID"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
||||
):
|
||||
"""Create dependency link between tasks."""
|
||||
output = get_output_controller()
|
||||
|
||||
try:
|
||||
store = _get_kanban_store()
|
||||
|
||||
link = store.add_link(parent_id, child_id)
|
||||
|
||||
if json_output:
|
||||
result = {
|
||||
'parent_id': link.parent_id,
|
||||
'child_id': link.child_id,
|
||||
'created_at': link.created_at.isoformat()
|
||||
}
|
||||
output.print_json(result)
|
||||
else:
|
||||
output.print_success(f"Linked {parent_id} -> {child_id}")
|
||||
|
||||
except Exception as e:
|
||||
output.print_error(f"Failed to link tasks: {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command("boards")
|
||||
def kanban_boards_cmd(
|
||||
action: str = typer.Argument("list", help="Action: list, switch"),
|
||||
board: Optional[str] = typer.Argument(None, help="Board name for switch action"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
||||
):
|
||||
"""Manage kanban boards."""
|
||||
output = get_output_controller()
|
||||
|
||||
try:
|
||||
if action == "list":
|
||||
from praisonai.kanban.paths import list_available_boards
|
||||
boards = list_available_boards()
|
||||
|
||||
if json_output:
|
||||
output.print_json({"boards": boards})
|
||||
else:
|
||||
output.print_info(f"Available boards:")
|
||||
for board_name in boards:
|
||||
output.print(f" - {board_name}")
|
||||
|
||||
elif action == "switch":
|
||||
if not board:
|
||||
output.print_error("Board name required for switch action")
|
||||
raise typer.Exit(1)
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
# Set for current session
|
||||
os.environ['PRAISONAI_KANBAN_BOARD'] = board
|
||||
|
||||
# Persist to config file for future sessions
|
||||
config_dir = Path.home() / ".praisonai"
|
||||
config_dir.mkdir(exist_ok=True)
|
||||
config_file = config_dir / "kanban_config.json"
|
||||
|
||||
try:
|
||||
config = {"active_board": board}
|
||||
with open(config_file, 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
output.print_success(f"Switched to board '{board}' (persisted)")
|
||||
except Exception as e:
|
||||
output.print_success(f"Switched to board '{board}' (session only)")
|
||||
output.print_info(f"Could not persist setting: {e}")
|
||||
|
||||
else:
|
||||
output.print_error(f"Unknown action: {action}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
except Exception as e:
|
||||
output.print_error(f"Board operation failed: {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command("dispatch")
|
||||
def kanban_dispatch_cmd(
|
||||
max_spawn: int = typer.Option(3, "--max-spawn", "-m", help="Maximum tasks to spawn"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
||||
):
|
||||
"""Manually trigger kanban task dispatcher."""
|
||||
output = get_output_controller()
|
||||
|
||||
try:
|
||||
import asyncio
|
||||
from praisonai.gateway.kanban_dispatcher import dispatch_once
|
||||
|
||||
# Run dispatcher once
|
||||
spawned = asyncio.run(dispatch_once(max_spawn))
|
||||
|
||||
if json_output:
|
||||
output.print_json({"spawned": spawned})
|
||||
else:
|
||||
if spawned > 0:
|
||||
output.print_success(f"Spawned {spawned} task workers")
|
||||
else:
|
||||
output.print_info("No ready tasks to spawn")
|
||||
|
||||
except Exception as e:
|
||||
output.print_error(f"Dispatch failed: {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command("block")
|
||||
def kanban_block_cmd(
|
||||
task_id: str = typer.Argument(..., help="Task ID to block"),
|
||||
reason: str = typer.Argument(..., help="Reason for blocking"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
||||
):
|
||||
"""Block a task with a reason."""
|
||||
output = get_output_controller()
|
||||
|
||||
try:
|
||||
store = _get_kanban_store()
|
||||
|
||||
# Move to blocked and add comment
|
||||
task = store.move_task(task_id, 'blocked')
|
||||
store.add_comment(task_id, 'cli', f"Blocked: {reason}")
|
||||
|
||||
if json_output:
|
||||
output.print_json(task.to_dict())
|
||||
else:
|
||||
output.print_success(f"Blocked task {task_id}: {reason}")
|
||||
|
||||
except Exception as e:
|
||||
output.print_error(f"Failed to block task: {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command("unblock")
|
||||
def kanban_unblock_cmd(
|
||||
task_id: str = typer.Argument(..., help="Task ID to unblock"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
||||
):
|
||||
"""Unblock a task (move to ready)."""
|
||||
output = get_output_controller()
|
||||
|
||||
try:
|
||||
store = _get_kanban_store()
|
||||
|
||||
task = store.move_task(task_id, 'ready')
|
||||
store.add_comment(task_id, 'cli', "Unblocked by CLI")
|
||||
|
||||
if json_output:
|
||||
output.print_json(task.to_dict())
|
||||
else:
|
||||
output.print_success(f"Unblocked task {task_id}")
|
||||
|
||||
except Exception as e:
|
||||
output.print_error(f"Failed to unblock task: {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command("complete")
|
||||
def kanban_complete_cmd(
|
||||
task_id: str = typer.Argument(..., help="Task ID to complete"),
|
||||
comment: str = typer.Option("", "--comment", "-c", help="Completion comment"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
||||
):
|
||||
"""Mark a task as completed."""
|
||||
output = get_output_controller()
|
||||
|
||||
try:
|
||||
store = _get_kanban_store()
|
||||
|
||||
task = store.move_task(task_id, 'done')
|
||||
|
||||
if comment:
|
||||
store.add_comment(task_id, 'cli', f"Completed: {comment}")
|
||||
|
||||
if json_output:
|
||||
output.print_json(task.to_dict())
|
||||
else:
|
||||
output.print_success(f"Completed task {task_id}")
|
||||
|
||||
except Exception as e:
|
||||
output.print_error(f"Failed to complete task: {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command("reclaim")
|
||||
def kanban_reclaim_cmd(
|
||||
task_id: str = typer.Argument(..., help="Task ID to reclaim"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
||||
):
|
||||
"""Reclaim a stuck/abandoned task."""
|
||||
output = get_output_controller()
|
||||
|
||||
try:
|
||||
store = _get_kanban_store()
|
||||
|
||||
# Force release any claim and move to ready
|
||||
store.update_task(task_id, {'claim_lock': None})
|
||||
task = store.move_task(task_id, 'ready')
|
||||
store.add_comment(task_id, 'cli', "Reclaimed from stuck state")
|
||||
|
||||
if json_output:
|
||||
output.print_json(task.to_dict())
|
||||
else:
|
||||
output.print_success(f"Reclaimed task {task_id}")
|
||||
|
||||
except Exception as e:
|
||||
output.print_error(f"Failed to reclaim task: {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
# Add to main CLI app if this module is being registered
|
||||
def register_kanban_commands(main_app):
|
||||
"""Register kanban commands with main CLI app."""
|
||||
main_app.add_typer(app, name="kanban")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
@@ -151,6 +151,8 @@ def mcp_test(
|
||||
timeout: float = typer.Option(10.0, "--timeout", "-t", help="Timeout in seconds"),
|
||||
):
|
||||
"""Test an MCP server connection."""
|
||||
from ..configuration.schema import MCPLocalConfig, MCPRemoteConfig
|
||||
|
||||
output = get_output_controller()
|
||||
loader = get_config_loader()
|
||||
|
||||
@@ -161,59 +163,103 @@ def mcp_test(
|
||||
|
||||
server = config.mcp.servers[name]
|
||||
|
||||
output.print_info(f"Testing MCP server: {name}")
|
||||
output.print(f" Command: {server.command} {' '.join(server.args)}")
|
||||
|
||||
# Try to start the server and check if it responds
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
try:
|
||||
cmd = [server.command] + server.args
|
||||
env = dict(**server.env) if server.env else None
|
||||
if isinstance(server, MCPRemoteConfig):
|
||||
# Test remote server
|
||||
output.print_info(f"Testing remote MCP server: {name}")
|
||||
output.print(f" URL: {server.url}")
|
||||
|
||||
# Start process
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
env=env,
|
||||
)
|
||||
|
||||
# Wait briefly and check if it's running
|
||||
time.sleep(0.5)
|
||||
|
||||
if proc.poll() is None:
|
||||
# Process is running
|
||||
proc.terminate()
|
||||
proc.wait(timeout=2)
|
||||
try:
|
||||
import httpx
|
||||
|
||||
# Simple HTTP probe to check if server is reachable
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
response = client.get(server.url)
|
||||
|
||||
if response.status_code == 200:
|
||||
if output.is_json_mode:
|
||||
output.print_json({"name": name, "status": "ok", "message": "Remote server reachable"})
|
||||
else:
|
||||
output.print_success(f"Remote server '{name}' is reachable")
|
||||
else:
|
||||
if output.is_json_mode:
|
||||
output.print_json({"name": name, "status": "error", "message": f"HTTP {response.status_code}"})
|
||||
else:
|
||||
output.print_error(f"Remote server returned HTTP {response.status_code}")
|
||||
raise typer.Exit(1)
|
||||
except ImportError:
|
||||
if output.is_json_mode:
|
||||
output.print_json({"name": name, "status": "ok", "message": "Server started successfully"})
|
||||
output.print_json({"name": name, "status": "error", "message": "httpx not available for remote testing"})
|
||||
else:
|
||||
output.print_success(f"Server '{name}' started successfully")
|
||||
else:
|
||||
# Process exited
|
||||
stderr = proc.stderr.read().decode() if proc.stderr else ""
|
||||
output.print_error("httpx package not available. Install with: pip install httpx")
|
||||
raise typer.Exit(1)
|
||||
except typer.Exit:
|
||||
# Let typer.Exit propagate, don't catch it
|
||||
raise
|
||||
except Exception as e:
|
||||
if output.is_json_mode:
|
||||
output.print_json({"name": name, "status": "error", "message": stderr or "Server exited immediately"})
|
||||
output.print_json({"name": name, "status": "error", "message": str(e)})
|
||||
else:
|
||||
output.print_error(f"Server exited immediately: {stderr}")
|
||||
output.print_error(f"Failed to connect to remote server: {e}")
|
||||
raise typer.Exit(1)
|
||||
else:
|
||||
# Test local server
|
||||
output.print_info(f"Testing local MCP server: {name}")
|
||||
output.print(f" Command: {server.command} {' '.join(server.args)}")
|
||||
|
||||
# Try to start the server and check if it responds
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
try:
|
||||
cmd = [server.command] + server.args
|
||||
# Fix environment inheritance - merge with current env instead of replacing
|
||||
env = None
|
||||
if server.env:
|
||||
import os
|
||||
env = {**os.environ, **server.env}
|
||||
|
||||
# Start process
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
env=env,
|
||||
)
|
||||
|
||||
# Wait briefly and check if it's running
|
||||
time.sleep(0.5)
|
||||
|
||||
if proc.poll() is None:
|
||||
# Process is running
|
||||
proc.terminate()
|
||||
proc.wait(timeout=2)
|
||||
|
||||
if output.is_json_mode:
|
||||
output.print_json({"name": name, "status": "ok", "message": "Server started successfully"})
|
||||
else:
|
||||
output.print_success(f"Server '{name}' started successfully")
|
||||
else:
|
||||
# Process exited
|
||||
stderr = proc.stderr.read().decode() if proc.stderr else ""
|
||||
if output.is_json_mode:
|
||||
output.print_json({"name": name, "status": "error", "message": stderr or "Server exited immediately"})
|
||||
else:
|
||||
output.print_error(f"Server exited immediately: {stderr}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
except FileNotFoundError:
|
||||
if output.is_json_mode:
|
||||
output.print_json({"name": name, "status": "error", "message": f"Command not found: {server.command}"})
|
||||
else:
|
||||
output.print_error(f"Command not found: {server.command}")
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
if output.is_json_mode:
|
||||
output.print_json({"name": name, "status": "error", "message": str(e)})
|
||||
else:
|
||||
output.print_error(f"Test failed: {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
except FileNotFoundError:
|
||||
if output.is_json_mode:
|
||||
output.print_json({"name": name, "status": "error", "message": f"Command not found: {server.command}"})
|
||||
else:
|
||||
output.print_error(f"Command not found: {server.command}")
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
if output.is_json_mode:
|
||||
output.print_json({"name": name, "status": "error", "message": str(e)})
|
||||
else:
|
||||
output.print_error(f"Test failed: {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command("sync")
|
||||
@@ -248,15 +294,28 @@ def mcp_sync(
|
||||
srv_config = config.mcp.servers[srv]
|
||||
|
||||
try:
|
||||
# Import schema types for checking
|
||||
from ..configuration.schema import MCPLocalConfig, MCPRemoteConfig
|
||||
# Actually connect to MCP server and fetch tools
|
||||
from praisonaiagents.mcp import MCP
|
||||
|
||||
# Build command string
|
||||
cmd_parts = [srv_config.command] + srv_config.args
|
||||
cmd_string = " ".join(cmd_parts)
|
||||
|
||||
# Create MCP instance with env vars
|
||||
mcp = MCP(cmd_string, timeout=int(timeout), env=srv_config.env or {})
|
||||
# Handle different server types
|
||||
if isinstance(srv_config, MCPRemoteConfig):
|
||||
# Remote server - connect via URL
|
||||
if not srv_config.url:
|
||||
output.print_error(f" Remote server {srv} has no URL configured")
|
||||
failed_servers.append(srv)
|
||||
continue
|
||||
|
||||
# Create MCP instance for remote server
|
||||
mcp = MCP(srv_config.url, timeout=int(timeout))
|
||||
else:
|
||||
# Local server - use command
|
||||
cmd_parts = [srv_config.command] + srv_config.args
|
||||
cmd_string = " ".join(cmd_parts)
|
||||
|
||||
# Create MCP instance with env vars
|
||||
mcp = MCP(cmd_string, timeout=int(timeout), env=srv_config.env or {})
|
||||
|
||||
# Get tools
|
||||
tools = mcp.get_tools()
|
||||
@@ -524,7 +583,11 @@ def mcp_auth(
|
||||
name: str = typer.Argument(..., help="Server name to authenticate"),
|
||||
timeout: float = typer.Option(300.0, "--timeout", "-t", help="Timeout for OAuth flow in seconds"),
|
||||
):
|
||||
"""Authenticate with an OAuth-enabled MCP server.
|
||||
"""[EXPERIMENTAL] Authenticate with an OAuth-enabled MCP server.
|
||||
|
||||
WARNING: OAuth implementation is currently experimental and stores
|
||||
placeholder tokens only. Real token exchange is not yet implemented.
|
||||
Use headers/API key authentication for production use.
|
||||
|
||||
This command initiates the OAuth 2.1 authorization flow for a remote
|
||||
MCP server. It will open your browser for authentication and wait
|
||||
|
||||
@@ -355,18 +355,10 @@ def _run_init(parsed) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _run_ai(parsed) -> int:
|
||||
"""Run the AI generation subcommand with ACP/LSP runtime support."""
|
||||
import asyncio
|
||||
async def _run_ai_with_runtime(parsed, config, slug):
|
||||
"""Helper function to run AI generation with proper runtime lifecycle."""
|
||||
from praisonai.standardise.ai_generator import AIGenerator
|
||||
from praisonai.standardise.models import ArtifactType, FeatureSlug
|
||||
|
||||
config = _create_config(parsed)
|
||||
|
||||
slug = FeatureSlug.from_string(parsed.feature)
|
||||
if not slug.is_valid:
|
||||
print(f"Error: Invalid feature slug: {slug.validation_error}")
|
||||
return 2
|
||||
from praisonai.standardise.models import ArtifactType
|
||||
|
||||
# Start ACP/LSP runtime for context gathering
|
||||
runtime = None
|
||||
@@ -377,14 +369,30 @@ def _run_ai(parsed) -> int:
|
||||
lsp=True,
|
||||
acp=False # ACP not needed for generation
|
||||
)
|
||||
# Start runtime in background
|
||||
asyncio.get_event_loop().run_until_complete(runtime.start())
|
||||
# Start runtime
|
||||
await runtime.start()
|
||||
if runtime.lsp_ready:
|
||||
print("🔧 LSP server ready for code intelligence")
|
||||
except Exception:
|
||||
# Runtime is optional, continue without it
|
||||
pass
|
||||
|
||||
try:
|
||||
return await _run_generation_logic(parsed, config, slug, runtime)
|
||||
finally:
|
||||
# Cleanup runtime
|
||||
if runtime:
|
||||
try:
|
||||
await runtime.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _run_generation_logic(parsed, config, slug, runtime):
|
||||
"""Main AI generation logic."""
|
||||
from praisonai.standardise.ai_generator import AIGenerator
|
||||
from praisonai.standardise.models import ArtifactType
|
||||
|
||||
generator = AIGenerator(
|
||||
model=parsed.model,
|
||||
sdk_root=config.sdk_root,
|
||||
@@ -485,16 +493,25 @@ def _run_ai(parsed) -> int:
|
||||
if not parsed.apply:
|
||||
print("\nRun with --apply to create these files.")
|
||||
|
||||
# Cleanup runtime
|
||||
if runtime:
|
||||
try:
|
||||
asyncio.get_event_loop().run_until_complete(runtime.stop())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _run_ai(parsed) -> int:
|
||||
"""Run the AI generation subcommand with ACP/LSP runtime support."""
|
||||
import asyncio
|
||||
from praisonai.standardise.models import FeatureSlug
|
||||
|
||||
config = _create_config(parsed)
|
||||
|
||||
slug = FeatureSlug.from_string(parsed.feature)
|
||||
if not slug.is_valid:
|
||||
print(f"Error: Invalid feature slug: {slug.validation_error}")
|
||||
return 2
|
||||
|
||||
# Run the async workflow with single event loop
|
||||
return asyncio.run(_run_ai_with_runtime(parsed, config, slug))
|
||||
|
||||
|
||||
def _run_checkpoint(parsed) -> int:
|
||||
"""Run the checkpoint subcommand."""
|
||||
from praisonai.standardise.undo_redo import UndoRedoManager
|
||||
|
||||
@@ -20,12 +20,30 @@ from ..registry import register_check
|
||||
|
||||
def _find_mcp_config() -> dict:
|
||||
"""Find MCP configuration files."""
|
||||
locations = [
|
||||
Path.cwd() / ".praison" / "mcp.json",
|
||||
Path.cwd() / ".praison" / "mcp" / "config.json",
|
||||
Path.home() / ".praison" / "mcp.json",
|
||||
Path.home() / ".config" / "praison" / "mcp.json",
|
||||
]
|
||||
# Use canonical MCP paths from praisonaiagents.paths
|
||||
try:
|
||||
from praisonaiagents.paths import get_mcp_dir, get_project_data_dir
|
||||
canonical_mcp_dir = get_mcp_dir()
|
||||
project_mcp_dir = get_project_data_dir() / "mcp"
|
||||
|
||||
locations = [
|
||||
project_mcp_dir / "config.json",
|
||||
canonical_mcp_dir / "config.json",
|
||||
# Legacy paths for backward compatibility
|
||||
Path.cwd() / ".praison" / "mcp.json",
|
||||
Path.cwd() / ".praison" / "mcp" / "config.json",
|
||||
Path.home() / ".praison" / "mcp.json",
|
||||
Path.home() / ".config" / "praison" / "mcp.json",
|
||||
]
|
||||
except ImportError:
|
||||
# Fallback to legacy paths if praisonaiagents not available
|
||||
locations = [
|
||||
Path.cwd() / ".praisonai" / "mcp" / "config.json",
|
||||
Path.cwd() / ".praison" / "mcp.json",
|
||||
Path.cwd() / ".praison" / "mcp" / "config.json",
|
||||
Path.home() / ".praison" / "mcp.json",
|
||||
Path.home() / ".config" / "praison" / "mcp.json",
|
||||
]
|
||||
|
||||
for loc in locations:
|
||||
if loc.exists():
|
||||
@@ -70,7 +88,7 @@ def check_mcp_config(config: DoctorConfig) -> CheckResult:
|
||||
category=CheckCategory.MCP,
|
||||
status=CheckStatus.SKIP,
|
||||
message="No MCP configuration found (optional)",
|
||||
details="Create .praison/mcp.json to configure MCP servers",
|
||||
details="Create ~/.praisonai/mcp/config.json to configure MCP servers",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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.48 gunicorn markdown\n")
|
||||
file.write("RUN pip install flask praisonai==4.6.52 gunicorn markdown\n")
|
||||
file.write("EXPOSE 8080\n")
|
||||
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ Provides lazy-loaded integration with AutoGen v0.2, AutoGen v0.4, and AG2 framew
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Dict, List, Any, Optional, Callable
|
||||
from .base import BaseFrameworkAdapter
|
||||
|
||||
@@ -12,7 +13,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AutoGenAdapter(BaseFrameworkAdapter):
|
||||
"""Adapter for AutoGen v0.2 framework."""
|
||||
"""Adapter for AutoGen v0.2 framework with version resolution."""
|
||||
|
||||
name = "autogen"
|
||||
install_hint = 'pip install "praisonai[autogen]"' # v0.2 only
|
||||
@@ -20,11 +21,39 @@ class AutoGenAdapter(BaseFrameworkAdapter):
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if AutoGen v0.2 is available for import."""
|
||||
try:
|
||||
import autogen # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
from .._framework_availability import is_available
|
||||
return is_available("autogen")
|
||||
|
||||
def resolve(self) -> "BaseFrameworkAdapter":
|
||||
"""Pick the concrete AutoGen adapter variant based on environment and availability."""
|
||||
autogen_version = os.environ.get("AUTOGEN_VERSION", "auto").lower()
|
||||
|
||||
# Import the specific adapters
|
||||
v4_adapter = AutoGenV4Adapter()
|
||||
v2_adapter = self # Current instance is v0.2
|
||||
|
||||
if autogen_version == "v0.4" and v4_adapter.is_available():
|
||||
logger.info("AutoGen version resolution: Using v0.4 (explicitly requested)")
|
||||
return v4_adapter
|
||||
elif autogen_version == "v0.2" and v2_adapter.is_available():
|
||||
logger.info("AutoGen version resolution: Using v0.2 (explicitly requested)")
|
||||
return v2_adapter
|
||||
elif autogen_version == "auto":
|
||||
# Auto-detect: prefer v0.4 if available, fallback to v0.2
|
||||
if v4_adapter.is_available():
|
||||
logger.info("AutoGen version resolution: Using v0.4 (auto-detected)")
|
||||
return v4_adapter
|
||||
else:
|
||||
logger.info("AutoGen version resolution: Using v0.2 (auto-detected fallback)")
|
||||
return v2_adapter
|
||||
else:
|
||||
# Invalid version or neither available, try both
|
||||
if v4_adapter.is_available() and not v2_adapter.is_available():
|
||||
logger.info("AutoGen version resolution: Using v0.4 (only available version)")
|
||||
return v4_adapter
|
||||
else:
|
||||
logger.info("AutoGen version resolution: Using v0.2 (default fallback)")
|
||||
return v2_adapter
|
||||
|
||||
def run(
|
||||
self,
|
||||
@@ -149,111 +178,144 @@ class AutoGenV4Adapter(BaseFrameworkAdapter):
|
||||
# Availability already validated at CLI entry
|
||||
|
||||
logger.info("Starting AutoGen v0.4 execution...")
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
|
||||
async def run_autogen_v4_async():
|
||||
from autogen_agentchat.agents import AssistantAgent
|
||||
from autogen_ext.models.openai import OpenAIChatCompletionClient
|
||||
from autogen_agentchat.teams import RoundRobinGroupChat
|
||||
from autogen_agentchat.conditions import TextMentionTermination, MaxMessageTermination
|
||||
|
||||
# Create model client for v0.4
|
||||
model_config = llm_config[0] if llm_config else {}
|
||||
model_client = OpenAIChatCompletionClient(
|
||||
model=model_config.get('model', 'gpt-4o-mini'),
|
||||
api_key=model_config.get('api_key', os.environ.get("OPENAI_API_KEY")),
|
||||
base_url=model_config.get('base_url', "https://api.openai.com/v1")
|
||||
)
|
||||
|
||||
agents = []
|
||||
combined_tasks = []
|
||||
|
||||
try:
|
||||
# Create agents from config
|
||||
for role, details in config['roles'].items():
|
||||
# For AutoGen v0.4, ensure agent name is a valid Python identifier
|
||||
agent_name = self._format_template(details['role'], topic=topic)
|
||||
agent_name = self._sanitize_agent_name_for_autogen_v4(agent_name)
|
||||
backstory = self._format_template(details['backstory'], topic=topic)
|
||||
|
||||
# Convert tools for v0.4 - simplified tool passing
|
||||
agent_tools = []
|
||||
for tool_name in details.get('tools', []):
|
||||
if tools_dict and tool_name in tools_dict:
|
||||
tool_instance = tools_dict[tool_name]
|
||||
# For v0.4, we can pass the tool's run method directly if it's callable
|
||||
if hasattr(tool_instance, 'run') and callable(tool_instance.run):
|
||||
agent_tools.append(tool_instance.run)
|
||||
|
||||
# Create v0.4 AssistantAgent
|
||||
assistant = AssistantAgent(
|
||||
name=agent_name,
|
||||
system_message=backstory + ". Must reply with 'TERMINATE' when the task is complete.",
|
||||
model_client=model_client,
|
||||
tools=agent_tools,
|
||||
reflect_on_tool_use=True
|
||||
)
|
||||
|
||||
agents.append(assistant)
|
||||
|
||||
# Collect all task descriptions for sequential execution
|
||||
for task_name, task_details in details.get('tasks', {}).items():
|
||||
description_filled = self._format_template(task_details['description'], topic=topic)
|
||||
combined_tasks.append(description_filled)
|
||||
|
||||
if not agents:
|
||||
return "No agents created from configuration"
|
||||
|
||||
# Create termination conditions
|
||||
text_termination = TextMentionTermination("TERMINATE")
|
||||
max_messages_termination = MaxMessageTermination(max_messages=20)
|
||||
termination_condition = text_termination | max_messages_termination
|
||||
|
||||
# Create RoundRobinGroupChat for parallel/sequential execution
|
||||
group_chat = RoundRobinGroupChat(
|
||||
agents,
|
||||
termination_condition=termination_condition,
|
||||
max_turns=len(agents) * 3 # Allow multiple rounds
|
||||
)
|
||||
|
||||
# Combine all tasks into a single task description
|
||||
task_description = f"Topic: {topic}\n\nTasks to complete:\n" + "\n".join(
|
||||
f"{i+1}. {task}" for i, task in enumerate(combined_tasks)
|
||||
)
|
||||
|
||||
# Run the group chat
|
||||
result = await group_chat.run(task=task_description)
|
||||
|
||||
# Extract the final message content
|
||||
if result.messages:
|
||||
final_message = result.messages[-1]
|
||||
if hasattr(final_message, 'content'):
|
||||
return f"### AutoGen v0.4 Output ###\n{final_message.content}"
|
||||
else:
|
||||
return f"### AutoGen v0.4 Output ###\n{str(final_message)}"
|
||||
else:
|
||||
return "### AutoGen v0.4 Output ###\nNo messages generated"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in AutoGen v0.4 execution: {str(e)}")
|
||||
return f"### AutoGen v0.4 Error ###\n{str(e)}"
|
||||
|
||||
finally:
|
||||
# Close the model client
|
||||
await model_client.close()
|
||||
|
||||
# Run the async function using safe bridge
|
||||
# Sync entry now goes via the async method via run_sync bridge
|
||||
try:
|
||||
from .._async_bridge import run_sync
|
||||
return run_sync(run_autogen_v4_async())
|
||||
return run_sync(self.arun(
|
||||
config, llm_config, topic,
|
||||
tools_dict=tools_dict,
|
||||
agent_callback=agent_callback,
|
||||
task_callback=task_callback,
|
||||
cli_config=cli_config
|
||||
))
|
||||
except ImportError:
|
||||
# Fallback if _async_bridge is not available
|
||||
return asyncio.run(run_autogen_v4_async())
|
||||
# Fallback if _async_bridge is not available
|
||||
import asyncio
|
||||
return asyncio.run(self.arun(
|
||||
config, llm_config, topic,
|
||||
tools_dict=tools_dict,
|
||||
agent_callback=agent_callback,
|
||||
task_callback=task_callback,
|
||||
cli_config=cli_config
|
||||
))
|
||||
except RuntimeError as e:
|
||||
# Re-raise run_sync event loop errors so users get clear guidance
|
||||
if "running event loop" in str(e):
|
||||
raise
|
||||
logger.error(f"AutoGen v0.4 runtime error: {str(e)}")
|
||||
return f"### AutoGen v0.4 Runtime Error ###\n{str(e)}"
|
||||
except Exception as e:
|
||||
logger.error(f"Error running AutoGen v0.4: {str(e)}")
|
||||
return f"### AutoGen v0.4 Error ###\n{str(e)}"
|
||||
|
||||
async def arun(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
llm_config: List[Dict],
|
||||
topic: str,
|
||||
*,
|
||||
tools_dict: Optional[Dict[str, Any]] = None,
|
||||
agent_callback: Optional[Callable] = None,
|
||||
task_callback: Optional[Callable] = None,
|
||||
cli_config: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Async-native AutoGen v0.4 execution without run_sync wrapper.
|
||||
"""
|
||||
# Availability already validated at CLI entry
|
||||
|
||||
logger.info("Starting AutoGen v0.4 async execution...")
|
||||
|
||||
from autogen_agentchat.agents import AssistantAgent
|
||||
from autogen_ext.models.openai import OpenAIChatCompletionClient
|
||||
from autogen_agentchat.teams import RoundRobinGroupChat
|
||||
from autogen_agentchat.conditions import TextMentionTermination, MaxMessageTermination
|
||||
|
||||
# Create model client for v0.4
|
||||
model_config = llm_config[0] if llm_config else {}
|
||||
model_client = OpenAIChatCompletionClient(
|
||||
model=model_config.get('model', 'gpt-4o-mini'),
|
||||
api_key=model_config.get('api_key', os.environ.get("OPENAI_API_KEY")),
|
||||
base_url=model_config.get('base_url', "https://api.openai.com/v1")
|
||||
)
|
||||
|
||||
agents = []
|
||||
combined_tasks = []
|
||||
|
||||
try:
|
||||
# Create agents from config
|
||||
for role, details in config['roles'].items():
|
||||
# For AutoGen v0.4, ensure agent name is a valid Python identifier
|
||||
agent_name = self._format_template(details['role'], topic=topic)
|
||||
agent_name = self._sanitize_agent_name_for_autogen_v4(agent_name)
|
||||
backstory = self._format_template(details['backstory'], topic=topic)
|
||||
|
||||
# Convert tools for v0.4 - simplified tool passing
|
||||
agent_tools = []
|
||||
for tool_name in details.get('tools', []):
|
||||
if tools_dict and tool_name in tools_dict:
|
||||
tool_instance = tools_dict[tool_name]
|
||||
# For v0.4, we can pass the tool's run method directly if it's callable
|
||||
if hasattr(tool_instance, 'run') and callable(tool_instance.run):
|
||||
agent_tools.append(tool_instance.run)
|
||||
|
||||
# Create v0.4 AssistantAgent
|
||||
assistant = AssistantAgent(
|
||||
name=agent_name,
|
||||
system_message=backstory + ". Must reply with 'TERMINATE' when the task is complete.",
|
||||
model_client=model_client,
|
||||
tools=agent_tools,
|
||||
reflect_on_tool_use=True
|
||||
)
|
||||
|
||||
agents.append(assistant)
|
||||
|
||||
# Collect all task descriptions for sequential execution
|
||||
for task_name, task_details in details.get('tasks', {}).items():
|
||||
description_filled = self._format_template(task_details['description'], topic=topic)
|
||||
combined_tasks.append(description_filled)
|
||||
|
||||
if not agents:
|
||||
return "No agents created from configuration"
|
||||
|
||||
# Create termination conditions
|
||||
text_termination = TextMentionTermination("TERMINATE")
|
||||
max_messages_termination = MaxMessageTermination(max_messages=20)
|
||||
termination_condition = text_termination | max_messages_termination
|
||||
|
||||
# Create RoundRobinGroupChat for parallel/sequential execution
|
||||
group_chat = RoundRobinGroupChat(
|
||||
agents,
|
||||
termination_condition=termination_condition,
|
||||
max_turns=len(agents) * 3 # Allow multiple rounds
|
||||
)
|
||||
|
||||
# Combine all tasks into a single task description
|
||||
task_description = f"Topic: {topic}\n\nTasks to complete:\n" + "\n".join(
|
||||
f"{i+1}. {task}" for i, task in enumerate(combined_tasks)
|
||||
)
|
||||
|
||||
# Run the group chat
|
||||
result = await group_chat.run(task=task_description)
|
||||
|
||||
# Extract the final message content
|
||||
if result.messages:
|
||||
final_message = result.messages[-1]
|
||||
if hasattr(final_message, 'content'):
|
||||
return f"### AutoGen v0.4 Output ###\n{final_message.content}"
|
||||
else:
|
||||
return f"### AutoGen v0.4 Output ###\n{str(final_message)}"
|
||||
else:
|
||||
return "### AutoGen v0.4 Output ###\nNo messages generated"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in AutoGen v0.4 async execution: {str(e)}")
|
||||
return f"### AutoGen v0.4 Error ###\n{str(e)}"
|
||||
|
||||
finally:
|
||||
# Close the model client
|
||||
await model_client.close()
|
||||
|
||||
def _sanitize_agent_name_for_autogen_v4(self, name):
|
||||
"""
|
||||
|
||||
@@ -20,6 +20,22 @@ class FrameworkAdapter(Protocol):
|
||||
"""Check if the framework is available for import."""
|
||||
...
|
||||
|
||||
def resolve(self) -> "FrameworkAdapter":
|
||||
"""Pick the concrete adapter variant (e.g. autogen v0.2 vs v0.4).
|
||||
|
||||
Returns:
|
||||
The resolved adapter instance (self or a different adapter)
|
||||
"""
|
||||
...
|
||||
|
||||
def setup(self, *, framework_tag: str) -> None:
|
||||
"""Framework-specific pre-run hooks (observability, sdk init, etc.).
|
||||
|
||||
Args:
|
||||
framework_tag: Framework name for observability tagging
|
||||
"""
|
||||
...
|
||||
|
||||
def run(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
@@ -47,6 +63,34 @@ class FrameworkAdapter(Protocol):
|
||||
Execution result as string
|
||||
"""
|
||||
...
|
||||
|
||||
async def arun(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
llm_config: List[Dict],
|
||||
topic: str,
|
||||
*,
|
||||
tools_dict: Optional[Dict[str, Any]] = None,
|
||||
agent_callback: Optional[Callable] = None,
|
||||
task_callback: Optional[Callable] = None,
|
||||
cli_config: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Async-native execution. Default = offload sync run() to a thread.
|
||||
|
||||
Args:
|
||||
config: Framework configuration
|
||||
llm_config: LLM configuration list
|
||||
topic: Topic for the tasks
|
||||
tools_dict: Available tools dictionary
|
||||
agent_callback: Callback for agent events
|
||||
task_callback: Callback for task events
|
||||
cli_config: CLI configuration
|
||||
|
||||
Returns:
|
||||
Execution result as string
|
||||
"""
|
||||
...
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Clean up any resources after execution."""
|
||||
@@ -87,6 +131,37 @@ class BaseFrameworkAdapter:
|
||||
logger.warning("Template formatting error: %s; returning original template", e)
|
||||
return template
|
||||
|
||||
def resolve(self) -> "FrameworkAdapter":
|
||||
"""Default implementation returns self."""
|
||||
return self
|
||||
|
||||
def setup(self, *, framework_tag: str) -> None:
|
||||
"""Default implementation does nothing."""
|
||||
pass
|
||||
|
||||
async def arun(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
llm_config: List[Dict],
|
||||
topic: str,
|
||||
*,
|
||||
tools_dict: Optional[Dict[str, Any]] = None,
|
||||
agent_callback: Optional[Callable] = None,
|
||||
task_callback: Optional[Callable] = None,
|
||||
cli_config: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Safe default for sync-only adapters (crewai, autogen v0.2):
|
||||
run the sync implementation in a worker thread, freeing the loop.
|
||||
"""
|
||||
import asyncio
|
||||
return await asyncio.to_thread(
|
||||
self.run, config, llm_config, topic,
|
||||
tools_dict=tools_dict,
|
||||
agent_callback=agent_callback,
|
||||
task_callback=task_callback,
|
||||
cli_config=cli_config
|
||||
)
|
||||
def cleanup(self) -> None:
|
||||
"""Clean up resources - default implementation does nothing."""
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
"""
|
||||
Kanban task dispatcher for gateway integration.
|
||||
|
||||
This module implements the background dispatcher that claims ready kanban tasks
|
||||
and spawns agent runs to execute them. It integrates with the PraisonAI gateway
|
||||
system and follows Hermes patterns for worker management.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Global dispatcher state
|
||||
_dispatcher_running = False
|
||||
_dispatcher_task = None
|
||||
|
||||
|
||||
class KanbanDispatcher:
|
||||
"""Kanban task dispatcher that claims ready tasks and spawns workers."""
|
||||
|
||||
def __init__(self, max_concurrent: int = 3, poll_interval: float = 5.0):
|
||||
"""
|
||||
Initialize kanban dispatcher.
|
||||
|
||||
Args:
|
||||
max_concurrent: Maximum concurrent task executions
|
||||
poll_interval: Seconds between task polling
|
||||
"""
|
||||
self.max_concurrent = max_concurrent
|
||||
self.poll_interval = poll_interval
|
||||
self.running_tasks: Dict[str, subprocess.Popen] = {}
|
||||
self.worker_id = f"gateway_{os.getpid()}"
|
||||
|
||||
def _get_kanban_store(self):
|
||||
"""Get kanban store instance."""
|
||||
try:
|
||||
from praisonai.kanban.sqlite_store import SQLiteKanbanStore
|
||||
return SQLiteKanbanStore()
|
||||
except ImportError:
|
||||
logger.error("Kanban store not available")
|
||||
return None
|
||||
|
||||
def _fire_hook_event(self, event_type: str, task_data: Dict[str, Any]):
|
||||
"""Fire kanban hook events (when available)."""
|
||||
try:
|
||||
# Try to import and fire hook events from praisonaiagents
|
||||
from praisonaiagents.hooks import fire_hook
|
||||
fire_hook(event_type, task_data)
|
||||
except (ImportError, AttributeError):
|
||||
# Hook system not available, just log
|
||||
logger.debug(f"Hook event {event_type}: {task_data.get('task_id', 'unknown')}")
|
||||
|
||||
async def dispatch_once(self) -> int:
|
||||
"""
|
||||
Single dispatch cycle: claim ready tasks and spawn workers.
|
||||
|
||||
Returns:
|
||||
Number of tasks spawned in this cycle
|
||||
"""
|
||||
store = self._get_kanban_store()
|
||||
if not store:
|
||||
return 0
|
||||
|
||||
# Clean up completed processes first
|
||||
self._cleanup_completed_tasks(store)
|
||||
|
||||
# Check how many slots we have available
|
||||
available_slots = self.max_concurrent - len(self.running_tasks)
|
||||
if available_slots <= 0:
|
||||
logger.debug(f"No available slots (running: {len(self.running_tasks)})")
|
||||
return 0
|
||||
|
||||
# Get ready tasks
|
||||
ready_tasks = store.list_tasks({'status': 'ready'})
|
||||
if not ready_tasks:
|
||||
logger.debug("No ready tasks found")
|
||||
return 0
|
||||
|
||||
spawned = 0
|
||||
for task in ready_tasks[:available_slots]:
|
||||
try:
|
||||
# Try to claim the task atomically
|
||||
claimed = store.claim_task(task.id, self.worker_id)
|
||||
if not claimed:
|
||||
logger.debug(f"Failed to claim task {task.id} (already claimed)")
|
||||
continue
|
||||
|
||||
# Spawn worker process
|
||||
success = await self._spawn_worker(task, store)
|
||||
if success:
|
||||
spawned += 1
|
||||
|
||||
# Fire claimed hook event
|
||||
self._fire_hook_event('KANBAN_TASK_CLAIMED', {
|
||||
'task_id': task.id,
|
||||
'worker_id': self.worker_id,
|
||||
'task': task.to_dict()
|
||||
})
|
||||
else:
|
||||
# Failed to spawn, release claim
|
||||
store.release_claim(task.id, self.worker_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing task {task.id}: {e}")
|
||||
# Release claim on error
|
||||
try:
|
||||
store.release_claim(task.id, self.worker_id)
|
||||
except:
|
||||
pass
|
||||
|
||||
if spawned > 0:
|
||||
logger.info(f"Spawned {spawned} kanban task workers")
|
||||
|
||||
return spawned
|
||||
|
||||
async def _spawn_worker(self, task: Any, store: Any) -> bool:
|
||||
"""
|
||||
Spawn a worker process for the task.
|
||||
|
||||
Args:
|
||||
task: Task object to execute
|
||||
store: Kanban store instance
|
||||
|
||||
Returns:
|
||||
True if worker was spawned successfully
|
||||
"""
|
||||
try:
|
||||
# Prepare environment variables
|
||||
env = os.environ.copy()
|
||||
env.update({
|
||||
'PRAISONAI_KANBAN_TASK': task.id,
|
||||
'PRAISONAI_KANBAN_BOARD': task.board,
|
||||
'PRAISONAI_KANBAN_WORKER': self.worker_id,
|
||||
})
|
||||
|
||||
# Build command to execute task
|
||||
# This could be configurable, but for now use a simple approach
|
||||
cmd = self._build_execution_command(task)
|
||||
|
||||
logger.info(f"Spawning worker for task {task.id}: {' '.join(cmd)}")
|
||||
|
||||
# Start process with output redirect to avoid deadlock
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(mode='w+', delete=False, suffix='.log') as temp_log:
|
||||
temp_log_path = temp_log.name
|
||||
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
env=env,
|
||||
stdout=open(temp_log_path, 'w'),
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True
|
||||
)
|
||||
|
||||
# Store log path for later cleanup
|
||||
if not hasattr(self, '_temp_logs'):
|
||||
self._temp_logs = {}
|
||||
self._temp_logs[task.id] = temp_log_path
|
||||
|
||||
# Track the running task
|
||||
self.running_tasks[task.id] = process
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to spawn worker for task {task.id}: {e}")
|
||||
return False
|
||||
|
||||
def _build_execution_command(self, task: Any) -> List[str]:
|
||||
"""
|
||||
Build the command to execute a kanban task.
|
||||
|
||||
This is a simplified implementation. In a full system, this would:
|
||||
1. Parse task.body for specific execution instructions
|
||||
2. Route to different executors based on task metadata
|
||||
3. Support various agent configurations
|
||||
|
||||
Args:
|
||||
task: Task to build command for
|
||||
|
||||
Returns:
|
||||
Command as list of strings
|
||||
"""
|
||||
# Default: use praisonai agent with the task as a prompt
|
||||
return [
|
||||
'praisonai',
|
||||
'agent',
|
||||
'run',
|
||||
'--name', f'kanban_worker_{task.id}',
|
||||
'--prompt', f'Task: {task.title}\n\nDetails: {task.body}',
|
||||
'--tools', 'kanban_heartbeat,kanban_comment',
|
||||
'--output-mode', 'quiet'
|
||||
]
|
||||
|
||||
def _cleanup_completed_tasks(self, store: Any):
|
||||
"""
|
||||
Clean up completed task processes and update their status.
|
||||
|
||||
Args:
|
||||
store: Kanban store instance
|
||||
"""
|
||||
completed = []
|
||||
|
||||
for task_id, process in self.running_tasks.items():
|
||||
poll_result = process.poll()
|
||||
|
||||
if poll_result is not None:
|
||||
# Process completed
|
||||
completed.append(task_id)
|
||||
|
||||
try:
|
||||
# Get return code
|
||||
return_code = process.returncode
|
||||
|
||||
# Read output from temp log file
|
||||
stdout_data = ""
|
||||
if hasattr(self, '_temp_logs') and task_id in self._temp_logs:
|
||||
try:
|
||||
with open(self._temp_logs[task_id], 'r') as f:
|
||||
stdout_data = f.read()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read log for task {task_id}: {e}")
|
||||
stdout_data = f"<log read error: {e}>"
|
||||
else:
|
||||
stdout_data = "<no log available>"
|
||||
|
||||
# Update task based on exit code
|
||||
if return_code == 0:
|
||||
# Success - mark as done
|
||||
store.move_task(task_id, 'done')
|
||||
store.add_comment(
|
||||
task_id,
|
||||
self.worker_id,
|
||||
f"Task completed successfully\n\nOutput:\n{stdout_data[:500]}"
|
||||
)
|
||||
|
||||
# Fire completion hook
|
||||
task = store.get_task(task_id)
|
||||
self._fire_hook_event('KANBAN_TASK_COMPLETED', {
|
||||
'task_id': task_id,
|
||||
'worker_id': self.worker_id,
|
||||
'return_code': return_code,
|
||||
'task': task.to_dict() if task else {}
|
||||
})
|
||||
|
||||
logger.info(f"Task {task_id} completed successfully")
|
||||
else:
|
||||
# Failed - mark as blocked
|
||||
store.move_task(task_id, 'blocked')
|
||||
store.add_comment(
|
||||
task_id,
|
||||
self.worker_id,
|
||||
f"Task failed with exit code {return_code}\n\nOutput:\n{stdout_data[:500]}"
|
||||
)
|
||||
|
||||
# Fire failure hook
|
||||
task = store.get_task(task_id)
|
||||
self._fire_hook_event('KANBAN_TASK_FAILED', {
|
||||
'task_id': task_id,
|
||||
'worker_id': self.worker_id,
|
||||
'return_code': return_code,
|
||||
'error': stdout_data[:500],
|
||||
'task': task.to_dict() if task else {}
|
||||
})
|
||||
|
||||
logger.warning(f"Task {task_id} failed with code {return_code}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing completed task {task_id}: {e}")
|
||||
# Release claim as fallback
|
||||
try:
|
||||
store.release_claim(task_id, self.worker_id)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Remove completed tasks from tracking and clean up temp logs
|
||||
for task_id in completed:
|
||||
del self.running_tasks[task_id]
|
||||
|
||||
# Clean up temporary log file
|
||||
if hasattr(self, '_temp_logs') and task_id in self._temp_logs:
|
||||
try:
|
||||
import os
|
||||
os.unlink(self._temp_logs[task_id])
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup temp log for task {task_id}: {e}")
|
||||
finally:
|
||||
del self._temp_logs[task_id]
|
||||
|
||||
async def run_forever(self):
|
||||
"""
|
||||
Run the dispatcher loop forever.
|
||||
"""
|
||||
logger.info(f"Starting kanban dispatcher (max_concurrent={self.max_concurrent})")
|
||||
|
||||
while True:
|
||||
try:
|
||||
await self.dispatch_once()
|
||||
await asyncio.sleep(self.poll_interval)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Kanban dispatcher cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Dispatcher error: {e}")
|
||||
await asyncio.sleep(self.poll_interval)
|
||||
|
||||
# Cleanup on exit
|
||||
await self._shutdown()
|
||||
|
||||
async def _shutdown(self):
|
||||
"""Clean shutdown - wait for running tasks."""
|
||||
if self.running_tasks:
|
||||
logger.info(f"Waiting for {len(self.running_tasks)} running tasks to complete...")
|
||||
|
||||
# Give tasks 30 seconds to complete gracefully
|
||||
timeout = 30
|
||||
start_time = time.time()
|
||||
|
||||
while self.running_tasks and (time.time() - start_time) < timeout:
|
||||
store = self._get_kanban_store()
|
||||
if store:
|
||||
self._cleanup_completed_tasks(store)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Force terminate remaining tasks
|
||||
for task_id, process in self.running_tasks.items():
|
||||
try:
|
||||
logger.warning(f"Force terminating task {task_id}")
|
||||
process.terminate()
|
||||
process.wait(timeout=5)
|
||||
except:
|
||||
process.kill()
|
||||
|
||||
self.running_tasks.clear()
|
||||
|
||||
logger.info("Kanban dispatcher shutdown complete")
|
||||
|
||||
|
||||
# Global dispatcher management functions
|
||||
|
||||
async def start_kanban_dispatcher(max_concurrent: int = 3, poll_interval: float = 5.0):
|
||||
"""
|
||||
Start the global kanban dispatcher.
|
||||
|
||||
Args:
|
||||
max_concurrent: Maximum concurrent task executions
|
||||
poll_interval: Seconds between polling cycles
|
||||
"""
|
||||
global _dispatcher_running, _dispatcher_task
|
||||
|
||||
if _dispatcher_running:
|
||||
logger.warning("Kanban dispatcher already running")
|
||||
return
|
||||
|
||||
# Check if disabled by environment
|
||||
if not _is_dispatcher_enabled():
|
||||
logger.info("Kanban dispatcher disabled by PRAISONAI_KANBAN_DISPATCH=0")
|
||||
return
|
||||
|
||||
dispatcher = KanbanDispatcher(max_concurrent, poll_interval)
|
||||
|
||||
_dispatcher_task = asyncio.create_task(dispatcher.run_forever())
|
||||
_dispatcher_running = True
|
||||
|
||||
logger.info("Kanban dispatcher started")
|
||||
|
||||
|
||||
async def stop_kanban_dispatcher():
|
||||
"""Stop the global kanban dispatcher."""
|
||||
global _dispatcher_running, _dispatcher_task
|
||||
|
||||
if not _dispatcher_running or _dispatcher_task is None:
|
||||
return
|
||||
|
||||
_dispatcher_task.cancel()
|
||||
|
||||
try:
|
||||
await _dispatcher_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
_dispatcher_running = False
|
||||
_dispatcher_task = None
|
||||
|
||||
logger.info("Kanban dispatcher stopped")
|
||||
|
||||
|
||||
def is_dispatcher_running() -> bool:
|
||||
"""Check if the kanban dispatcher is running."""
|
||||
return _dispatcher_running
|
||||
|
||||
|
||||
def _is_dispatcher_enabled() -> bool:
|
||||
"""Check if kanban dispatcher is enabled via environment variable."""
|
||||
return os.environ.get('PRAISONAI_KANBAN_DISPATCH', '1').strip().lower() not in ('0', 'false', 'no')
|
||||
|
||||
|
||||
# Manual dispatch function for CLI/testing
|
||||
|
||||
async def dispatch_once(max_spawn: int = 3) -> int:
|
||||
"""
|
||||
Manually trigger a single dispatch cycle.
|
||||
|
||||
Args:
|
||||
max_spawn: Maximum tasks to spawn in this cycle
|
||||
|
||||
Returns:
|
||||
Number of tasks spawned
|
||||
"""
|
||||
dispatcher = KanbanDispatcher(max_spawn)
|
||||
return await dispatcher.dispatch_once()
|
||||
@@ -925,13 +925,10 @@ class WebSocketGateway:
|
||||
def _make_stream_relay(
|
||||
self, client_id: str, session: "GatewaySession"
|
||||
) -> Callable:
|
||||
"""Create a StreamCallback that relays events to a WS client.
|
||||
|
||||
The callback is synchronous (called from the LLM streaming thread)
|
||||
and uses asyncio.run_coroutine_threadsafe to push events into the
|
||||
gateway's event loop for WS delivery.
|
||||
"""
|
||||
"""Create a StreamCallback that relays events to a WS client."""
|
||||
gateway = self
|
||||
# Capture the running loop while we are still on it.
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _relay(event) -> None:
|
||||
try:
|
||||
@@ -967,15 +964,13 @@ class WebSocketGateway:
|
||||
target=client_id,
|
||||
)
|
||||
|
||||
# Thread-safe async send
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
gateway._send_to_client(client_id, gw_event.to_dict()),
|
||||
loop,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Stream relay error (non-fatal): {e}")
|
||||
# No get_event_loop() in the threaded callback.
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
gateway._send_to_client(client_id, gw_event.to_dict()),
|
||||
loop,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Stream relay error (non-fatal)", exc_info=True)
|
||||
|
||||
return _relay
|
||||
|
||||
@@ -1653,6 +1648,7 @@ class WebSocketGateway:
|
||||
allowed_users=list(_raw_allowed),
|
||||
allowed_channels=list(_raw_channels),
|
||||
mention_required=mention_required,
|
||||
group_policy=group_policy,
|
||||
auto_approve_tools=auto_approve_tools,
|
||||
)
|
||||
|
||||
@@ -1881,19 +1877,16 @@ class WebSocketGateway:
|
||||
gateway = self
|
||||
|
||||
async def handle_message(update: Update, context: Any):
|
||||
if not update.message:
|
||||
return
|
||||
# Import the shared security pipeline from telegram.py
|
||||
from praisonai.bots.telegram import process_inbound_telegram_message
|
||||
|
||||
# Use shared security pipeline for consistent enforcement
|
||||
message = await process_inbound_telegram_message(update, bot)
|
||||
if not message:
|
||||
return # Message was dropped by security checks
|
||||
|
||||
message_text = None
|
||||
if update.message.voice or update.message.audio:
|
||||
message_text = await bot._transcribe_audio(update)
|
||||
elif update.message.text:
|
||||
message_text = update.message.text
|
||||
|
||||
if not message_text:
|
||||
return
|
||||
|
||||
user_id = str(update.message.from_user.id) if update.message.from_user else "unknown"
|
||||
user_id = message.sender.user_id if message.sender else "unknown"
|
||||
message_text = message.content
|
||||
|
||||
# Determine routing context
|
||||
chat_type = update.message.chat.type if update.message.chat else "private"
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Wire kanban store and jobs components for PraisonAIUI integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
def get_kanban_store_factory():
|
||||
"""Return kanban store factory for PraisonAIUI injection."""
|
||||
try:
|
||||
from praisonai.kanban.sqlite_store import SQLiteKanbanStore
|
||||
|
||||
def _kanban_store_factory():
|
||||
"""Create SQLite kanban store instance."""
|
||||
# Reads PRAISONAI_KANBAN_BOARD env var for board selection
|
||||
return SQLiteKanbanStore()
|
||||
|
||||
return _kanban_store_factory
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
|
||||
def get_jobs_store():
|
||||
"""Return jobs store for PraisonAIUI injection."""
|
||||
try:
|
||||
from praisonai.jobs.store import get_store
|
||||
return get_store
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
|
||||
def get_jobs_executor():
|
||||
"""Return jobs executor for PraisonAIUI injection."""
|
||||
try:
|
||||
from praisonai.jobs.executor import get_executor
|
||||
return get_executor
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
|
||||
def register_kanban_backends():
|
||||
"""Register kanban-related backends with PraisonAIUI."""
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import praisonaiui.backends as backends
|
||||
|
||||
# Kanban store factory
|
||||
kanban_factory = get_kanban_store_factory()
|
||||
if kanban_factory is not None:
|
||||
backends.set_backend("kanban_store", kanban_factory)
|
||||
log.debug("kanban_store backend registered")
|
||||
|
||||
# Jobs store
|
||||
jobs_store = get_jobs_store()
|
||||
if jobs_store is not None:
|
||||
backends.set_backend("jobs_store", jobs_store)
|
||||
log.debug("jobs_store backend registered")
|
||||
|
||||
# Jobs executor
|
||||
jobs_executor = get_jobs_executor()
|
||||
if jobs_executor is not None:
|
||||
backends.set_backend("jobs_executor", jobs_executor)
|
||||
log.debug("jobs_executor backend registered")
|
||||
|
||||
# Register kanban dispatcher startup handler
|
||||
register_kanban_startup_handler()
|
||||
|
||||
return True
|
||||
|
||||
except Exception as exc:
|
||||
log.debug("kanban backend registration failed: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
def register_kanban_startup_handler():
|
||||
"""Register kanban dispatcher startup handler with the ASGI app."""
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import praisonaiui.backends as backends
|
||||
from praisonai.gateway.kanban_dispatcher import start_kanban_dispatcher, is_dispatcher_running
|
||||
|
||||
async def _startup_handler():
|
||||
"""Startup handler for kanban dispatcher."""
|
||||
try:
|
||||
if not is_dispatcher_running():
|
||||
await start_kanban_dispatcher()
|
||||
log.info("kanban dispatcher started")
|
||||
except Exception as exc:
|
||||
log.warning("failed to start kanban dispatcher: %s", exc)
|
||||
|
||||
# Register startup handler if the backend supports it
|
||||
if hasattr(backends, 'register_startup_handler'):
|
||||
backends.register_startup_handler(_startup_handler)
|
||||
log.debug("kanban dispatcher startup handler registered")
|
||||
else:
|
||||
# Fallback: try immediate start if event loop is running
|
||||
try:
|
||||
import asyncio
|
||||
loop = asyncio.get_running_loop()
|
||||
if loop and not is_dispatcher_running():
|
||||
asyncio.create_task(start_kanban_dispatcher())
|
||||
log.debug("kanban dispatcher started via task")
|
||||
except RuntimeError:
|
||||
log.debug("kanban dispatcher deferred (no running event loop)")
|
||||
|
||||
except Exception as exc:
|
||||
log.debug("kanban startup handler registration failed: %s", exc)
|
||||
@@ -165,7 +165,12 @@ def setup_bridges() -> None:
|
||||
|
||||
backends.set_backend("approvals_pending", list_pending_approvals)
|
||||
backends.set_backend("approvals_policies", get_approval_policies)
|
||||
except ImportError as exc:
|
||||
|
||||
# Kanban and jobs backends
|
||||
from praisonai.integration.bridges.kanban_bridge import register_kanban_backends
|
||||
register_kanban_backends()
|
||||
|
||||
except Exception as exc:
|
||||
log.debug("aiui backend injection failed: %s", exc)
|
||||
except Exception as exc:
|
||||
log.warning("aiui backend injection failed: %s", exc)
|
||||
|
||||
@@ -56,73 +56,7 @@ __all__ = [
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
"""Lazy load integrations to minimize import overhead."""
|
||||
if name == 'BaseCLIIntegration':
|
||||
from .base import BaseCLIIntegration
|
||||
return BaseCLIIntegration
|
||||
elif name == 'CLIExecutionError':
|
||||
from .base import CLIExecutionError
|
||||
return CLIExecutionError
|
||||
elif name == 'ClaudeCodeIntegration':
|
||||
from .claude_code import ClaudeCodeIntegration
|
||||
return ClaudeCodeIntegration
|
||||
elif name == 'GeminiCLIIntegration':
|
||||
from .gemini_cli import GeminiCLIIntegration
|
||||
return GeminiCLIIntegration
|
||||
elif name == 'CodexCLIIntegration':
|
||||
from .codex_cli import CodexCLIIntegration
|
||||
return CodexCLIIntegration
|
||||
elif name == 'CursorCLIIntegration':
|
||||
from .cursor_cli import CursorCLIIntegration
|
||||
return CursorCLIIntegration
|
||||
elif name in ('ManagedAgent', 'ManagedAgentIntegration'):
|
||||
from .managed_agents import ManagedAgent
|
||||
return ManagedAgent
|
||||
elif name == 'AnthropicManagedAgent':
|
||||
from .managed_agents import AnthropicManagedAgent
|
||||
return AnthropicManagedAgent
|
||||
elif name == 'LocalManagedAgent':
|
||||
from .managed_local import LocalManagedAgent
|
||||
return LocalManagedAgent
|
||||
elif name == 'LocalManagedConfig':
|
||||
from .managed_local import LocalManagedConfig
|
||||
return LocalManagedConfig
|
||||
elif name == 'SandboxedAgent':
|
||||
from .sandboxed_agent import SandboxedAgent
|
||||
return SandboxedAgent
|
||||
elif name == 'SandboxedAgentConfig':
|
||||
from .sandboxed_agent import SandboxedAgentConfig
|
||||
return SandboxedAgentConfig
|
||||
elif name in ('ManagedConfig', 'ManagedBackendConfig'):
|
||||
from .managed_agents import ManagedConfig
|
||||
return ManagedConfig
|
||||
elif name == 'get_available_integrations':
|
||||
from .base import get_available_integrations
|
||||
return get_available_integrations
|
||||
elif name == 'ExternalAgentRegistry':
|
||||
from .registry import ExternalAgentRegistry
|
||||
return ExternalAgentRegistry
|
||||
elif name == 'get_registry':
|
||||
from .registry import get_registry
|
||||
return get_registry
|
||||
elif name == 'register_integration':
|
||||
from .registry import register_integration
|
||||
return register_integration
|
||||
elif name == 'create_integration':
|
||||
from .registry import create_integration
|
||||
return create_integration
|
||||
# New canonical agent backends
|
||||
elif name == 'HostedAgent':
|
||||
from .hosted_agent import HostedAgent
|
||||
return HostedAgent
|
||||
elif name == 'HostedAgentConfig':
|
||||
from .hosted_agent import HostedAgentConfig
|
||||
return HostedAgentConfig
|
||||
elif name == 'LocalAgent':
|
||||
from .local_agent import LocalAgent
|
||||
return LocalAgent
|
||||
elif name == 'LocalAgentConfig':
|
||||
from .local_agent import LocalAgentConfig
|
||||
return LocalAgentConfig
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
def __getattr__(name: str):
|
||||
"""Lazy load integrations using unified registry."""
|
||||
from ._unified_registry import INTEGRATIONS_REGISTRY
|
||||
return INTEGRATIONS_REGISTRY.get_by_attr(__name__, name)
|
||||
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
Unified registry for all integration types in PraisonAI.
|
||||
|
||||
This replaces the manual __getattr__ ladder with a data-driven registry approach,
|
||||
supporting CLI tools, managed agents, framework adapters, and RAG components.
|
||||
"""
|
||||
|
||||
from typing import Any, Callable, Dict
|
||||
from .._registry import PluginRegistry
|
||||
|
||||
|
||||
class IntegrationRegistry(PluginRegistry[Any]):
|
||||
"""Registry for all integration types with lazy loading."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(entry_point_group="praisonai.integrations")
|
||||
self._loaders: Dict[str, Callable[[], Any]] = {}
|
||||
self._register_builtin_integrations()
|
||||
|
||||
def register_lazy(
|
||||
self,
|
||||
name: str,
|
||||
loader: Callable[[], Any],
|
||||
*,
|
||||
aliases: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Register a lazily loaded integration."""
|
||||
with self._lock:
|
||||
canonical_name = name.lower()
|
||||
self._loaders[canonical_name] = loader
|
||||
|
||||
if aliases:
|
||||
for alias in aliases:
|
||||
self._aliases[alias.lower()] = canonical_name
|
||||
|
||||
def resolve(self, name: str) -> Any:
|
||||
"""Resolve integration name with lazy loader materialization."""
|
||||
with self._lock:
|
||||
normalized_name = name.lower()
|
||||
canonical_name = self._aliases.get(normalized_name, normalized_name)
|
||||
|
||||
item = self._items.get(canonical_name)
|
||||
if item is None:
|
||||
loader = self._loaders.get(canonical_name)
|
||||
if loader is not None:
|
||||
item = loader()
|
||||
self._items[canonical_name] = item
|
||||
|
||||
if item is not None:
|
||||
return item
|
||||
|
||||
available_snapshot = sorted(
|
||||
set(self._items.keys()) | set(self._loaders.keys()) | set(self._aliases.keys())
|
||||
)
|
||||
|
||||
raise ValueError(
|
||||
f"Unknown {self._entry_point_group} plugin: {name!r}. "
|
||||
f"Available: {available_snapshot}"
|
||||
)
|
||||
|
||||
def _register_builtin_integrations(self):
|
||||
"""Register all built-in integrations with lazy loading."""
|
||||
|
||||
# CLI Tools
|
||||
def _claude_code():
|
||||
from .claude_code import ClaudeCodeIntegration
|
||||
return ClaudeCodeIntegration
|
||||
|
||||
def _gemini_cli():
|
||||
from .gemini_cli import GeminiCLIIntegration
|
||||
return GeminiCLIIntegration
|
||||
|
||||
def _codex_cli():
|
||||
from .codex_cli import CodexCLIIntegration
|
||||
return CodexCLIIntegration
|
||||
|
||||
def _cursor_cli():
|
||||
from .cursor_cli import CursorCLIIntegration
|
||||
return CursorCLIIntegration
|
||||
|
||||
# Base classes
|
||||
def _base_cli_integration():
|
||||
from .base import BaseCLIIntegration
|
||||
return BaseCLIIntegration
|
||||
|
||||
def _cli_execution_error():
|
||||
from .base import CLIExecutionError
|
||||
return CLIExecutionError
|
||||
|
||||
# Managed agents
|
||||
def _managed_agent():
|
||||
from .managed_agents import ManagedAgent
|
||||
return ManagedAgent
|
||||
|
||||
def _anthropic_managed_agent():
|
||||
from .managed_agents import AnthropicManagedAgent
|
||||
return AnthropicManagedAgent
|
||||
|
||||
def _managed_config():
|
||||
from .managed_agents import ManagedConfig
|
||||
return ManagedConfig
|
||||
|
||||
# Local agents
|
||||
def _local_managed_agent():
|
||||
from .managed_local import LocalManagedAgent
|
||||
return LocalManagedAgent
|
||||
|
||||
def _local_managed_config():
|
||||
from .managed_local import LocalManagedConfig
|
||||
return LocalManagedConfig
|
||||
|
||||
# Sandboxed agents
|
||||
def _sandboxed_agent():
|
||||
from .sandboxed_agent import SandboxedAgent
|
||||
return SandboxedAgent
|
||||
|
||||
def _sandboxed_agent_config():
|
||||
from .sandboxed_agent import SandboxedAgentConfig
|
||||
return SandboxedAgentConfig
|
||||
|
||||
# Canonical agent backends
|
||||
def _hosted_agent():
|
||||
from .hosted_agent import HostedAgent
|
||||
return HostedAgent
|
||||
|
||||
def _hosted_agent_config():
|
||||
from .hosted_agent import HostedAgentConfig
|
||||
return HostedAgentConfig
|
||||
|
||||
def _local_agent():
|
||||
from .local_agent import LocalAgent
|
||||
return LocalAgent
|
||||
|
||||
def _local_agent_config():
|
||||
from .local_agent import LocalAgentConfig
|
||||
return LocalAgentConfig
|
||||
|
||||
# Registry functions
|
||||
def _get_available_integrations():
|
||||
from .base import get_available_integrations
|
||||
return get_available_integrations
|
||||
|
||||
def _external_agent_registry():
|
||||
from .registry import ExternalAgentRegistry
|
||||
return ExternalAgentRegistry
|
||||
|
||||
def _get_registry():
|
||||
from .registry import get_registry
|
||||
return get_registry
|
||||
|
||||
def _register_integration():
|
||||
from .registry import register_integration
|
||||
return register_integration
|
||||
|
||||
def _create_integration():
|
||||
from .registry import create_integration
|
||||
return create_integration
|
||||
|
||||
# Register all with appropriate aliases
|
||||
self.register_lazy("BaseCLIIntegration", _base_cli_integration)
|
||||
self.register_lazy("CLIExecutionError", _cli_execution_error)
|
||||
|
||||
self.register_lazy("ClaudeCodeIntegration", _claude_code)
|
||||
self.register_lazy("GeminiCLIIntegration", _gemini_cli)
|
||||
self.register_lazy("CodexCLIIntegration", _codex_cli)
|
||||
self.register_lazy("CursorCLIIntegration", _cursor_cli)
|
||||
|
||||
self.register_lazy("ManagedAgent", _managed_agent,
|
||||
aliases=["ManagedAgentIntegration"])
|
||||
self.register_lazy("AnthropicManagedAgent", _anthropic_managed_agent)
|
||||
self.register_lazy("ManagedConfig", _managed_config,
|
||||
aliases=["ManagedBackendConfig"])
|
||||
|
||||
self.register_lazy("LocalManagedAgent", _local_managed_agent)
|
||||
self.register_lazy("LocalManagedConfig", _local_managed_config)
|
||||
|
||||
self.register_lazy("SandboxedAgent", _sandboxed_agent)
|
||||
self.register_lazy("SandboxedAgentConfig", _sandboxed_agent_config)
|
||||
|
||||
self.register_lazy("HostedAgent", _hosted_agent)
|
||||
self.register_lazy("HostedAgentConfig", _hosted_agent_config)
|
||||
self.register_lazy("LocalAgent", _local_agent)
|
||||
self.register_lazy("LocalAgentConfig", _local_agent_config)
|
||||
|
||||
self.register_lazy("get_available_integrations", _get_available_integrations)
|
||||
self.register_lazy("ExternalAgentRegistry", _external_agent_registry)
|
||||
self.register_lazy("get_registry", _get_registry)
|
||||
self.register_lazy("register_integration", _register_integration)
|
||||
self.register_lazy("create_integration", _create_integration)
|
||||
|
||||
|
||||
# Global registry instance
|
||||
INTEGRATIONS_REGISTRY = IntegrationRegistry()
|
||||
@@ -272,6 +272,12 @@ class DbSessionAdapter:
|
||||
self.metadata = meta
|
||||
return _SessionProxy(session_id, self.get_metadata(session_id))
|
||||
|
||||
def update_session_metadata(self, session_id: str, metadata: Dict[str, Any]) -> None:
|
||||
"""Update session metadata (alias for set_metadata)."""
|
||||
self.set_metadata(session_id, metadata)
|
||||
def update_session_metadata(self, session_id: str, **fields: Any) -> None:
|
||||
"""Update session metadata fields (DefaultSessionStore-compatible)."""
|
||||
if not fields:
|
||||
return
|
||||
if session_id not in self._metadata:
|
||||
self._metadata[session_id] = {}
|
||||
for key, value in fields.items():
|
||||
if value is not None:
|
||||
self._metadata[session_id][key] = value
|
||||
@@ -599,9 +599,11 @@ class LocalManagedAgent:
|
||||
subprocess.run(cmd, check=True, capture_output=True, timeout=120)
|
||||
logger.info("[local_managed] host pip install completed")
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.warning("[local_managed] pip install failed: %s", e.stderr)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("[local_managed] pip install timed out")
|
||||
raise RuntimeError(
|
||||
f"pip install failed for {pip_pkgs}: {e.stderr.decode(errors='replace')}"
|
||||
) from e
|
||||
except subprocess.TimeoutExpired as e:
|
||||
raise RuntimeError(f"pip install timed out after 120s for {pip_pkgs}") from e
|
||||
else:
|
||||
# Sandbox installation via compute provider
|
||||
self._install_packages_in_compute(pip_pkgs)
|
||||
@@ -629,10 +631,14 @@ class LocalManagedAgent:
|
||||
if result.get("exit_code", 0) == 0:
|
||||
logger.info("[local_managed] compute pip install completed")
|
||||
else:
|
||||
logger.warning("[local_managed] compute pip install failed: %s", result.get("stderr", ""))
|
||||
raise RuntimeError(
|
||||
f"pip install failed in compute for {pip_pkgs}: {result.get('stderr', '')}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("[local_managed] compute pip install error: %s", e)
|
||||
if "pip install failed in compute" in str(e):
|
||||
raise # Re-raise RuntimeError from above
|
||||
raise RuntimeError(f"pip install error in compute for {pip_pkgs}: {e}") from e
|
||||
|
||||
def _ensure_agent(self) -> Any:
|
||||
"""Create or return the inner PraisonAI Agent."""
|
||||
@@ -775,20 +781,30 @@ class LocalManagedAgent:
|
||||
q: queue.Queue[Optional[str]] = queue.Queue()
|
||||
|
||||
def _producer():
|
||||
full = ""
|
||||
try:
|
||||
agent = self._ensure_agent()
|
||||
self._ensure_session()
|
||||
self._persist_message("user", prompt)
|
||||
gen = agent.chat(prompt, stream=True)
|
||||
if hasattr(gen, '__iter__'):
|
||||
for chunk in gen:
|
||||
if chunk:
|
||||
q.put(str(chunk))
|
||||
text = str(chunk)
|
||||
q.put(text)
|
||||
full += text
|
||||
else:
|
||||
if gen:
|
||||
q.put(str(gen))
|
||||
text = str(gen)
|
||||
q.put(text)
|
||||
full = text
|
||||
except Exception as e:
|
||||
logger.error("[local_managed] stream error: %s", e)
|
||||
finally:
|
||||
if full:
|
||||
self._persist_message("assistant", full)
|
||||
self._sync_usage()
|
||||
self._persist_state()
|
||||
q.put(None)
|
||||
|
||||
thread = threading.Thread(target=_producer, daemon=True)
|
||||
|
||||
@@ -283,11 +283,7 @@ class JobExecutor:
|
||||
await self._notify_progress(job)
|
||||
|
||||
# Execute the recipe
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: execute_resolved_recipe(resolved)
|
||||
)
|
||||
result = await asyncio.to_thread(execute_resolved_recipe, resolved)
|
||||
|
||||
# Update progress
|
||||
job.update_progress(percentage=90.0, step="Finalizing")
|
||||
@@ -323,11 +319,7 @@ class JobExecutor:
|
||||
await self._notify_progress(job)
|
||||
|
||||
# Run the agent
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: agent.start(job.prompt)
|
||||
)
|
||||
result = await asyncio.to_thread(agent.start, job.prompt)
|
||||
|
||||
# Update progress
|
||||
job.update_progress(percentage=90.0, step="Finalizing")
|
||||
@@ -360,8 +352,7 @@ class JobExecutor:
|
||||
await self._notify_progress(job)
|
||||
|
||||
# Run in executor (blocking call)
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(None, praisonai.run)
|
||||
result = await asyncio.to_thread(praisonai.run)
|
||||
|
||||
# Update progress
|
||||
job.update_progress(percentage=90.0, step="Finalizing")
|
||||
|
||||
@@ -6,6 +6,7 @@ Provides FastAPI application setup and server startup.
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from typing import Optional
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@@ -21,13 +22,17 @@ logger = logging.getLogger(__name__)
|
||||
# Global instances (for single-process deployment)
|
||||
_store: Optional[JobStore] = None
|
||||
_executor: Optional[JobExecutor] = None
|
||||
_store_lock = threading.Lock()
|
||||
_executor_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_store() -> JobStore:
|
||||
"""Get or create the job store."""
|
||||
global _store
|
||||
if _store is None:
|
||||
_store = InMemoryJobStore(max_jobs=1000)
|
||||
with _store_lock:
|
||||
if _store is None:
|
||||
_store = InMemoryJobStore(max_jobs=1000)
|
||||
return _store
|
||||
|
||||
|
||||
@@ -35,11 +40,13 @@ def get_executor() -> JobExecutor:
|
||||
"""Get or create the job executor."""
|
||||
global _executor
|
||||
if _executor is None:
|
||||
_executor = JobExecutor(
|
||||
store=get_store(),
|
||||
max_concurrent=int(os.environ.get("PRAISONAI_MAX_CONCURRENT_JOBS", "10")),
|
||||
default_timeout=int(os.environ.get("PRAISONAI_JOB_TIMEOUT", "3600"))
|
||||
)
|
||||
with _executor_lock:
|
||||
if _executor is None:
|
||||
_executor = JobExecutor(
|
||||
store=get_store(),
|
||||
max_concurrent=int(os.environ.get("PRAISONAI_MAX_CONCURRENT_JOBS", "10")),
|
||||
default_timeout=int(os.environ.get("PRAISONAI_JOB_TIMEOUT", "3600"))
|
||||
)
|
||||
return _executor
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Kanban module for PraisonAI - persistent task management
|
||||
from .sqlite_store import SQLiteKanbanStore
|
||||
from .models import TaskStatus, Task, TaskEvent
|
||||
from .paths import get_kanban_db_path
|
||||
|
||||
__all__ = [
|
||||
'SQLiteKanbanStore',
|
||||
'TaskStatus',
|
||||
'Task',
|
||||
'TaskEvent',
|
||||
'get_kanban_db_path'
|
||||
]
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Task dataclasses and models for kanban system."""
|
||||
import json
|
||||
from dataclasses import dataclass, asdict
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
|
||||
class TaskStatus(str, Enum):
|
||||
"""Task status enum matching Hermes v1."""
|
||||
TRIAGE = "triage"
|
||||
TODO = "todo"
|
||||
SCHEDULED = "scheduled"
|
||||
READY = "ready"
|
||||
RUNNING = "running"
|
||||
BLOCKED = "blocked"
|
||||
REVIEW = "review"
|
||||
DONE = "done"
|
||||
ARCHIVED = "archived"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Task:
|
||||
"""Task model for kanban system."""
|
||||
id: str
|
||||
title: str
|
||||
body: str = ""
|
||||
status: TaskStatus = TaskStatus.TODO
|
||||
assignee: str = ""
|
||||
priority: int = 0 # Higher number = higher priority
|
||||
tenant: str = "default"
|
||||
board: str = "default"
|
||||
workspace_kind: str = "default"
|
||||
claim_lock: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
|
||||
def __post_init__(self):
|
||||
from datetime import timezone
|
||||
if self.created_at is None:
|
||||
self.created_at = datetime.now(timezone.utc)
|
||||
if self.updated_at is None:
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
if self.metadata is None:
|
||||
self.metadata = {}
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for API/JSON serialization."""
|
||||
result = asdict(self)
|
||||
result['created_at'] = self.created_at.isoformat() if self.created_at else None
|
||||
result['updated_at'] = self.updated_at.isoformat() if self.updated_at else None
|
||||
result['status'] = self.status.value
|
||||
result['metadata'] = json.dumps(self.metadata) if self.metadata else None
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Task':
|
||||
"""Create from dictionary."""
|
||||
# Remove fields that don't belong to Task model
|
||||
filtered_data = data.copy()
|
||||
filtered_data.pop('version', None) # Remove version field used for optimistic locking
|
||||
|
||||
# Handle datetime fields
|
||||
if 'created_at' in filtered_data and filtered_data['created_at']:
|
||||
filtered_data['created_at'] = datetime.fromisoformat(filtered_data['created_at'].replace('Z', '+00:00'))
|
||||
if 'updated_at' in filtered_data and filtered_data['updated_at']:
|
||||
filtered_data['updated_at'] = datetime.fromisoformat(filtered_data['updated_at'].replace('Z', '+00:00'))
|
||||
|
||||
# Handle status enum
|
||||
if 'status' in filtered_data:
|
||||
filtered_data['status'] = TaskStatus(filtered_data['status'])
|
||||
|
||||
# Handle metadata JSON
|
||||
if 'metadata' in filtered_data and filtered_data['metadata']:
|
||||
try:
|
||||
filtered_data['metadata'] = json.loads(filtered_data['metadata']) if isinstance(filtered_data['metadata'], str) else filtered_data['metadata']
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
filtered_data['metadata'] = {}
|
||||
|
||||
return cls(**filtered_data)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskLink:
|
||||
"""Task dependency link (DAG)."""
|
||||
parent_id: str
|
||||
child_id: str
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.created_at is None:
|
||||
self.created_at = datetime.utcnow()
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskComment:
|
||||
"""Task comment model."""
|
||||
id: str
|
||||
task_id: str
|
||||
author: str
|
||||
text: str
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.created_at is None:
|
||||
self.created_at = datetime.utcnow()
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
'id': self.id,
|
||||
'task_id': self.task_id,
|
||||
'author': self.author,
|
||||
'text': self.text,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskEvent:
|
||||
"""Task audit event for SSE/WebSocket."""
|
||||
id: str
|
||||
task_id: str
|
||||
event_type: str # created, updated, moved, deleted, commented
|
||||
data: Dict[str, Any]
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.created_at is None:
|
||||
self.created_at = datetime.utcnow()
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
'id': self.id,
|
||||
'task_id': self.task_id,
|
||||
'event_type': self.event_type,
|
||||
'data': self.data,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
|
||||
# Protocol placeholder - will import from praisonaiagents when available
|
||||
class KanbanStoreProtocol:
|
||||
"""Minimal kanban store protocol - matches praisonaiagents when available."""
|
||||
|
||||
def create_task(self, task_data: Dict[str, Any]) -> Task:
|
||||
"""Create a new task."""
|
||||
...
|
||||
|
||||
def get_task(self, task_id: str) -> Optional[Task]:
|
||||
"""Get task by ID."""
|
||||
...
|
||||
|
||||
def update_task(self, task_id: str, updates: Dict[str, Any]) -> Task:
|
||||
"""Update task."""
|
||||
...
|
||||
|
||||
def delete_task(self, task_id: str) -> bool:
|
||||
"""Delete task."""
|
||||
...
|
||||
|
||||
def list_tasks(self, filters: Optional[Dict[str, Any]] = None) -> List[Task]:
|
||||
"""List tasks with optional filters."""
|
||||
...
|
||||
|
||||
def move_task(self, task_id: str, status: str) -> Task:
|
||||
"""Move task to new status."""
|
||||
...
|
||||
|
||||
def get_board(self, board: str = "default") -> Dict[str, Any]:
|
||||
"""Get board layout for UI."""
|
||||
...
|
||||
|
||||
def bulk_update(self, operations: List[Dict[str, Any]]) -> List[Task]:
|
||||
"""Bulk update operations."""
|
||||
...
|
||||
|
||||
def add_comment(self, task_id: str, author: str, text: str) -> TaskComment:
|
||||
"""Add comment to task."""
|
||||
...
|
||||
|
||||
def get_comments(self, task_id: str) -> List[TaskComment]:
|
||||
"""Get task comments."""
|
||||
...
|
||||
|
||||
def add_link(self, parent_id: str, child_id: str) -> TaskLink:
|
||||
"""Create task dependency."""
|
||||
...
|
||||
|
||||
def remove_link(self, parent_id: str, child_id: str) -> bool:
|
||||
"""Remove task dependency."""
|
||||
...
|
||||
|
||||
def list_events(self, since: Optional[datetime] = None) -> List[TaskEvent]:
|
||||
"""List events for SSE."""
|
||||
...
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Path utilities for kanban data storage."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def get_praisonai_home() -> Path:
|
||||
"""Get PraisonAI home directory."""
|
||||
return Path.home() / ".praisonai"
|
||||
|
||||
|
||||
def get_kanban_db_path(board: Optional[str] = None) -> Path:
|
||||
"""Get kanban database path.
|
||||
|
||||
Args:
|
||||
board: Board slug for multi-board support. None for default board.
|
||||
|
||||
Returns:
|
||||
Path to SQLite database file.
|
||||
"""
|
||||
home = get_praisonai_home()
|
||||
|
||||
# Read from environment variable for override
|
||||
if board is None:
|
||||
board = os.environ.get('PRAISONAI_KANBAN_BOARD')
|
||||
if not board:
|
||||
# Try to read from persisted config file
|
||||
try:
|
||||
import json
|
||||
config_file = home / "kanban_config.json"
|
||||
if config_file.exists():
|
||||
with open(config_file) as f:
|
||||
config = json.load(f)
|
||||
board = config.get('active_board', 'default')
|
||||
else:
|
||||
board = 'default'
|
||||
except Exception:
|
||||
board = 'default'
|
||||
|
||||
# Legacy override for single DB file
|
||||
if db_override := os.environ.get('PRAISONAI_KANBAN_DB'):
|
||||
return Path(db_override)
|
||||
|
||||
# Default board at root
|
||||
if board == 'default':
|
||||
return home / "kanban.db"
|
||||
|
||||
# Multi-board at boards/<slug>/
|
||||
boards_dir = home / "kanban" / "boards" / board
|
||||
boards_dir.mkdir(parents=True, exist_ok=True)
|
||||
return boards_dir / "kanban.db"
|
||||
|
||||
|
||||
def get_kanban_boards_dir() -> Path:
|
||||
"""Get kanban boards directory."""
|
||||
return get_praisonai_home() / "kanban" / "boards"
|
||||
|
||||
|
||||
def list_available_boards() -> list[str]:
|
||||
"""List all available board slugs."""
|
||||
boards_dir = get_kanban_boards_dir()
|
||||
if not boards_dir.exists():
|
||||
return ["default"]
|
||||
|
||||
boards = ["default"] # Always include default
|
||||
|
||||
for board_dir in boards_dir.iterdir():
|
||||
if board_dir.is_dir() and (board_dir / "kanban.db").exists():
|
||||
boards.append(board_dir.name)
|
||||
|
||||
return sorted(set(boards))
|
||||
@@ -0,0 +1,565 @@
|
||||
"""SQLite kanban store implementation."""
|
||||
import sqlite3
|
||||
import json
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, List
|
||||
from pathlib import Path
|
||||
|
||||
from .models import Task, TaskStatus, TaskComment, TaskLink, TaskEvent, KanbanStoreProtocol
|
||||
from .paths import get_kanban_db_path
|
||||
|
||||
|
||||
class SQLiteKanbanStore:
|
||||
"""SQLite implementation of kanban store with WAL mode and CAS operations."""
|
||||
|
||||
def __init__(self, board: Optional[str] = None):
|
||||
"""Initialize store.
|
||||
|
||||
Args:
|
||||
board: Board slug. Uses PRAISONAI_KANBAN_BOARD env var or 'default'.
|
||||
"""
|
||||
self.board = board
|
||||
self.db_path = get_kanban_db_path(board)
|
||||
|
||||
# Ensure db_path is a Path object
|
||||
if not isinstance(self.db_path, Path):
|
||||
self.db_path = Path(self.db_path)
|
||||
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._init_db()
|
||||
|
||||
def _init_db(self):
|
||||
"""Initialize database with schema."""
|
||||
with self._get_connection() as conn:
|
||||
# Enable WAL mode for better concurrency
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
|
||||
# Tasks table
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT DEFAULT '',
|
||||
status TEXT DEFAULT 'todo',
|
||||
assignee TEXT DEFAULT '',
|
||||
priority INTEGER DEFAULT 0,
|
||||
tenant TEXT DEFAULT 'default',
|
||||
board TEXT DEFAULT 'default',
|
||||
workspace_kind TEXT DEFAULT 'default',
|
||||
claim_lock TEXT,
|
||||
metadata TEXT, -- JSON blob
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
version INTEGER DEFAULT 1 -- For optimistic locking
|
||||
)
|
||||
""")
|
||||
|
||||
# Task links table (DAG)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS task_links (
|
||||
parent_id TEXT NOT NULL,
|
||||
child_id TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (parent_id, child_id),
|
||||
FOREIGN KEY (parent_id) REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (child_id) REFERENCES tasks(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
# Task comments table
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS task_comments (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
author TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
# Task events table (audit log)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS task_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
data TEXT NOT NULL, -- JSON blob
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
# Indexes for performance
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_board ON tasks(board)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority DESC)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_task_comments_task ON task_comments(task_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_task_events_task ON task_events(task_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_task_events_created ON task_events(created_at)")
|
||||
|
||||
@contextmanager
|
||||
def _get_connection(self):
|
||||
"""Get database connection with proper cleanup."""
|
||||
conn = sqlite3.connect(self.db_path, timeout=30.0)
|
||||
conn.row_factory = sqlite3.Row
|
||||
# Enable foreign key constraints for this connection
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _generate_id(self) -> str:
|
||||
"""Generate unique task ID."""
|
||||
return f"task_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
def _log_event(self, conn: sqlite3.Connection, task_id: str, event_type: str, data: Dict[str, Any]):
|
||||
"""Log audit event."""
|
||||
event_id = f"event_{uuid.uuid4().hex[:12]}"
|
||||
conn.execute(
|
||||
"INSERT INTO task_events (id, task_id, event_type, data) VALUES (?, ?, ?, ?)",
|
||||
(event_id, task_id, event_type, json.dumps(data))
|
||||
)
|
||||
|
||||
def create_task(self, task_data: Dict[str, Any]) -> Task:
|
||||
"""Create a new task."""
|
||||
task_id = task_data.get('id', self._generate_id())
|
||||
now = datetime.utcnow()
|
||||
|
||||
task = Task(
|
||||
id=task_id,
|
||||
title=task_data['title'],
|
||||
body=task_data.get('body', ''),
|
||||
status=TaskStatus(task_data.get('status', 'todo')),
|
||||
assignee=task_data.get('assignee', ''),
|
||||
priority=task_data.get('priority', 0),
|
||||
tenant=task_data.get('tenant', 'default'),
|
||||
board=task_data.get('board', self.board or 'default'),
|
||||
workspace_kind=task_data.get('workspace_kind', 'default'),
|
||||
metadata=task_data.get('metadata', {}),
|
||||
created_at=now,
|
||||
updated_at=now
|
||||
)
|
||||
|
||||
with self._get_connection() as conn:
|
||||
conn.execute("""
|
||||
INSERT INTO tasks (
|
||||
id, title, body, status, assignee, priority,
|
||||
tenant, board, workspace_kind, metadata,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
task.id, task.title, task.body, task.status.value,
|
||||
task.assignee, task.priority, task.tenant, task.board,
|
||||
task.workspace_kind, json.dumps(task.metadata),
|
||||
task.created_at.isoformat(), task.updated_at.isoformat()
|
||||
))
|
||||
|
||||
self._log_event(conn, task_id, 'created', task.to_dict())
|
||||
|
||||
return task
|
||||
|
||||
def get_task(self, task_id: str) -> Optional[Task]:
|
||||
"""Get task by ID."""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
return Task.from_dict(dict(row))
|
||||
|
||||
def update_task(self, task_id: str, updates: Dict[str, Any]) -> Task:
|
||||
"""Update task with optimistic locking."""
|
||||
with self._get_connection() as conn:
|
||||
# Get current version for CAS
|
||||
cursor = conn.execute("SELECT version FROM tasks WHERE id = ?", (task_id,))
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"Task {task_id} not found")
|
||||
|
||||
current_version = row['version']
|
||||
new_version = current_version + 1
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Build update query dynamically
|
||||
update_fields = []
|
||||
values = []
|
||||
|
||||
for field, value in updates.items():
|
||||
if field in ['status', 'title', 'body', 'assignee', 'priority', 'claim_lock', 'metadata']:
|
||||
if field == 'status' and isinstance(value, str):
|
||||
value = TaskStatus(value).value
|
||||
elif field == 'metadata':
|
||||
value = json.dumps(value) if value else None
|
||||
update_fields.append(f"{field} = ?")
|
||||
values.append(value)
|
||||
|
||||
if not update_fields:
|
||||
# No valid updates, return current task
|
||||
return self.get_task(task_id)
|
||||
|
||||
update_fields.append("updated_at = ?")
|
||||
update_fields.append("version = ?")
|
||||
values.extend([now.isoformat(), new_version, task_id, current_version])
|
||||
|
||||
# CAS update with version check
|
||||
result = conn.execute(f"""
|
||||
UPDATE tasks
|
||||
SET {', '.join(update_fields)}
|
||||
WHERE id = ? AND version = ?
|
||||
""", values)
|
||||
|
||||
if result.rowcount == 0:
|
||||
raise ValueError(f"Task {task_id} was modified by another process")
|
||||
|
||||
# Log event
|
||||
self._log_event(conn, task_id, 'updated', updates)
|
||||
|
||||
return self.get_task(task_id)
|
||||
|
||||
def delete_task(self, task_id: str) -> bool:
|
||||
"""Delete task."""
|
||||
with self._get_connection() as conn:
|
||||
# Log before deletion
|
||||
self._log_event(conn, task_id, 'deleted', {'task_id': task_id})
|
||||
|
||||
result = conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
|
||||
return result.rowcount > 0
|
||||
|
||||
def list_tasks(self, filters: Optional[Dict[str, Any]] = None) -> List[Task]:
|
||||
"""List tasks with optional filters."""
|
||||
query = "SELECT * FROM tasks WHERE 1=1"
|
||||
values = []
|
||||
|
||||
if filters:
|
||||
if 'status' in filters:
|
||||
if isinstance(filters['status'], list):
|
||||
placeholders = ','.join(['?'] * len(filters['status']))
|
||||
query += f" AND status IN ({placeholders})"
|
||||
values.extend(filters['status'])
|
||||
else:
|
||||
query += " AND status = ?"
|
||||
values.append(filters['status'])
|
||||
|
||||
if 'assignee' in filters:
|
||||
query += " AND assignee = ?"
|
||||
values.append(filters['assignee'])
|
||||
|
||||
if 'board' in filters:
|
||||
query += " AND board = ?"
|
||||
values.append(filters['board'])
|
||||
|
||||
if 'tenant' in filters:
|
||||
query += " AND tenant = ?"
|
||||
values.append(filters['tenant'])
|
||||
|
||||
query += " ORDER BY priority DESC, created_at ASC"
|
||||
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.execute(query, values)
|
||||
return [Task.from_dict(dict(row)) for row in cursor.fetchall()]
|
||||
|
||||
def _get_task_with_conn(self, task_id: str, conn: sqlite3.Connection) -> Optional[Task]:
|
||||
"""Get task using existing connection to avoid nesting."""
|
||||
cursor = conn.execute(
|
||||
"SELECT * FROM tasks WHERE id = ?", (task_id,)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return Task.from_dict(dict(row))
|
||||
return None
|
||||
|
||||
def _update_task_with_conn(self, task_id: str, updates: Dict[str, Any], conn: sqlite3.Connection) -> Task:
|
||||
"""Update task using existing connection to avoid nesting."""
|
||||
from datetime import timezone
|
||||
|
||||
if not updates:
|
||||
return self._get_task_with_conn(task_id, conn)
|
||||
|
||||
# Build update query
|
||||
set_clauses = []
|
||||
params = []
|
||||
|
||||
for key, value in updates.items():
|
||||
if key in ['id', 'created_at']: # Don't allow updating immutable fields
|
||||
continue
|
||||
set_clauses.append(f"{key} = ?")
|
||||
params.append(value)
|
||||
|
||||
if not set_clauses:
|
||||
return self._get_task_with_conn(task_id, conn)
|
||||
|
||||
# Always update timestamp and version
|
||||
set_clauses.append("updated_at = ?")
|
||||
set_clauses.append("version = version + 1")
|
||||
params.append(datetime.now(timezone.utc).isoformat())
|
||||
params.append(task_id)
|
||||
|
||||
query = f"UPDATE tasks SET {', '.join(set_clauses)} WHERE id = ?"
|
||||
cursor = conn.execute(query, params)
|
||||
|
||||
if cursor.rowcount == 0:
|
||||
raise ValueError(f"Task {task_id} not found")
|
||||
|
||||
# Log the update
|
||||
self._log_event(conn, task_id, 'updated', updates)
|
||||
|
||||
return self._get_task_with_conn(task_id, conn)
|
||||
|
||||
def move_task(self, task_id: str, status: str) -> Task:
|
||||
"""Move task to new status with parent/child promotion logic."""
|
||||
with self._get_connection() as conn:
|
||||
# Check if task exists
|
||||
task = self._get_task_with_conn(task_id, conn)
|
||||
if not task:
|
||||
raise ValueError(f"Task {task_id} not found")
|
||||
|
||||
new_status = TaskStatus(status)
|
||||
|
||||
# Check for blocking parents if moving to ready
|
||||
if new_status == TaskStatus.READY:
|
||||
cursor = conn.execute("""
|
||||
SELECT COUNT(*) as incomplete_parents
|
||||
FROM task_links tl
|
||||
JOIN tasks t ON t.id = tl.parent_id
|
||||
WHERE tl.child_id = ? AND t.status NOT IN ('done', 'archived')
|
||||
""", (task_id,))
|
||||
|
||||
if cursor.fetchone()['incomplete_parents'] > 0:
|
||||
raise ValueError("Cannot move to ready: incomplete parent tasks")
|
||||
|
||||
# Update task status
|
||||
updated_task = self._update_task_with_conn(task_id, {'status': status}, conn)
|
||||
|
||||
# Promote children if moving to done
|
||||
if new_status == TaskStatus.DONE:
|
||||
cursor = conn.execute("""
|
||||
SELECT child_id FROM task_links WHERE parent_id = ?
|
||||
""", (task_id,))
|
||||
|
||||
for row in cursor.fetchall():
|
||||
child_id = row['child_id']
|
||||
child_task = self._get_task_with_conn(child_id, conn)
|
||||
|
||||
if child_task and child_task.status == TaskStatus.TODO:
|
||||
# Check if all other parents are done
|
||||
parent_check = conn.execute("""
|
||||
SELECT COUNT(*) as incomplete_parents
|
||||
FROM task_links tl
|
||||
JOIN tasks t ON t.id = tl.parent_id
|
||||
WHERE tl.child_id = ? AND t.status NOT IN ('done', 'archived')
|
||||
""", (child_id,))
|
||||
|
||||
if parent_check.fetchone()['incomplete_parents'] == 0:
|
||||
self._update_task_with_conn(child_id, {'status': TaskStatus.READY.value}, conn)
|
||||
|
||||
self._log_event(conn, task_id, 'moved', {
|
||||
'old_status': task.status.value,
|
||||
'new_status': status
|
||||
})
|
||||
|
||||
return updated_task
|
||||
|
||||
def get_board(self, board: str = "default") -> Dict[str, Any]:
|
||||
"""Get board layout for UI."""
|
||||
tasks = self.list_tasks({'board': board})
|
||||
|
||||
# Group by status for kanban columns
|
||||
columns = {}
|
||||
for status in TaskStatus:
|
||||
columns[status.value] = []
|
||||
|
||||
for task in tasks:
|
||||
if task.status.value in columns:
|
||||
columns[task.status.value].append(task.to_dict())
|
||||
|
||||
return {
|
||||
'board': board,
|
||||
'columns': columns,
|
||||
'task_count': len(tasks)
|
||||
}
|
||||
|
||||
def bulk_update(self, operations: List[Dict[str, Any]]) -> List[Task]:
|
||||
"""Bulk update operations."""
|
||||
results = []
|
||||
|
||||
with self._get_connection() as conn:
|
||||
for op in operations:
|
||||
op_type = op.get('operation')
|
||||
|
||||
if op_type == 'update':
|
||||
task = self.update_task(op['task_id'], op['updates'])
|
||||
results.append(task)
|
||||
elif op_type == 'move':
|
||||
task = self.move_task(op['task_id'], op['status'])
|
||||
results.append(task)
|
||||
elif op_type == 'delete':
|
||||
if self.delete_task(op['task_id']):
|
||||
results.append({'deleted': op['task_id']})
|
||||
|
||||
return results
|
||||
|
||||
def add_comment(self, task_id: str, author: str, text: str) -> TaskComment:
|
||||
"""Add comment to task."""
|
||||
comment_id = f"comment_{uuid.uuid4().hex[:12]}"
|
||||
comment = TaskComment(
|
||||
id=comment_id,
|
||||
task_id=task_id,
|
||||
author=author,
|
||||
text=text
|
||||
)
|
||||
|
||||
with self._get_connection() as conn:
|
||||
conn.execute("""
|
||||
INSERT INTO task_comments (id, task_id, author, text, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (
|
||||
comment.id, comment.task_id, comment.author,
|
||||
comment.text, comment.created_at.isoformat()
|
||||
))
|
||||
|
||||
self._log_event(conn, task_id, 'commented', {
|
||||
'author': author,
|
||||
'text': text[:100] + '...' if len(text) > 100 else text
|
||||
})
|
||||
|
||||
return comment
|
||||
|
||||
def get_comments(self, task_id: str) -> List[TaskComment]:
|
||||
"""Get task comments."""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.execute("""
|
||||
SELECT * FROM task_comments
|
||||
WHERE task_id = ?
|
||||
ORDER BY created_at ASC
|
||||
""", (task_id,))
|
||||
|
||||
comments = []
|
||||
for row in cursor.fetchall():
|
||||
comment_data = dict(row)
|
||||
comment_data['created_at'] = datetime.fromisoformat(comment_data['created_at'])
|
||||
comments.append(TaskComment(**comment_data))
|
||||
|
||||
return comments
|
||||
|
||||
def add_link(self, parent_id: str, child_id: str) -> TaskLink:
|
||||
"""Create task dependency."""
|
||||
link = TaskLink(parent_id=parent_id, child_id=child_id)
|
||||
|
||||
with self._get_connection() as conn:
|
||||
# Check for cycles (simplified cycle detection)
|
||||
cursor = conn.execute("""
|
||||
WITH RECURSIVE cycle_check AS (
|
||||
SELECT parent_id, child_id, 1 as depth
|
||||
FROM task_links
|
||||
WHERE parent_id = ?
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT tl.parent_id, tl.child_id, cc.depth + 1
|
||||
FROM task_links tl
|
||||
JOIN cycle_check cc ON cc.child_id = tl.parent_id
|
||||
WHERE cc.depth < 10
|
||||
)
|
||||
SELECT COUNT(*) as cycle_found
|
||||
FROM cycle_check
|
||||
WHERE child_id = ?
|
||||
""", (child_id, parent_id))
|
||||
|
||||
if cursor.fetchone()['cycle_found'] > 0:
|
||||
raise ValueError("Would create dependency cycle")
|
||||
|
||||
conn.execute("""
|
||||
INSERT OR IGNORE INTO task_links (parent_id, child_id, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
""", (parent_id, child_id, link.created_at.isoformat()))
|
||||
|
||||
self._log_event(conn, child_id, 'linked', {
|
||||
'parent_id': parent_id,
|
||||
'child_id': child_id
|
||||
})
|
||||
|
||||
return link
|
||||
|
||||
def remove_link(self, parent_id: str, child_id: str) -> bool:
|
||||
"""Remove task dependency."""
|
||||
with self._get_connection() as conn:
|
||||
result = conn.execute("""
|
||||
DELETE FROM task_links
|
||||
WHERE parent_id = ? AND child_id = ?
|
||||
""", (parent_id, child_id))
|
||||
|
||||
if result.rowcount > 0:
|
||||
self._log_event(conn, child_id, 'unlinked', {
|
||||
'parent_id': parent_id,
|
||||
'child_id': child_id
|
||||
})
|
||||
|
||||
return result.rowcount > 0
|
||||
|
||||
def list_events(self, since: Optional[datetime] = None) -> List[TaskEvent]:
|
||||
"""List events for SSE."""
|
||||
query = "SELECT * FROM task_events"
|
||||
values = []
|
||||
|
||||
if since:
|
||||
query += " WHERE created_at > ?"
|
||||
values.append(since.isoformat())
|
||||
|
||||
query += " ORDER BY created_at DESC LIMIT 100"
|
||||
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.execute(query, values)
|
||||
events = []
|
||||
|
||||
for row in cursor.fetchall():
|
||||
event_data = dict(row)
|
||||
event_data['created_at'] = datetime.fromisoformat(event_data['created_at'])
|
||||
event_data['data'] = json.loads(event_data['data'])
|
||||
events.append(TaskEvent(**event_data))
|
||||
|
||||
return events
|
||||
|
||||
def claim_task(self, task_id: str, worker_id: str) -> bool:
|
||||
"""Claim a ready task for execution (CAS operation)."""
|
||||
with self._get_connection() as conn:
|
||||
# Atomic claim with CAS on status and claim_lock, increment version for optimistic locking
|
||||
from datetime import timezone
|
||||
result = conn.execute("""
|
||||
UPDATE tasks
|
||||
SET claim_lock = ?, updated_at = ?, status = 'running', version = version + 1
|
||||
WHERE id = ? AND status = 'ready' AND (claim_lock IS NULL OR claim_lock = '')
|
||||
""", (worker_id, datetime.now(timezone.utc).isoformat(), task_id))
|
||||
|
||||
if result.rowcount > 0:
|
||||
self._log_event(conn, task_id, 'claimed', {'worker_id': worker_id})
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def release_claim(self, task_id: str, worker_id: str) -> bool:
|
||||
"""Release claim on task."""
|
||||
with self._get_connection() as conn:
|
||||
# Release claim and increment version for optimistic locking
|
||||
from datetime import timezone
|
||||
result = conn.execute("""
|
||||
UPDATE tasks
|
||||
SET claim_lock = NULL, updated_at = ?, status = 'ready', version = version + 1
|
||||
WHERE id = ? AND claim_lock = ?
|
||||
""", (datetime.now(timezone.utc).isoformat(), task_id, worker_id))
|
||||
|
||||
if result.rowcount > 0:
|
||||
self._log_event(conn, task_id, 'released', {'worker_id': worker_id})
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
Observability hooks for PraisonAI.
|
||||
|
||||
Centralizes observability initialization (AgentOps, etc.) so it can be
|
||||
used consistently across all entry points without duplicating logic.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def init_observability(framework_tag: str, *, tags: Optional[List[str]] = None) -> None:
|
||||
"""
|
||||
Initialize observability providers (AgentOps, etc.) if available.
|
||||
|
||||
Args:
|
||||
framework_tag: Primary framework tag (e.g., "crewai", "autogen_v4")
|
||||
tags: Additional tags to include
|
||||
"""
|
||||
# Try to initialize AgentOps if available
|
||||
_init_agentops(framework_tag, tags or [])
|
||||
|
||||
# Future: Add other observability providers here
|
||||
# _init_langfuse(framework_tag, tags)
|
||||
# _init_wandb(framework_tag, tags)
|
||||
|
||||
|
||||
def _init_agentops(framework_tag: str, additional_tags: List[str]) -> None:
|
||||
"""Initialize AgentOps if available."""
|
||||
try:
|
||||
import agentops
|
||||
agentops_api_key = os.getenv("AGENTOPS_API_KEY")
|
||||
if agentops_api_key:
|
||||
all_tags = [framework_tag] + additional_tags
|
||||
agentops.init(agentops_api_key, default_tags=all_tags)
|
||||
logger.debug("Initialized AgentOps with tags: %s", all_tags)
|
||||
except ImportError:
|
||||
logger.debug("AgentOps not available, skipping initialization")
|
||||
except Exception as e:
|
||||
logger.warning("Failed to initialize AgentOps: %s", e)
|
||||
|
||||
|
||||
# Constants for checking availability
|
||||
try:
|
||||
import agentops
|
||||
AGENTOPS_AVAILABLE = True
|
||||
except ImportError:
|
||||
AGENTOPS_AVAILABLE = False
|
||||
@@ -41,6 +41,7 @@ def create_valkey_client(
|
||||
config = GlideClientConfiguration(
|
||||
addresses=addresses,
|
||||
credentials=creds,
|
||||
client_name="praisonai_valkey_client",
|
||||
database_id=db,
|
||||
client_name=_CLIENT_NAME,
|
||||
)
|
||||
|
||||
@@ -12,7 +12,12 @@ import time
|
||||
from typing import List, Optional
|
||||
|
||||
from .base import ConversationStore, ConversationSession, ConversationMessage, validate_identifier
|
||||
from ..._async_bridge import run_sync
|
||||
# Note: This store is async-only. For sync operations, use sync_sqlite.SyncSQLiteConversationStore
|
||||
|
||||
try:
|
||||
import aiosqlite
|
||||
except ImportError:
|
||||
aiosqlite = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -28,6 +33,43 @@ class AsyncSQLiteConversationStore(ConversationStore):
|
||||
await store.init()
|
||||
"""
|
||||
|
||||
# Sync method stubs - this store is async-only
|
||||
def create_session(self, session) -> None:
|
||||
"""This store is async-only; use async_create_session or the sync_sqlite store."""
|
||||
raise RuntimeError("This store is async-only; use async_create_session() or the sync_sqlite store")
|
||||
|
||||
def get_session(self, session_id: str) -> None:
|
||||
"""This store is async-only; use async_get_session or the sync_sqlite store."""
|
||||
raise RuntimeError("This store is async-only; use async_get_session() or the sync_sqlite store")
|
||||
|
||||
def update_session(self, session) -> None:
|
||||
"""This store is async-only; use async_update_session or the sync_sqlite store."""
|
||||
raise RuntimeError("This store is async-only; use async_update_session() or the sync_sqlite store")
|
||||
|
||||
def delete_session(self, session_id: str) -> None:
|
||||
"""This store is async-only; use async_delete_session or the sync_sqlite store."""
|
||||
raise RuntimeError("This store is async-only; use async_delete_session() or the sync_sqlite store")
|
||||
|
||||
def list_sessions(self, **kwargs) -> None:
|
||||
"""This store is async-only; use async_list_sessions or the sync_sqlite store."""
|
||||
raise RuntimeError("This store is async-only; use async_list_sessions() or the sync_sqlite store")
|
||||
|
||||
def add_message(self, session_id: str, message) -> None:
|
||||
"""This store is async-only; use async_add_message or the sync_sqlite store."""
|
||||
raise RuntimeError("This store is async-only; use async_add_message() or the sync_sqlite store")
|
||||
|
||||
def get_messages(self, session_id: str, **kwargs) -> None:
|
||||
"""This store is async-only; use async_get_messages or the sync_sqlite store."""
|
||||
raise RuntimeError("This store is async-only; use async_get_messages() or the sync_sqlite store")
|
||||
|
||||
def delete_messages(self, session_id: str, **kwargs) -> None:
|
||||
"""This store is async-only; use async_delete_messages or the sync_sqlite store."""
|
||||
raise RuntimeError("This store is async-only; use async_delete_messages() or the sync_sqlite store")
|
||||
|
||||
def close(self) -> None:
|
||||
"""This store is async-only; use async_close or the sync_sqlite store."""
|
||||
raise RuntimeError("This store is async-only; use async_close() or the sync_sqlite store")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str = "praisonai_conversations.db",
|
||||
@@ -51,9 +93,7 @@ class AsyncSQLiteConversationStore(ConversationStore):
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
try:
|
||||
import aiosqlite
|
||||
except ImportError:
|
||||
if aiosqlite is None:
|
||||
raise ImportError(
|
||||
"aiosqlite is required for async SQLite support. "
|
||||
"Install with: pip install aiosqlite"
|
||||
@@ -120,11 +160,6 @@ class AsyncSQLiteConversationStore(ConversationStore):
|
||||
|
||||
return session
|
||||
|
||||
def create_session(self, session: ConversationSession) -> ConversationSession:
|
||||
"""Sync wrapper for create_session."""
|
||||
return run_sync(
|
||||
self.async_create_session(session)
|
||||
)
|
||||
|
||||
async def async_get_session(self, session_id: str) -> Optional[ConversationSession]:
|
||||
"""Get a session by ID asynchronously."""
|
||||
@@ -150,11 +185,6 @@ class AsyncSQLiteConversationStore(ConversationStore):
|
||||
)
|
||||
return None
|
||||
|
||||
def get_session(self, session_id: str) -> Optional[ConversationSession]:
|
||||
"""Sync wrapper for get_session."""
|
||||
return run_sync(
|
||||
self.async_get_session(session_id)
|
||||
)
|
||||
|
||||
async def async_update_session(self, session: ConversationSession) -> ConversationSession:
|
||||
"""Update an existing session asynchronously."""
|
||||
@@ -176,11 +206,6 @@ class AsyncSQLiteConversationStore(ConversationStore):
|
||||
|
||||
return session
|
||||
|
||||
def update_session(self, session: ConversationSession) -> ConversationSession:
|
||||
"""Sync wrapper for update_session."""
|
||||
return run_sync(
|
||||
self.async_update_session(session)
|
||||
)
|
||||
|
||||
async def async_delete_session(self, session_id: str) -> bool:
|
||||
"""Delete a session asynchronously."""
|
||||
@@ -195,11 +220,6 @@ class AsyncSQLiteConversationStore(ConversationStore):
|
||||
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def delete_session(self, session_id: str) -> bool:
|
||||
"""Sync wrapper for delete_session."""
|
||||
return run_sync(
|
||||
self.async_delete_session(session_id)
|
||||
)
|
||||
|
||||
async def async_list_sessions(
|
||||
self,
|
||||
@@ -248,17 +268,6 @@ class AsyncSQLiteConversationStore(ConversationStore):
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def list_sessions(
|
||||
self,
|
||||
user_id: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> List[ConversationSession]:
|
||||
"""Sync wrapper for list_sessions."""
|
||||
return run_sync(
|
||||
self.async_list_sessions(user_id, agent_id, limit, offset)
|
||||
)
|
||||
|
||||
async def async_add_message(self, session_id: str, message: ConversationMessage) -> ConversationMessage:
|
||||
"""Add a message asynchronously."""
|
||||
@@ -280,11 +289,6 @@ class AsyncSQLiteConversationStore(ConversationStore):
|
||||
|
||||
return message
|
||||
|
||||
def add_message(self, session_id: str, message: ConversationMessage) -> ConversationMessage:
|
||||
"""Sync wrapper for add_message."""
|
||||
return run_sync(
|
||||
self.async_add_message(session_id, message)
|
||||
)
|
||||
|
||||
async def async_get_messages(
|
||||
self,
|
||||
@@ -334,17 +338,6 @@ class AsyncSQLiteConversationStore(ConversationStore):
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def get_messages(
|
||||
self,
|
||||
session_id: str,
|
||||
limit: Optional[int] = None,
|
||||
before: Optional[float] = None,
|
||||
after: Optional[float] = None
|
||||
) -> List[ConversationMessage]:
|
||||
"""Sync wrapper for get_messages."""
|
||||
return run_sync(
|
||||
self.async_get_messages(session_id, limit, before, after)
|
||||
)
|
||||
|
||||
async def async_delete_messages(self, session_id: str, message_ids: Optional[List[str]] = None) -> int:
|
||||
"""Delete messages asynchronously."""
|
||||
@@ -366,11 +359,6 @@ class AsyncSQLiteConversationStore(ConversationStore):
|
||||
await self._conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
def delete_messages(self, session_id: str, message_ids: Optional[List[str]] = None) -> int:
|
||||
"""Sync wrapper for delete_messages."""
|
||||
return run_sync(
|
||||
self.async_delete_messages(session_id, message_ids)
|
||||
)
|
||||
|
||||
async def async_close(self) -> None:
|
||||
"""Close the connection."""
|
||||
@@ -379,7 +367,3 @@ class AsyncSQLiteConversationStore(ConversationStore):
|
||||
self._conn = None
|
||||
self._initialized = False
|
||||
|
||||
def close(self) -> None:
|
||||
"""Sync wrapper for close."""
|
||||
if self._conn:
|
||||
run_sync(self.async_close())
|
||||
@@ -0,0 +1,374 @@
|
||||
"""
|
||||
Synchronous SQLite implementation of ConversationStore.
|
||||
|
||||
Provides blocking database operations using the standard sqlite3 library.
|
||||
Safe for multi-agent use via per-operation connection locking.
|
||||
|
||||
Example:
|
||||
store = SyncSQLiteConversationStore(path="./conversations.db")
|
||||
store.init()
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from typing import List, Optional
|
||||
|
||||
from .base import ConversationStore, ConversationSession, ConversationMessage, validate_identifier
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SyncSQLiteConversationStore(ConversationStore):
|
||||
"""
|
||||
Synchronous SQLite conversation store using sqlite3.
|
||||
|
||||
Provides blocking database operations with per-call locking
|
||||
for multi-agent safety.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str = "praisonai_conversations.db",
|
||||
table_prefix: str = "praison_",
|
||||
):
|
||||
"""
|
||||
Initialize sync SQLite store.
|
||||
|
||||
Args:
|
||||
path: Path to SQLite database file
|
||||
table_prefix: Prefix for table names
|
||||
"""
|
||||
self.path = path
|
||||
validate_identifier(table_prefix, "table_prefix")
|
||||
self.table_prefix = table_prefix
|
||||
self._lock = threading.RLock()
|
||||
self._initialized = False
|
||||
|
||||
def init(self):
|
||||
"""Initialize connection and create tables."""
|
||||
with self._lock:
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
conn = sqlite3.connect(self.path)
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
try:
|
||||
self._create_tables(conn)
|
||||
self._initialized = True
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _create_tables(self, conn: sqlite3.Connection):
|
||||
"""Create database tables."""
|
||||
sessions_table = f"{self.table_prefix}sessions"
|
||||
messages_table = f"{self.table_prefix}messages"
|
||||
|
||||
conn.executescript(f"""
|
||||
CREATE TABLE IF NOT EXISTS {sessions_table} (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
user_id TEXT,
|
||||
agent_id TEXT,
|
||||
name TEXT,
|
||||
state TEXT,
|
||||
metadata TEXT,
|
||||
created_at REAL,
|
||||
updated_at REAL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS {messages_table} (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT,
|
||||
role TEXT,
|
||||
content TEXT,
|
||||
tool_calls TEXT,
|
||||
tool_call_id TEXT,
|
||||
metadata TEXT,
|
||||
created_at REAL,
|
||||
FOREIGN KEY (session_id) REFERENCES {sessions_table}(session_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_{self.table_prefix}sessions_user_agent
|
||||
ON {sessions_table}(user_id, agent_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_{self.table_prefix}messages_session
|
||||
ON {messages_table}(session_id, created_at);
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
def _get_connection(self) -> sqlite3.Connection:
|
||||
"""Get a thread-safe connection."""
|
||||
if not self._initialized:
|
||||
self.init()
|
||||
|
||||
conn = sqlite3.connect(self.path)
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def create_session(self, session: ConversationSession) -> ConversationSession:
|
||||
"""Create a new session."""
|
||||
with self._lock:
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
table = f"{self.table_prefix}sessions"
|
||||
conn.execute(f"""
|
||||
INSERT INTO {table} (session_id, user_id, agent_id, name, state, metadata, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
session.session_id,
|
||||
session.user_id,
|
||||
session.agent_id,
|
||||
session.name,
|
||||
json.dumps(session.state) if session.state else None,
|
||||
json.dumps(session.metadata) if session.metadata else None,
|
||||
session.created_at,
|
||||
session.updated_at
|
||||
))
|
||||
conn.commit()
|
||||
return session
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_session(self, session_id: str) -> Optional[ConversationSession]:
|
||||
"""Get a session by ID."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
table = f"{self.table_prefix}sessions"
|
||||
cursor = conn.execute(f"""
|
||||
SELECT * FROM {table} WHERE session_id = ?
|
||||
""", (session_id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
return ConversationSession(
|
||||
session_id=row['session_id'],
|
||||
user_id=row['user_id'],
|
||||
agent_id=row['agent_id'],
|
||||
name=row['name'],
|
||||
state=json.loads(row['state']) if row['state'] else None,
|
||||
metadata=json.loads(row['metadata']) if row['metadata'] else None,
|
||||
created_at=row['created_at'],
|
||||
updated_at=row['updated_at']
|
||||
)
|
||||
return None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def update_session(self, session: ConversationSession) -> ConversationSession:
|
||||
"""Update an existing session."""
|
||||
with self._lock:
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
table = f"{self.table_prefix}sessions"
|
||||
session.updated_at = time.time()
|
||||
|
||||
conn.execute(f"""
|
||||
UPDATE {table}
|
||||
SET user_id = ?, agent_id = ?, name = ?, state = ?, metadata = ?, updated_at = ?
|
||||
WHERE session_id = ?
|
||||
""", (
|
||||
session.user_id,
|
||||
session.agent_id,
|
||||
session.name,
|
||||
json.dumps(session.state) if session.state else None,
|
||||
json.dumps(session.metadata) if session.metadata else None,
|
||||
session.updated_at,
|
||||
session.session_id
|
||||
))
|
||||
conn.commit()
|
||||
return session
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def delete_session(self, session_id: str) -> bool:
|
||||
"""Delete a session and all its messages."""
|
||||
with self._lock:
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
table = f"{self.table_prefix}sessions"
|
||||
cursor = conn.execute(f"""
|
||||
DELETE FROM {table} WHERE session_id = ?
|
||||
""", (session_id,))
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def list_sessions(
|
||||
self,
|
||||
user_id: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = None
|
||||
) -> List[ConversationSession]:
|
||||
"""List sessions with optional filtering."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
table = f"{self.table_prefix}sessions"
|
||||
conditions = []
|
||||
params = []
|
||||
|
||||
if user_id:
|
||||
conditions.append("user_id = ?")
|
||||
params.append(user_id)
|
||||
|
||||
if agent_id:
|
||||
conditions.append("agent_id = ?")
|
||||
params.append(agent_id)
|
||||
|
||||
where_clause = " WHERE " + " AND ".join(conditions) if conditions else ""
|
||||
|
||||
# Handle pagination - SQLite requires LIMIT when OFFSET is used
|
||||
if limit is not None:
|
||||
params.append(limit)
|
||||
limit_clause = " LIMIT ?"
|
||||
elif offset is not None:
|
||||
# When offset is provided without limit, use -1 for unbounded
|
||||
params.append(-1)
|
||||
limit_clause = " LIMIT ?"
|
||||
else:
|
||||
limit_clause = ""
|
||||
|
||||
if offset is not None:
|
||||
params.append(offset)
|
||||
offset_clause = " OFFSET ?"
|
||||
else:
|
||||
offset_clause = ""
|
||||
|
||||
cursor = conn.execute(f"""
|
||||
SELECT * FROM {table}{where_clause}
|
||||
ORDER BY updated_at DESC{limit_clause}{offset_clause}
|
||||
""", params)
|
||||
|
||||
sessions = []
|
||||
for row in cursor.fetchall():
|
||||
sessions.append(ConversationSession(
|
||||
session_id=row['session_id'],
|
||||
user_id=row['user_id'],
|
||||
agent_id=row['agent_id'],
|
||||
name=row['name'],
|
||||
state=json.loads(row['state']) if row['state'] else None,
|
||||
metadata=json.loads(row['metadata']) if row['metadata'] else None,
|
||||
created_at=row['created_at'],
|
||||
updated_at=row['updated_at']
|
||||
))
|
||||
|
||||
return sessions
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def add_message(self, session_id: str, message: ConversationMessage) -> ConversationMessage:
|
||||
"""Add a message to the conversation."""
|
||||
with self._lock:
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
table = f"{self.table_prefix}messages"
|
||||
# Use the provided session_id, falling back to message.session_id if needed
|
||||
actual_session_id = session_id or message.session_id
|
||||
message.session_id = actual_session_id # Ensure message object is consistent
|
||||
|
||||
conn.execute(f"""
|
||||
INSERT INTO {table} (id, session_id, role, content, tool_calls, tool_call_id, metadata, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
message.id,
|
||||
actual_session_id,
|
||||
message.role,
|
||||
message.content,
|
||||
json.dumps(message.tool_calls) if message.tool_calls else None,
|
||||
message.tool_call_id,
|
||||
json.dumps(message.metadata) if message.metadata else None,
|
||||
message.created_at
|
||||
))
|
||||
conn.commit()
|
||||
return message
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_messages(
|
||||
self,
|
||||
session_id: str,
|
||||
limit: Optional[int] = None,
|
||||
before: Optional[float] = None,
|
||||
after: Optional[float] = None
|
||||
) -> List[ConversationMessage]:
|
||||
"""Get messages for a session."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
table = f"{self.table_prefix}messages"
|
||||
params: list = [session_id]
|
||||
conditions = []
|
||||
|
||||
# Handle timestamp filtering
|
||||
if before is not None:
|
||||
conditions.append("created_at < ?")
|
||||
params.append(before)
|
||||
|
||||
if after is not None:
|
||||
conditions.append("created_at > ?")
|
||||
params.append(after)
|
||||
|
||||
# Build where clause
|
||||
timestamp_filter = " AND " + " AND ".join(conditions) if conditions else ""
|
||||
|
||||
# Handle limit
|
||||
if limit is not None:
|
||||
params.append(limit)
|
||||
limit_clause = " LIMIT ?"
|
||||
else:
|
||||
limit_clause = ""
|
||||
|
||||
cursor = conn.execute(f"""
|
||||
SELECT * FROM {table} WHERE session_id = ?{timestamp_filter}
|
||||
ORDER BY created_at{limit_clause}
|
||||
""", params)
|
||||
|
||||
messages = []
|
||||
for row in cursor.fetchall():
|
||||
messages.append(ConversationMessage(
|
||||
id=row['id'],
|
||||
session_id=row['session_id'],
|
||||
role=row['role'],
|
||||
content=row['content'],
|
||||
tool_calls=json.loads(row['tool_calls']) if row['tool_calls'] else None,
|
||||
tool_call_id=row['tool_call_id'],
|
||||
metadata=json.loads(row['metadata']) if row['metadata'] else None,
|
||||
created_at=row['created_at']
|
||||
))
|
||||
|
||||
return messages
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def delete_messages(self, session_id: str, message_ids: Optional[List[str]] = None) -> int:
|
||||
"""Delete messages from a session."""
|
||||
with self._lock:
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
table = f"{self.table_prefix}messages"
|
||||
if message_ids is None:
|
||||
cursor = conn.execute(f"""
|
||||
DELETE FROM {table} WHERE session_id = ?
|
||||
""", (session_id,))
|
||||
else:
|
||||
placeholders = ','.join(['?' for _ in message_ids])
|
||||
cursor = conn.execute(f"""
|
||||
DELETE FROM {table} WHERE session_id = ? AND id IN ({placeholders})
|
||||
""", [session_id] + message_ids)
|
||||
conn.commit()
|
||||
return cursor.rowcount
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def clear_session(self, session_id: str) -> bool:
|
||||
"""Clear all messages from a session (keep session metadata)."""
|
||||
return self.delete_messages(session_id) > 0
|
||||
|
||||
def close(self) -> None:
|
||||
"""No persistent connection is held; method exists for protocol compliance."""
|
||||
return None
|
||||
@@ -6,7 +6,7 @@ unused dependencies. Uses registry pattern for extensibility.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, Literal, Optional
|
||||
|
||||
from .config import PersistenceConfig
|
||||
from .registry import CONVERSATION_STORES, KNOWLEDGE_STORES, STATE_STORES
|
||||
@@ -17,6 +17,7 @@ logger = logging.getLogger(__name__)
|
||||
def create_conversation_store(
|
||||
backend: str,
|
||||
url: Optional[str] = None,
|
||||
mode: Literal["sync", "async", "auto"] = "auto",
|
||||
**options: Any
|
||||
):
|
||||
"""
|
||||
@@ -25,18 +26,44 @@ def create_conversation_store(
|
||||
Args:
|
||||
backend: Backend type (postgres, mysql, sqlite, etc.)
|
||||
url: Connection URL
|
||||
mode: "sync" for blocking, "async" for async-only, "auto" for legacy behavior
|
||||
**options: Backend-specific options
|
||||
|
||||
Returns:
|
||||
ConversationStore instance
|
||||
|
||||
Example:
|
||||
# Async-safe SQLite store
|
||||
store = create_conversation_store(
|
||||
"postgres",
|
||||
url="postgresql://localhost:5432/praisonai"
|
||||
"sqlite",
|
||||
path="./conversations.db",
|
||||
mode="async"
|
||||
)
|
||||
|
||||
# Sync-only SQLite store (multi-agent safe)
|
||||
store = create_conversation_store(
|
||||
"sqlite",
|
||||
path="./conversations.db",
|
||||
mode="sync"
|
||||
)
|
||||
"""
|
||||
return CONVERSATION_STORES.create(backend, url=url, **options)
|
||||
|
||||
# Validate mode parameter
|
||||
if mode not in ("sync", "async", "auto"):
|
||||
raise ValueError(f"Invalid conversation mode: {mode}. Expected sync|async|auto")
|
||||
|
||||
# Handle sync/async mode routing
|
||||
if mode == "sync" and backend in ("sqlite", "postgres", "mysql"):
|
||||
backend_name = f"sync_{backend}"
|
||||
elif mode == "async" and backend in ("sqlite", "postgres", "mysql"):
|
||||
backend_name = f"async_{backend}"
|
||||
elif mode == "auto":
|
||||
# Legacy behavior - use existing backend as-is
|
||||
backend_name = backend
|
||||
else:
|
||||
backend_name = backend
|
||||
|
||||
return CONVERSATION_STORES.create(backend_name, url=url, **options)
|
||||
|
||||
|
||||
def create_knowledge_store(
|
||||
@@ -103,10 +130,15 @@ def create_stores_from_config(config: PersistenceConfig) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
if config.conversation_store:
|
||||
# Extract mode from conversation_options if present
|
||||
conversation_options = dict(config.conversation_options)
|
||||
mode = conversation_options.pop("mode", "auto")
|
||||
|
||||
stores["conversation"] = create_conversation_store(
|
||||
config.conversation_store,
|
||||
url=config.conversation_url,
|
||||
**config.conversation_options
|
||||
mode=mode,
|
||||
**conversation_options
|
||||
)
|
||||
|
||||
if config.knowledge_store:
|
||||
|
||||
@@ -5,6 +5,7 @@ Provides wrapper functions to add persistence capabilities to PraisonAI agents
|
||||
without modifying the core SDK.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import uuid
|
||||
import logging
|
||||
@@ -60,15 +61,13 @@ def wrap_agent_with_persistence(
|
||||
session_id = session_id or f"session-{uuid.uuid4().hex[:8]}"
|
||||
user_id = user_id or getattr(agent, 'user_id', 'default')
|
||||
|
||||
# Store original chat method
|
||||
# Store original methods
|
||||
original_chat = agent.chat
|
||||
original_achat = getattr(agent, 'achat', None)
|
||||
_session_initialized = [False] # Use list to allow mutation in closure
|
||||
|
||||
@wraps(original_chat)
|
||||
def chat_with_persistence(prompt, *args, **kwargs):
|
||||
nonlocal _session_initialized
|
||||
|
||||
# Initialize session on first call
|
||||
def _ensure_session_sync():
|
||||
"""Ensure session is initialized (sync version)."""
|
||||
if not _session_initialized[0]:
|
||||
history = orchestrator.on_agent_start(
|
||||
agent,
|
||||
@@ -86,6 +85,31 @@ def wrap_agent_with_persistence(
|
||||
logger.info(f"Resumed session {session_id} with {len(history)} messages")
|
||||
|
||||
_session_initialized[0] = True
|
||||
|
||||
async def _ensure_session_async():
|
||||
"""Ensure session is initialized (async version)."""
|
||||
if not _session_initialized[0]:
|
||||
history = await orchestrator.aon_agent_start(
|
||||
agent,
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
resume=auto_resume
|
||||
)
|
||||
|
||||
# Inject history into agent's chat_history if resuming
|
||||
if auto_resume and history:
|
||||
agent.chat_history = [
|
||||
{"role": msg.role, "content": msg.content}
|
||||
for msg in history
|
||||
]
|
||||
logger.info(f"Resumed session {session_id} with {len(history)} messages")
|
||||
|
||||
_session_initialized[0] = True
|
||||
|
||||
@wraps(original_chat)
|
||||
def chat_with_persistence(prompt, *args, **kwargs):
|
||||
# Initialize session on first call
|
||||
_ensure_session_sync()
|
||||
|
||||
# Persist user message
|
||||
orchestrator.on_message(session_id, "user", prompt if isinstance(prompt, str) else str(prompt))
|
||||
@@ -102,6 +126,27 @@ def wrap_agent_with_persistence(
|
||||
# Replace chat method
|
||||
agent.chat = chat_with_persistence
|
||||
|
||||
# Replace achat method if it exists
|
||||
if original_achat is not None:
|
||||
@wraps(original_achat)
|
||||
async def achat_with_persistence(prompt, *args, **kwargs):
|
||||
# Initialize session on first call
|
||||
await _ensure_session_async()
|
||||
|
||||
# Persist user message
|
||||
await orchestrator.aon_message(session_id, "user", prompt if isinstance(prompt, str) else str(prompt))
|
||||
|
||||
# Call original achat
|
||||
response = await original_achat(prompt, *args, **kwargs)
|
||||
|
||||
# Persist assistant response
|
||||
if response:
|
||||
await orchestrator.aon_message(session_id, "assistant", response)
|
||||
|
||||
return response
|
||||
|
||||
agent.achat = achat_with_persistence
|
||||
|
||||
# Add persistence-related methods to agent
|
||||
agent._persistence_orchestrator = orchestrator
|
||||
agent._persistence_session_id = session_id
|
||||
@@ -118,9 +163,14 @@ def wrap_agent_with_persistence(
|
||||
"""End the current session."""
|
||||
orchestrator.on_agent_end(agent, session_id)
|
||||
|
||||
async def aend_session():
|
||||
"""End the current session (async version)."""
|
||||
await orchestrator.aon_agent_end(agent, session_id)
|
||||
|
||||
agent.get_session = get_session
|
||||
agent.get_messages = get_messages
|
||||
agent.end_session = end_session
|
||||
agent.aend_session = aend_session
|
||||
|
||||
return agent
|
||||
|
||||
@@ -164,7 +214,7 @@ class PersistentAgent:
|
||||
return getattr(self._agent, name)
|
||||
|
||||
def _ensure_session(self):
|
||||
"""Ensure session is initialized."""
|
||||
"""Ensure session is initialized (sync version)."""
|
||||
if not self._session_initialized:
|
||||
history = self._orchestrator.on_agent_start(
|
||||
self._agent,
|
||||
@@ -182,6 +232,25 @@ class PersistentAgent:
|
||||
|
||||
self._session_initialized = True
|
||||
|
||||
async def _ensure_session_async(self):
|
||||
"""Ensure session is initialized (async version)."""
|
||||
if not self._session_initialized:
|
||||
history = await self._orchestrator.aon_agent_start(
|
||||
self._agent,
|
||||
session_id=self._session_id,
|
||||
user_id=self._user_id,
|
||||
resume=self._auto_resume
|
||||
)
|
||||
|
||||
if self._auto_resume and history:
|
||||
self._agent.chat_history = [
|
||||
{"role": msg.role, "content": msg.content}
|
||||
for msg in history
|
||||
]
|
||||
logger.info(f"Resumed session {self._session_id} with {len(history)} messages")
|
||||
|
||||
self._session_initialized = True
|
||||
|
||||
def chat(self, prompt, *args, **kwargs):
|
||||
"""Chat with automatic persistence."""
|
||||
self._ensure_session()
|
||||
@@ -201,6 +270,29 @@ class PersistentAgent:
|
||||
|
||||
return response
|
||||
|
||||
async def achat(self, prompt, *args, **kwargs):
|
||||
"""Async chat with automatic persistence."""
|
||||
await self._ensure_session_async()
|
||||
|
||||
# Persist user message
|
||||
await self._orchestrator.aon_message(
|
||||
self._session_id, "user",
|
||||
prompt if isinstance(prompt, str) else str(prompt)
|
||||
)
|
||||
|
||||
# Call agent achat
|
||||
if hasattr(self._agent, 'achat'):
|
||||
response = await self._agent.achat(prompt, *args, **kwargs)
|
||||
else:
|
||||
# Fallback to sync chat if achat is not available - use asyncio.to_thread to avoid blocking
|
||||
response = await asyncio.to_thread(self._agent.chat, prompt, *args, **kwargs)
|
||||
|
||||
# Persist assistant response
|
||||
if response:
|
||||
await self._orchestrator.aon_message(self._session_id, "assistant", response)
|
||||
|
||||
return response
|
||||
|
||||
def get_session(self) -> Optional[ConversationSession]:
|
||||
"""Get the current session."""
|
||||
return self._orchestrator.get_session(self._session_id)
|
||||
@@ -213,6 +305,10 @@ class PersistentAgent:
|
||||
"""End the current session."""
|
||||
self._orchestrator.on_agent_end(self._agent, self._session_id)
|
||||
|
||||
async def aend_session(self):
|
||||
"""End the current session (async version)."""
|
||||
await self._orchestrator.aon_agent_end(self._agent, self._session_id)
|
||||
|
||||
@property
|
||||
def session_id(self) -> str:
|
||||
"""Get the session ID."""
|
||||
|
||||
@@ -9,6 +9,7 @@ import logging
|
||||
import time
|
||||
import threading
|
||||
import uuid
|
||||
import inspect
|
||||
from copy import deepcopy
|
||||
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
@@ -244,6 +245,164 @@ class PersistenceOrchestrator:
|
||||
self._cache_put(session)
|
||||
logger.debug(f"Updated session metadata: {session_id}")
|
||||
|
||||
# =========================================================================
|
||||
# Async Agent Lifecycle Hooks
|
||||
# =========================================================================
|
||||
|
||||
async def aon_agent_start(
|
||||
self,
|
||||
agent: Any,
|
||||
session_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
resume: bool = True,
|
||||
) -> List[ConversationMessage]:
|
||||
"""
|
||||
Async version of on_agent_start.
|
||||
|
||||
Args:
|
||||
agent: The agent instance
|
||||
session_id: Session ID (generated if not provided)
|
||||
user_id: User ID for session
|
||||
resume: Whether to load existing session history
|
||||
|
||||
Returns:
|
||||
List of previous messages if resuming, empty list otherwise
|
||||
"""
|
||||
if not session_id:
|
||||
session_id = str(uuid.uuid4())
|
||||
|
||||
if not self.conversation:
|
||||
logger.debug("No conversation store configured, skipping session load")
|
||||
return []
|
||||
|
||||
# Try to load existing session
|
||||
session = None
|
||||
if resume:
|
||||
getter = getattr(self.conversation, 'async_get_session', self.conversation.get_session)
|
||||
if inspect.iscoroutinefunction(getter):
|
||||
session = await getter(session_id)
|
||||
else:
|
||||
session = getter(session_id)
|
||||
|
||||
if session:
|
||||
logger.info(f"Resuming session: {session_id}")
|
||||
self._current_session = session
|
||||
self._cache_put(session)
|
||||
|
||||
# Load previous messages
|
||||
msg_getter = getattr(self.conversation, 'async_get_messages', self.conversation.get_messages)
|
||||
if inspect.iscoroutinefunction(msg_getter):
|
||||
messages = await msg_getter(session_id)
|
||||
else:
|
||||
messages = msg_getter(session_id)
|
||||
return messages
|
||||
else:
|
||||
# Create new session
|
||||
agent_id = getattr(agent, "name", None) or getattr(agent, "agent_id", None)
|
||||
session = ConversationSession(
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
name=f"Session {session_id[:8]}",
|
||||
metadata={"agent_type": type(agent).__name__},
|
||||
)
|
||||
|
||||
creator = getattr(self.conversation, 'async_create_session', self.conversation.create_session)
|
||||
if inspect.iscoroutinefunction(creator):
|
||||
await creator(session)
|
||||
else:
|
||||
creator(session)
|
||||
|
||||
logger.info(f"Created new session: {session_id}")
|
||||
self._current_session = session
|
||||
self._cache_put(session)
|
||||
return []
|
||||
|
||||
async def aon_message(
|
||||
self,
|
||||
session_id: str,
|
||||
role: str,
|
||||
content: str,
|
||||
tool_calls: Optional[List[Dict]] = None,
|
||||
tool_call_id: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[ConversationMessage]:
|
||||
"""
|
||||
Async version of on_message.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
role: Message role (user, assistant, system, tool)
|
||||
content: Message content
|
||||
tool_calls: Tool calls (for assistant messages)
|
||||
tool_call_id: Tool call ID (for tool response messages)
|
||||
metadata: Additional metadata
|
||||
|
||||
Returns:
|
||||
The persisted message, or None if no store configured
|
||||
"""
|
||||
if not self.conversation:
|
||||
return None
|
||||
|
||||
message = ConversationMessage(
|
||||
id=str(uuid.uuid4()),
|
||||
session_id=session_id,
|
||||
role=role,
|
||||
content=content,
|
||||
tool_calls=tool_calls,
|
||||
tool_call_id=tool_call_id,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
adder = getattr(self.conversation, 'async_add_message', self.conversation.add_message)
|
||||
if inspect.iscoroutinefunction(adder):
|
||||
await adder(session_id, message)
|
||||
else:
|
||||
adder(session_id, message)
|
||||
|
||||
logger.debug(f"Persisted {role} message to session {session_id}")
|
||||
return message
|
||||
|
||||
async def aon_agent_end(
|
||||
self,
|
||||
agent: Any,
|
||||
session_id: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Async version of on_agent_end.
|
||||
|
||||
Args:
|
||||
agent: The agent instance
|
||||
session_id: Session ID
|
||||
metadata: Additional metadata to store
|
||||
"""
|
||||
if not self.conversation:
|
||||
return
|
||||
|
||||
session = self._cache_get(session_id)
|
||||
if not session:
|
||||
getter = getattr(self.conversation, 'async_get_session', self.conversation.get_session)
|
||||
if inspect.iscoroutinefunction(getter):
|
||||
session = await getter(session_id)
|
||||
else:
|
||||
session = getter(session_id)
|
||||
|
||||
if session:
|
||||
session.updated_at = time.time()
|
||||
if metadata:
|
||||
session.metadata = {**(session.metadata or {}), **metadata}
|
||||
|
||||
updater = getattr(self.conversation, 'async_update_session', self.conversation.update_session)
|
||||
if inspect.iscoroutinefunction(updater):
|
||||
await updater(session)
|
||||
else:
|
||||
updater(session)
|
||||
|
||||
# Update cache with the modified session
|
||||
self._cache_put(session)
|
||||
logger.debug(f"Updated session metadata: {session_id}")
|
||||
|
||||
# =========================================================================
|
||||
# Knowledge Retrieval
|
||||
# =========================================================================
|
||||
|
||||
@@ -121,6 +121,13 @@ def _register_builtin_conversation_stores(registry: StoreRegistry):
|
||||
from .conversation.sqlite import SQLiteConversationStore
|
||||
return SQLiteConversationStore(path=url or path, **kwargs)
|
||||
|
||||
def _sync_sqlite(url=None, path=None, **kwargs):
|
||||
from .conversation.sync_sqlite import SyncSQLiteConversationStore
|
||||
resolved_path = url or path
|
||||
if resolved_path is None:
|
||||
return SyncSQLiteConversationStore(**kwargs)
|
||||
return SyncSQLiteConversationStore(path=resolved_path, **kwargs)
|
||||
|
||||
def _async_sqlite(url=None, path=None, **kwargs):
|
||||
from .conversation.async_sqlite import AsyncSQLiteConversationStore
|
||||
return AsyncSQLiteConversationStore(path=url or path, **kwargs)
|
||||
@@ -154,6 +161,8 @@ def _register_builtin_conversation_stores(registry: StoreRegistry):
|
||||
registry.register("async_mysql", _async_mysql,
|
||||
aliases=("aiomysql", "mysql_async"))
|
||||
registry.register("sqlite", _sqlite)
|
||||
registry.register("sync_sqlite", _sync_sqlite,
|
||||
aliases=("sqlite_sync",))
|
||||
registry.register("async_sqlite", _async_sqlite,
|
||||
aliases=("aiosqlite", "sqlite_async"))
|
||||
registry.register("json", _json)
|
||||
|
||||
@@ -100,9 +100,7 @@ class E2BSandbox:
|
||||
|
||||
if self._sandbox:
|
||||
try:
|
||||
await asyncio.get_event_loop().run_in_executor(
|
||||
None, self._sandbox.kill
|
||||
)
|
||||
await asyncio.to_thread(self._sandbox.kill)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to kill E2B sandbox: {e}")
|
||||
self._sandbox = None
|
||||
@@ -161,9 +159,8 @@ class E2BSandbox:
|
||||
"""Execute Python code using E2B code interpreter."""
|
||||
try:
|
||||
# Run in executor to avoid blocking the event loop
|
||||
execution = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
lambda: self._sandbox.run_code(code, timeout=limits.timeout_seconds)
|
||||
execution = await asyncio.to_thread(
|
||||
self._sandbox.run_code, code, timeout=limits.timeout_seconds
|
||||
)
|
||||
|
||||
# Extract results from E2B execution
|
||||
@@ -219,23 +216,20 @@ class E2BSandbox:
|
||||
if env:
|
||||
import shlex
|
||||
for key, value in env.items():
|
||||
await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
lambda k=key, v=value: self._sandbox.commands.run(f"export {shlex.quote(k)}={shlex.quote(v)}", timeout=5)
|
||||
await asyncio.to_thread(
|
||||
self._sandbox.commands.run, f"export {shlex.quote(key)}={shlex.quote(value)}", timeout=5
|
||||
)
|
||||
|
||||
# Change directory if needed
|
||||
if working_dir:
|
||||
import shlex
|
||||
await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
lambda: self._sandbox.commands.run(f"cd {shlex.quote(working_dir)}", timeout=5)
|
||||
await asyncio.to_thread(
|
||||
self._sandbox.commands.run, f"cd {shlex.quote(working_dir)}", timeout=5
|
||||
)
|
||||
|
||||
# Execute the command
|
||||
result = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
lambda: self._sandbox.commands.run(command, timeout=limits.timeout_seconds)
|
||||
result = await asyncio.to_thread(
|
||||
self._sandbox.commands.run, command, timeout=limits.timeout_seconds
|
||||
)
|
||||
|
||||
return SandboxResult(
|
||||
@@ -318,10 +312,7 @@ class E2BSandbox:
|
||||
if isinstance(content, bytes):
|
||||
content = content.decode("utf-8")
|
||||
|
||||
await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
lambda: self._sandbox.files.write(path, content)
|
||||
)
|
||||
await asyncio.to_thread(self._sandbox.files.write, path, content)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to write file {path}: {e}")
|
||||
@@ -336,10 +327,7 @@ class E2BSandbox:
|
||||
await self.start()
|
||||
|
||||
try:
|
||||
content = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
lambda: self._sandbox.files.read(path)
|
||||
)
|
||||
content = await asyncio.to_thread(self._sandbox.files.read, path)
|
||||
return content
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read file {path}: {e}")
|
||||
|
||||
@@ -71,12 +71,17 @@ class AsyncAgentScheduler:
|
||||
- Cancellation support
|
||||
- No global state pollution
|
||||
- Native async coordination
|
||||
- Timeout support (TODO: needs porting from sync version)
|
||||
- Budget tracking (TODO: needs porting from sync version)
|
||||
- YAML/recipe constructors (TODO: needs porting from sync version)
|
||||
- Timeout support
|
||||
- Budget tracking
|
||||
- YAML/recipe constructors
|
||||
|
||||
Example:
|
||||
scheduler = AsyncAgentScheduler(agent, task="Check news")
|
||||
scheduler = AsyncAgentScheduler(
|
||||
agent,
|
||||
task="Check news",
|
||||
timeout=30, # 30 second timeout per execution
|
||||
max_cost=1.00 # $1.00 budget limit
|
||||
)
|
||||
await scheduler.start(schedule_expr="hourly")
|
||||
await asyncio.sleep(3600) # Let it run
|
||||
await scheduler.stop()
|
||||
@@ -89,9 +94,8 @@ class AsyncAgentScheduler:
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
on_success: Optional[Callable] = None,
|
||||
on_failure: Optional[Callable] = None,
|
||||
# TODO: Add these missing features from sync version:
|
||||
# timeout: Optional[int] = None,
|
||||
# max_cost: Optional[float] = 1.00
|
||||
timeout: Optional[int] = None,
|
||||
max_cost: Optional[float] = 1.00
|
||||
):
|
||||
"""
|
||||
Initialize async agent scheduler.
|
||||
@@ -102,18 +106,17 @@ class AsyncAgentScheduler:
|
||||
config: Optional configuration dict
|
||||
on_success: Callback function on successful execution
|
||||
on_failure: Callback function on failed execution
|
||||
# TODO: Add timeout and max_cost parameters
|
||||
timeout: Maximum execution time per run in seconds (None = no limit)
|
||||
max_cost: Maximum total cost in USD (default: $1.00 for safety)
|
||||
"""
|
||||
self.agent = agent
|
||||
self.task = task
|
||||
self.config = config or {}
|
||||
self.on_success = on_success
|
||||
self.on_failure = on_failure
|
||||
|
||||
# TODO: Add these missing features from sync version:
|
||||
# self.timeout = timeout
|
||||
# self.max_cost = max_cost
|
||||
# self._total_cost = 0.0
|
||||
self.timeout = timeout
|
||||
self.max_cost = max_cost
|
||||
self._total_cost = 0.0
|
||||
|
||||
self.is_running = False
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
@@ -172,7 +175,10 @@ class AsyncAgentScheduler:
|
||||
logger.info(f"Starting async agent scheduler: {getattr(self.agent, 'name', 'Agent')}")
|
||||
logger.info(f"Task: {self.task}")
|
||||
logger.info(f"Schedule: {schedule_expr} ({interval}s interval)")
|
||||
# TODO: Add timeout and budget logging from sync version
|
||||
if self.timeout:
|
||||
logger.info(f"Timeout per execution: {self.timeout}s")
|
||||
if self.max_cost is not None:
|
||||
logger.info(f"Budget limit: ${self.max_cost}")
|
||||
|
||||
# Run immediately if requested
|
||||
if run_immediately:
|
||||
@@ -265,26 +271,12 @@ class AsyncAgentScheduler:
|
||||
|
||||
async def get_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get current execution statistics (best-effort synchronous access).
|
||||
|
||||
Warning: This method provides a best-effort view of stats without
|
||||
guaranteeing atomicity. For consistent snapshots in async context,
|
||||
use get_stats_async() instead.
|
||||
Get current execution statistics (async, atomic snapshot).
|
||||
|
||||
Returns:
|
||||
Dictionary with execution stats
|
||||
"""
|
||||
# Best-effort read without lock for backward compatibility
|
||||
return {
|
||||
"is_running": self.is_running,
|
||||
"total_executions": self._execution_count,
|
||||
"successful_executions": self._success_count,
|
||||
"failed_executions": self._failure_count,
|
||||
"success_rate": (self._success_count / self._execution_count * 100) if self._execution_count > 0 else 0,
|
||||
# TODO: Add cost tracking from sync version:
|
||||
# "total_cost": self._total_cost,
|
||||
# "remaining_budget": (self.max_cost - self._total_cost) if self.max_cost else None,
|
||||
}
|
||||
return await self.get_stats_async()
|
||||
|
||||
async def get_stats_async(self) -> Dict[str, Any]:
|
||||
"""
|
||||
@@ -295,13 +287,14 @@ class AsyncAgentScheduler:
|
||||
"""
|
||||
if self._stats_lock is None:
|
||||
# Not yet started: stats are all zero, no lock needed
|
||||
execs, success, failed = 0, 0, 0
|
||||
execs, success, failed, total_cost = 0, 0, 0, 0.0
|
||||
else:
|
||||
# Take atomic snapshot of all counters
|
||||
async with self._stats_lock:
|
||||
execs = self._execution_count
|
||||
success = self._success_count
|
||||
failed = self._failure_count
|
||||
total_cost = self._total_cost
|
||||
|
||||
return {
|
||||
"is_running": self.is_running,
|
||||
@@ -309,9 +302,8 @@ class AsyncAgentScheduler:
|
||||
"successful_executions": success,
|
||||
"failed_executions": failed,
|
||||
"success_rate": (success / execs * 100) if execs > 0 else 0,
|
||||
# TODO: Add cost tracking from sync version:
|
||||
# "total_cost": self._total_cost,
|
||||
# "remaining_budget": (self.max_cost - self._total_cost) if self.max_cost else None,
|
||||
"total_cost_usd": round(total_cost, 4),
|
||||
"remaining_budget": round(self.max_cost - total_cost, 4) if self.max_cost is not None else None,
|
||||
}
|
||||
|
||||
def get_stats_sync(self) -> Dict[str, Any]:
|
||||
@@ -328,6 +320,8 @@ class AsyncAgentScheduler:
|
||||
"successful_executions": self._success_count,
|
||||
"failed_executions": self._failure_count,
|
||||
"success_rate": (self._success_count / self._execution_count * 100) if self._execution_count > 0 else 0,
|
||||
"total_cost_usd": round(self._total_cost, 4),
|
||||
"remaining_budget": round(self.max_cost - self._total_cost, 4) if self.max_cost is not None else None,
|
||||
}
|
||||
|
||||
async def _run_schedule(self, interval: int, max_retries: int):
|
||||
@@ -350,45 +344,50 @@ class AsyncAgentScheduler:
|
||||
self.is_running = False
|
||||
|
||||
async def _execute_with_retry(self, max_retries: int):
|
||||
"""Execute agent with retry logic.
|
||||
|
||||
TODO: Port missing features from sync version:
|
||||
- Timeout support per execution
|
||||
- Budget tracking and limits
|
||||
- Daemon state updates (_update_state_if_daemon)
|
||||
"""
|
||||
"""Execute agent with retry logic."""
|
||||
self._ensure_async_primitives() # guarantees _stats_lock is bound to current loop
|
||||
|
||||
# Check budget limit before incrementing execution count
|
||||
if self.max_cost and self._total_cost >= self.max_cost:
|
||||
logger.warning(f"Budget limit reached: ${self._total_cost:.4f} >= ${self.max_cost}")
|
||||
logger.warning("Stopping scheduler to prevent additional costs")
|
||||
self._stop_event.set() # Actually stop the scheduler
|
||||
return
|
||||
|
||||
async with self._stats_lock:
|
||||
self._execution_count += 1
|
||||
|
||||
# TODO: Add budget check from sync version:
|
||||
# if self.max_cost and self._total_cost >= self.max_cost:
|
||||
# logger.warning(f"Budget limit reached: ${self._total_cost:.4f} >= ${self.max_cost}")
|
||||
# return
|
||||
# Check budget limit before execution
|
||||
if self.max_cost is not None and self._total_cost >= self.max_cost:
|
||||
logger.warning(f"Budget limit reached: ${self._total_cost:.4f} >= ${self.max_cost}")
|
||||
if self._stop_event is not None:
|
||||
self._stop_event.set()
|
||||
self.is_running = False
|
||||
return
|
||||
|
||||
last_exc: Optional[Exception] = None
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
logger.info(f"Async attempt {attempt + 1}/{max_retries}")
|
||||
|
||||
# TODO: Add timeout support from sync version:
|
||||
# if self.timeout:
|
||||
# result = await asyncio.wait_for(
|
||||
# self._executor.execute(self.task),
|
||||
# timeout=self.timeout
|
||||
# )
|
||||
# else:
|
||||
result = await self._executor.execute(self.task)
|
||||
# Execute with timeout if specified
|
||||
if self.timeout:
|
||||
result = await asyncio.wait_for(
|
||||
self._executor.execute(self.task),
|
||||
timeout=self.timeout
|
||||
)
|
||||
else:
|
||||
result = await self._executor.execute(self.task)
|
||||
|
||||
logger.info(f"Async agent execution successful on attempt {attempt + 1}")
|
||||
logger.info(f"Result: {result}")
|
||||
|
||||
# Estimate cost (rough: ~$0.0001 per execution for gpt-4o-mini)
|
||||
estimated_cost = 0.0001 # Base cost estimate
|
||||
async with self._stats_lock:
|
||||
self._success_count += 1
|
||||
# TODO: Add cost tracking from sync version:
|
||||
# estimated_cost = self._estimate_cost(result)
|
||||
# self._total_cost += estimated_cost
|
||||
self._total_cost += estimated_cost
|
||||
logger.info(f"Estimated cost this run: ${estimated_cost:.4f}, Total: ${self._total_cost:.4f}")
|
||||
|
||||
safe_call(self.on_success, result)
|
||||
# TODO: Add daemon state update from sync version:
|
||||
@@ -438,22 +437,207 @@ class AsyncAgentScheduler:
|
||||
logger.error(f"One-time async execution failed: {e}")
|
||||
raise
|
||||
|
||||
# TODO: Add these missing factory methods from sync version:
|
||||
# @classmethod
|
||||
# async def from_yaml(cls, yaml_path: str, ...) -> "AsyncAgentScheduler":
|
||||
# """Create scheduler from YAML configuration."""
|
||||
# pass
|
||||
#
|
||||
# @classmethod
|
||||
# async def from_recipe(cls, recipe_name: str, ...) -> "AsyncAgentScheduler":
|
||||
# """Create scheduler from recipe configuration."""
|
||||
# pass
|
||||
@classmethod
|
||||
def from_yaml(
|
||||
cls,
|
||||
yaml_path: str = "agents.yaml",
|
||||
interval_override: Optional[str] = None,
|
||||
max_retries_override: Optional[int] = None,
|
||||
timeout_override: Optional[int] = None,
|
||||
max_cost_override: Optional[float] = None,
|
||||
on_success: Optional[Callable] = None,
|
||||
on_failure: Optional[Callable] = None
|
||||
) -> 'AsyncAgentScheduler':
|
||||
"""
|
||||
Create AsyncAgentScheduler from agents.yaml file.
|
||||
|
||||
Args:
|
||||
yaml_path: Path to agents.yaml file
|
||||
interval_override: Override schedule interval from YAML
|
||||
max_retries_override: Override max_retries from YAML
|
||||
timeout_override: Override timeout from YAML
|
||||
max_cost_override: Override max_cost from YAML
|
||||
on_success: Callback function on successful execution
|
||||
on_failure: Callback function on failed execution
|
||||
|
||||
Returns:
|
||||
Configured AsyncAgentScheduler instance
|
||||
|
||||
Example:
|
||||
scheduler = AsyncAgentScheduler.from_yaml("agents.yaml")
|
||||
await scheduler.start("hourly")
|
||||
"""
|
||||
from .yaml_loader import load_agent_yaml_with_schedule, create_agent_from_config
|
||||
|
||||
# Load configuration from YAML
|
||||
agent_config, schedule_config = load_agent_yaml_with_schedule(yaml_path)
|
||||
|
||||
# Create agent from config
|
||||
agent = create_agent_from_config(agent_config)
|
||||
|
||||
# Get task
|
||||
task = agent_config.get('task', '')
|
||||
if not task:
|
||||
raise ValueError("No task specified in YAML file")
|
||||
|
||||
# Apply overrides to schedule config
|
||||
if interval_override:
|
||||
schedule_config['interval'] = interval_override
|
||||
if max_retries_override is not None:
|
||||
schedule_config['max_retries'] = max_retries_override
|
||||
if timeout_override is not None:
|
||||
schedule_config['timeout'] = timeout_override
|
||||
if max_cost_override is not None:
|
||||
schedule_config['max_cost'] = max_cost_override
|
||||
|
||||
# Create scheduler instance with timeout and cost limits
|
||||
scheduler = cls(
|
||||
agent=agent,
|
||||
task=task,
|
||||
config=agent_config,
|
||||
timeout=schedule_config.get('timeout'),
|
||||
max_cost=schedule_config.get('max_cost'),
|
||||
on_success=on_success,
|
||||
on_failure=on_failure
|
||||
)
|
||||
|
||||
# Store schedule config for later use
|
||||
scheduler._yaml_schedule_config = schedule_config
|
||||
|
||||
return scheduler
|
||||
|
||||
async def start_from_yaml_config(self) -> bool:
|
||||
"""
|
||||
Start scheduler using configuration from YAML file.
|
||||
|
||||
Must be called after from_yaml() class method.
|
||||
|
||||
Returns:
|
||||
True if started successfully
|
||||
"""
|
||||
if not hasattr(self, '_yaml_schedule_config'):
|
||||
raise ValueError("No YAML configuration found. Use from_yaml() first.")
|
||||
|
||||
schedule_config = self._yaml_schedule_config
|
||||
interval = schedule_config.get('interval', 'hourly')
|
||||
max_retries = schedule_config.get('max_retries', 3)
|
||||
run_immediately = schedule_config.get('run_immediately', False)
|
||||
|
||||
return await self.start(interval, max_retries, run_immediately)
|
||||
|
||||
@classmethod
|
||||
def from_recipe(
|
||||
cls,
|
||||
recipe_name: str,
|
||||
*,
|
||||
input_data: Any = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
interval_override: Optional[str] = None,
|
||||
max_retries_override: Optional[int] = None,
|
||||
timeout_override: Optional[int] = None,
|
||||
max_cost_override: Optional[float] = None,
|
||||
on_success: Optional[Callable] = None,
|
||||
on_failure: Optional[Callable] = None
|
||||
) -> 'AsyncAgentScheduler':
|
||||
"""
|
||||
Create AsyncAgentScheduler from a recipe name.
|
||||
|
||||
Args:
|
||||
recipe_name: Name of the recipe to schedule
|
||||
input_data: Input data for the recipe
|
||||
config: Configuration overrides for the recipe
|
||||
interval_override: Override schedule interval from recipe runtime config
|
||||
max_retries_override: Override max_retries from recipe runtime config
|
||||
timeout_override: Override timeout from recipe runtime config
|
||||
max_cost_override: Override max_cost from recipe runtime config
|
||||
on_success: Callback function on successful execution
|
||||
on_failure: Callback function on failed execution
|
||||
|
||||
Returns:
|
||||
Configured AsyncAgentScheduler instance
|
||||
|
||||
Example:
|
||||
scheduler = AsyncAgentScheduler.from_recipe("news-monitor")
|
||||
await scheduler.start("hourly")
|
||||
"""
|
||||
from praisonai.recipe.bridge import resolve, execute_resolved_recipe, get_recipe_task_description
|
||||
|
||||
# Resolve the recipe
|
||||
resolved = resolve(
|
||||
recipe_name,
|
||||
input_data=input_data,
|
||||
config=config or {},
|
||||
options={'timeout_sec': timeout_override or 300},
|
||||
)
|
||||
|
||||
# Get runtime config defaults from recipe
|
||||
interval = interval_override or "hourly"
|
||||
max_retries = max_retries_override if max_retries_override is not None else 3
|
||||
timeout = timeout_override or 300
|
||||
max_cost = max_cost_override if max_cost_override is not None else 1.00
|
||||
|
||||
runtime = resolved.runtime_config
|
||||
if runtime and hasattr(runtime, 'schedule'):
|
||||
sched_config = runtime.schedule
|
||||
interval = interval_override or sched_config.interval
|
||||
max_retries = max_retries_override if max_retries_override is not None else sched_config.max_retries
|
||||
timeout = timeout_override or sched_config.timeout_sec
|
||||
max_cost = max_cost_override if max_cost_override is not None else sched_config.max_cost_usd
|
||||
|
||||
# Create a recipe executor agent wrapper that supports async
|
||||
class AsyncRecipeExecutorAgent:
|
||||
"""Wrapper that makes a recipe look like an agent for the async scheduler."""
|
||||
def __init__(self, resolved_recipe):
|
||||
self.resolved = resolved_recipe
|
||||
self.name = f"AsyncRecipeAgent:{resolved_recipe.name}"
|
||||
|
||||
async def astart(self, task: str) -> Any:
|
||||
# Run recipe execution in thread to avoid blocking async loop
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(
|
||||
None, lambda: execute_resolved_recipe(self.resolved)
|
||||
)
|
||||
|
||||
def start(self, task: str) -> Any:
|
||||
# Fallback sync method
|
||||
return execute_resolved_recipe(self.resolved)
|
||||
|
||||
# Create the agent wrapper
|
||||
agent = AsyncRecipeExecutorAgent(resolved)
|
||||
task = get_recipe_task_description(resolved)
|
||||
|
||||
# Create scheduler instance
|
||||
scheduler = cls(
|
||||
agent=agent,
|
||||
task=task,
|
||||
timeout=timeout,
|
||||
max_cost=max_cost,
|
||||
on_success=on_success,
|
||||
on_failure=on_failure,
|
||||
)
|
||||
|
||||
# Store recipe metadata and schedule config
|
||||
scheduler._recipe_name = recipe_name
|
||||
scheduler._recipe_resolved = resolved
|
||||
scheduler._yaml_schedule_config = {
|
||||
'interval': interval,
|
||||
'max_retries': max_retries,
|
||||
'run_immediately': False,
|
||||
'timeout': timeout,
|
||||
'max_cost': max_cost,
|
||||
}
|
||||
|
||||
return scheduler
|
||||
|
||||
|
||||
def create_async_agent_scheduler(
|
||||
agent,
|
||||
task: str,
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
on_success: Optional[Callable] = None,
|
||||
on_failure: Optional[Callable] = None,
|
||||
timeout: Optional[int] = None,
|
||||
max_cost: Optional[float] = 1.00
|
||||
) -> AsyncAgentScheduler:
|
||||
"""
|
||||
Factory function to create async agent scheduler.
|
||||
@@ -462,8 +646,20 @@ def create_async_agent_scheduler(
|
||||
agent: PraisonAI Agent instance
|
||||
task: Task description
|
||||
config: Optional configuration
|
||||
on_success: Callback function on successful execution
|
||||
on_failure: Callback function on failed execution
|
||||
timeout: Maximum execution time per run in seconds (None = no limit)
|
||||
max_cost: Maximum total cost in USD (default: $1.00 for safety)
|
||||
|
||||
Returns:
|
||||
Configured AsyncAgentScheduler instance
|
||||
"""
|
||||
return AsyncAgentScheduler(agent, task, config)
|
||||
return AsyncAgentScheduler(
|
||||
agent,
|
||||
task,
|
||||
config=config,
|
||||
on_success=on_success,
|
||||
on_failure=on_failure,
|
||||
timeout=timeout,
|
||||
max_cost=max_cost
|
||||
)
|
||||
@@ -19,8 +19,8 @@ class ToolRegistry:
|
||||
|
||||
def __init__(self):
|
||||
self._functions: Dict[str, Callable] = {}
|
||||
self._autogen_adapters: Dict[str, Callable] = {}
|
||||
self._lock = threading.Lock()
|
||||
# Note: AutoGen-specific adapters moved to framework_adapters.autogen
|
||||
|
||||
def register_function(self, name: str, func: Callable) -> None:
|
||||
"""Register a function tool."""
|
||||
@@ -30,13 +30,24 @@ class ToolRegistry:
|
||||
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."""
|
||||
def register_autogen_adapter(self, tool_type_name: str, adapter: Callable, _suppress_deprecation_warning: bool = False) -> None:
|
||||
"""Deprecated: AutoGen adapters moved to framework_adapters.autogen module."""
|
||||
if not _suppress_deprecation_warning:
|
||||
import warnings
|
||||
warnings.warn(
|
||||
"ToolRegistry.register_autogen_adapter is deprecated. "
|
||||
"AutoGen-specific logic has been moved to framework_adapters.autogen module.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2
|
||||
)
|
||||
# For backward compatibility, still store but warn
|
||||
if not callable(adapter):
|
||||
raise ValueError(f"AutoGen adapter for {tool_type_name} must be callable")
|
||||
with self._lock:
|
||||
if not hasattr(self, '_autogen_adapters'):
|
||||
self._autogen_adapters: Dict[str, Callable] = {}
|
||||
self._autogen_adapters[tool_type_name] = adapter
|
||||
logger.debug(f"Registered AutoGen adapter: {tool_type_name}")
|
||||
logger.debug(f"Registered AutoGen adapter: {tool_type_name} (deprecated)")
|
||||
|
||||
def get_function(self, name: str) -> Optional[Callable]:
|
||||
"""Get a function tool by name."""
|
||||
@@ -44,9 +55,19 @@ class ToolRegistry:
|
||||
return self._functions.get(name)
|
||||
|
||||
def get_autogen_adapter(self, tool_type_name: str) -> Optional[Callable]:
|
||||
"""Get an AutoGen adapter by tool type name."""
|
||||
with self._lock:
|
||||
return self._autogen_adapters.get(tool_type_name)
|
||||
"""Deprecated: AutoGen adapters moved to framework_adapters.autogen module."""
|
||||
import warnings
|
||||
warnings.warn(
|
||||
"ToolRegistry.get_autogen_adapter is deprecated. "
|
||||
"AutoGen-specific logic has been moved to framework_adapters.autogen module.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2
|
||||
)
|
||||
# For backward compatibility
|
||||
if hasattr(self, '_autogen_adapters'):
|
||||
with self._lock:
|
||||
return self._autogen_adapters.get(tool_type_name)
|
||||
return None
|
||||
|
||||
def list_functions(self) -> List[str]:
|
||||
"""List all registered function tool names."""
|
||||
@@ -54,9 +75,19 @@ class ToolRegistry:
|
||||
return list(self._functions.keys())
|
||||
|
||||
def list_autogen_adapters(self) -> List[str]:
|
||||
"""List all registered AutoGen adapter names."""
|
||||
with self._lock:
|
||||
return list(self._autogen_adapters.keys())
|
||||
"""Deprecated: AutoGen adapters moved to framework_adapters.autogen module."""
|
||||
import warnings
|
||||
warnings.warn(
|
||||
"ToolRegistry.list_autogen_adapters is deprecated. "
|
||||
"AutoGen-specific logic has been moved to framework_adapters.autogen module.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2
|
||||
)
|
||||
# For backward compatibility
|
||||
if hasattr(self, '_autogen_adapters'):
|
||||
with self._lock:
|
||||
return list(self._autogen_adapters.keys())
|
||||
return []
|
||||
|
||||
def get_functions_dict(self) -> Dict[str, Callable]:
|
||||
"""Get a copy of all registered functions."""
|
||||
@@ -67,7 +98,8 @@ class ToolRegistry:
|
||||
"""Clear all registered tools."""
|
||||
with self._lock:
|
||||
self._functions.clear()
|
||||
self._autogen_adapters.clear()
|
||||
if hasattr(self, '_autogen_adapters'):
|
||||
self._autogen_adapters.clear()
|
||||
logger.debug("Cleared tool registry")
|
||||
|
||||
def register_from_module(self, module: Any) -> List[str]:
|
||||
@@ -91,25 +123,27 @@ class ToolRegistry:
|
||||
logger.debug(f"Registered {len(registered)} functions from module: {registered}")
|
||||
return registered
|
||||
|
||||
def register_builtin_autogen_adapters(self) -> None:
|
||||
"""Register builtin AutoGen adapters from inbuilt_tools."""
|
||||
def register_builtin_autogen_adapters(self, _suppress_deprecation_warning: bool = False) -> None:
|
||||
"""Deprecated: AutoGen adapters moved to framework_adapters.autogen module."""
|
||||
if not _suppress_deprecation_warning:
|
||||
import warnings
|
||||
warnings.warn(
|
||||
"ToolRegistry.register_builtin_autogen_adapters is deprecated. "
|
||||
"AutoGen-specific logic has been moved to framework_adapters.autogen module.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2
|
||||
)
|
||||
# For backward compatibility, attempt old logic but warn
|
||||
try:
|
||||
# Lazy import to avoid circular dependencies
|
||||
from .inbuilt_tools import _get_autogen_tools
|
||||
|
||||
# Get the autogen_tools module
|
||||
tools_module = _get_autogen_tools()
|
||||
if tools_module:
|
||||
# Register adapters based on the pattern in the original code
|
||||
for attr_name in dir(tools_module):
|
||||
if attr_name.startswith('autogen_') and not attr_name.startswith('__'):
|
||||
adapter = getattr(tools_module, attr_name)
|
||||
if callable(adapter):
|
||||
# Extract tool type name from adapter function name
|
||||
# e.g., 'autogen_CodeDocsSearchTool' -> 'CodeDocsSearchTool'
|
||||
tool_type_name = attr_name.replace('autogen_', '')
|
||||
self.register_autogen_adapter(tool_type_name, adapter)
|
||||
|
||||
self.register_autogen_adapter(tool_type_name, adapter, _suppress_deprecation_warning=True)
|
||||
except ImportError as e:
|
||||
logger.warning(f"Could not register builtin AutoGen adapters: {e}")
|
||||
except Exception as e:
|
||||
@@ -118,7 +152,8 @@ class ToolRegistry:
|
||||
def __len__(self) -> int:
|
||||
"""Return total number of registered tools."""
|
||||
with self._lock:
|
||||
return len(self._functions) + len(self._autogen_adapters)
|
||||
autogen_count = len(self._autogen_adapters) if hasattr(self, '_autogen_adapters') else 0
|
||||
return len(self._functions) + autogen_count
|
||||
|
||||
def __contains__(self, name: str) -> bool:
|
||||
"""Check if a tool function is registered."""
|
||||
|
||||
@@ -621,15 +621,3 @@ def validate_yaml_tools(yaml_config: Dict[str, Any], resolver: Optional[ToolReso
|
||||
return (resolver or _get_default_resolver()).validate_yaml_tools(yaml_config)
|
||||
|
||||
|
||||
def reset_default_resolver() -> None:
|
||||
"""Clear the process-default resolver.
|
||||
|
||||
Call this between tenants, on CWD change, or in test setup to ensure
|
||||
that local tools.py resolution is not affected by previous calls.
|
||||
|
||||
This follows the same pattern as _framework_availability.invalidate()
|
||||
for resetting cached state.
|
||||
"""
|
||||
global _default_resolver
|
||||
with _default_resolver_lock:
|
||||
_default_resolver = None
|
||||
@@ -13,6 +13,8 @@ __all__ = [
|
||||
"stt_tool",
|
||||
"create_tts_tool",
|
||||
"create_stt_tool",
|
||||
"get_kanban_tools",
|
||||
"KANBAN_TOOLS",
|
||||
]
|
||||
|
||||
_lazy_cache = {}
|
||||
@@ -58,4 +60,14 @@ def __getattr__(name: str):
|
||||
_lazy_cache[name] = create_stt_tool
|
||||
return create_stt_tool
|
||||
|
||||
if name == "get_kanban_tools":
|
||||
from .kanban_tools import get_kanban_tools
|
||||
_lazy_cache[name] = get_kanban_tools
|
||||
return get_kanban_tools
|
||||
|
||||
if name == "KANBAN_TOOLS":
|
||||
from .kanban_tools import KANBAN_TOOLS
|
||||
_lazy_cache[name] = KANBAN_TOOLS
|
||||
return KANBAN_TOOLS
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,399 @@
|
||||
"""
|
||||
Kanban Tools for PraisonAI Agents.
|
||||
|
||||
Provides kanban task management tools that allow agents to create, update,
|
||||
and manage kanban tasks for multi-agent coordination and workflow management.
|
||||
|
||||
All tools connect to the same SQLite kanban store as the PraisonAIUI dashboard.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Lazy-loaded kanban store instance
|
||||
_kanban_store = None
|
||||
|
||||
|
||||
def _get_kanban_store():
|
||||
"""Lazy load kanban store."""
|
||||
global _kanban_store
|
||||
if _kanban_store is None:
|
||||
try:
|
||||
from praisonai.kanban.sqlite_store import SQLiteKanbanStore
|
||||
_kanban_store = SQLiteKanbanStore()
|
||||
except ImportError as e:
|
||||
logger.error(f"Failed to import kanban store: {e}")
|
||||
raise ImportError("Kanban functionality requires praisonai kanban module") from e
|
||||
return _kanban_store
|
||||
|
||||
|
||||
def kanban_create(
|
||||
title: str,
|
||||
body: str = "",
|
||||
assignee: str = "",
|
||||
status: str = "todo",
|
||||
priority: int = 0,
|
||||
board: str = "default"
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a kanban task for multi-agent coordination.
|
||||
|
||||
Args:
|
||||
title: Task title (required)
|
||||
body: Task description/details
|
||||
assignee: Username or agent name to assign to
|
||||
status: Task status (todo, ready, running, blocked, done)
|
||||
priority: Task priority (higher number = higher priority)
|
||||
board: Board name to create task on
|
||||
|
||||
Returns:
|
||||
Dict containing the created task details including task ID
|
||||
|
||||
Example:
|
||||
>>> kanban_create("Implement user authentication", assignee="coder")
|
||||
{'id': 'task_abc123', 'title': 'Implement user authentication', ...}
|
||||
"""
|
||||
try:
|
||||
store = _get_kanban_store()
|
||||
|
||||
task_data = {
|
||||
'title': title,
|
||||
'body': body,
|
||||
'assignee': assignee,
|
||||
'status': status,
|
||||
'priority': priority,
|
||||
'board': board
|
||||
}
|
||||
|
||||
task = store.create_task(task_data)
|
||||
return task.to_dict()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create kanban task: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
def kanban_list(
|
||||
status: Optional[str] = None,
|
||||
assignee: Optional[str] = None,
|
||||
board: str = "default",
|
||||
limit: int = 50
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
List kanban tasks with optional filtering.
|
||||
|
||||
Args:
|
||||
status: Filter by status (todo, ready, running, blocked, done)
|
||||
assignee: Filter by assignee username
|
||||
board: Board name to list from
|
||||
limit: Maximum number of tasks to return
|
||||
|
||||
Returns:
|
||||
Dict containing list of tasks and metadata
|
||||
|
||||
Example:
|
||||
>>> kanban_list(status="ready", assignee="coder")
|
||||
{'tasks': [...], 'count': 3, 'filters': {...}}
|
||||
"""
|
||||
try:
|
||||
store = _get_kanban_store()
|
||||
|
||||
filters = {'board': board}
|
||||
if status:
|
||||
filters['status'] = status
|
||||
if assignee:
|
||||
filters['assignee'] = assignee
|
||||
|
||||
tasks = store.list_tasks(filters)
|
||||
|
||||
# Limit results
|
||||
if len(tasks) > limit:
|
||||
tasks = tasks[:limit]
|
||||
|
||||
return {
|
||||
'tasks': [task.to_dict() for task in tasks],
|
||||
'count': len(tasks),
|
||||
'filters': filters,
|
||||
'limited': len(tasks) == limit
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list kanban tasks: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
def kanban_show(task_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Show detailed information about a specific task.
|
||||
|
||||
Args:
|
||||
task_id: The task ID to retrieve
|
||||
|
||||
Returns:
|
||||
Dict containing task details, comments, and dependencies
|
||||
|
||||
Example:
|
||||
>>> kanban_show("task_abc123")
|
||||
{'task': {...}, 'comments': [...], 'dependencies': [...]}
|
||||
"""
|
||||
try:
|
||||
store = _get_kanban_store()
|
||||
|
||||
task = store.get_task(task_id)
|
||||
if not task:
|
||||
return {'error': f'Task {task_id} not found'}
|
||||
|
||||
# Get comments
|
||||
comments = store.get_comments(task_id)
|
||||
|
||||
# Get parent/child links (simplified - would need more complex query for full DAG)
|
||||
result = {
|
||||
'task': task.to_dict(),
|
||||
'comments': [comment.to_dict() for comment in comments],
|
||||
'comment_count': len(comments)
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to show kanban task {task_id}: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
def kanban_complete(task_id: str, comment: str = "") -> Dict[str, Any]:
|
||||
"""
|
||||
Mark a task as completed.
|
||||
|
||||
Args:
|
||||
task_id: The task ID to complete
|
||||
comment: Optional completion comment
|
||||
|
||||
Returns:
|
||||
Dict containing the updated task
|
||||
|
||||
Example:
|
||||
>>> kanban_complete("task_abc123", "Authentication system working")
|
||||
{'task': {...}, 'status': 'done'}
|
||||
"""
|
||||
try:
|
||||
store = _get_kanban_store()
|
||||
|
||||
# Move to done status
|
||||
task = store.move_task(task_id, 'done')
|
||||
|
||||
# Add completion comment if provided
|
||||
if comment:
|
||||
store.add_comment(task_id, 'agent', f"Completed: {comment}")
|
||||
|
||||
return {
|
||||
'task': task.to_dict(),
|
||||
'status': 'done',
|
||||
'completed': True
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to complete kanban task {task_id}: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
def kanban_block(task_id: str, reason: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Mark a task as blocked with a reason.
|
||||
|
||||
Args:
|
||||
task_id: The task ID to block
|
||||
reason: Reason why the task is blocked
|
||||
|
||||
Returns:
|
||||
Dict containing the updated task
|
||||
|
||||
Example:
|
||||
>>> kanban_block("task_abc123", "Waiting for API credentials")
|
||||
{'task': {...}, 'status': 'blocked'}
|
||||
"""
|
||||
try:
|
||||
store = _get_kanban_store()
|
||||
|
||||
# Move to blocked status
|
||||
task = store.move_task(task_id, 'blocked')
|
||||
|
||||
# Add blocking reason as comment
|
||||
store.add_comment(task_id, 'agent', f"Blocked: {reason}")
|
||||
|
||||
return {
|
||||
'task': task.to_dict(),
|
||||
'status': 'blocked',
|
||||
'reason': reason
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to block kanban task {task_id}: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
def kanban_comment(task_id: str, text: str, author: str = "agent") -> Dict[str, Any]:
|
||||
"""
|
||||
Add a comment to a kanban task.
|
||||
|
||||
Args:
|
||||
task_id: The task ID to comment on
|
||||
text: Comment text
|
||||
author: Comment author (defaults to 'agent')
|
||||
|
||||
Returns:
|
||||
Dict containing the created comment
|
||||
|
||||
Example:
|
||||
>>> kanban_comment("task_abc123", "Made progress on auth module")
|
||||
{'comment': {...}, 'task_id': 'task_abc123'}
|
||||
"""
|
||||
try:
|
||||
store = _get_kanban_store()
|
||||
|
||||
comment = store.add_comment(task_id, author, text)
|
||||
|
||||
return {
|
||||
'comment': comment.to_dict(),
|
||||
'task_id': task_id
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add comment to kanban task {task_id}: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
def kanban_link(parent_id: str, child_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a dependency link between two tasks.
|
||||
|
||||
Args:
|
||||
parent_id: The parent task ID (must be completed before child)
|
||||
child_id: The child task ID (depends on parent)
|
||||
|
||||
Returns:
|
||||
Dict containing the created link
|
||||
|
||||
Example:
|
||||
>>> kanban_link("task_design", "task_implement")
|
||||
{'link': {...}, 'parent_id': 'task_design', 'child_id': 'task_implement'}
|
||||
"""
|
||||
try:
|
||||
store = _get_kanban_store()
|
||||
|
||||
link = store.add_link(parent_id, child_id)
|
||||
|
||||
return {
|
||||
'link': {
|
||||
'parent_id': link.parent_id,
|
||||
'child_id': link.child_id,
|
||||
'created_at': link.created_at.isoformat()
|
||||
},
|
||||
'parent_id': parent_id,
|
||||
'child_id': child_id
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to link kanban tasks {parent_id} -> {child_id}: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
def kanban_heartbeat(task_id: str, status: str = "working") -> Dict[str, Any]:
|
||||
"""
|
||||
Send a heartbeat signal while working on a task.
|
||||
|
||||
Args:
|
||||
task_id: The task ID being worked on
|
||||
status: Status message (e.g., "working", "testing", "debugging")
|
||||
|
||||
Returns:
|
||||
Dict containing heartbeat confirmation
|
||||
|
||||
Example:
|
||||
>>> kanban_heartbeat("task_abc123", "testing authentication flow")
|
||||
{'heartbeat': True, 'task_id': 'task_abc123', 'status': 'testing...'}
|
||||
"""
|
||||
try:
|
||||
store = _get_kanban_store()
|
||||
|
||||
# Verify task exists and is claimed
|
||||
task = store.get_task(task_id)
|
||||
if not task:
|
||||
return {'error': f'Task {task_id} not found'}
|
||||
|
||||
# Add heartbeat comment
|
||||
store.add_comment(
|
||||
task_id,
|
||||
'agent',
|
||||
f"Heartbeat: {status} at {task.updated_at.strftime('%H:%M:%S')}"
|
||||
)
|
||||
|
||||
return {
|
||||
'heartbeat': True,
|
||||
'task_id': task_id,
|
||||
'status': status,
|
||||
'timestamp': task.updated_at.isoformat()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send heartbeat for kanban task {task_id}: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
# Export all kanban tools for agent registration
|
||||
KANBAN_TOOLS = [
|
||||
kanban_create,
|
||||
kanban_list,
|
||||
kanban_show,
|
||||
kanban_complete,
|
||||
kanban_block,
|
||||
kanban_comment,
|
||||
kanban_link,
|
||||
kanban_heartbeat,
|
||||
]
|
||||
|
||||
|
||||
def get_kanban_tools() -> List[callable]:
|
||||
"""
|
||||
Get all kanban tools for agent registration.
|
||||
|
||||
Returns:
|
||||
List of kanban tool functions
|
||||
|
||||
Example:
|
||||
>>> from praisonai.tools.kanban_tools import get_kanban_tools
|
||||
>>> agent = Agent(name="coordinator", tools=get_kanban_tools())
|
||||
"""
|
||||
return KANBAN_TOOLS
|
||||
|
||||
|
||||
def create_kanban_agent_tools(board: str = "default") -> List[callable]:
|
||||
"""
|
||||
Create kanban tools bound to a specific board.
|
||||
|
||||
Args:
|
||||
board: Board name to bind tools to
|
||||
|
||||
Returns:
|
||||
List of board-specific kanban tools
|
||||
"""
|
||||
# Create board-specific versions of tools with board parameter pre-filled
|
||||
def _make_board_tool(tool_func, board_name):
|
||||
def wrapper(*args, **kwargs):
|
||||
if 'board' not in kwargs:
|
||||
kwargs['board'] = board_name
|
||||
return tool_func(*args, **kwargs)
|
||||
wrapper.__name__ = f"{tool_func.__name__}_{board_name}"
|
||||
wrapper.__doc__ = tool_func.__doc__
|
||||
return wrapper
|
||||
|
||||
board_tools = []
|
||||
for tool in KANBAN_TOOLS:
|
||||
if 'board' in tool.__code__.co_varnames:
|
||||
board_tools.append(_make_board_tool(tool, board))
|
||||
else:
|
||||
board_tools.append(tool)
|
||||
|
||||
return board_tools
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.6.48"
|
||||
__version__ = "4.6.52"
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"rich>=13.7",
|
||||
"markdown>=3.5",
|
||||
"pyparsing>=3.0.0",
|
||||
"praisonaiagents>=1.6.48",
|
||||
"praisonaiagents>=1.6.52",
|
||||
"python-dotenv>=0.19.0",
|
||||
"litellm>=1.83.14,<2",
|
||||
"PyYAML>=6.0",
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Integration tests for kanban host injection."""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_kanban_dir():
|
||||
"""Create temporary directory for kanban testing."""
|
||||
temp_dir = Path(tempfile.mkdtemp())
|
||||
yield temp_dir
|
||||
if temp_dir.exists():
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
|
||||
def test_kanban_bridge_registration():
|
||||
"""Test that kanban bridge can register backends."""
|
||||
# Mock praisonaiui backends
|
||||
mock_backends = MagicMock()
|
||||
|
||||
with patch.dict('sys.modules', {'praisonaiui.backends': mock_backends}):
|
||||
from praisonai.integration.bridges.kanban_bridge import register_kanban_backends
|
||||
|
||||
result = register_kanban_backends()
|
||||
|
||||
# Should return True indicating successful registration
|
||||
assert result is True
|
||||
|
||||
# Verify backends were set
|
||||
assert mock_backends.set_backend.call_count >= 1
|
||||
|
||||
# Check specific backend calls
|
||||
backend_calls = [call[0] for call in mock_backends.set_backend.call_args_list]
|
||||
assert any('kanban_store' in str(call) for call in backend_calls)
|
||||
|
||||
|
||||
def test_kanban_store_factory():
|
||||
"""Test kanban store factory creation."""
|
||||
from praisonai.integration.bridges.kanban_bridge import get_kanban_store_factory
|
||||
|
||||
factory = get_kanban_store_factory()
|
||||
|
||||
assert factory is not None
|
||||
|
||||
# Test factory creates store
|
||||
with patch('praisonai.integration.bridges.kanban_bridge.SQLiteKanbanStore') as mock_store:
|
||||
store = factory()
|
||||
mock_store.assert_called_once()
|
||||
|
||||
|
||||
def test_setup_bridges_includes_kanban(temp_kanban_dir):
|
||||
"""Test that setup_bridges includes kanban registration."""
|
||||
# Mock all the dependencies
|
||||
mock_backends = MagicMock()
|
||||
mock_praisonaiui = MagicMock()
|
||||
mock_praisonaiui.backends = mock_backends
|
||||
|
||||
# Mock the kanban path to use temp directory
|
||||
def mock_get_path(board=None):
|
||||
return temp_kanban_dir / "test_kanban.db"
|
||||
|
||||
with patch.dict('sys.modules', {
|
||||
'praisonaiui': mock_praisonaiui,
|
||||
'praisonaiui.backends': mock_backends
|
||||
}), patch('praisonai.kanban.paths.get_kanban_db_path', mock_get_path):
|
||||
|
||||
from praisonai.integration.host_app import setup_bridges
|
||||
|
||||
# This should not raise and should call kanban registration
|
||||
setup_bridges()
|
||||
|
||||
# Verify some backend was registered (exact calls depend on available components)
|
||||
assert mock_backends.set_backend.called
|
||||
|
||||
|
||||
def test_kanban_store_environment_variable_support(temp_kanban_dir):
|
||||
"""Test that kanban store respects environment variables."""
|
||||
import os
|
||||
|
||||
test_board = "test_board"
|
||||
|
||||
with patch.dict(os.environ, {'PRAISONAI_KANBAN_BOARD': test_board}):
|
||||
with patch('praisonai.kanban.paths.get_kanban_db_path') as mock_path:
|
||||
mock_path.return_value = temp_kanban_dir / f"{test_board}_kanban.db"
|
||||
|
||||
from praisonai.integration.bridges.kanban_bridge import get_kanban_store_factory
|
||||
|
||||
factory = get_kanban_store_factory()
|
||||
|
||||
# This should use the environment variable for board selection
|
||||
with patch('praisonai.integration.bridges.kanban_bridge.SQLiteKanbanStore') as mock_store:
|
||||
store = factory()
|
||||
mock_store.assert_called_once()
|
||||
|
||||
|
||||
def test_missing_dependencies_graceful_degradation():
|
||||
"""Test that missing dependencies don't break setup."""
|
||||
# Simulate missing praisonaiui
|
||||
with patch.dict('sys.modules', {}, clear=True):
|
||||
# Remove praisonaiui from modules to simulate missing dependency
|
||||
if 'praisonaiui' in __import__('sys').modules:
|
||||
del __import__('sys').modules['praisonaiui']
|
||||
if 'praisonaiui.backends' in __import__('sys').modules:
|
||||
del __import__('sys').modules['praisonaiui.backends']
|
||||
|
||||
from praisonai.integration.bridges.kanban_bridge import register_kanban_backends
|
||||
|
||||
# Should not raise, just return False
|
||||
result = register_kanban_backends()
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_jobs_store_bridge():
|
||||
"""Test jobs store bridge functionality."""
|
||||
from praisonai.integration.bridges.kanban_bridge import get_jobs_store, get_jobs_executor
|
||||
|
||||
# These may return None if jobs module isn't available, which is fine
|
||||
jobs_store = get_jobs_store()
|
||||
jobs_executor = get_jobs_executor()
|
||||
|
||||
# Just test that the functions don't raise exceptions
|
||||
# Actual functionality depends on jobs module implementation
|
||||
assert jobs_store is None or callable(jobs_store)
|
||||
assert jobs_executor is None or callable(jobs_executor)
|
||||
@@ -0,0 +1,259 @@
|
||||
"""
|
||||
Test that Telegram gateway polling path enforces security checks.
|
||||
|
||||
This test verifies the fix for issue #1747 where gateway Telegram polling
|
||||
bypassed allowed_users, pairing, and group policy enforcement.
|
||||
|
||||
The fix introduces a shared security pipeline `process_inbound_telegram_message()`
|
||||
used by both standalone and gateway paths to ensure identical security enforcement.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
|
||||
from praisonaiagents.bots import BotConfig, BotUser
|
||||
from praisonai.bots.telegram import TelegramBot, process_inbound_telegram_message
|
||||
from praisonai.bots._unknown_user import UnknownUserHandler
|
||||
|
||||
|
||||
def create_mock_telegram_update(user_id: str = "12345", chat_id: str = "-100123456789", text: str = "test message", chat_type: str = "group"):
|
||||
"""Create a mock Telegram Update for testing."""
|
||||
update = MagicMock()
|
||||
|
||||
# Mock message
|
||||
update.message = MagicMock()
|
||||
update.message.text = text
|
||||
update.message.voice = None
|
||||
update.message.audio = None
|
||||
update.message.date.timestamp.return_value = 1234567890.0
|
||||
update.message.message_id = 123
|
||||
update.message.message_thread_id = None
|
||||
|
||||
# Mock user
|
||||
update.message.from_user = MagicMock()
|
||||
update.message.from_user.id = int(user_id)
|
||||
update.message.from_user.username = f"user_{user_id}"
|
||||
update.message.from_user.first_name = f"User {user_id}"
|
||||
update.message.from_user.is_bot = False
|
||||
|
||||
# Mock chat
|
||||
update.message.chat = MagicMock()
|
||||
update.message.chat.id = int(chat_id)
|
||||
update.message.chat.type = chat_type
|
||||
update.message.chat.title = "Test Group" if chat_type == "group" else None
|
||||
update.message.chat.username = f"chat_{chat_id}"
|
||||
|
||||
return update
|
||||
|
||||
|
||||
def create_test_bot(allowed_users=None, allowed_channels=None, group_policy="mention_only") -> TelegramBot:
|
||||
"""Create a TelegramBot for testing with specified security config."""
|
||||
config = BotConfig(
|
||||
token="test_token",
|
||||
allowed_users=allowed_users or [],
|
||||
allowed_channels=allowed_channels or [],
|
||||
group_policy=group_policy,
|
||||
)
|
||||
|
||||
bot = TelegramBot(token="test_token", config=config)
|
||||
|
||||
# Mock required attributes
|
||||
bot._bot_user = BotUser(
|
||||
user_id="123456789",
|
||||
username="test_bot",
|
||||
display_name="Test Bot",
|
||||
is_bot=True,
|
||||
)
|
||||
|
||||
# Mock the fire_message_received method
|
||||
bot.fire_message_received = MagicMock()
|
||||
|
||||
return bot
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_allowlist_enforcement():
|
||||
"""Test that user allowlist is enforced in the security pipeline."""
|
||||
|
||||
# Bot with restricted user allowlist
|
||||
bot = create_test_bot(allowed_users=["42"])
|
||||
|
||||
# Message from allowed user
|
||||
allowed_update = create_mock_telegram_update(user_id="42", text="hello", chat_type="private")
|
||||
allowed_message = await process_inbound_telegram_message(allowed_update, bot)
|
||||
assert allowed_message is not None, "Message from allowed user should pass"
|
||||
assert allowed_message.sender.user_id == "42"
|
||||
|
||||
# Message from disallowed user
|
||||
disallowed_update = create_mock_telegram_update(user_id="99", text="hello", chat_type="private")
|
||||
disallowed_message = await process_inbound_telegram_message(disallowed_update, bot)
|
||||
assert disallowed_message is None, "Message from disallowed user should be blocked"
|
||||
|
||||
# Group message from allowed user should still pass allowlist checks
|
||||
allowed_group_update = create_mock_telegram_update(user_id="42", text="@test_bot hello", chat_type="group")
|
||||
allowed_group_message = await process_inbound_telegram_message(allowed_group_update, bot)
|
||||
assert allowed_group_message is not None, "Allowlisted users should pass in group chats too"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_allowlist_enforcement():
|
||||
"""Test that channel allowlist is enforced in the security pipeline."""
|
||||
|
||||
# Bot with restricted channel allowlist
|
||||
bot = create_test_bot(allowed_channels=["-100123456789"])
|
||||
|
||||
# Message from allowed channel
|
||||
allowed_update = create_mock_telegram_update(chat_id="-100123456789", text="@test_bot hello", chat_type="supergroup")
|
||||
allowed_message = await process_inbound_telegram_message(allowed_update, bot)
|
||||
assert allowed_message is not None, "Message from allowed channel should pass"
|
||||
assert allowed_message.channel.channel_id == "-100123456789"
|
||||
|
||||
# Message from disallowed channel
|
||||
disallowed_update = create_mock_telegram_update(chat_id="-100999999999", text="@test_bot hello", chat_type="supergroup")
|
||||
disallowed_message = await process_inbound_telegram_message(disallowed_update, bot)
|
||||
assert disallowed_message is None, "Message from disallowed channel should be blocked"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_policy_mention_enforcement():
|
||||
"""Test that group mention policy is enforced in the security pipeline."""
|
||||
|
||||
# Bot with mention_only group policy
|
||||
bot = create_test_bot(group_policy="mention_only")
|
||||
bot._bot_user.username = "Test_Bot"
|
||||
|
||||
# Group message with bot mention - should pass
|
||||
mention_update = create_mock_telegram_update(
|
||||
chat_type="group",
|
||||
text="@test_bot please help"
|
||||
)
|
||||
mention_message = await process_inbound_telegram_message(mention_update, bot)
|
||||
assert mention_message is not None, "Group message with mention should pass"
|
||||
|
||||
# Group message without mention - should be blocked
|
||||
no_mention_update = create_mock_telegram_update(
|
||||
chat_type="group",
|
||||
text="hello everyone"
|
||||
)
|
||||
no_mention_message = await process_inbound_telegram_message(no_mention_update, bot)
|
||||
assert no_mention_message is None, "Group message without mention should be blocked"
|
||||
|
||||
# Commands should always pass regardless of mention
|
||||
command_update = create_mock_telegram_update(
|
||||
chat_type="group",
|
||||
text="/help"
|
||||
)
|
||||
command_message = await process_inbound_telegram_message(command_update, bot)
|
||||
assert command_message is not None, "Commands should always pass in groups"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_messages_bypass_group_policies():
|
||||
"""Test that DM messages bypass group-specific policies."""
|
||||
|
||||
# Bot with mention_only group policy
|
||||
bot = create_test_bot(group_policy="mention_only")
|
||||
|
||||
# DM message without mention - should pass
|
||||
dm_update = create_mock_telegram_update(
|
||||
chat_type="private",
|
||||
text="hello bot"
|
||||
)
|
||||
dm_message = await process_inbound_telegram_message(dm_update, bot)
|
||||
assert dm_message is not None, "DM messages should bypass group mention requirements"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_policy_command_only_enforcement():
|
||||
"""Test that command_only only allows commands in groups."""
|
||||
bot = create_test_bot(group_policy="command_only")
|
||||
|
||||
message_update = create_mock_telegram_update(chat_type="group", text="hello everyone")
|
||||
message = await process_inbound_telegram_message(message_update, bot)
|
||||
assert message is None, "Non-command group messages should be blocked in command_only mode"
|
||||
|
||||
command_update = create_mock_telegram_update(chat_type="group", text="/help")
|
||||
command_message = await process_inbound_telegram_message(command_update, bot)
|
||||
assert command_message is not None, "Commands should pass in command_only mode"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch.object(UnknownUserHandler, 'handle')
|
||||
async def test_pairing_system_integration(mock_unknown_handler):
|
||||
"""Test that pairing system is properly integrated."""
|
||||
|
||||
# Mock the UnknownUserHandler to simulate pairing rejection
|
||||
mock_unknown_handler.return_value = False # User not approved
|
||||
|
||||
# Bot with explicit allowlist that does not include unknown user
|
||||
bot = create_test_bot(allowed_users=["42"])
|
||||
|
||||
# Message from unknown user
|
||||
unknown_update = create_mock_telegram_update(user_id="12345", text="hello")
|
||||
unknown_message = await process_inbound_telegram_message(unknown_update, bot)
|
||||
|
||||
# Should be blocked by pairing system
|
||||
assert unknown_message is None, "Unknown user should be blocked by pairing system"
|
||||
|
||||
# Verify UnknownUserHandler.handle was called
|
||||
mock_unknown_handler.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_allowlists_allow_all():
|
||||
"""Test that empty allowlists allow all users/channels (default behavior)."""
|
||||
|
||||
# Bot with no restrictions
|
||||
bot = create_test_bot(allowed_users=[], allowed_channels=[])
|
||||
|
||||
# Message from any user in any channel
|
||||
update = create_mock_telegram_update(user_id="99999", chat_id="-999999999", text="hello", chat_type="private")
|
||||
message = await process_inbound_telegram_message(update, bot)
|
||||
assert message is not None, "Empty allowlists should allow all users and channels"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audio_message_transcription():
|
||||
"""Test that audio messages are properly transcribed in the security pipeline."""
|
||||
|
||||
bot = create_test_bot()
|
||||
|
||||
# Mock the transcribe_audio method
|
||||
bot._transcribe_audio = AsyncMock(return_value="[Voice message]: transcribed text")
|
||||
|
||||
# Create update with voice message
|
||||
update = create_mock_telegram_update(text=None, chat_type="private")
|
||||
update.message.text = None
|
||||
update.message.voice = MagicMock() # Simulate voice message
|
||||
|
||||
message = await process_inbound_telegram_message(update, bot)
|
||||
|
||||
assert message is not None, "Voice message should be processed"
|
||||
assert message.content == "[Voice message]: transcribed text"
|
||||
bot._transcribe_audio.assert_called_once()
|
||||
|
||||
|
||||
def test_security_pipeline_exists():
|
||||
"""Basic smoke test to ensure the security pipeline function exists and is importable."""
|
||||
from praisonai.bots.telegram import process_inbound_telegram_message
|
||||
assert callable(process_inbound_telegram_message), "Security pipeline function should be callable"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shared_pipeline_consistency():
|
||||
"""Test that the shared pipeline provides consistent results."""
|
||||
|
||||
# Create identical bot configs
|
||||
bot1 = create_test_bot(allowed_users=["42"], group_policy="mention_only")
|
||||
bot2 = create_test_bot(allowed_users=["42"], group_policy="mention_only")
|
||||
|
||||
# Same message update
|
||||
update = create_mock_telegram_update(user_id="42", text="@test_bot hello")
|
||||
|
||||
# Both should produce identical results
|
||||
message1 = await process_inbound_telegram_message(update, bot1)
|
||||
message2 = await process_inbound_telegram_message(update, bot2)
|
||||
|
||||
assert message1 is not None and message2 is not None
|
||||
assert message1.content == message2.content
|
||||
assert message1.sender.user_id == message2.sender.user_id
|
||||
@@ -195,6 +195,12 @@ class TestCodexCLIStructuredOutput:
|
||||
class TestIntegrationExports:
|
||||
"""Tests for integration module exports."""
|
||||
|
||||
def test_exported_codex_integration_is_class(self):
|
||||
"""Test exported CodexCLIIntegration resolves to the class, not a loader."""
|
||||
from praisonai.integrations import CodexCLIIntegration
|
||||
|
||||
assert inspect.isclass(CodexCLIIntegration)
|
||||
|
||||
def test_get_available_integrations_export_is_sync(self):
|
||||
"""Test exported get_available_integrations remains synchronous."""
|
||||
from praisonai.integrations import get_available_integrations
|
||||
|
||||
@@ -298,6 +298,42 @@ def test_backward_compatible_aliases():
|
||||
assert ManagedBackendConfig == ManagedConfig
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_managed_stream_persists_messages():
|
||||
"""stream() must persist user/assistant messages like execute()."""
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from praisonaiagents.session.store import DefaultSessionStore
|
||||
from praisonai.integrations.managed_local import LocalManagedAgent
|
||||
|
||||
with tempfile.TemporaryDirectory() as session_dir:
|
||||
store = DefaultSessionStore(session_dir=session_dir)
|
||||
managed = LocalManagedAgent(session_store=store)
|
||||
|
||||
mock_agent = MagicMock()
|
||||
|
||||
def _fake_chat(prompt, stream=False):
|
||||
if stream:
|
||||
return iter(["Hel", "lo"])
|
||||
return "Hello"
|
||||
|
||||
mock_agent.chat.side_effect = _fake_chat
|
||||
|
||||
with patch.object(managed, "_ensure_agent", return_value=mock_agent):
|
||||
chunks = []
|
||||
async for chunk in managed.stream("Hi there"):
|
||||
chunks.append(chunk)
|
||||
|
||||
assert "".join(chunks) == "Hello"
|
||||
assert managed.session_id
|
||||
history = store.get_chat_history(managed.session_id)
|
||||
assert history == [
|
||||
{"role": "user", "content": "Hi there"},
|
||||
{"role": "assistant", "content": "Hello"},
|
||||
]
|
||||
|
||||
|
||||
def test_local_managed_persist_state_preserves_messages():
|
||||
"""_persist_state must not overwrite messages added concurrently on disk."""
|
||||
import tempfile
|
||||
|
||||
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user