mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-07 17:33:33 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a36352196 | ||
|
|
6ae4ebedcb | ||
|
|
3377347087 | ||
|
|
f47aa248ea | ||
|
|
0379e9f196 | ||
|
|
c841d46ae9 | ||
|
|
cd6fdd0107 | ||
|
|
d840407b12 |
No files matched your search
@@ -381,7 +381,7 @@ from praisonaiagents import GuardrailAction, GuardrailConfig, LLMGuardrail
|
||||
Methods:
|
||||
|
||||
* <code title="class GuardrailConfig">GuardrailConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">to_dict</a>() -> Dict[str, Any]</code>
|
||||
* <code title="class LLMGuardrail">LLMGuardrail.<a href="./src/praisonai-agents/praisonaiagents/guardrails/llm_guardrail.py">__call__</a>(task_output: TaskOutput) -> Tuple[bool, Union[str, TaskOutput]]</code>
|
||||
* <code title="class LLMGuardrail">LLMGuardrail.<a href="./src/praisonai-agents/praisonaiagents/guardrails/llm_guardrail.py">__call__</a>(task_output) -> Tuple[bool, Union[str, 'TaskOutput']]</code>
|
||||
|
||||
# Planning
|
||||
|
||||
@@ -868,7 +868,9 @@ Methods:
|
||||
* <code title="cli">praisonai examples run <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
|
||||
* <code title="cli">praisonai examples run-all <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
|
||||
* <code title="cli">praisonai examples stats <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway channels <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway gateway-callback <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway send <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway start <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway status <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai hooks add <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
|
||||
@@ -964,6 +966,7 @@ Methods:
|
||||
* <code title="cli">praisonai sandbox recreate <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
|
||||
* <code title="cli">praisonai sandbox sandbox-callback <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
|
||||
* <code title="cli">praisonai sandbox status <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule add <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule delete <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule describe <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule list <a href="./src/praisonai/praisonai/cli/commands/schedule.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.5.35" \
|
||||
"praisonai>=4.5.39" \
|
||||
"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.5.35" \
|
||||
"praisonai>=4.5.39" \
|
||||
"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.5.35" \
|
||||
"praisonai>=4.5.39" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -989,7 +989,9 @@ class Agent:
|
||||
if learn is False:
|
||||
_learn_config = None
|
||||
elif learn is True:
|
||||
_learn_config = LearnConfig()
|
||||
# learn=True shorthand enables AGENTIC mode (auto-learning)
|
||||
from ..memory.learn.protocols import LearnMode
|
||||
_learn_config = LearnConfig(mode=LearnMode.AGENTIC)
|
||||
elif isinstance(learn, LearnConfig):
|
||||
_learn_config = learn
|
||||
elif isinstance(learn, dict):
|
||||
@@ -1000,7 +1002,9 @@ class Agent:
|
||||
# Fallback to memory.learn for backward compatibility
|
||||
if _memory_config.learn:
|
||||
if _memory_config.learn is True:
|
||||
_learn_config = LearnConfig()
|
||||
# learn=True shorthand enables AGENTIC mode
|
||||
from ..memory.learn.protocols import LearnMode
|
||||
_learn_config = LearnConfig(mode=LearnMode.AGENTIC)
|
||||
elif isinstance(_memory_config.learn, LearnConfig):
|
||||
_learn_config = _memory_config.learn
|
||||
elif isinstance(_memory_config.learn, dict):
|
||||
@@ -3471,10 +3475,17 @@ Summary:"""
|
||||
if not self._memory_instance:
|
||||
return
|
||||
|
||||
if memory_type == "short_term" and hasattr(self._memory_instance, 'add_short_term'):
|
||||
self._memory_instance.add_short_term(content, **kwargs)
|
||||
elif memory_type == "long_term" and hasattr(self._memory_instance, 'add_long_term'):
|
||||
self._memory_instance.add_long_term(content, **kwargs)
|
||||
# Use protocol names first (store_*), fallback to legacy names (add_*)
|
||||
if memory_type == "short_term":
|
||||
if hasattr(self._memory_instance, 'store_short_term'):
|
||||
self._memory_instance.store_short_term(content, **kwargs)
|
||||
elif hasattr(self._memory_instance, 'add_short_term'):
|
||||
self._memory_instance.add_short_term(content, **kwargs)
|
||||
elif memory_type == "long_term":
|
||||
if hasattr(self._memory_instance, 'store_long_term'):
|
||||
self._memory_instance.store_long_term(content, **kwargs)
|
||||
elif hasattr(self._memory_instance, 'add_long_term'):
|
||||
self._memory_instance.add_long_term(content, **kwargs)
|
||||
elif memory_type == "entity" and hasattr(self._memory_instance, 'add_entity'):
|
||||
self._memory_instance.add_entity(content, **kwargs)
|
||||
elif memory_type == "episodic" and hasattr(self._memory_instance, 'add_episodic'):
|
||||
@@ -4652,6 +4663,36 @@ Your Goal: {self.goal}"""
|
||||
prompt_str = prompt if isinstance(prompt, str) else str(prompt)
|
||||
self._process_auto_memory(prompt_str, str(response))
|
||||
|
||||
# Auto-learning extraction (opt-in via LearnConfig(mode=LearnMode.AGENTIC))
|
||||
self._process_auto_learning()
|
||||
|
||||
return response
|
||||
|
||||
async def _atrigger_after_agent_hook(self, prompt, response, start_time, tools_used=None):
|
||||
"""Async version: Trigger AFTER_AGENT hook and return response."""
|
||||
from ..hooks import HookEvent, AfterAgentInput
|
||||
after_agent_input = AfterAgentInput(
|
||||
session_id=getattr(self, '_session_id', 'default'),
|
||||
cwd=os.getcwd(),
|
||||
event_name=HookEvent.AFTER_AGENT,
|
||||
timestamp=str(time.time()),
|
||||
agent_name=self.name,
|
||||
prompt=prompt if isinstance(prompt, str) else str(prompt),
|
||||
response=response or "",
|
||||
tools_used=tools_used or [],
|
||||
total_tokens=0,
|
||||
execution_time_ms=(time.time() - start_time) * 1000
|
||||
)
|
||||
await self._hook_runner.execute(HookEvent.AFTER_AGENT, after_agent_input)
|
||||
|
||||
# Auto-memory extraction (opt-in via MemoryConfig(auto_memory=True))
|
||||
if response:
|
||||
prompt_str = prompt if isinstance(prompt, str) else str(prompt)
|
||||
self._process_auto_memory(prompt_str, str(response))
|
||||
|
||||
# Auto-learning extraction (opt-in via LearnConfig(mode=LearnMode.AGENTIC))
|
||||
self._process_auto_learning()
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@@ -6058,7 +6099,7 @@ Your Goal: {self.goal}"""
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return validated_response
|
||||
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for JSON output: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
@@ -6090,7 +6131,7 @@ Your Goal: {self.goal}"""
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return validated_response
|
||||
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
@@ -6160,7 +6201,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
self._end_run(validated_response, "completed", {"duration_ms": (time.time() - start_time) * 1000})
|
||||
return validated_response
|
||||
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed after reflection: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
@@ -6179,7 +6220,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return validated_response
|
||||
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed after max reflections: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
@@ -6367,7 +6408,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(normalized_content, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return validated_response
|
||||
return await self._atrigger_after_agent_hook(prompt, validated_response, start_time)
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for custom LLM: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
@@ -6445,7 +6486,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
logging.debug(f"Agent.achat completed in {total_time:.2f} seconds")
|
||||
# Execute callback after tool completion
|
||||
self._execute_callback_and_display(original_prompt, result, time.time() - start_time, task_name, task_description, task_id)
|
||||
return result
|
||||
return await self._atrigger_after_agent_hook(original_prompt, result, start_time)
|
||||
elif output_json or output_pydantic:
|
||||
response = await self._openai_client.async_client.chat.completions.create(
|
||||
model=self.llm,
|
||||
@@ -6459,7 +6500,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
logging.debug(f"Agent.achat completed in {total_time:.2f} seconds")
|
||||
# Execute callback after JSON/Pydantic completion
|
||||
self._execute_callback_and_display(original_prompt, response_text, time.time() - start_time, task_name, task_description, task_id)
|
||||
return response_text
|
||||
return await self._atrigger_after_agent_hook(original_prompt, response_text, start_time)
|
||||
else:
|
||||
response = await self._openai_client.async_client.chat.completions.create(
|
||||
model=self.llm,
|
||||
@@ -6499,7 +6540,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if logging.getLogger().getEffectiveLevel() == logging.DEBUG:
|
||||
total_time = time.time() - start_time
|
||||
logging.debug(f"Agent.achat completed in {total_time:.2f} seconds")
|
||||
return response_text
|
||||
return await self._atrigger_after_agent_hook(original_prompt, response_text, start_time)
|
||||
|
||||
reflection_response = await self._openai_client.async_client.beta.chat.completions.parse(
|
||||
model=self.reflect_llm if self.reflect_llm else self.llm,
|
||||
@@ -6557,7 +6598,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return validated_response
|
||||
return await self._atrigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for OpenAI client: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
@@ -7278,6 +7319,77 @@ Write the complete compiled report:"""
|
||||
except Exception as e:
|
||||
logging.debug(f"Auto-memory extraction failed: {e}")
|
||||
|
||||
def _process_auto_learning(self):
|
||||
"""Process auto-learning extraction after agent response.
|
||||
|
||||
Called after each agent response when LearnMode.AGENTIC is set.
|
||||
Uses LearnManager.process_conversation() to extract and store learnings
|
||||
(persona, insights, patterns) from the conversation. No-op when learning
|
||||
is disabled or mode is not AGENTIC.
|
||||
"""
|
||||
# Quick exit if no learn config or mode is not AGENTIC
|
||||
if not self._learn_config:
|
||||
return
|
||||
|
||||
# Check if mode is AGENTIC (auto-extract learnings)
|
||||
from ..memory.learn.protocols import LearnMode
|
||||
mode = getattr(self._learn_config, 'mode', None)
|
||||
|
||||
# Handle PROPOSE mode — extract but store as pending for user approval
|
||||
if (isinstance(mode, LearnMode) and mode == LearnMode.PROPOSE) or \
|
||||
(isinstance(mode, str) and mode == 'propose'):
|
||||
learn_manager = None
|
||||
if self._memory_instance and hasattr(self._memory_instance, 'learn'):
|
||||
learn_manager = self._memory_instance.learn
|
||||
if learn_manager:
|
||||
try:
|
||||
recent_messages = self.chat_history[-2:] if len(self.chat_history) >= 2 else self.chat_history
|
||||
if recent_messages:
|
||||
# Use same extraction as AGENTIC, but don't auto-store
|
||||
result = learn_manager.process_conversation(
|
||||
messages=recent_messages,
|
||||
llm=getattr(self._learn_config, 'llm', None) or self.llm,
|
||||
extract_only=True,
|
||||
)
|
||||
# Move extracted items to pending queue
|
||||
if result:
|
||||
for p in result.get("persona", []):
|
||||
if p:
|
||||
learn_manager.add_pending(p, category="persona")
|
||||
for i in result.get("insights", []):
|
||||
if i:
|
||||
learn_manager.add_pending(i, category="insights")
|
||||
for pt in result.get("patterns", []):
|
||||
if pt:
|
||||
learn_manager.add_pending(pt, category="patterns")
|
||||
except Exception as e:
|
||||
logging.debug(f"PROPOSE mode learning extraction failed: {e}")
|
||||
return
|
||||
|
||||
if mode is None or (isinstance(mode, LearnMode) and mode != LearnMode.AGENTIC):
|
||||
return
|
||||
if isinstance(mode, str) and mode != 'agentic':
|
||||
return
|
||||
|
||||
# Get LearnManager from memory instance
|
||||
learn_manager = None
|
||||
if self._memory_instance and hasattr(self._memory_instance, 'learn'):
|
||||
learn_manager = self._memory_instance.learn
|
||||
|
||||
if not learn_manager:
|
||||
return
|
||||
|
||||
try:
|
||||
# Process recent conversation (last 2 messages: user + assistant)
|
||||
recent_messages = self.chat_history[-2:] if len(self.chat_history) >= 2 else self.chat_history
|
||||
if recent_messages:
|
||||
learn_manager.process_conversation(
|
||||
messages=recent_messages,
|
||||
llm=getattr(self._learn_config, 'llm', None) or self.llm,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.debug(f"Auto-learning extraction failed: {e}")
|
||||
|
||||
def _auto_save_session(self):
|
||||
"""Auto-save session if auto_save is enabled.
|
||||
|
||||
|
||||
@@ -62,7 +62,9 @@ class LearnMode(str, Enum):
|
||||
|
||||
DISABLED: No automatic learning (manual capture only)
|
||||
AGENTIC: Agent autonomously extracts and stores learnings after each conversation
|
||||
PROPOSE: Agent proposes learnings for user approval before storing
|
||||
PROPOSE: (Future) Agent proposes learnings for user approval before storing.
|
||||
Note: PROPOSE mode is defined but not yet implemented. Using it will
|
||||
behave the same as DISABLED until the approval workflow is added.
|
||||
"""
|
||||
DISABLED = "disabled"
|
||||
AGENTIC = "agentic"
|
||||
@@ -132,10 +134,6 @@ class LearnConfig:
|
||||
# LLM for learning extraction
|
||||
llm: Optional[str] = None # LLM model for extracting learnings (defaults to agent's LLM)
|
||||
|
||||
# Maintenance settings
|
||||
auto_consolidate: bool = True # Auto-consolidate learnings
|
||||
retention_days: Optional[int] = None # Days to retain (None = forever)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
@@ -152,8 +150,6 @@ class LearnConfig:
|
||||
"db_url": self.db_url,
|
||||
"store_path": self.store_path,
|
||||
"llm": self.llm,
|
||||
"auto_consolidate": self.auto_consolidate,
|
||||
"retention_days": self.retention_days,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -67,16 +67,22 @@ class LLMGuardrail:
|
||||
self.logger.warning(f"Unknown LLM type: {type(llm)}, treating as-is")
|
||||
return llm
|
||||
|
||||
def __call__(self, task_output: TaskOutput) -> Tuple[bool, Union[str, TaskOutput]]:
|
||||
def __call__(self, task_output) -> Tuple[bool, Union[str, "TaskOutput"]]:
|
||||
"""Validate the task output using the LLM.
|
||||
|
||||
Args:
|
||||
task_output: The task output to validate
|
||||
task_output: The task output to validate (TaskOutput or plain str)
|
||||
|
||||
Returns:
|
||||
Tuple of (success, result) where result is the output or error message
|
||||
"""
|
||||
try:
|
||||
# Accept plain str for convenience (e.g. chat-input screening)
|
||||
if isinstance(task_output, str):
|
||||
raw_text = task_output
|
||||
else:
|
||||
raw_text = task_output.raw
|
||||
|
||||
if not self.llm:
|
||||
self.logger.warning("No LLM provided for guardrail validation")
|
||||
return True, task_output
|
||||
@@ -88,7 +94,7 @@ You are a quality assurance validator. Your task is to evaluate the following ou
|
||||
Validation Criteria: {self.description}
|
||||
|
||||
Output to Validate:
|
||||
{task_output.raw}
|
||||
{raw_text}
|
||||
|
||||
Please evaluate if this output meets the criteria. Respond with:
|
||||
1. "PASS" if the output meets all criteria
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import os
|
||||
import logging
|
||||
import uuid
|
||||
import time
|
||||
from datetime import datetime
|
||||
from .chunking import Chunking
|
||||
from functools import cached_property
|
||||
@@ -414,8 +412,9 @@ class Knowledge:
|
||||
|
||||
@cached_property
|
||||
def config(self):
|
||||
# Generate unique collection name for each instance (only if not provided in config)
|
||||
default_collection = f"test_{int(time.time())}_{str(uuid.uuid4())[:8]}"
|
||||
# Use deterministic collection name for persistence across restarts
|
||||
# Only generate unique name if explicitly requested via config
|
||||
default_collection = "praisonai_knowledge"
|
||||
from ..paths import get_project_data_dir
|
||||
persist_dir = str(get_project_data_dir())
|
||||
|
||||
|
||||
@@ -245,7 +245,7 @@ class AutoMemory:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
memory: "FileMemory",
|
||||
memory: Any,
|
||||
enabled: bool = True,
|
||||
extractor: Optional[AutoMemoryExtractor] = None,
|
||||
min_importance: float = 0.6,
|
||||
|
||||
@@ -1066,6 +1066,55 @@ class FileMemory:
|
||||
"storage_path": str(self.user_path)
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# MemoryProtocol Compliance Aliases
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def store_short_term(
|
||||
self,
|
||||
text: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs
|
||||
) -> str:
|
||||
"""Protocol-compliant alias for add_short_term."""
|
||||
return self.add_short_term(text, metadata)
|
||||
|
||||
def store_long_term(
|
||||
self,
|
||||
text: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs
|
||||
) -> str:
|
||||
"""Protocol-compliant alias for add_long_term."""
|
||||
return self.add_long_term(text, metadata)
|
||||
|
||||
def search_short_term(
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 5,
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Protocol-compliant alias for search (short_term only)."""
|
||||
return self.search(query, memory_types=["short_term"], limit=limit)
|
||||
|
||||
def search_long_term(
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 5,
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Protocol-compliant alias for search (long_term only)."""
|
||||
return self.search(query, memory_types=["long_term"], limit=limit)
|
||||
|
||||
def get_all_memories(self, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""Get all memories from short-term and long-term stores."""
|
||||
all_memories = []
|
||||
for item in self._short_term:
|
||||
all_memories.append({"type": "short_term", **item.to_dict()})
|
||||
for item in self._long_term:
|
||||
all_memories.append({"type": "long_term", **item.to_dict()})
|
||||
return all_memories
|
||||
|
||||
def export(self) -> Dict[str, Any]:
|
||||
"""Export all memory data."""
|
||||
return {
|
||||
|
||||
@@ -6,8 +6,9 @@ capturing and retrieving learned information.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
import logging
|
||||
|
||||
from ...config.feature_configs import LearnConfig, LearnScope
|
||||
from ...config.feature_configs import LearnConfig, LearnScope, LearnBackend
|
||||
from .stores import (
|
||||
PersonaStore,
|
||||
InsightStore,
|
||||
@@ -43,65 +44,163 @@ class LearnManager:
|
||||
config: Optional[LearnConfig] = None,
|
||||
user_id: Optional[str] = None,
|
||||
store_path: Optional[str] = None,
|
||||
custom_stores: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
"""
|
||||
Initialize LearnManager.
|
||||
|
||||
Args:
|
||||
config: LearnConfig for enabling/disabling learning capabilities
|
||||
user_id: User identifier for scoping learnings
|
||||
store_path: Custom path for storing learning data
|
||||
custom_stores: Optional dict of custom store implementations.
|
||||
Keys are store names (e.g., "domain", "skills").
|
||||
Values must implement LearnProtocol (add, search, list_all, etc.)
|
||||
|
||||
Example:
|
||||
```python
|
||||
class MyDomainStore:
|
||||
def add(self, content, metadata=None): ...
|
||||
def search(self, query, limit=10): ...
|
||||
def list_all(self, limit=100): ...
|
||||
def get(self, entry_id): ...
|
||||
def update(self, entry_id, content, metadata=None): ...
|
||||
def delete(self, entry_id): ...
|
||||
def clear(self): ...
|
||||
|
||||
manager = LearnManager(
|
||||
config=LearnConfig(persona=True),
|
||||
custom_stores={"domain": MyDomainStore()}
|
||||
)
|
||||
```
|
||||
"""
|
||||
self.config = config or LearnConfig()
|
||||
self.user_id = user_id or "default"
|
||||
self.store_path = store_path or self.config.store_path
|
||||
|
||||
# Resolve backend from config
|
||||
self._backend = self._create_backend()
|
||||
if self._backend is not None:
|
||||
logging.info(
|
||||
f"LearnManager using {self.config.backend} backend"
|
||||
)
|
||||
|
||||
scope = self.config.scope
|
||||
if isinstance(scope, LearnScope):
|
||||
scope = scope.value
|
||||
|
||||
self._stores: Dict[str, Any] = {}
|
||||
self._custom_stores = custom_stores or {}
|
||||
|
||||
if self.config.persona:
|
||||
# Built-in stores are added first, custom stores can override them
|
||||
if self.config.persona and "persona" not in self._custom_stores:
|
||||
self._stores["persona"] = PersonaStore(
|
||||
store_path=self._get_store_path("persona"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
if self.config.insights:
|
||||
if self.config.insights and "insights" not in self._custom_stores:
|
||||
self._stores["insights"] = InsightStore(
|
||||
store_path=self._get_store_path("insights"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
if self.config.thread:
|
||||
if self.config.thread and "threads" not in self._custom_stores:
|
||||
self._stores["threads"] = ThreadStore(
|
||||
store_path=self._get_store_path("threads"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
if self.config.patterns:
|
||||
if self.config.patterns and "patterns" not in self._custom_stores:
|
||||
self._stores["patterns"] = PatternStore(
|
||||
store_path=self._get_store_path("patterns"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
if self.config.decisions:
|
||||
if self.config.decisions and "decisions" not in self._custom_stores:
|
||||
self._stores["decisions"] = DecisionStore(
|
||||
store_path=self._get_store_path("decisions"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
if self.config.feedback:
|
||||
if self.config.feedback and "feedback" not in self._custom_stores:
|
||||
self._stores["feedback"] = FeedbackStore(
|
||||
store_path=self._get_store_path("feedback"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
if self.config.improvements:
|
||||
if self.config.improvements and "improvements" not in self._custom_stores:
|
||||
self._stores["improvements"] = ImprovementStore(
|
||||
store_path=self._get_store_path("improvements"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
# Add custom stores last (they override built-in stores)
|
||||
if self._custom_stores:
|
||||
for name, store in self._custom_stores.items():
|
||||
self._stores[name] = store
|
||||
|
||||
# PROPOSE mode: pending learnings queue
|
||||
self._pending: List[LearnEntry] = []
|
||||
|
||||
def _create_backend(self) -> Any:
|
||||
"""Create a storage backend from config.
|
||||
|
||||
Returns backend instance or None for FILE (default) backend.
|
||||
Falls back to None (FILE) on error.
|
||||
"""
|
||||
backend = self.config.backend
|
||||
backend_str = backend.value if isinstance(backend, LearnBackend) else str(backend)
|
||||
|
||||
if backend_str == "file":
|
||||
return None # FILE uses BaseJSONStore default
|
||||
|
||||
if backend_str == "sqlite":
|
||||
try:
|
||||
from ...storage.backends import SQLiteBackend
|
||||
db_path = self.config.db_url or f"{self.store_path or 'learn'}/learn.db"
|
||||
# Strip sqlite:/// prefix if present
|
||||
if db_path.startswith("sqlite:///"):
|
||||
db_path = db_path[len("sqlite:///"):]
|
||||
return SQLiteBackend(db_path=db_path, table_name="learn_store")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to create SQLite backend: {e}. Falling back to FILE.")
|
||||
return None
|
||||
|
||||
if backend_str == "redis":
|
||||
try:
|
||||
from ...storage.backends import RedisBackend
|
||||
url = self.config.db_url or "redis://localhost:6379"
|
||||
return RedisBackend(url=url, prefix="praison:learn:")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to create Redis backend: {e}. Falling back to FILE.")
|
||||
return None
|
||||
|
||||
if backend_str == "mongodb":
|
||||
try:
|
||||
from ...storage.backends import MongoDBBackend
|
||||
url = self.config.db_url or "mongodb://localhost:27017/"
|
||||
return MongoDBBackend(url=url, collection="praison_learn")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to create MongoDB backend: {e}. Falling back to FILE.")
|
||||
return None
|
||||
|
||||
logging.warning(f"Unknown learn backend '{backend_str}'. Falling back to FILE.")
|
||||
return None
|
||||
|
||||
def _get_store_path(self, store_name: str) -> Optional[str]:
|
||||
"""Get path for a specific store."""
|
||||
@@ -216,6 +315,88 @@ class LearnManager:
|
||||
for store_name, store in self._stores.items()
|
||||
}
|
||||
|
||||
@property
|
||||
def pending_learnings(self) -> List[LearnEntry]:
|
||||
"""Get list of pending learnings awaiting approval (PROPOSE mode)."""
|
||||
return list(self._pending)
|
||||
|
||||
def add_pending(
|
||||
self,
|
||||
content: str,
|
||||
category: str = "persona",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> LearnEntry:
|
||||
"""Add a learning to the pending queue for user approval.
|
||||
|
||||
Args:
|
||||
content: The learning content
|
||||
category: Store category (persona, insights, patterns, etc.)
|
||||
metadata: Optional metadata
|
||||
|
||||
Returns:
|
||||
The pending LearnEntry
|
||||
"""
|
||||
from datetime import datetime
|
||||
entry_id = f"pending_{category}_{len(self._pending)}_{datetime.utcnow().timestamp()}"
|
||||
entry = LearnEntry(
|
||||
id=entry_id,
|
||||
content=content,
|
||||
metadata={**(metadata or {}), "category": category, "status": "pending"},
|
||||
)
|
||||
self._pending.append(entry)
|
||||
return entry
|
||||
|
||||
def approve_learning(self, entry_id: str) -> bool:
|
||||
"""Approve a pending learning — move it to the appropriate store.
|
||||
|
||||
Args:
|
||||
entry_id: ID of the pending learning to approve
|
||||
|
||||
Returns:
|
||||
True if approved and stored, False if not found
|
||||
"""
|
||||
for i, entry in enumerate(self._pending):
|
||||
if entry.id == entry_id:
|
||||
category = entry.metadata.get("category", "persona")
|
||||
# Route to appropriate store
|
||||
if category in self._stores:
|
||||
self._stores[category].add(entry.content, entry.metadata)
|
||||
elif "persona" in self._stores:
|
||||
self._stores["persona"].add(entry.content, entry.metadata)
|
||||
self._pending.pop(i)
|
||||
return True
|
||||
return False
|
||||
|
||||
def reject_learning(self, entry_id: str) -> bool:
|
||||
"""Reject a pending learning — discard it.
|
||||
|
||||
Args:
|
||||
entry_id: ID of the pending learning to reject
|
||||
|
||||
Returns:
|
||||
True if found and rejected, False if not found
|
||||
"""
|
||||
for i, entry in enumerate(self._pending):
|
||||
if entry.id == entry_id:
|
||||
self._pending.pop(i)
|
||||
return True
|
||||
return False
|
||||
|
||||
def approve_all_learnings(self) -> int:
|
||||
"""Approve all pending learnings at once.
|
||||
|
||||
Returns:
|
||||
Number of learnings approved
|
||||
"""
|
||||
count = 0
|
||||
while self._pending:
|
||||
entry = self._pending[0]
|
||||
if self.approve_learning(entry.id):
|
||||
count += 1
|
||||
else:
|
||||
break # Safety
|
||||
return count
|
||||
|
||||
def to_system_prompt_context(self) -> str:
|
||||
"""
|
||||
Generate context suitable for injection into system prompt.
|
||||
@@ -280,6 +461,7 @@ class LearnManager:
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
llm: Optional[str] = None,
|
||||
extract_only: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Process a conversation to extract learnings automatically.
|
||||
@@ -334,40 +516,42 @@ Return empty arrays if nothing is found for a category."""
|
||||
|
||||
try:
|
||||
# Lazy import to avoid circular dependency
|
||||
from ...llm import llm_call
|
||||
from ...llm import LLM
|
||||
import json
|
||||
|
||||
model = llm or "gpt-4o-mini"
|
||||
response = llm_call(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": extraction_prompt}],
|
||||
response_format={"type": "json_object"},
|
||||
llm_instance = LLM(model=model)
|
||||
response = llm_instance.get_response(
|
||||
prompt=extraction_prompt,
|
||||
output_json=True,
|
||||
)
|
||||
|
||||
# Parse response
|
||||
import json
|
||||
content = response.get("content", "{}")
|
||||
if hasattr(content, "content"):
|
||||
content = content.content
|
||||
# Parse response - handle both string and dict responses
|
||||
if isinstance(response, str):
|
||||
extracted = json.loads(response)
|
||||
elif isinstance(response, dict):
|
||||
extracted = response
|
||||
else:
|
||||
extracted = {"persona": [], "insights": [], "patterns": []}
|
||||
|
||||
extracted = json.loads(content) if isinstance(content, str) else content
|
||||
|
||||
# Store extracted learnings
|
||||
# Store extracted learnings (unless extract_only)
|
||||
stored = {"persona": 0, "insights": 0, "patterns": 0}
|
||||
|
||||
for preference in extracted.get("persona", []):
|
||||
if preference and "persona" in self._stores:
|
||||
self.capture_persona(preference)
|
||||
stored["persona"] += 1
|
||||
|
||||
for insight in extracted.get("insights", []):
|
||||
if insight and "insights" in self._stores:
|
||||
self.capture_insight(insight, source="auto_extraction")
|
||||
stored["insights"] += 1
|
||||
|
||||
for pattern in extracted.get("patterns", []):
|
||||
if pattern and "patterns" in self._stores:
|
||||
self.capture_pattern(pattern, pattern_type="auto_extracted")
|
||||
stored["patterns"] += 1
|
||||
if not extract_only:
|
||||
for preference in extracted.get("persona", []):
|
||||
if preference and "persona" in self._stores:
|
||||
self.capture_persona(preference)
|
||||
stored["persona"] += 1
|
||||
|
||||
for insight in extracted.get("insights", []):
|
||||
if insight and "insights" in self._stores:
|
||||
self.capture_insight(insight, source="auto_extraction")
|
||||
stored["insights"] += 1
|
||||
|
||||
for pattern in extracted.get("patterns", []):
|
||||
if pattern and "patterns" in self._stores:
|
||||
self.capture_pattern(pattern, pattern_type="auto_extracted")
|
||||
stored["patterns"] += 1
|
||||
|
||||
return {
|
||||
"persona": extracted.get("persona", []),
|
||||
@@ -426,21 +610,31 @@ Only include items that are clearly evident from the conversation.
|
||||
Return empty arrays if nothing is found for a category."""
|
||||
|
||||
try:
|
||||
from ...llm import allm_call
|
||||
# Lazy import to avoid circular dependency
|
||||
from ...llm import LLM
|
||||
import json
|
||||
import asyncio
|
||||
|
||||
model = llm or "gpt-4o-mini"
|
||||
response = await allm_call(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": extraction_prompt}],
|
||||
response_format={"type": "json_object"},
|
||||
llm_instance = LLM(model=model)
|
||||
|
||||
# Run sync LLM call in executor for async compatibility
|
||||
loop = asyncio.get_event_loop()
|
||||
response = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: llm_instance.get_response(
|
||||
prompt=extraction_prompt,
|
||||
output_json=True,
|
||||
)
|
||||
)
|
||||
|
||||
import json
|
||||
content = response.get("content", "{}")
|
||||
if hasattr(content, "content"):
|
||||
content = content.content
|
||||
|
||||
extracted = json.loads(content) if isinstance(content, str) else content
|
||||
# Parse response - handle both string and dict responses
|
||||
if isinstance(response, str):
|
||||
extracted = json.loads(response)
|
||||
elif isinstance(response, dict):
|
||||
extracted = response
|
||||
else:
|
||||
extracted = {"persona": [], "insights": [], "patterns": []}
|
||||
|
||||
stored = {"persona": 0, "insights": 0, "patterns": 0}
|
||||
|
||||
|
||||
@@ -5,19 +5,10 @@ Provides Protocol interfaces for learning implementations.
|
||||
Enables custom learning backends and extensibility.
|
||||
"""
|
||||
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class LearnMode(str, Enum):
|
||||
"""Learning mode for automatic learning extraction.
|
||||
|
||||
DISABLED: No automatic learning (manual capture only)
|
||||
AGENTIC: Agent autonomously extracts and stores learnings
|
||||
PROPOSE: Agent proposes learnings for user approval
|
||||
"""
|
||||
DISABLED = "disabled"
|
||||
AGENTIC = "agentic"
|
||||
PROPOSE = "propose"
|
||||
# Re-export LearnMode from feature_configs (canonical location)
|
||||
# This avoids duplication while maintaining backward compatibility
|
||||
from ...config.feature_configs import LearnMode
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
|
||||
@@ -73,12 +73,23 @@ class BaseStore(ABC):
|
||||
self._backend = backend
|
||||
self.store_path = store_path or self._default_path()
|
||||
self._entries: Dict[str, LearnEntry] = {}
|
||||
self._was_updated: bool = False
|
||||
|
||||
# DRY: Use BaseJSONStore for thread-safe storage
|
||||
self._store = BaseJSONStore(
|
||||
storage_path=self.store_path,
|
||||
backend=backend,
|
||||
)
|
||||
# Fall back to FILE backend if connection fails (e.g., Redis unavailable)
|
||||
try:
|
||||
self._store = BaseJSONStore(
|
||||
storage_path=self.store_path,
|
||||
backend=backend,
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.warning(f"Failed to connect to backend: {e}. Falling back to FILE.")
|
||||
self._backend = None
|
||||
self._store = BaseJSONStore(
|
||||
storage_path=self.store_path,
|
||||
backend=None,
|
||||
)
|
||||
self._load()
|
||||
|
||||
@property
|
||||
@@ -87,6 +98,15 @@ class BaseStore(ABC):
|
||||
"""Name of the store (used for file naming)."""
|
||||
pass
|
||||
|
||||
@property
|
||||
def was_updated(self) -> bool:
|
||||
"""Whether this store has been modified since last reset."""
|
||||
return self._was_updated
|
||||
|
||||
def reset_updated(self) -> None:
|
||||
"""Reset the was_updated flag."""
|
||||
self._was_updated = False
|
||||
|
||||
def _default_path(self) -> str:
|
||||
"""Default storage path (uses centralized paths - DRY)."""
|
||||
from ...paths import get_learn_dir, ensure_dir
|
||||
@@ -106,7 +126,24 @@ class BaseStore(ABC):
|
||||
self._store.save({k: v.to_dict() for k, v in self._entries.items()})
|
||||
|
||||
def add(self, content: str, metadata: Optional[Dict[str, Any]] = None) -> LearnEntry:
|
||||
"""Add a new entry."""
|
||||
"""Add a new entry with deduplication.
|
||||
|
||||
Checks for exact content matches to prevent duplicate learnings.
|
||||
If a duplicate is found, returns the existing entry instead of creating a new one.
|
||||
"""
|
||||
# Deduplication: Check for exact content match
|
||||
content_normalized = content.strip().lower()
|
||||
for existing_entry in self._entries.values():
|
||||
if existing_entry.content.strip().lower() == content_normalized:
|
||||
# Update metadata if provided, but don't create duplicate
|
||||
if metadata:
|
||||
existing_entry.metadata.update(metadata)
|
||||
existing_entry.updated_at = datetime.utcnow().isoformat()
|
||||
self._save()
|
||||
self._was_updated = True
|
||||
return existing_entry
|
||||
|
||||
# No duplicate found, create new entry
|
||||
entry_id = f"{self.store_name}_{len(self._entries)}_{datetime.utcnow().timestamp()}"
|
||||
entry = LearnEntry(
|
||||
id=entry_id,
|
||||
@@ -115,6 +152,7 @@ class BaseStore(ABC):
|
||||
)
|
||||
self._entries[entry_id] = entry
|
||||
self._save()
|
||||
self._was_updated = True
|
||||
return entry
|
||||
|
||||
def get(self, entry_id: str) -> Optional[LearnEntry]:
|
||||
@@ -146,6 +184,7 @@ class BaseStore(ABC):
|
||||
entry.metadata.update(metadata)
|
||||
entry.updated_at = datetime.utcnow().isoformat()
|
||||
self._save()
|
||||
self._was_updated = True
|
||||
return entry
|
||||
|
||||
def delete(self, entry_id: str) -> bool:
|
||||
@@ -153,6 +192,7 @@ class BaseStore(ABC):
|
||||
if entry_id in self._entries:
|
||||
del self._entries[entry_id]
|
||||
self._save()
|
||||
self._was_updated = True
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -161,6 +201,7 @@ class BaseStore(ABC):
|
||||
count = len(self._entries)
|
||||
self._entries = {}
|
||||
self._save()
|
||||
self._was_updated = True
|
||||
return count
|
||||
|
||||
|
||||
|
||||
@@ -40,6 +40,9 @@ class MemoryProtocol(Protocol):
|
||||
|
||||
def search_long_term(self, query: str, limit: int = 5, **kwargs):
|
||||
return self._data[:limit]
|
||||
|
||||
def get_all_memories(self, **kwargs):
|
||||
return self._data
|
||||
|
||||
# Use in agent
|
||||
memory: MemoryProtocol = InMemoryStore()
|
||||
@@ -122,6 +125,15 @@ class MemoryProtocol(Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
def get_all_memories(self, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all memories from all backends.
|
||||
|
||||
Returns:
|
||||
List of all memory entries (short-term and long-term combined)
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ResettableMemoryProtocol(MemoryProtocol, Protocol):
|
||||
|
||||
@@ -20,7 +20,7 @@ Default storage: ~/.praisonai/schedules/jobs.json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .models import Schedule, ScheduleJob
|
||||
from .models import Schedule, ScheduleJob, DeliveryTarget
|
||||
from .store import FileScheduleStore
|
||||
from .parser import parse_schedule
|
||||
from .runner import ScheduleRunner
|
||||
@@ -33,10 +33,11 @@ def __getattr__(name: str):
|
||||
if name in _module_cache:
|
||||
return _module_cache[name]
|
||||
|
||||
if name in ("Schedule", "ScheduleJob"):
|
||||
from .models import Schedule, ScheduleJob
|
||||
if name in ("Schedule", "ScheduleJob", "DeliveryTarget"):
|
||||
from .models import Schedule, ScheduleJob, DeliveryTarget
|
||||
_module_cache["Schedule"] = Schedule
|
||||
_module_cache["ScheduleJob"] = ScheduleJob
|
||||
_module_cache["DeliveryTarget"] = DeliveryTarget
|
||||
return _module_cache[name]
|
||||
|
||||
if name == "FileScheduleStore":
|
||||
@@ -60,6 +61,7 @@ def __getattr__(name: str):
|
||||
__all__ = [
|
||||
"Schedule",
|
||||
"ScheduleJob",
|
||||
"DeliveryTarget",
|
||||
"FileScheduleStore",
|
||||
"parse_schedule",
|
||||
"ScheduleRunner",
|
||||
|
||||
@@ -51,6 +51,45 @@ class Schedule:
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeliveryTarget:
|
||||
"""Where to deliver the result when a scheduled job fires.
|
||||
|
||||
Attributes:
|
||||
channel: Platform name (``"telegram"``, ``"discord"``,
|
||||
``"slack"``, ``"whatsapp"``).
|
||||
channel_id: Platform-specific chat / channel / group ID.
|
||||
thread_id: Optional thread ID for threaded delivery.
|
||||
session_id: Optional session ID to preserve conversation
|
||||
context when the cron fires.
|
||||
"""
|
||||
|
||||
channel: str = ""
|
||||
channel_id: str = ""
|
||||
thread_id: Optional[str] = None
|
||||
session_id: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
d: Dict[str, Any] = {
|
||||
"channel": self.channel,
|
||||
"channel_id": self.channel_id,
|
||||
}
|
||||
if self.thread_id is not None:
|
||||
d["thread_id"] = self.thread_id
|
||||
if self.session_id is not None:
|
||||
d["session_id"] = self.session_id
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict[str, Any]) -> "DeliveryTarget":
|
||||
return cls(
|
||||
channel=d.get("channel", ""),
|
||||
channel_id=d.get("channel_id", ""),
|
||||
thread_id=d.get("thread_id"),
|
||||
session_id=d.get("session_id"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScheduleJob:
|
||||
"""A persisted scheduled job.
|
||||
@@ -67,6 +106,8 @@ class ScheduleJob:
|
||||
delete_after_run: Auto-remove after first execution (one-shot).
|
||||
created_at: Epoch timestamp.
|
||||
last_run_at: Epoch timestamp of most recent execution.
|
||||
delivery: Optional delivery target for routing results back
|
||||
to a channel bot (e.g. Telegram chat).
|
||||
"""
|
||||
|
||||
name: str = ""
|
||||
@@ -79,11 +120,12 @@ class ScheduleJob:
|
||||
delete_after_run: bool = False
|
||||
created_at: float = field(default_factory=time.time)
|
||||
last_run_at: Optional[float] = None
|
||||
delivery: Optional[DeliveryTarget] = None
|
||||
|
||||
# ── serialisation ────────────────────────────────────────────────
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
d: Dict[str, Any] = {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"schedule": self.schedule.to_dict(),
|
||||
@@ -95,10 +137,14 @@ class ScheduleJob:
|
||||
"created_at": self.created_at,
|
||||
"last_run_at": self.last_run_at,
|
||||
}
|
||||
if self.delivery is not None:
|
||||
d["delivery"] = self.delivery.to_dict()
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict[str, Any]) -> "ScheduleJob":
|
||||
sched_data = d.get("schedule", {})
|
||||
delivery_data = d.get("delivery")
|
||||
return cls(
|
||||
id=d.get("id", uuid.uuid4().hex[:12]),
|
||||
name=d.get("name", ""),
|
||||
@@ -110,4 +156,5 @@ class ScheduleJob:
|
||||
delete_after_run=d.get("delete_after_run", False),
|
||||
created_at=d.get("created_at", time.time()),
|
||||
last_run_at=d.get("last_run_at"),
|
||||
delivery=DeliveryTarget.from_dict(delivery_data) if isinstance(delivery_data, dict) else None,
|
||||
)
|
||||
@@ -29,6 +29,7 @@ __all__ = [
|
||||
"FileBackend",
|
||||
"SQLiteBackend",
|
||||
"RedisBackend",
|
||||
"MongoDBBackend",
|
||||
"get_backend",
|
||||
]
|
||||
|
||||
@@ -47,6 +48,7 @@ _LAZY_IMPORTS = {
|
||||
"FileBackend": ("backends", "FileBackend"),
|
||||
"SQLiteBackend": ("backends", "SQLiteBackend"),
|
||||
"RedisBackend": ("backends", "RedisBackend"),
|
||||
"MongoDBBackend": ("backends", "MongoDBBackend"),
|
||||
"get_backend": ("backends", "get_backend"),
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ DRY: All backends implement StorageBackendProtocol for consistent interface.
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
import tempfile
|
||||
import logging
|
||||
@@ -456,6 +457,114 @@ class RedisBackend:
|
||||
self._client = None
|
||||
|
||||
|
||||
class MongoDBBackend:
|
||||
"""
|
||||
MongoDB-based storage backend.
|
||||
|
||||
Uses MongoDB for document-oriented data storage.
|
||||
Requires the `pymongo` package (optional dependency).
|
||||
|
||||
Example:
|
||||
```python
|
||||
backend = MongoDBBackend(url="mongodb://localhost:27017/")
|
||||
backend.save("session_123", {"messages": []})
|
||||
data = backend.load("session_123")
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str = "mongodb://localhost:27017/",
|
||||
database: str = "praisonai",
|
||||
collection: str = "praison_storage",
|
||||
max_pool_size: int = 50,
|
||||
):
|
||||
self.url = url
|
||||
self.database = database
|
||||
self.collection_name = collection
|
||||
self.max_pool_size = max_pool_size
|
||||
self._client = None
|
||||
self._collection = None
|
||||
|
||||
def _get_collection(self):
|
||||
"""Lazy initialize MongoDB client and collection."""
|
||||
if self._collection is None:
|
||||
try:
|
||||
import pymongo
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"MongoDB backend requires the 'pymongo' package. "
|
||||
"Install with: pip install pymongo"
|
||||
)
|
||||
self._client = pymongo.MongoClient(
|
||||
self.url,
|
||||
maxPoolSize=self.max_pool_size,
|
||||
retryWrites=True,
|
||||
retryReads=True,
|
||||
serverSelectionTimeoutMS=5000, # 5 second timeout for connection
|
||||
)
|
||||
db = self._client[self.database]
|
||||
self._collection = db[self.collection_name]
|
||||
return self._collection
|
||||
|
||||
def save(self, key: str, data: Dict[str, Any]) -> None:
|
||||
"""Save data with the given key (upsert)."""
|
||||
collection = self._get_collection()
|
||||
json_data = json.dumps(data, default=str, ensure_ascii=False)
|
||||
collection.replace_one(
|
||||
{"_id": key},
|
||||
{"_id": key, "data": json_data, "updated_at": time.time()},
|
||||
upsert=True,
|
||||
)
|
||||
|
||||
def load(self, key: str) -> Optional[Dict[str, Any]]:
|
||||
"""Load data by key."""
|
||||
collection = self._get_collection()
|
||||
doc = collection.find_one({"_id": key})
|
||||
if doc and "data" in doc:
|
||||
try:
|
||||
return json.loads(doc["data"])
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return None
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""Delete data by key."""
|
||||
collection = self._get_collection()
|
||||
result = collection.delete_one({"_id": key})
|
||||
return result.deleted_count > 0
|
||||
|
||||
def list_keys(self, prefix: str = "") -> List[str]:
|
||||
"""List all keys, optionally filtered by prefix."""
|
||||
collection = self._get_collection()
|
||||
if prefix:
|
||||
cursor = collection.find(
|
||||
{"_id": {"$regex": f"^{prefix}"}},
|
||||
{"_id": 1}
|
||||
)
|
||||
else:
|
||||
cursor = collection.find({}, {"_id": 1})
|
||||
return sorted([doc["_id"] for doc in cursor])
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
"""Check if a key exists."""
|
||||
collection = self._get_collection()
|
||||
return collection.count_documents({"_id": key}, limit=1) > 0
|
||||
|
||||
def clear(self) -> int:
|
||||
"""Clear all data. Returns number of items deleted."""
|
||||
collection = self._get_collection()
|
||||
result = collection.delete_many({})
|
||||
return result.deleted_count
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the MongoDB connection."""
|
||||
if self._client:
|
||||
self._client.close()
|
||||
self._client = None
|
||||
self._collection = None
|
||||
|
||||
|
||||
def get_backend(
|
||||
backend_type: str = "file",
|
||||
**kwargs
|
||||
@@ -488,6 +597,8 @@ def get_backend(
|
||||
return SQLiteBackend(**kwargs)
|
||||
elif backend_type == "redis":
|
||||
return RedisBackend(**kwargs)
|
||||
elif backend_type == "mongodb":
|
||||
return MongoDBBackend(**kwargs)
|
||||
else:
|
||||
raise ValueError(f"Unknown backend type: {backend_type}")
|
||||
|
||||
@@ -496,5 +607,6 @@ __all__ = [
|
||||
'FileBackend',
|
||||
'SQLiteBackend',
|
||||
'RedisBackend',
|
||||
'MongoDBBackend',
|
||||
'get_backend',
|
||||
]
|
||||
@@ -41,6 +41,10 @@ def schedule_add(
|
||||
name: str,
|
||||
schedule: str,
|
||||
message: str = "",
|
||||
channel: str = "",
|
||||
channel_id: str = "",
|
||||
agent_id: str = "",
|
||||
session_id: str = "",
|
||||
) -> str:
|
||||
"""Add a new scheduled job.
|
||||
|
||||
@@ -53,16 +57,39 @@ def schedule_add(
|
||||
- "at:2026-03-01T09:00:00" (one-shot ISO timestamp)
|
||||
- "in 20 minutes" (relative one-shot)
|
||||
message: The prompt or reminder text to deliver when triggered.
|
||||
channel: Delivery platform ("telegram", "discord", "slack",
|
||||
"whatsapp"). If set with channel_id, the result will
|
||||
be sent back to that chat when the job fires.
|
||||
channel_id: Target chat/channel/group ID on the platform.
|
||||
agent_id: Which agent should execute this job. If empty, the
|
||||
executor uses its default agent.
|
||||
session_id: Optional session ID to preserve conversation context.
|
||||
If set, the agent will have access to prior chat history.
|
||||
|
||||
Returns:
|
||||
Confirmation string with the job id.
|
||||
"""
|
||||
try:
|
||||
from ..scheduler.parser import parse_schedule
|
||||
from ..scheduler.models import ScheduleJob
|
||||
from ..scheduler.models import ScheduleJob, DeliveryTarget
|
||||
|
||||
sched = parse_schedule(schedule)
|
||||
job = ScheduleJob(name=name, schedule=sched, message=message)
|
||||
|
||||
delivery = None
|
||||
if channel and channel_id:
|
||||
delivery = DeliveryTarget(
|
||||
channel=channel,
|
||||
channel_id=channel_id,
|
||||
session_id=session_id or None,
|
||||
)
|
||||
|
||||
job = ScheduleJob(
|
||||
name=name,
|
||||
schedule=sched,
|
||||
message=message,
|
||||
agent_id=agent_id or None,
|
||||
delivery=delivery,
|
||||
)
|
||||
|
||||
store = _get_store()
|
||||
|
||||
@@ -72,7 +99,9 @@ def schedule_add(
|
||||
return f"A schedule named '{name}' already exists (id: {existing.id}). Remove it first or choose a different name."
|
||||
|
||||
store.add(job)
|
||||
return f"Schedule '{name}' added (id: {job.id}, {schedule})."
|
||||
delivery_note = f" → deliver to {channel}:{channel_id}" if delivery else ""
|
||||
agent_note = f" agent={agent_id}" if agent_id else ""
|
||||
return f"Schedule '{name}' added (id: {job.id}, {schedule}{agent_note}{delivery_note})."
|
||||
except ValueError as e:
|
||||
return f"Error adding schedule: {e}"
|
||||
except Exception as e:
|
||||
@@ -105,10 +134,15 @@ def schedule_list() -> str:
|
||||
sched_str = f"at: {sched.at}"
|
||||
else:
|
||||
sched_str = str(sched.kind)
|
||||
lines.append(
|
||||
f" - {j.name} (id: {j.id}) [{status}] — {sched_str}"
|
||||
+ (f" — \"{j.message}\"" if j.message else "")
|
||||
)
|
||||
|
||||
parts = [f" - {j.name} (id: {j.id}) [{status}] — {sched_str}"]
|
||||
if j.agent_id:
|
||||
parts.append(f" agent={j.agent_id}")
|
||||
if j.delivery:
|
||||
parts.append(f" → {j.delivery.channel}:{j.delivery.channel_id}")
|
||||
if j.message:
|
||||
parts.append(f' — "{j.message}"')
|
||||
lines.append("".join(parts))
|
||||
return "\n".join(lines)
|
||||
except Exception as e:
|
||||
logger.error("schedule_list failed: %s", e, exc_info=True)
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "1.5.35"
|
||||
version = "1.5.39"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -33,8 +33,6 @@ class TestLearnConfig:
|
||||
assert config.feedback is False
|
||||
assert config.improvements is False
|
||||
assert config.scope == LearnScope.PRIVATE
|
||||
assert config.auto_consolidate is True
|
||||
assert config.retention_days is None
|
||||
|
||||
def test_custom_values(self):
|
||||
"""Test LearnConfig with custom values."""
|
||||
@@ -43,12 +41,10 @@ class TestLearnConfig:
|
||||
insights=True,
|
||||
patterns=True,
|
||||
scope=LearnScope.SHARED,
|
||||
retention_days=30,
|
||||
)
|
||||
assert config.persona is False
|
||||
assert config.patterns is True
|
||||
assert config.scope == LearnScope.SHARED
|
||||
assert config.retention_days == 30
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test LearnConfig to_dict method."""
|
||||
@@ -271,6 +267,198 @@ class TestLearnManager:
|
||||
assert "Python" in context
|
||||
|
||||
|
||||
class TestBackendWiring:
|
||||
"""P3-A: Test LearnBackend wiring — SQLite, Redis, FILE."""
|
||||
|
||||
def setup_method(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
|
||||
def teardown_method(self):
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_sqlite_backend_round_trip(self):
|
||||
"""SQLite backend stores and retrieves learnings."""
|
||||
from praisonaiagents.config.feature_configs import LearnBackend
|
||||
config = LearnConfig(
|
||||
persona=True, insights=True, patterns=False,
|
||||
backend=LearnBackend.SQLITE,
|
||||
)
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
|
||||
# Store
|
||||
entry = manager.capture_persona("Prefers dark mode")
|
||||
assert entry is not None
|
||||
assert "dark mode" in entry.content
|
||||
|
||||
# Retrieve
|
||||
context = manager.get_persona_context()
|
||||
assert any("dark mode" in c for c in context)
|
||||
|
||||
def test_file_backend_unchanged(self):
|
||||
"""FILE backend works as before (default)."""
|
||||
config = LearnConfig(persona=True)
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
entry = manager.capture_persona("Prefers light mode")
|
||||
assert entry is not None
|
||||
|
||||
# Verify JSON file exists
|
||||
store_files = list(Path(self.temp_dir).rglob("*.json"))
|
||||
assert len(store_files) > 0
|
||||
|
||||
def test_redis_fallback_when_unavailable(self):
|
||||
"""Redis backend falls back to FILE when Redis not running."""
|
||||
import warnings
|
||||
from praisonaiagents.config.feature_configs import LearnBackend
|
||||
config = LearnConfig(
|
||||
persona=True,
|
||||
backend=LearnBackend.REDIS,
|
||||
db_url="redis://localhost:59999", # unlikely port
|
||||
)
|
||||
with warnings.catch_warnings(record=True):
|
||||
warnings.simplefilter("always")
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
|
||||
# Should still work with FILE fallback
|
||||
entry = manager.capture_persona("Redis fallback test")
|
||||
assert entry is not None
|
||||
|
||||
def test_mongodb_warns_and_falls_back(self):
|
||||
"""MongoDB warns and falls back to FILE."""
|
||||
from praisonaiagents.config.feature_configs import LearnBackend
|
||||
config = LearnConfig(
|
||||
persona=True,
|
||||
backend=LearnBackend.MONGODB,
|
||||
)
|
||||
# Should not crash, falls back to FILE
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
entry = manager.capture_persona("Mongo fallback test")
|
||||
assert entry is not None
|
||||
|
||||
|
||||
class TestWasUpdated:
|
||||
"""P3-B: Test was_updated tracking on BaseStore."""
|
||||
|
||||
def setup_method(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
|
||||
def teardown_method(self):
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_was_updated_false_initially(self):
|
||||
"""New store starts with was_updated=False."""
|
||||
store = PersonaStore(store_path=os.path.join(self.temp_dir, "persona.json"))
|
||||
assert store.was_updated is False
|
||||
|
||||
def test_was_updated_true_after_add(self):
|
||||
"""was_updated becomes True after add()."""
|
||||
store = PersonaStore(store_path=os.path.join(self.temp_dir, "persona.json"))
|
||||
store.add("test preference")
|
||||
assert store.was_updated is True
|
||||
|
||||
def test_was_updated_true_after_update(self):
|
||||
"""was_updated becomes True after update()."""
|
||||
store = PersonaStore(store_path=os.path.join(self.temp_dir, "persona.json"))
|
||||
entry = store.add("preference")
|
||||
store.reset_updated()
|
||||
store.update(entry.id, "updated preference")
|
||||
assert store.was_updated is True
|
||||
|
||||
def test_was_updated_true_after_delete(self):
|
||||
"""was_updated becomes True after delete()."""
|
||||
store = PersonaStore(store_path=os.path.join(self.temp_dir, "persona.json"))
|
||||
entry = store.add("to delete")
|
||||
store.reset_updated()
|
||||
store.delete(entry.id)
|
||||
assert store.was_updated is True
|
||||
|
||||
def test_was_updated_true_after_clear(self):
|
||||
"""was_updated becomes True after clear()."""
|
||||
store = PersonaStore(store_path=os.path.join(self.temp_dir, "persona.json"))
|
||||
store.add("something")
|
||||
store.reset_updated()
|
||||
store.clear()
|
||||
assert store.was_updated is True
|
||||
|
||||
def test_reset_updated(self):
|
||||
"""reset_updated() sets flag back to False."""
|
||||
store = PersonaStore(store_path=os.path.join(self.temp_dir, "persona.json"))
|
||||
store.add("test")
|
||||
assert store.was_updated is True
|
||||
store.reset_updated()
|
||||
assert store.was_updated is False
|
||||
|
||||
|
||||
class TestProposeMode:
|
||||
"""P3-C: Test PROPOSE mode — extract but don't auto-store."""
|
||||
|
||||
def setup_method(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
|
||||
def teardown_method(self):
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_pending_learnings_initially_empty(self):
|
||||
"""Pending learnings list starts empty."""
|
||||
config = LearnConfig(persona=True, mode="propose")
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
assert manager.pending_learnings == []
|
||||
|
||||
def test_add_pending_learning(self):
|
||||
"""Can add a pending learning manually."""
|
||||
config = LearnConfig(persona=True, mode="propose")
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
manager.add_pending("User prefers Python", category="persona")
|
||||
assert len(manager.pending_learnings) == 1
|
||||
assert "Python" in manager.pending_learnings[0].content
|
||||
|
||||
def test_approve_learning_moves_to_store(self):
|
||||
"""Approving a pending learning moves it to the main store."""
|
||||
config = LearnConfig(persona=True, mode="propose")
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
manager.add_pending("User likes concise answers", category="persona")
|
||||
pending_id = manager.pending_learnings[0].id
|
||||
|
||||
result = manager.approve_learning(pending_id)
|
||||
assert result is True
|
||||
assert len(manager.pending_learnings) == 0
|
||||
# Should now be in persona store
|
||||
personas = manager.get_persona_context()
|
||||
assert any("concise" in p for p in personas)
|
||||
|
||||
def test_reject_learning_discards(self):
|
||||
"""Rejecting a pending learning discards it."""
|
||||
config = LearnConfig(persona=True, mode="propose")
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
manager.add_pending("Bad learning", category="persona")
|
||||
pending_id = manager.pending_learnings[0].id
|
||||
|
||||
result = manager.reject_learning(pending_id)
|
||||
assert result is True
|
||||
assert len(manager.pending_learnings) == 0
|
||||
# Should NOT be in any store
|
||||
personas = manager.get_persona_context()
|
||||
assert not any("Bad learning" in p for p in personas)
|
||||
|
||||
def test_approve_all_learnings(self):
|
||||
"""Bulk approve all pending learnings."""
|
||||
config = LearnConfig(persona=True, insights=True, mode="propose")
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
manager.add_pending("Preference A", category="persona")
|
||||
manager.add_pending("Insight B", category="insights")
|
||||
assert len(manager.pending_learnings) == 2
|
||||
|
||||
count = manager.approve_all_learnings()
|
||||
assert count == 2
|
||||
assert len(manager.pending_learnings) == 0
|
||||
|
||||
def test_reject_nonexistent_learning(self):
|
||||
"""Rejecting a nonexistent ID returns False."""
|
||||
config = LearnConfig(persona=True, mode="propose")
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
result = manager.reject_learning("nonexistent_id")
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestNoCortexParam:
|
||||
"""Test that cortex parameter does not exist."""
|
||||
|
||||
|
||||
@@ -265,3 +265,77 @@ class TestAgentWithLearnMode:
|
||||
|
||||
assert agent._learn_config is not None
|
||||
assert agent._learn_config.backend == LearnBackend.SQLITE
|
||||
|
||||
def test_agent_has_process_auto_learning_method(self):
|
||||
"""Agent should have _process_auto_learning method."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(name="test", instructions="Test agent")
|
||||
assert hasattr(agent, '_process_auto_learning')
|
||||
assert callable(agent._process_auto_learning)
|
||||
|
||||
|
||||
class TestCustomStores:
|
||||
"""Test custom_stores support in LearnManager."""
|
||||
|
||||
def test_learn_manager_accepts_custom_stores(self):
|
||||
"""LearnManager should accept custom_stores parameter."""
|
||||
from praisonaiagents import LearnManager, LearnConfig
|
||||
|
||||
class MyCustomStore:
|
||||
def __init__(self):
|
||||
self.data = []
|
||||
def add(self, content, metadata=None):
|
||||
self.data.append(content)
|
||||
return {"id": "1", "content": content}
|
||||
def search(self, query, limit=10):
|
||||
return []
|
||||
def list_all(self, limit=100):
|
||||
return self.data
|
||||
def get(self, entry_id):
|
||||
return None
|
||||
def update(self, entry_id, content, metadata=None):
|
||||
return None
|
||||
def delete(self, entry_id):
|
||||
return True
|
||||
def clear(self):
|
||||
return 0
|
||||
|
||||
custom_store = MyCustomStore()
|
||||
manager = LearnManager(
|
||||
config=LearnConfig(persona=True),
|
||||
custom_stores={"domain": custom_store}
|
||||
)
|
||||
|
||||
assert "domain" in manager._stores
|
||||
assert manager._stores["domain"] is custom_store
|
||||
|
||||
def test_custom_stores_can_override_builtin(self):
|
||||
"""Custom stores can override built-in stores."""
|
||||
from praisonaiagents import LearnManager, LearnConfig
|
||||
|
||||
class MyPersonaStore:
|
||||
def add(self, content, metadata=None):
|
||||
return {"id": "custom", "content": content}
|
||||
def search(self, query, limit=10):
|
||||
return []
|
||||
def list_all(self, limit=100):
|
||||
return []
|
||||
def get(self, entry_id):
|
||||
return None
|
||||
def update(self, entry_id, content, metadata=None):
|
||||
return None
|
||||
def delete(self, entry_id):
|
||||
return True
|
||||
def clear(self):
|
||||
return 0
|
||||
|
||||
custom_persona = MyPersonaStore()
|
||||
manager = LearnManager(
|
||||
config=LearnConfig(persona=True),
|
||||
custom_stores={"persona": custom_persona}
|
||||
)
|
||||
|
||||
# Custom stores should override built-in stores
|
||||
assert "persona" in manager._stores
|
||||
assert manager._stores["persona"] is custom_persona
|
||||
Generated
+1
-1
@@ -2564,7 +2564,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.5.35"
|
||||
version = "1.5.39"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -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.5.35.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.35.tar.gz | shasum -a 256`.split.first
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.39.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.39.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -51,6 +51,149 @@ def gateway_status(
|
||||
handler.status(host=host, port=port)
|
||||
|
||||
|
||||
@app.command("channels")
|
||||
def gateway_channels(
|
||||
config: str = typer.Option("gateway.yaml", "--config", "-c", help="Path to gateway.yaml"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
||||
):
|
||||
"""List channels configured in a gateway.yaml file.
|
||||
|
||||
Examples:
|
||||
praisonai gateway channels
|
||||
praisonai gateway channels --config my-gateway.yaml --json
|
||||
"""
|
||||
import os
|
||||
import yaml
|
||||
|
||||
if not os.path.exists(config):
|
||||
print(f"Error: Config file not found: {config}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
with open(config) as f:
|
||||
cfg = yaml.safe_load(f) or {}
|
||||
|
||||
channels = cfg.get("channels", {})
|
||||
|
||||
if not channels:
|
||||
print("No channels configured.")
|
||||
raise typer.Exit(0)
|
||||
|
||||
if json_output:
|
||||
import json
|
||||
print(json.dumps(channels, indent=2))
|
||||
raise typer.Exit(0)
|
||||
|
||||
try:
|
||||
from rich.table import Table
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
table = Table(title="Configured Channels")
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Platform", style="green")
|
||||
table.add_column("Token", style="yellow")
|
||||
table.add_column("Config Keys", style="dim")
|
||||
|
||||
for name, ch_cfg in channels.items():
|
||||
platform = ch_cfg.get("platform", "unknown")
|
||||
token_val = ch_cfg.get("token", "")
|
||||
has_token = "✅ set" if token_val else "❌ missing"
|
||||
keys = ", ".join(
|
||||
k for k in ch_cfg.keys() if k not in ("platform", "token")
|
||||
)
|
||||
table.add_row(name, platform, has_token, keys or "—")
|
||||
|
||||
console.print(table)
|
||||
except ImportError:
|
||||
print(f"{'Name':<20} {'Platform':<12} {'Token':<12}")
|
||||
print("-" * 44)
|
||||
for name, ch_cfg in channels.items():
|
||||
platform = ch_cfg.get("platform", "unknown")
|
||||
has_token = "set" if ch_cfg.get("token") else "missing"
|
||||
print(f"{name:<20} {platform:<12} {has_token:<12}")
|
||||
|
||||
|
||||
@app.command("send")
|
||||
def gateway_send(
|
||||
config: str = typer.Option("gateway.yaml", "--config", "-c", help="Path to gateway.yaml"),
|
||||
channel: str = typer.Option(..., "--channel", help="Channel name from config (e.g. 'telegram')"),
|
||||
channel_id: str = typer.Option(..., "--channel-id", help="Target chat/channel ID"),
|
||||
message: str = typer.Option(..., "--message", "-m", help="Message text to send"),
|
||||
thread_id: Optional[str] = typer.Option(None, "--thread-id", help="Optional thread ID"),
|
||||
):
|
||||
"""Send a one-shot test message to a channel bot.
|
||||
|
||||
Instantiates the bot from gateway.yaml config, sends the message, then exits.
|
||||
Useful for testing scheduled delivery targets.
|
||||
|
||||
Examples:
|
||||
praisonai gateway send --config gateway.yaml --channel telegram --channel-id 12345 -m "Hello"
|
||||
"""
|
||||
import os
|
||||
import asyncio
|
||||
import yaml
|
||||
|
||||
if not os.path.exists(config):
|
||||
print(f"Error: Config file not found: {config}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
with open(config) as f:
|
||||
cfg = yaml.safe_load(f) or {}
|
||||
|
||||
channels_cfg = cfg.get("channels", {})
|
||||
ch_cfg = channels_cfg.get(channel)
|
||||
|
||||
if not ch_cfg:
|
||||
available = ", ".join(channels_cfg.keys()) if channels_cfg else "(none)"
|
||||
print(f"Error: Channel '{channel}' not found in config. Available: {available}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
platform = ch_cfg.get("platform", channel)
|
||||
token = ch_cfg.get("token", "")
|
||||
|
||||
# Resolve env vars in token
|
||||
if token and token.startswith("${") and token.endswith("}"):
|
||||
env_var = token[2:-1]
|
||||
token = os.environ.get(env_var, "")
|
||||
if not token:
|
||||
print(f"Error: Environment variable {env_var} not set")
|
||||
raise typer.Exit(1)
|
||||
|
||||
async def _send():
|
||||
try:
|
||||
from praisonai.gateway.server import WebSocketGateway
|
||||
bot = WebSocketGateway._create_bot(channel, ch_cfg)
|
||||
except Exception as e:
|
||||
print(f"Error creating bot: {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
try:
|
||||
await bot.start()
|
||||
await asyncio.sleep(1) # Let bot initialise
|
||||
result = await bot.send_message(
|
||||
channel_id, message, thread_id=thread_id,
|
||||
)
|
||||
print(f"✅ Message sent to {channel}:{channel_id}")
|
||||
if hasattr(result, "message_id"):
|
||||
print(f" Message ID: {result.message_id}")
|
||||
except Exception as e:
|
||||
print(f"❌ Send failed: {e}")
|
||||
raise typer.Exit(1)
|
||||
finally:
|
||||
try:
|
||||
await bot.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
asyncio.run(_send())
|
||||
except typer.Exit:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def gateway_callback(ctx: typer.Context):
|
||||
"""Show gateway help if no subcommand provided."""
|
||||
@@ -61,8 +204,10 @@ def gateway_callback(ctx: typer.Context):
|
||||
Manage the gateway server: praisonai gateway <command>
|
||||
|
||||
[bold]Commands:[/bold]
|
||||
[green]start[/green] Start the gateway server
|
||||
[green]status[/green] Check gateway status
|
||||
[green]start[/green] Start the gateway server
|
||||
[green]status[/green] Check gateway status
|
||||
[green]channels[/green] List channels from gateway.yaml
|
||||
[green]send[/green] Send a test message to a channel
|
||||
|
||||
[bold]Multi-Bot Mode:[/bold]
|
||||
praisonai gateway start --config gateway.yaml
|
||||
@@ -73,11 +218,15 @@ Manage the gateway server: praisonai gateway <command>
|
||||
|
||||
[bold]Check Status:[/bold]
|
||||
praisonai gateway status
|
||||
|
||||
[bold]Channel Management:[/bold]
|
||||
praisonai gateway channels --config gateway.yaml
|
||||
praisonai gateway send --config gateway.yaml --channel telegram --channel-id 12345 -m "test"
|
||||
"""
|
||||
try:
|
||||
from rich import print as rprint
|
||||
rprint(help_text)
|
||||
except ImportError:
|
||||
import re
|
||||
plain = re.sub(r'\[/?[^\]]+\]', '', help_text)
|
||||
plain = re.sub(r'\\[/?[^\\]]+\\]', '', help_text)
|
||||
print(plain)
|
||||
@@ -37,6 +37,51 @@ def _run_schedule(args: list) -> int:
|
||||
return 4
|
||||
|
||||
|
||||
@app.command("add")
|
||||
def schedule_add_cmd(
|
||||
name: str = typer.Argument(..., help="Schedule name (e.g. 'morning-hello')"),
|
||||
schedule: str = typer.Option(..., "--schedule", "-s", help="When to run: 'hourly', 'daily', '*/30m', 'cron:0 9 * * *', 'at:2026-03-01T09:00', 'in 20 minutes'"),
|
||||
message: str = typer.Option("", "--message", "-m", help="Prompt / reminder text"),
|
||||
agent: str = typer.Option("", "--agent", "-a", help="Agent ID to execute this job (default: first registered agent)"),
|
||||
channel: str = typer.Option("", "--channel", help="Delivery platform: telegram, discord, slack, whatsapp"),
|
||||
channel_id: str = typer.Option("", "--channel-id", help="Target chat/channel ID on the platform"),
|
||||
session_id: str = typer.Option("", "--session-id", help="Session ID to preserve conversation context"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
||||
):
|
||||
"""Add a job to the schedule store (with optional delivery target).
|
||||
|
||||
Examples:
|
||||
praisonai schedule add "morning-hello" -s "cron:0 9 * * *" -m "say hello"
|
||||
praisonai schedule add "tg-reminder" -s daily -m "check email" --agent support --channel telegram --channel-id 12345
|
||||
"""
|
||||
output = get_output_controller()
|
||||
try:
|
||||
from praisonaiagents.tools.schedule_tools import schedule_add as _schedule_add
|
||||
|
||||
result = _schedule_add(
|
||||
name=name,
|
||||
schedule=schedule,
|
||||
message=message,
|
||||
agent_id=agent,
|
||||
channel=channel,
|
||||
channel_id=channel_id,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
if json_output:
|
||||
import json as _json
|
||||
print(_json.dumps({"result": result}))
|
||||
else:
|
||||
if "Error" in result:
|
||||
output.print_error(result)
|
||||
raise typer.Exit(1)
|
||||
else:
|
||||
output.print_success(result)
|
||||
except ImportError as e:
|
||||
output.print_error(f"Schedule tools not available: {e}")
|
||||
raise typer.Exit(4)
|
||||
|
||||
|
||||
@app.command("start")
|
||||
def schedule_start(
|
||||
agents_file: str = typer.Argument("agents.yaml", help="Agents YAML file"),
|
||||
|
||||
@@ -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.5.35 gunicorn markdown\n")
|
||||
file.write("RUN pip install flask praisonai==4.5.39 gunicorn markdown\n")
|
||||
file.write("EXPOSE 8080\n")
|
||||
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
|
||||
|
||||
|
||||
@@ -202,6 +202,9 @@ class WebSocketGateway:
|
||||
self._channel_bots: Dict[str, Any] = {} # channel_name -> bot instance
|
||||
self._routing_rules: Dict[str, Dict[str, str]] = {} # channel_name -> {context -> agent_id}
|
||||
self._channel_tasks: List[asyncio.Task] = []
|
||||
|
||||
# Scheduler tick background task
|
||||
self._scheduler_task: Optional[asyncio.Task] = None
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
@@ -722,6 +725,91 @@ class WebSocketGateway:
|
||||
"channels": channel_status,
|
||||
}
|
||||
|
||||
# ── Scheduled delivery ────────────────────────────────────────────
|
||||
|
||||
async def _deliver_scheduled_result(
|
||||
self, delivery: Any, text: str,
|
||||
) -> None:
|
||||
"""Route a scheduled job result to the correct channel bot.
|
||||
|
||||
Args:
|
||||
delivery: A ``DeliveryTarget`` with ``channel`` and ``channel_id``.
|
||||
text: The agent response to deliver.
|
||||
"""
|
||||
channel = getattr(delivery, "channel", "") or ""
|
||||
channel_id = getattr(delivery, "channel_id", "") or ""
|
||||
thread_id = getattr(delivery, "thread_id", None)
|
||||
|
||||
if not channel or not channel_id:
|
||||
logger.warning("Delivery target missing channel or channel_id, skipping")
|
||||
return
|
||||
|
||||
bot = self.get_channel_bot(channel)
|
||||
if bot is None:
|
||||
# Try case-insensitive lookup
|
||||
for name, b in self._channel_bots.items():
|
||||
if name.lower() == channel.lower():
|
||||
bot = b
|
||||
break
|
||||
|
||||
if bot is None:
|
||||
logger.warning(
|
||||
"No channel bot '%s' found for scheduled delivery", channel,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
await bot.send_message(
|
||||
channel_id, text, thread_id=thread_id,
|
||||
)
|
||||
logger.info(
|
||||
"Delivered scheduled result to %s:%s", channel, channel_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to deliver to %s:%s: %s", channel, channel_id, e,
|
||||
)
|
||||
|
||||
def _start_scheduler_tick(self, interval: float = 15.0) -> None:
|
||||
"""Start a background task that polls the scheduler for due jobs.
|
||||
|
||||
Creates a ``ScheduledAgentExecutor`` wired to:
|
||||
- a ``ScheduleRunner`` with a ``FileScheduleStore``
|
||||
- this gateway's agent registry for resolution
|
||||
- ``_deliver_scheduled_result`` for outbound delivery
|
||||
"""
|
||||
async def _run():
|
||||
try:
|
||||
from praisonaiagents.scheduler import (
|
||||
ScheduleRunner,
|
||||
FileScheduleStore,
|
||||
)
|
||||
from praisonai.scheduler.executor import ScheduledAgentExecutor
|
||||
except ImportError as e:
|
||||
logger.warning(
|
||||
"Scheduler dependencies not available, skipping tick: %s", e,
|
||||
)
|
||||
return
|
||||
|
||||
store = FileScheduleStore()
|
||||
runner = ScheduleRunner(store)
|
||||
|
||||
def _resolve_agent(agent_id):
|
||||
if agent_id:
|
||||
return self._agents.get(agent_id)
|
||||
# Fallback: first registered agent
|
||||
return next(iter(self._agents.values()), None) if self._agents else None
|
||||
|
||||
executor = ScheduledAgentExecutor(
|
||||
runner=runner,
|
||||
agent_resolver=_resolve_agent,
|
||||
delivery_handler=self._deliver_scheduled_result,
|
||||
)
|
||||
await executor.run_loop(interval=interval)
|
||||
|
||||
self._scheduler_task = asyncio.create_task(_run())
|
||||
logger.info("Scheduler tick started (interval=15s)")
|
||||
|
||||
# ── Multi-bot lifecycle ───────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
@@ -782,13 +870,15 @@ class WebSocketGateway:
|
||||
if not isinstance(cdef, dict):
|
||||
errors.append(f"Channel '{cname}' must be a dictionary")
|
||||
continue
|
||||
if cname.lower() not in valid_platforms:
|
||||
# Resolve platform: explicit field takes priority over key
|
||||
platform = cdef.get("platform", cname).lower()
|
||||
if platform not in valid_platforms:
|
||||
logger.warning(
|
||||
f"Channel '{cname}' is not a known platform "
|
||||
f"({', '.join(valid_platforms)})"
|
||||
f"Channel '{cname}' platform '{platform}' is not "
|
||||
f"a known platform ({', '.join(sorted(valid_platforms))})"
|
||||
)
|
||||
# WhatsApp web mode doesn't require a token
|
||||
is_wa_web = (cname.lower() == "whatsapp" and
|
||||
is_wa_web = (platform == "whatsapp" and
|
||||
cdef.get("mode", "cloud").lower().strip() == "web")
|
||||
if not cdef.get("token") and not is_wa_web:
|
||||
errors.append(
|
||||
@@ -814,9 +904,15 @@ class WebSocketGateway:
|
||||
|
||||
return _resolve(raw)
|
||||
|
||||
def _create_agents_from_config(self, agents_cfg: Dict[str, Dict[str, Any]]) -> None:
|
||||
"""Create and register Agent instances from the agents section of gateway.yaml.
|
||||
|
||||
def _create_agents_from_config(
|
||||
self,
|
||||
agents_cfg: Dict[str, Dict[str, Any]],
|
||||
*,
|
||||
default_model: Optional[str] = None,
|
||||
guardrails_cfg: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Create and register Agent instances from the agents section of config.
|
||||
|
||||
Supports all agent configuration options including:
|
||||
- tools: List of tool names to resolve via ToolResolver
|
||||
- tool_choice: Tool selection mode ('auto', 'required', 'none')
|
||||
@@ -825,10 +921,19 @@ class WebSocketGateway:
|
||||
- role: Agent role (CrewAI-style)
|
||||
- goal: Agent goal (CrewAI-style)
|
||||
- backstory: Agent backstory (CrewAI-style)
|
||||
- temperature: LLM temperature (optional, uses SDK default)
|
||||
- base_url: Custom LLM endpoint URL (optional)
|
||||
- api_key: API key for LLM provider (optional)
|
||||
|
||||
Args:
|
||||
agents_cfg: The ``agents`` section of the config.
|
||||
default_model: Fallback model when an agent has no ``model`` key
|
||||
(e.g. from the ``provider.model`` section of UI config).
|
||||
guardrails_cfg: Optional ``guardrails.registry`` dict to wire per-agent.
|
||||
"""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# G1: Resolve tool names to callables (same pattern as agents_generator)
|
||||
# Resolve tool names to callables (same pattern as agents_generator)
|
||||
tool_resolver = None
|
||||
try:
|
||||
from praisonai.tool_resolver import ToolResolver
|
||||
@@ -838,15 +943,26 @@ class WebSocketGateway:
|
||||
|
||||
for agent_id, agent_def in agents_cfg.items():
|
||||
instructions = agent_def.get("instructions", "")
|
||||
model = agent_def.get("model", None)
|
||||
# G7: Apply provider.model as fallback when agent has no model
|
||||
model = agent_def.get("model", None) or default_model
|
||||
memory = agent_def.get("memory", False)
|
||||
|
||||
# G4: Support role/goal/backstory for CrewAI-style agents
|
||||
|
||||
# G2: Pass temperature through (optional, SDK uses its own default)
|
||||
temperature = agent_def.get("temperature", None)
|
||||
# G3/G4: Pass base_url and api_key for multi-provider support
|
||||
base_url = agent_def.get("base_url", None)
|
||||
api_key = agent_def.get("api_key", None)
|
||||
|
||||
# Support role/goal/backstory for CrewAI-style agents
|
||||
role = agent_def.get("role", None)
|
||||
goal = agent_def.get("goal", None)
|
||||
backstory = agent_def.get("backstory", None)
|
||||
|
||||
# G1: Resolve tools from YAML config
|
||||
|
||||
# G6: Fall back to system_prompt if instructions is empty
|
||||
if not instructions:
|
||||
instructions = agent_def.get("system_prompt", "") or ""
|
||||
|
||||
# Resolve tools from YAML config
|
||||
agent_tools = []
|
||||
yaml_tool_names = agent_def.get("tools", [])
|
||||
if yaml_tool_names and tool_resolver:
|
||||
@@ -860,16 +976,32 @@ class WebSocketGateway:
|
||||
logger.debug(f"Resolved tool '{tool_name}' for agent '{agent_id}'")
|
||||
else:
|
||||
logger.warning(f"Tool '{tool_name}' not found for agent '{agent_id}'")
|
||||
|
||||
|
||||
# Additional agent options from YAML
|
||||
tool_choice = agent_def.get("tool_choice", None)
|
||||
reflection = agent_def.get("reflection", True) # Default: enable reflection/interactive mode
|
||||
reflection = agent_def.get("reflection", True)
|
||||
allow_delegation = agent_def.get("allow_delegation", False)
|
||||
|
||||
# G5: Wire guardrails if config has a matching agent_name or global rule
|
||||
guardrail_prompt = None
|
||||
if guardrails_cfg:
|
||||
for _gr_id, gr_def in guardrails_cfg.items():
|
||||
gr_agent = gr_def.get("agent_name", "")
|
||||
if not gr_agent or gr_agent == agent_id:
|
||||
guardrail_prompt = gr_def.get("description", None)
|
||||
break # first match wins
|
||||
|
||||
# Compose model as dict when temperature is specified
|
||||
# (Agent accepts temperature via dict model config)
|
||||
model_cfg = model
|
||||
if temperature is not None and model and isinstance(model, str):
|
||||
model_cfg = {"model": model, "temperature": temperature}
|
||||
|
||||
# Use model= (preferred) instead of deprecated llm=
|
||||
agent = Agent(
|
||||
name=agent_id,
|
||||
instructions=instructions,
|
||||
llm=model,
|
||||
model=model_cfg,
|
||||
memory=memory,
|
||||
tools=agent_tools if agent_tools else None,
|
||||
reflection=reflection,
|
||||
@@ -877,14 +1009,21 @@ class WebSocketGateway:
|
||||
role=role,
|
||||
goal=goal,
|
||||
backstory=backstory,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
guardrails=guardrail_prompt,
|
||||
)
|
||||
|
||||
|
||||
# Store tool_choice for later use in chat()
|
||||
if tool_choice:
|
||||
agent._yaml_tool_choice = tool_choice
|
||||
|
||||
|
||||
self.register_agent(agent, agent_id=agent_id)
|
||||
logger.info(f"Created agent '{agent_id}' (model={model}, tools={len(agent_tools)}, reflection={reflection})")
|
||||
logger.info(
|
||||
f"Created agent '{agent_id}' "
|
||||
f"(model={model}, tools={len(agent_tools)}, "
|
||||
f"reflection={reflection})"
|
||||
)
|
||||
|
||||
def _determine_routing_context(
|
||||
self, channel_type: str, message_metadata: Dict[str, Any]
|
||||
@@ -934,7 +1073,7 @@ class WebSocketGateway:
|
||||
from praisonaiagents.bots import BotConfig
|
||||
|
||||
for channel_name, ch_cfg in channels_cfg.items():
|
||||
channel_type = channel_name.lower()
|
||||
channel_type = ch_cfg.get("platform", channel_name).lower()
|
||||
token = ch_cfg.get("token", "")
|
||||
# WhatsApp web mode doesn't require a token
|
||||
wa_web_mode = (channel_type == "whatsapp" and
|
||||
@@ -1222,9 +1361,16 @@ class WebSocketGateway:
|
||||
|
||||
# Recreate agents
|
||||
agents_cfg = cfg.get("agents", {})
|
||||
provider_cfg = cfg.get("provider", {})
|
||||
default_model = provider_cfg.get("model") if provider_cfg else None
|
||||
guardrails_cfg = (cfg.get("guardrails") or {}).get("registry")
|
||||
if agents_cfg:
|
||||
self._agents.clear()
|
||||
self._create_agents_from_config(agents_cfg)
|
||||
self._create_agents_from_config(
|
||||
agents_cfg,
|
||||
default_model=default_model,
|
||||
guardrails_cfg=guardrails_cfg,
|
||||
)
|
||||
|
||||
# Restart channels
|
||||
channels_cfg = cfg.get("channels", {})
|
||||
@@ -1273,8 +1419,15 @@ class WebSocketGateway:
|
||||
|
||||
# Create agents
|
||||
agents_cfg = cfg.get("agents", {})
|
||||
provider_cfg = cfg.get("provider", {})
|
||||
default_model = provider_cfg.get("model") if provider_cfg else None
|
||||
guardrails_cfg = (cfg.get("guardrails") or {}).get("registry")
|
||||
if agents_cfg:
|
||||
self._create_agents_from_config(agents_cfg)
|
||||
self._create_agents_from_config(
|
||||
agents_cfg,
|
||||
default_model=default_model,
|
||||
guardrails_cfg=guardrails_cfg,
|
||||
)
|
||||
|
||||
# Start channels + WebSocket server concurrently
|
||||
channels_cfg = cfg.get("channels", {})
|
||||
@@ -1289,6 +1442,8 @@ class WebSocketGateway:
|
||||
self._config_watch_task = asyncio.create_task(
|
||||
self._watch_config(config_path)
|
||||
)
|
||||
# Launch scheduler tick to poll for due jobs
|
||||
self._start_scheduler_tick()
|
||||
await self.start()
|
||||
|
||||
# Register signal handlers for graceful shutdown using
|
||||
@@ -1316,4 +1471,6 @@ class WebSocketGateway:
|
||||
finally:
|
||||
if self._config_watch_task:
|
||||
self._config_watch_task.cancel()
|
||||
if self._scheduler_task:
|
||||
self._scheduler_task.cancel()
|
||||
await self.stop_channels()
|
||||
@@ -17,6 +17,8 @@ __all__ = [
|
||||
'ScheduleParser',
|
||||
'ExecutorInterface',
|
||||
'PraisonAgentExecutor',
|
||||
'ScheduledAgentExecutor',
|
||||
'JobResult',
|
||||
]
|
||||
|
||||
# Lazy imports for modules that will be created later
|
||||
@@ -42,4 +44,7 @@ def __getattr__(name):
|
||||
elif name == 'create_deployment_scheduler':
|
||||
from .agent_scheduler import create_agent_scheduler
|
||||
return create_agent_scheduler
|
||||
elif name in ('ScheduledAgentExecutor', 'JobResult'):
|
||||
from .executor import ScheduledAgentExecutor, JobResult
|
||||
return ScheduledAgentExecutor if name == 'ScheduledAgentExecutor' else JobResult
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,268 @@
|
||||
"""
|
||||
ScheduledAgentExecutor — bridges ScheduleRunner + agent resolution.
|
||||
|
||||
This is a thin reusable bridge that any consumer (UI, CLI, custom app)
|
||||
can use to poll the scheduler and dispatch due jobs to agents, without
|
||||
reimplementing the same polling + agent-lookup logic each time.
|
||||
|
||||
Lives in the **wrapper** layer (not the SDK core) because it composes
|
||||
two independent core modules: gateway (agent registry) and scheduler
|
||||
(due-job detection).
|
||||
|
||||
Usage::
|
||||
|
||||
from praisonaiagents.scheduler import ScheduleRunner, FileScheduleStore
|
||||
from praisonai.scheduler.executor import ScheduledAgentExecutor
|
||||
|
||||
store = FileScheduleStore()
|
||||
runner = ScheduleRunner(store)
|
||||
|
||||
executor = ScheduledAgentExecutor(
|
||||
runner=runner,
|
||||
agent_resolver=lambda agent_id: my_gateway.get_agent(agent_id),
|
||||
)
|
||||
|
||||
# In your async event loop:
|
||||
async for job, result in executor.tick():
|
||||
print(f"Job {job.name}: {result}")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import (
|
||||
Any,
|
||||
AsyncIterator,
|
||||
Callable,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
Tuple,
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from praisonaiagents.scheduler import ScheduleRunner, ScheduleJob
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class JobResult:
|
||||
"""Result of a single scheduled job execution.
|
||||
|
||||
Attributes:
|
||||
job: The ScheduleJob that was executed.
|
||||
result: Agent response (str) or None on failure.
|
||||
status: ``"succeeded"``, ``"failed"``, or ``"skipped"``.
|
||||
error: Error message if status is ``"failed"``.
|
||||
duration: Wall-clock seconds for agent execution.
|
||||
delivered: Whether the result was delivered to a channel bot.
|
||||
"""
|
||||
|
||||
job: Any # ScheduleJob
|
||||
result: Optional[str] = None
|
||||
status: str = "succeeded"
|
||||
error: Optional[str] = None
|
||||
duration: float = 0.0
|
||||
delivered: bool = False
|
||||
|
||||
|
||||
class ScheduledAgentExecutor:
|
||||
"""Bridges ScheduleRunner + agent resolution for any consumer.
|
||||
|
||||
This is a **stateless** helper — it does not own a loop or thread.
|
||||
Callers decide how and when to call :meth:`tick` or :meth:`run_loop`.
|
||||
|
||||
Parameters:
|
||||
runner: An SDK ``ScheduleRunner`` instance.
|
||||
agent_resolver: A callable ``(agent_id: str | None) -> Agent``
|
||||
that returns an agent for the given ID. May return ``None``
|
||||
if no agent is available.
|
||||
delivery_handler: Optional async callable
|
||||
``(delivery: DeliveryTarget, text: str) -> None``.
|
||||
Called after successful execution when the job has a
|
||||
delivery target. Typically routes to a channel bot's
|
||||
``send_message()``.
|
||||
on_success: Optional callback ``(job, result) -> None``.
|
||||
on_failure: Optional callback ``(job, error) -> None``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
runner: "ScheduleRunner",
|
||||
agent_resolver: Callable[[Optional[str]], Any],
|
||||
*,
|
||||
delivery_handler: Optional[Callable[..., Any]] = None,
|
||||
on_success: Optional[Callable[..., None]] = None,
|
||||
on_failure: Optional[Callable[..., None]] = None,
|
||||
) -> None:
|
||||
self._runner = runner
|
||||
self._resolve = agent_resolver
|
||||
self._deliver = delivery_handler
|
||||
self._on_success = on_success
|
||||
self._on_failure = on_failure
|
||||
|
||||
# ── public API ───────────────────────────────────────────────────
|
||||
|
||||
async def tick(self) -> AsyncIterator[JobResult]:
|
||||
"""Check for due jobs and execute them, yielding results.
|
||||
|
||||
Each due job is dispatched to the resolved agent via
|
||||
``agent.chat(job.message)`` in a background thread (so sync
|
||||
agents work correctly in an async context).
|
||||
|
||||
Yields:
|
||||
:class:`JobResult` for each due job.
|
||||
"""
|
||||
due_jobs: List["ScheduleJob"] = self._runner.get_due_jobs()
|
||||
|
||||
for job in due_jobs:
|
||||
result = await self._execute_one(job)
|
||||
yield result
|
||||
|
||||
async def tick_all(self) -> List[JobResult]:
|
||||
"""Like :meth:`tick` but collects all results into a list."""
|
||||
results: List[JobResult] = []
|
||||
async for r in self.tick():
|
||||
results.append(r)
|
||||
return results
|
||||
|
||||
async def run_loop(
|
||||
self,
|
||||
interval: float = 15.0,
|
||||
*,
|
||||
max_ticks: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Convenience loop that calls :meth:`tick` at a fixed interval.
|
||||
|
||||
Args:
|
||||
interval: Seconds between ticks (default 15).
|
||||
max_ticks: Stop after this many ticks (``None`` = forever).
|
||||
"""
|
||||
tick_count = 0
|
||||
logger.info(
|
||||
"ScheduledAgentExecutor loop started (interval=%.1fs)", interval,
|
||||
)
|
||||
try:
|
||||
while max_ticks is None or tick_count < max_ticks:
|
||||
async for result in self.tick():
|
||||
logger.info(
|
||||
"Job '%s' %s (%.1fs)",
|
||||
getattr(result.job, "name", result.job),
|
||||
result.status,
|
||||
result.duration,
|
||||
)
|
||||
tick_count += 1
|
||||
await asyncio.sleep(interval)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
logger.info("ScheduledAgentExecutor loop stopped after %d ticks", tick_count)
|
||||
|
||||
# ── internals ────────────────────────────────────────────────────
|
||||
|
||||
async def _execute_one(self, job: "ScheduleJob") -> JobResult:
|
||||
"""Execute a single job and return the result."""
|
||||
started = time.time()
|
||||
message = getattr(job, "message", "") or ""
|
||||
agent_id = getattr(job, "agent_id", None)
|
||||
|
||||
# Skip jobs with no message
|
||||
if not message:
|
||||
self._runner.mark_run(job)
|
||||
return JobResult(
|
||||
job=job,
|
||||
status="skipped",
|
||||
error="No message configured",
|
||||
duration=time.time() - started,
|
||||
)
|
||||
|
||||
# Resolve the agent
|
||||
try:
|
||||
agent = self._resolve(agent_id)
|
||||
except Exception as e:
|
||||
logger.warning("Agent resolution failed for job '%s': %s", job.id, e)
|
||||
err = f"Agent resolution failed: {e}"
|
||||
if self._on_failure:
|
||||
self._on_failure(job, err)
|
||||
return JobResult(
|
||||
job=job, status="failed", error=err, duration=time.time() - started,
|
||||
)
|
||||
|
||||
if agent is None:
|
||||
err = f"No agent found for agent_id={agent_id!r}"
|
||||
logger.warning("Skipping job '%s': %s", job.id, err)
|
||||
if self._on_failure:
|
||||
self._on_failure(job, err)
|
||||
return JobResult(
|
||||
job=job, status="failed", error=err, duration=time.time() - started,
|
||||
)
|
||||
|
||||
# Execute via agent.chat() in a thread (sync-safe)
|
||||
# Wire session_target: "main" preserves context, "isolated" is fresh
|
||||
try:
|
||||
session_target = getattr(job, "session_target", "isolated")
|
||||
delivery = getattr(job, "delivery", None)
|
||||
session_id = (
|
||||
getattr(delivery, "session_id", None)
|
||||
if delivery else None
|
||||
)
|
||||
|
||||
# Build chat kwargs
|
||||
chat_kwargs: Dict[str, Any] = {}
|
||||
if session_target == "main" and session_id:
|
||||
chat_kwargs["session_id"] = session_id
|
||||
else:
|
||||
# Isolated mode: use a cron-specific session
|
||||
chat_kwargs["session_id"] = f"cron_{job.id}"
|
||||
|
||||
result = await asyncio.to_thread(
|
||||
agent.chat, message, **chat_kwargs,
|
||||
)
|
||||
result_str = str(result)
|
||||
except Exception as e:
|
||||
logger.warning("Job '%s' execution failed: %s", job.id, e)
|
||||
err = str(e)
|
||||
if self._on_failure:
|
||||
self._on_failure(job, err)
|
||||
return JobResult(
|
||||
job=job, status="failed", error=err, duration=time.time() - started,
|
||||
)
|
||||
|
||||
# Success
|
||||
self._runner.mark_run(job)
|
||||
duration = time.time() - started
|
||||
|
||||
# Deliver to channel bot if delivery target exists
|
||||
delivered = False
|
||||
delivery = getattr(job, "delivery", None)
|
||||
if delivery and self._deliver:
|
||||
try:
|
||||
coro = self._deliver(delivery, result_str)
|
||||
if asyncio.iscoroutine(coro):
|
||||
await coro
|
||||
delivered = True
|
||||
logger.info(
|
||||
"Delivered job '%s' result to %s:%s",
|
||||
job.id, delivery.channel, delivery.channel_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Delivery failed for job '%s' to %s:%s: %s",
|
||||
job.id, delivery.channel, delivery.channel_id, e,
|
||||
)
|
||||
|
||||
if self._on_success:
|
||||
self._on_success(job, result_str)
|
||||
|
||||
return JobResult(
|
||||
job=job,
|
||||
result=result_str,
|
||||
status="succeeded",
|
||||
duration=duration,
|
||||
delivered=delivered,
|
||||
)
|
||||
@@ -1156,7 +1156,7 @@ print(f'Section 5: {{result5}}')
|
||||
"1. Follow the template structure exactly",
|
||||
"2. Use REAL, WORKING code examples (not placeholders)",
|
||||
"3. Be concise but comprehensive",
|
||||
"4. Include a mermaid diagram if this is a concept page",
|
||||
"4. Include exactly TWO mermaid diagrams for concept pages: (1) high-level flow graph LR, (2) detailed flowchart TB with subgraphs. Use colour scheme: classDef agent fill:#8B0000,color:#fff and classDef tool fill:#189AB4,color:#fff - apply :::agent to inputs/outputs/agents, :::tool to processes",
|
||||
"5. Use Mintlify MDX components (CodeGroup, Tabs, Cards)",
|
||||
"6. Ensure all imports are correct for praisonaiagents",
|
||||
"7. Code examples must be complete and runnable",
|
||||
|
||||
@@ -28,25 +28,31 @@ icon: "{icon}"
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[User Request]:::agent --> B[Process]:::tool
|
||||
B --> C[Validate]:::tool
|
||||
C --> D[Execute]:::tool
|
||||
D --> E[Result]:::agent
|
||||
|
||||
classDef agent fill:#8B0000,color:#fff
|
||||
classDef tool fill:#189AB4,color:#fff
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Input
|
||||
A[User Request]
|
||||
A[User Request]:::agent
|
||||
end
|
||||
|
||||
subgraph {name}
|
||||
B[Process] --> C[Validate]
|
||||
C --> D[Execute]
|
||||
B[Process]:::tool --> C[Validate]:::tool --> D[Execute]:::tool
|
||||
end
|
||||
|
||||
subgraph Output
|
||||
E[Result]
|
||||
E[Result]:::agent
|
||||
end
|
||||
|
||||
A --> B
|
||||
D --> E
|
||||
|
||||
style B fill:#189AB4,color:#fff
|
||||
style C fill:#2E8B57,color:#fff
|
||||
style D fill:#8B0000,color:#fff
|
||||
|
||||
classDef agent fill:#8B0000,color:#fff
|
||||
classDef tool fill:#189AB4,color:#fff
|
||||
```
|
||||
|
||||
## Types of {name}
|
||||
|
||||
@@ -148,8 +148,10 @@ icon: "lightbulb"
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Input] --> B[{name}]
|
||||
B --> C[Output]
|
||||
A[Input]:::agent --> B[{name}]:::tool --> C[Output]:::agent
|
||||
|
||||
classDef agent fill:#8B0000,color:#fff
|
||||
classDef tool fill:#189AB4,color:#fff
|
||||
```
|
||||
|
||||
## When to Use
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.5.35"
|
||||
__version__ = "4.5.39"
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"rich>=13.7",
|
||||
"markdown>=3.5",
|
||||
"pyparsing>=3.0.0",
|
||||
"praisonaiagents>=1.5.35",
|
||||
"praisonaiagents>=1.5.39",
|
||||
"python-dotenv>=0.19.0",
|
||||
"litellm>=1.81.0",
|
||||
"PyYAML>=6.0",
|
||||
|
||||
Generated
+4
-4
@@ -4124,7 +4124,7 @@ requires-dist = [
|
||||
{ name = "praisonai-tools", marker = "extra == 'autogen'", specifier = ">=0.1.0" },
|
||||
{ name = "praisonai-tools", marker = "extra == 'autogen-v4'", specifier = ">=0.1.0" },
|
||||
{ name = "praisonai-tools", marker = "extra == 'crewai'", specifier = ">=0.1.0" },
|
||||
{ name = "praisonaiagents", specifier = ">=1.5.35" },
|
||||
{ name = "praisonaiagents", specifier = ">=1.5.39" },
|
||||
{ name = "pyautogen", marker = "extra == 'autogen'", specifier = "==0.2.29" },
|
||||
{ name = "pyjwt", marker = "extra == 'os'", specifier = ">=2.8.0" },
|
||||
{ name = "pyngrok", marker = "extra == 'call'", specifier = ">=1.4.0" },
|
||||
@@ -4182,7 +4182,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.5.35"
|
||||
version = "1.5.39"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -4191,9 +4191,9 @@ dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "rich" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/07/5a/21b34e1f6b0a8c4d671eda201a5106b2a5f80947f3f6c09c2e4368cfb6f6/praisonaiagents-1.5.35.tar.gz", hash = "sha256:620dc3a8e1443c8796773fe157b18f1b250c9177a86b11926c8fe64d71bb352b", size = 1230384, upload-time = "2026-03-10T13:07:11.071Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/be/3171cb9b2c982380822408c4c43994e3e27c7cf3e6ced85bdeb8a37a6b21/praisonaiagents-1.5.39.tar.gz", hash = "sha256:578c61d2fc7c64de3e62724c95b7089526fca056a6b6397abf9ac2c8ff9ae8ca", size = 1235116, upload-time = "2026-03-11T00:19:26.686Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/68/6e/74ac46697ba557d26554c776d4fff99408678c4f27ad4fab1ad7ac4a252c/praisonaiagents-1.5.35-py3-none-any.whl", hash = "sha256:b9fb8cd0a450999a6877d531853b6963108d83a787859a64e45f855c8d12fd7a", size = 1230825, upload-time = "2026-03-10T13:07:08.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/52/da460fb48819f81aad48e7bc84cfb1749f6cf701f291611a1195f7c7ac10/praisonaiagents-1.5.39-py3-none-any.whl", hash = "sha256:063045dfc147a65f639750d81d2bec3b571d48d47d7921230d0ca8a35c88c84e", size = 1235755, upload-time = "2026-03-11T00:19:24.008Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in new issue
Block a user