Compare commits

...
4 Commits
Author SHA1 Message Date
Mervin Praison 6df3e32640 Release v3.8.2 2026-01-13 04:16:08 +00:00
github-actions[bot] fb36e1c50c docs: auto-update api.md [skip ci] 2026-01-12 17:00:39 +00:00
Mervin Praison ac5454cb00 Release v3.8.1 2026-01-12 17:00:20 +00:00
github-actions[bot] bbe95795d1 docs: auto-update api.md [skip ci] 2026-01-12 12:49:30 +00:00
52 changed files with 6632 additions and 113 deletions

No files matched your search

+14
View File
@@ -60,6 +60,7 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">get_skills_prompt</a>() -> str</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">handoff_to</a>(target_agent: 'Agent', prompt: str, context: Optional[Dict[str, Any]] = None, config: Optional['HandoffConfig'] = None) -> 'HandoffResult'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">handoff_to_async</a>(target_agent: 'Agent', prompt: str, context: Optional[Dict[str, Any]] = None, config: Optional['HandoffConfig'] = None) -> 'HandoffResult'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">iter_stream</a>(prompt: str, **kwargs)</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">launch</a>(path: str = '/', port: int = 8000, host: str = '0.0.0.0', debug: bool = False, protocol: str = 'http')</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">llm_model</a>()</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">output_style</a>()</code>
@@ -314,6 +315,8 @@ Methods:
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">compute_quality_score</a>(completeness: float, relevance: float, clarity: float, accuracy: float, weights: Dict[str, float] = None) -> float</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">finalize_task_output</a>(content: str, agent_name: str, quality_score: float, threshold: float = 0.7, metrics: Dict[str, Any] = None, task_id: str = None)</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">get_all_memories</a>() -> List[Dict[str, Any]]</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">get_learn_context</a>() -> str</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">learn</a>()</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">reset_all</a>()</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">reset_entity_only</a>()</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">reset_long_term</a>()</code>
@@ -615,8 +618,11 @@ Methods:
* <code title="cli">praisonai context clear <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
* <code title="cli">praisonai context compact <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
* <code title="cli">praisonai context export <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
* <code title="cli">praisonai context grep <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
* <code title="cli">praisonai context list <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
* <code title="cli">praisonai context show <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
* <code title="cli">praisonai context stats <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
* <code title="cli">praisonai context tail <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
* <code title="cli">praisonai debug acp <a href="./src/praisonai/praisonai/cli/commands/debug.py">--help</a></code>
* <code title="cli">praisonai debug debug-callback <a href="./src/praisonai/praisonai/cli/commands/debug.py">--help</a></code>
* <code title="cli">praisonai debug interactive <a href="./src/praisonai/praisonai/cli/commands/debug.py">--help</a></code>
@@ -671,14 +677,19 @@ Methods:
* <code title="cli">praisonai lsp status <a href="./src/praisonai/praisonai/cli/commands/lsp.py">--help</a></code>
* <code title="cli">praisonai lsp stop <a href="./src/praisonai/praisonai/cli/commands/lsp.py">--help</a></code>
* <code title="cli">praisonai mcp add <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
* <code title="cli">praisonai mcp describe <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
* <code title="cli">praisonai mcp list <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
* <code title="cli">praisonai mcp mcp-callback <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
* <code title="cli">praisonai mcp remove <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
* <code title="cli">praisonai mcp status <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
* <code title="cli">praisonai mcp sync <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
* <code title="cli">praisonai mcp test <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
* <code title="cli">praisonai mcp tools <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
* <code title="cli">praisonai memory add <a href="./src/praisonai/praisonai/cli/commands/memory.py">--help</a></code>
* <code title="cli">praisonai memory clear <a href="./src/praisonai/praisonai/cli/commands/memory.py">--help</a></code>
* <code title="cli">praisonai memory search <a href="./src/praisonai/praisonai/cli/commands/memory.py">--help</a></code>
* <code title="cli">praisonai memory show <a href="./src/praisonai/praisonai/cli/commands/memory.py">--help</a></code>
* <code title="cli">praisonai memory status <a href="./src/praisonai/praisonai/cli/commands/memory.py">--help</a></code>
* <code title="cli">praisonai package install <a href="./src/praisonai/praisonai/cli/commands/package.py">--help</a></code>
* <code title="cli">praisonai package list <a href="./src/praisonai/praisonai/cli/commands/package.py">--help</a></code>
* <code title="cli">praisonai package uninstall <a href="./src/praisonai/praisonai/cli/commands/package.py">--help</a></code>
@@ -729,8 +740,10 @@ Methods:
* <code title="cli">praisonai session resume <a href="./src/praisonai/praisonai/cli/commands/session.py">--help</a></code>
* <code title="cli">praisonai session show <a href="./src/praisonai/praisonai/cli/commands/session.py">--help</a></code>
* <code title="cli">praisonai skills create <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai skills info <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai skills install <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai skills list <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai skills search <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai skills validate <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai standardise check <a href="./src/praisonai/praisonai/cli/commands/standardise.py">--help</a></code>
* <code title="cli">praisonai standardise fix <a href="./src/praisonai/praisonai/cli/commands/standardise.py">--help</a></code>
@@ -739,6 +752,7 @@ Methods:
* <code title="cli">praisonai templates create <a href="./src/praisonai/praisonai/cli/commands/templates.py">--help</a></code>
* <code title="cli">praisonai templates list <a href="./src/praisonai/praisonai/cli/commands/templates.py">--help</a></code>
* <code title="cli">praisonai test info <a href="./src/praisonai/praisonai/cli/commands/test.py">--help</a></code>
* <code title="cli">praisonai test interactive <a href="./src/praisonai/praisonai/cli/commands/test.py">--help</a></code>
* <code title="cli">praisonai test run <a href="./src/praisonai/praisonai/cli/commands/test.py">--help</a></code>
* <code title="cli">praisonai todo add <a href="./src/praisonai/praisonai/cli/commands/todo.py">--help</a></code>
* <code title="cli">praisonai todo done <a href="./src/praisonai/praisonai/cli/commands/todo.py">--help</a></code>
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=3.8.0" \
"praisonai>=3.8.2" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=3.8.0" \
"praisonai>=3.8.2" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=3.8.0" \
"praisonai>=3.8.2" \
"praisonai[ui]" \
"praisonai[crewai]"
+2 -12
View File
@@ -1,14 +1,4 @@
#!/usr/bin/env python3
from praisonaiagents import Agent
print("Creating agent...")
agent = Agent(
instructions="You are a helpful assistant"
)
print("Starting agent...")
agent = Agent(instructions="You are a helpful assistant")
result = agent.start("Why is the sky blue?")
print("Agent completed successfully!")
print(f"Result: {result}")
print(result)
@@ -704,4 +704,17 @@ __all__ = [
'parse_policy_string',
# Performance utilities
'warmup',
# Cortex - Continuous Improvement System
'CortexConfig',
'CaptureStore',
'CaptureMode',
'CaptureScope',
'PersonaConfig',
'InsightConfig',
'ThreadConfig',
'EntityConfig',
'PatternConfig',
'DecisionConfig',
'FeedbackConfig',
'ImprovementConfig',
]
@@ -297,7 +297,7 @@ class Agent:
backstory: Background context shaping personality and decisions.
instructions: Direct instructions (overrides role/goal/backstory). Recommended for simple agents.
llm: Model name string ("gpt-4o", "anthropic/claude-3-sonnet") or LLM object.
Defaults to OPENAI_MODEL_NAME env var or "gpt-5-nano".
Defaults to OPENAI_MODEL_NAME env var or "gpt-4o-mini".
model: Alias for llm parameter.
function_calling_llm: Dedicated LLM for function calling. Deprecated: use llm=.
llm_config: LLM configuration dict. Deprecated: use llm=.
@@ -844,7 +844,7 @@ class Agent:
self.llm_instance = LLM(**llm_config)
else:
# Create LLM with model string and base_url
model_name = llm or os.getenv('OPENAI_MODEL_NAME', 'gpt-5-nano')
model_name = llm or os.getenv('OPENAI_MODEL_NAME', 'gpt-4o-mini')
self.llm_instance = LLM(
model=model_name,
base_url=base_url,
@@ -907,7 +907,7 @@ class Agent:
) from e
# Otherwise, fall back to OpenAI environment/name
else:
self.llm = llm or os.getenv('OPENAI_MODEL_NAME', 'gpt-5-nano')
self.llm = llm or os.getenv('OPENAI_MODEL_NAME', 'gpt-4o-mini')
# Handle tools parameter - ensure it's always a list
if callable(tools):
# If a single function/callable is passed, wrap it in a list
@@ -953,7 +953,7 @@ class Agent:
self.min_reflect = min_reflect
self.reflect_prompt = reflect_prompt
# Use the same model selection logic for reflect_llm
self.reflect_llm = reflect_llm or os.getenv('OPENAI_MODEL_NAME', 'gpt-5-nano')
self.reflect_llm = reflect_llm or os.getenv('OPENAI_MODEL_NAME', 'gpt-4o-mini')
self._console = None # Lazy load console when needed
# Initialize system prompt
@@ -1743,7 +1743,7 @@ Your Goal: {self.goal}
elif hasattr(self, 'llm') and self.llm:
model_name = self.llm
else:
model_name = "gpt-5-nano"
model_name = "gpt-4o-mini"
return supports_web_search(model_name)
@@ -1765,7 +1765,7 @@ Your Goal: {self.goal}
elif hasattr(self, 'llm') and self.llm:
model_name = self.llm
else:
model_name = "gpt-5-nano"
model_name = "gpt-4o-mini"
return supports_web_fetch(model_name)
@@ -2009,7 +2009,7 @@ Your Goal: {self.goal}
Returns:
The LLM model/instance being used by this agent.
- For standard models: returns the model string (e.g., "gpt-5-nano")
- For standard models: returns the model string (e.g., "gpt-4o-mini")
- For custom LLM instances: returns the LLM instance object
- For provider models: returns the LLM instance object
"""
@@ -2019,7 +2019,7 @@ Your Goal: {self.goal}
return self.llm
else:
# Default fallback
return "gpt-5-nano"
return "gpt-4o-mini"
def _ensure_knowledge_processed(self):
"""Ensure knowledge is initialized and processed when first accessed."""
@@ -3719,7 +3719,7 @@ Your Goal: {self.goal}"""
if self.verbose:
logging.debug(f"Agent {self.name} final response: {response_text}")
# Return only reasoning content if reasoning_steps is True
if reasoning_steps and hasattr(response.choices[0].message, 'reasoning_content'):
if reasoning_steps and hasattr(response.choices[0].message, 'reasoning_content') and response.choices[0].message.reasoning_content:
# Apply guardrail to reasoning content
try:
validated_reasoning = self._apply_guardrail_with_retry(response.choices[0].message.reasoning_content, original_prompt, temperature, tools, task_name, task_description, task_id)
@@ -4244,7 +4244,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
final_response = process_stream_chunks(chunks)
# Return only reasoning content if reasoning_steps is True
if reasoning_steps and hasattr(final_response.choices[0].message, 'reasoning_content'):
if reasoning_steps and hasattr(final_response.choices[0].message, 'reasoning_content') and final_response.choices[0].message.reasoning_content:
return final_response.choices[0].message.reasoning_content
return final_response.choices[0].message.content if final_response else full_response_text
@@ -434,7 +434,7 @@ This report contains all agent interactions and outputs from a complete ContextA
Provide comprehensive analysis that follows the PRD template principles and enables
AI assistants to implement features that perfectly match existing codebase patterns.""",
llm=self.llm if hasattr(self, 'llm') else "gpt-5-nano",
llm=self.llm if hasattr(self, 'llm') else "gpt-4o-mini",
verbose=getattr(self, 'verbose', True)
)
@@ -515,7 +515,7 @@ codebase style and architecture following PRD template principles."""
role="Expert Manual Codebase Analysis Specialist",
goal="Perform comprehensive manual codebase analysis following PRD methodology",
instructions="""Analyze the codebase samples following PRD template methodology for complete understanding.""",
llm=self.llm if hasattr(self, 'llm') else "gpt-5-nano",
llm=self.llm if hasattr(self, 'llm') else "gpt-4o-mini",
verbose=getattr(self, 'verbose', True)
)
@@ -566,7 +566,7 @@ Analyze following PRD principles to extract patterns, conventions, and architect
6. Design pattern implementations
7. Code complexity metrics
8. API and interface patterns""",
llm=self.llm if hasattr(self, 'llm') else "gpt-5-nano",
llm=self.llm if hasattr(self, 'llm') else "gpt-4o-mini",
verbose=getattr(self, 'verbose', True)
)
@@ -624,7 +624,7 @@ Extract comprehensive patterns that follow PRD template principles for implement
For each pattern, provide the pattern name, where it's used, and how to replicate it
following PRD template principles.""",
llm=self.llm if hasattr(self, 'llm') else "gpt-5-nano",
llm=self.llm if hasattr(self, 'llm') else "gpt-4o-mini",
verbose=getattr(self, 'verbose', True)
)
@@ -674,7 +674,7 @@ patterns and best practices for first-try success."""
goal="Analyze testing patterns for comprehensive validation framework design",
instructions="""Analyze testing patterns to understand validation approaches and create
comprehensive test frameworks following PRD methodology.""",
llm=self.llm if hasattr(self, 'llm') else "gpt-5-nano",
llm=self.llm if hasattr(self, 'llm') else "gpt-4o-mini",
verbose=getattr(self, 'verbose', True)
)
@@ -801,7 +801,7 @@ Extract testing patterns for validation framework creation following PRD princip
Confidence level for one-pass implementation
Generate PRPs following this EXACT structure for first-try implementation success.""",
llm=self.llm if hasattr(self, 'llm') else "gpt-5-nano",
llm=self.llm if hasattr(self, 'llm') else "gpt-4o-mini",
verbose=getattr(self, 'verbose', True)
)
@@ -850,7 +850,7 @@ on the first try following PRD template principles."""
6. CODE QUALITY: Complexity analysis, maintainability
7. DOCUMENTATION VALIDATION: Documentation completeness
8. DEPENDENCY VALIDATION: Dependency analysis and security""",
llm=self.llm if hasattr(self, 'llm') else "gpt-5-nano",
llm=self.llm if hasattr(self, 'llm') else "gpt-4o-mini",
verbose=getattr(self, 'verbose', True)
)
@@ -897,7 +897,7 @@ following PRD template principles."""
instructions="""Compile all available documentation following PRD methodology including:
README files, API documentation, setup guides, architecture docs, and any other
relevant documentation that provides context for implementation.""",
llm=self.llm if hasattr(self, 'llm') else "gpt-5-nano",
llm=self.llm if hasattr(self, 'llm') else "gpt-4o-mini",
verbose=getattr(self, 'verbose', True)
)
@@ -943,7 +943,7 @@ following PRD template principles."""
instructions="""Analyze integration points following PRD methodology including:
APIs, databases, external services, configuration points, and any other
integration requirements that affect implementation.""",
llm=self.llm if hasattr(self, 'llm') else "gpt-5-nano",
llm=self.llm if hasattr(self, 'llm') else "gpt-4o-mini",
verbose=getattr(self, 'verbose', True)
)
@@ -1004,7 +1004,7 @@ following PRD template principles."""
8. DOCUMENTATION UPDATES: Documentation to create/update
9. INTEGRATION STEPS: How to integrate with existing systems
10. VALIDATION CHECKPOINTS: Validation steps at each phase""",
llm=self.llm if hasattr(self, 'llm') else "gpt-5-nano",
llm=self.llm if hasattr(self, 'llm') else "gpt-4o-mini",
verbose=getattr(self, 'verbose', True)
)
@@ -1302,7 +1302,7 @@ Every agent interaction has been saved for full audit trail and reproducibility.
GOAL: [extracted implementation goal]
Be precise and extract only what is explicitly mentioned or clearly implied.""",
llm=self.llm if hasattr(self, 'llm') else "gpt-5-nano",
llm=self.llm if hasattr(self, 'llm') else "gpt-4o-mini",
verbose=getattr(self, 'verbose', True)
)
@@ -1536,7 +1536,7 @@ Note: Detailed function/class metadata not available due to content access limit
5. Documentation topics
Make the output easy for a file selection agent to understand which files contain what functionality.""",
llm=self.llm if hasattr(self, 'llm') else "gpt-5-nano",
llm=self.llm if hasattr(self, 'llm') else "gpt-4o-mini",
verbose=getattr(self, 'verbose', True)
)
@@ -1770,7 +1770,7 @@ Focus on creating clear, structured metadata that will help with intelligent fil
["README.md", "src/auth/login.py", "config/settings.py", ...]
Maximum 50 files for efficient analysis.""",
llm=self.llm if hasattr(self, 'llm') else "gpt-5-nano",
llm=self.llm if hasattr(self, 'llm') else "gpt-4o-mini",
verbose=getattr(self, 'verbose', True)
)
@@ -1923,7 +1923,7 @@ Maximum 50 files.""".format(goal=goal)
8. EXAMPLES: Similar features that can guide {goal} implementation
Since these files were pre-selected for relevance, provide deep analysis of how each contributes to implementing: {goal}""",
llm=self.llm if hasattr(self, 'llm') else "gpt-5-nano",
llm=self.llm if hasattr(self, 'llm') else "gpt-4o-mini",
verbose=getattr(self, 'verbose', True)
)
@@ -2024,7 +2024,7 @@ Since these files were pre-selected for relevance, explain how each contributes
- Success criteria for {goal}
Focus everything on successfully implementing: {goal}""",
llm=self.llm if hasattr(self, 'llm') else "gpt-5-nano",
llm=self.llm if hasattr(self, 'llm') else "gpt-4o-mini",
verbose=getattr(self, 'verbose', True)
)
@@ -2303,13 +2303,13 @@ def create_context_agent(llm: Optional[Union[str, Any]] = None, **kwargs) -> Con
Factory function to create a ContextAgent following Context Engineering and PRD methodology.
Args:
llm: Language model to use (e.g., "gpt-5-nano", "claude-3-haiku")
llm: Language model to use (e.g., "gpt-4o-mini", "claude-3-haiku")
**kwargs: Additional arguments to pass to ContextAgent constructor
Returns:
ContextAgent: Configured ContextAgent for comprehensive context generation following PRD principles
"""
if llm is None:
llm = "gpt-5-nano"
llm = "gpt-4o-mini"
return ContextAgent(llm=llm, **kwargs)
@@ -44,7 +44,7 @@ class RouterAgent(Agent):
# Initialize model router
self.model_router = model_router or ModelRouter()
self.routing_strategy = routing_strategy
self.fallback_model = fallback_model or os.getenv('OPENAI_MODEL_NAME', 'gpt-5-nano')
self.fallback_model = fallback_model or os.getenv('OPENAI_MODEL_NAME', 'gpt-4o-mini')
# Process models configuration
self.available_models = self._process_models_config(models)
@@ -363,7 +363,7 @@ class Agents:
self.variables = variables if variables else {}
# Check for manager_llm in environment variable if not provided
self.manager_llm = manager_llm or os.getenv('OPENAI_MODEL_NAME', 'gpt-5-nano')
self.manager_llm = manager_llm or os.getenv('OPENAI_MODEL_NAME', 'gpt-4o-mini')
# Set logger level based on verbose
if _verbose >= 5:
@@ -103,7 +103,7 @@ class AutoAgents(Agents):
self.self_reflect = self_reflect
self.max_reflect = max_reflect
self.min_reflect = min_reflect
self.llm = llm or os.getenv('OPENAI_MODEL_NAME', 'gpt-5-nano')
self.llm = llm or os.getenv('OPENAI_MODEL_NAME', 'gpt-4o-mini')
self.function_calling_llm = function_calling_llm
self.code_execution_mode = code_execution_mode
self.embedder_config = embedder_config
@@ -44,12 +44,79 @@ class MemoryBackend(str, Enum):
MONGODB = "mongodb"
class LearnScope(str, Enum):
"""Scope for learning data visibility.
PRIVATE: Learning data is private to this user/agent (default, safest)
SHARED: Learning data is shared with all agents
"""
PRIVATE = "private" # Private to this user/agent
SHARED = "shared" # Shared with all agents
@dataclass
class LearnConfig:
"""
Configuration for continuous learning within memory system.
Learning captures patterns, preferences, and insights from agent interactions
to improve future responses. All learning data is stored within the memory system.
Usage:
# Simple enable
Agent(memory=MemoryConfig(learn=True))
# With specific capabilities
Agent(memory=MemoryConfig(
learn=LearnConfig(
persona=True, # User preferences
insights=True, # Observations
patterns=True, # Reusable knowledge
)
))
"""
# Learning capabilities
persona: bool = True # User preferences and profile
insights: bool = True # Observations and learnings
thread: bool = True # Session/conversation context
patterns: bool = False # Reusable knowledge patterns
decisions: bool = False # Decision logging
feedback: bool = False # Outcome signals
improvements: bool = False # Self-improvement proposals
# Scope configuration
scope: Union[str, LearnScope] = LearnScope.PRIVATE
# Storage configuration
store_path: Optional[str] = None # Custom storage path
# 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 {
"persona": self.persona,
"insights": self.insights,
"thread": self.thread,
"patterns": self.patterns,
"decisions": self.decisions,
"feedback": self.feedback,
"improvements": self.improvements,
"scope": self.scope.value if isinstance(self.scope, LearnScope) else self.scope,
"store_path": self.store_path,
"auto_consolidate": self.auto_consolidate,
"retention_days": self.retention_days,
}
@dataclass
class MemoryConfig:
"""
Configuration for agent memory and session management.
Consolidates: memory, auto_memory, claude_memory, user_id, session_id, db
Consolidates: memory, auto_memory, claude_memory, user_id, session_id, db, learn
Usage:
# Simple enable (uses FileMemory)
@@ -58,12 +125,22 @@ class MemoryConfig:
# With backend
Agent(memory=MemoryConfig(backend="redis"))
# Full config
# Full config with learning
Agent(memory=MemoryConfig(
backend="sqlite",
user_id="user123",
session_id="session456",
auto_memory=True,
learn=True, # Enable continuous learning
))
# With detailed learn config
Agent(memory=MemoryConfig(
learn=LearnConfig(
persona=True,
insights=True,
patterns=True,
)
))
"""
# Backend selection
@@ -85,8 +162,17 @@ class MemoryConfig:
# Memory configuration dict (for provider-specific settings)
config: Optional[Dict[str, Any]] = None
# Continuous learning configuration
learn: Optional[Union[bool, LearnConfig]] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
learn_dict = None
if self.learn is True:
learn_dict = LearnConfig().to_dict()
elif isinstance(self.learn, LearnConfig):
learn_dict = self.learn.to_dict()
return {
"backend": self.backend.value if isinstance(self.backend, MemoryBackend) else self.backend,
"user_id": self.user_id,
@@ -94,6 +180,7 @@ class MemoryConfig:
"auto_memory": self.auto_memory,
"claude_memory": self.claude_memory,
"config": self.config,
"learn": learn_dict,
}
@@ -878,9 +965,259 @@ SkillsParam = Union[List[str], SkillsConfig]
AutonomyParam = Union[bool, Dict[str, Any], AutonomyConfig]
# =============================================================================
# PRECEDENCE LADDER RESOLVERS
# =============================================================================
# Precedence: Instance > Config > Array > Dict > String > Bool > Default
#
# These resolvers normalize various input types into the canonical Config form.
# This allows users to pass simplified inputs while maintaining type safety.
def resolve_memory(value: MemoryParam) -> Optional[MemoryConfig]:
"""
Resolve memory= parameter following precedence ladder.
Precedence: Instance > Config > Dict > String > Bool > Default
Args:
value: Memory parameter in any supported form
Returns:
MemoryConfig if enabled, None if disabled
Examples:
>>> resolve_memory(None) # Default: disabled
None
>>> resolve_memory(False) # Explicit disable
None
>>> resolve_memory(True) # Enable with defaults
MemoryConfig(backend='file')
>>> resolve_memory("redis") # String shorthand
MemoryConfig(backend='redis')
>>> resolve_memory({"backend": "sqlite", "user_id": "alice"}) # Dict
MemoryConfig(backend='sqlite', user_id='alice')
>>> resolve_memory(MemoryConfig(backend="postgres")) # Config passthrough
MemoryConfig(backend='postgres')
"""
# Default: disabled
if value is None:
return None
# Bool: False = disabled, True = defaults
if value is False:
return None
if value is True:
return MemoryConfig()
# String: backend shorthand
if isinstance(value, str):
try:
backend = MemoryBackend(value.lower())
except ValueError:
backend = value # Allow custom backend strings
return MemoryConfig(backend=backend)
# Dict: expand to config
if isinstance(value, dict):
# Handle backend enum conversion
backend = value.get("backend", MemoryBackend.FILE)
if isinstance(backend, str):
try:
backend = MemoryBackend(backend.lower())
except ValueError:
pass # Keep as string for custom backends
return MemoryConfig(
backend=backend,
user_id=value.get("user_id"),
session_id=value.get("session_id"),
auto_memory=value.get("auto_memory", False),
claude_memory=value.get("claude_memory", False),
db=value.get("db"),
config=value.get("config"),
)
# Config: passthrough
if isinstance(value, MemoryConfig):
return value
# Instance (MemoryManager): return as-is (caller handles)
# This is the highest precedence - user provided a pre-configured instance
return value
def resolve_knowledge(value: KnowledgeParam) -> Optional[KnowledgeConfig]:
"""
Resolve knowledge= parameter following precedence ladder.
Precedence: Instance > Config > Array > Dict > String > Bool > Default
Args:
value: Knowledge parameter in any supported form
Returns:
KnowledgeConfig if enabled, None if disabled
"""
# Default: disabled
if value is None:
return None
# Bool: False = disabled, True = defaults
if value is False:
return None
if value is True:
return KnowledgeConfig()
# String: single source
if isinstance(value, str):
return KnowledgeConfig(sources=[value])
# Array: list of sources
if isinstance(value, list):
return KnowledgeConfig(sources=value)
# Dict: expand to config
if isinstance(value, dict):
return KnowledgeConfig(
sources=value.get("sources", []),
embedder=value.get("embedder", "openai"),
embedder_config=value.get("embedder_config"),
chunking_strategy=value.get("chunking_strategy", ChunkingStrategy.SEMANTIC),
chunk_size=value.get("chunk_size", 1000),
chunk_overlap=value.get("chunk_overlap", 200),
retrieval_k=value.get("retrieval_k", 5),
retrieval_threshold=value.get("retrieval_threshold", 0.0),
rerank=value.get("rerank", False),
rerank_model=value.get("rerank_model"),
auto_retrieve=value.get("auto_retrieve", True),
)
# Config: passthrough
if isinstance(value, KnowledgeConfig):
return value
# Instance: return as-is
return value
def resolve_planning(value: PlanningParam) -> Optional[PlanningConfig]:
"""Resolve planning= parameter following precedence ladder."""
if value is None or value is False:
return None
if value is True:
return PlanningConfig()
if isinstance(value, dict):
return PlanningConfig(**value)
if isinstance(value, PlanningConfig):
return value
return value
def resolve_reflection(value: ReflectionParam) -> Optional[ReflectionConfig]:
"""Resolve reflection= parameter following precedence ladder."""
if value is None or value is False:
return None
if value is True:
return ReflectionConfig()
if isinstance(value, dict):
return ReflectionConfig(**value)
if isinstance(value, ReflectionConfig):
return value
return value
def resolve_guardrails(value: GuardrailParam) -> Optional[GuardrailConfig]:
"""Resolve guardrails= parameter following precedence ladder."""
if value is None or value is False:
return None
if value is True:
return GuardrailConfig()
if callable(value):
return GuardrailConfig(validator=value)
if isinstance(value, dict):
return GuardrailConfig(**value)
if isinstance(value, GuardrailConfig):
return value
return value
def resolve_web(value: WebParam) -> Optional[WebConfig]:
"""Resolve web= parameter following precedence ladder."""
if value is None or value is False:
return None
if value is True:
return WebConfig()
if isinstance(value, dict):
return WebConfig(**value)
if isinstance(value, WebConfig):
return value
return value
def resolve_output(value: OutputParam) -> Optional[OutputConfig]:
"""Resolve output= parameter following precedence ladder."""
if value is None:
return None
if isinstance(value, str):
try:
preset = OutputPreset(value.lower())
return OutputConfig.from_preset(preset)
except ValueError:
return None
if isinstance(value, dict):
return OutputConfig(**value)
if isinstance(value, OutputConfig):
return value
return value
def resolve_execution(value: ExecutionParam) -> Optional[ExecutionConfig]:
"""Resolve execution= parameter following precedence ladder."""
if value is None:
return None
if isinstance(value, str):
try:
preset = ExecutionPreset(value.lower())
return ExecutionConfig.from_preset(preset)
except ValueError:
return None
if isinstance(value, dict):
return ExecutionConfig(**value)
if isinstance(value, ExecutionConfig):
return value
return value
def resolve_caching(value: CachingParam) -> Optional[CachingConfig]:
"""Resolve caching= parameter following precedence ladder."""
if value is None or value is False:
return None
if value is True:
return CachingConfig()
if isinstance(value, dict):
return CachingConfig(**value)
if isinstance(value, CachingConfig):
return value
return value
def resolve_autonomy(value: AutonomyParam) -> Optional[AutonomyConfig]:
"""Resolve autonomy= parameter following precedence ladder."""
if value is None or value is False:
return None
if value is True:
return AutonomyConfig()
if isinstance(value, dict):
return AutonomyConfig(**value)
if isinstance(value, AutonomyConfig):
return value
return value
__all__ = [
# Enums
"MemoryBackend",
"LearnScope",
"ChunkingStrategy",
"GuardrailAction",
"WebSearchProvider",
@@ -888,6 +1225,7 @@ __all__ = [
"ExecutionPreset",
"AutonomyLevel",
# Config classes (Agent)
"LearnConfig",
"MemoryConfig",
"KnowledgeConfig",
"PlanningConfig",
@@ -921,4 +1259,15 @@ __all__ = [
"HooksParam",
"SkillsParam",
"AutonomyParam",
# Precedence ladder resolvers
"resolve_memory",
"resolve_knowledge",
"resolve_planning",
"resolve_reflection",
"resolve_guardrails",
"resolve_web",
"resolve_output",
"resolve_execution",
"resolve_caching",
"resolve_autonomy",
]
@@ -157,6 +157,16 @@ __all__ = [
"FastContextResult",
"FileMatch",
"LineRange",
# Artifacts (Dynamic Context Discovery)
"ArtifactRef",
"ArtifactMetadata",
"GrepMatch",
"QueueConfig",
"ArtifactStoreProtocol",
"HistoryStoreProtocol",
"TerminalLoggerProtocol",
"compute_checksum",
"generate_summary",
]
@@ -252,4 +262,11 @@ def __getattr__(name: str):
from praisonaiagents.context.fast.result import LineRange
return LineRange
# Artifacts (Dynamic Context Discovery)
if name in ("ArtifactRef", "ArtifactMetadata", "GrepMatch", "QueueConfig",
"ArtifactStoreProtocol", "HistoryStoreProtocol", "TerminalLoggerProtocol",
"compute_checksum", "generate_summary"):
from . import artifacts
return getattr(artifacts, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,580 @@
"""
Artifact Types and Protocols for Dynamic Context Discovery.
This module provides lightweight types and protocols for artifact-based
context management. Implementations live in the wrapper (praisonai).
Zero Performance Impact:
- Pure dataclasses and protocols
- No heavy imports
- No I/O at import time
Usage:
from praisonaiagents.context.artifacts import ArtifactRef, ArtifactStoreProtocol
# Create a reference to a stored artifact
ref = ArtifactRef(
path="/path/to/artifact.json",
summary="API response with 50,000 records",
size_bytes=1_200_000,
mime_type="application/json",
)
# Implement the protocol in wrapper
class FileSystemArtifactStore:
def store(self, content, metadata): ...
def load(self, ref): ...
"""
import time
import hashlib
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Protocol, Union, runtime_checkable
@dataclass
class ArtifactRef:
"""
Reference to a stored artifact.
Used to replace large tool outputs in context with a lightweight pointer.
The agent can then use tail/grep/load operations to access content on demand.
Attributes:
path: Absolute path to the artifact file
summary: Brief description or first ~200 chars of content
size_bytes: Original content size in bytes
mime_type: Content type (e.g., "application/json", "text/plain")
checksum: SHA256 hash for integrity verification
created_at: Unix timestamp when artifact was created
artifact_id: Unique identifier for the artifact
agent_id: ID of the agent that created this artifact
run_id: ID of the run/session this artifact belongs to
tool_name: Name of the tool that produced this output (if applicable)
turn_id: Conversation turn number when artifact was created
"""
path: str
summary: str
size_bytes: int
mime_type: str = "application/octet-stream"
checksum: str = ""
created_at: float = field(default_factory=time.time)
artifact_id: str = ""
agent_id: str = ""
run_id: str = ""
tool_name: Optional[str] = None
turn_id: int = 0
def to_inline(self) -> str:
"""
Generate inline representation for context.
Returns a compact string that can be included in the conversation
context, allowing the agent to reference the artifact.
"""
size_str = self._format_size(self.size_bytes)
tool_info = f" from {self.tool_name}" if self.tool_name else ""
return f"[Artifact{tool_info}: {self.path} ({size_str}) - {self.summary}]"
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
return {
"path": self.path,
"summary": self.summary,
"size_bytes": self.size_bytes,
"mime_type": self.mime_type,
"checksum": self.checksum,
"created_at": self.created_at,
"artifact_id": self.artifact_id,
"agent_id": self.agent_id,
"run_id": self.run_id,
"tool_name": self.tool_name,
"turn_id": self.turn_id,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "ArtifactRef":
"""Create from dictionary."""
return cls(
path=data.get("path", ""),
summary=data.get("summary", ""),
size_bytes=data.get("size_bytes", 0),
mime_type=data.get("mime_type", "application/octet-stream"),
checksum=data.get("checksum", ""),
created_at=data.get("created_at", time.time()),
artifact_id=data.get("artifact_id", ""),
agent_id=data.get("agent_id", ""),
run_id=data.get("run_id", ""),
tool_name=data.get("tool_name"),
turn_id=data.get("turn_id", 0),
)
@staticmethod
def _format_size(size_bytes: int) -> str:
"""Format size in human-readable form."""
if size_bytes < 1024:
return f"{size_bytes} B"
elif size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.1f} KB"
elif size_bytes < 1024 * 1024 * 1024:
return f"{size_bytes / (1024 * 1024):.1f} MB"
else:
return f"{size_bytes / (1024 * 1024 * 1024):.1f} GB"
@dataclass
class ArtifactMetadata:
"""
Metadata for artifact creation and tracking.
Passed to ArtifactStore.store() to provide context about the artifact.
"""
agent_id: str = ""
run_id: str = ""
tool_name: Optional[str] = None
turn_id: int = 0
content_type: str = "auto" # auto, json, text, binary
tags: List[str] = field(default_factory=list)
extra: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
return {
"agent_id": self.agent_id,
"run_id": self.run_id,
"tool_name": self.tool_name,
"turn_id": self.turn_id,
"content_type": self.content_type,
"tags": self.tags,
"extra": self.extra,
}
@dataclass
class GrepMatch:
"""A single match from grep operation."""
line_number: int
line_content: str
context_before: List[str] = field(default_factory=list)
context_after: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {
"line_number": self.line_number,
"line_content": self.line_content,
"context_before": self.context_before,
"context_after": self.context_after,
}
@runtime_checkable
class ArtifactStoreProtocol(Protocol):
"""
Protocol for artifact storage backends.
Implementations should handle:
- Storing content to persistent storage
- Loading content back
- Efficient tail/grep operations for large files
- Checksum verification
- Secret redaction (if enabled)
Example implementation in wrapper:
class FileSystemArtifactStore:
def store(self, content, metadata):
# Write to ~/.praison/runs/{run_id}/artifacts/
...
"""
def store(
self,
content: Any,
metadata: ArtifactMetadata,
) -> ArtifactRef:
"""
Store content as an artifact.
Args:
content: The content to store (will be serialized)
metadata: Metadata about the artifact
Returns:
ArtifactRef pointing to the stored artifact
"""
...
def load(self, ref: ArtifactRef) -> Any:
"""
Load full content from an artifact.
Args:
ref: Reference to the artifact
Returns:
The deserialized content
"""
...
def tail(self, ref: ArtifactRef, lines: int = 50) -> str:
"""
Get the last N lines of an artifact.
Args:
ref: Reference to the artifact
lines: Number of lines to return
Returns:
String containing the last N lines
"""
...
def head(self, ref: ArtifactRef, lines: int = 50) -> str:
"""
Get the first N lines of an artifact.
Args:
ref: Reference to the artifact
lines: Number of lines to return
Returns:
String containing the first N lines
"""
...
def grep(
self,
ref: ArtifactRef,
pattern: str,
context_lines: int = 2,
max_matches: int = 50,
) -> List[GrepMatch]:
"""
Search for pattern in artifact content.
Args:
ref: Reference to the artifact
pattern: Regex pattern to search for
context_lines: Number of context lines before/after match
max_matches: Maximum number of matches to return
Returns:
List of GrepMatch objects
"""
...
def chunk(
self,
ref: ArtifactRef,
start_line: int = 1,
end_line: Optional[int] = None,
) -> str:
"""
Get a chunk of lines from an artifact.
Args:
ref: Reference to the artifact
start_line: Starting line number (1-indexed)
end_line: Ending line number (inclusive), None for end of file
Returns:
String containing the requested lines
"""
...
def delete(self, ref: ArtifactRef) -> bool:
"""
Delete an artifact.
Args:
ref: Reference to the artifact
Returns:
True if deleted successfully
"""
...
def list_artifacts(
self,
run_id: Optional[str] = None,
agent_id: Optional[str] = None,
tool_name: Optional[str] = None,
) -> List[ArtifactRef]:
"""
List artifacts matching filters.
Args:
run_id: Filter by run ID
agent_id: Filter by agent ID
tool_name: Filter by tool name
Returns:
List of matching ArtifactRef objects
"""
...
@runtime_checkable
class HistoryStoreProtocol(Protocol):
"""
Protocol for conversation history storage.
Enables loss recovery during summarization by persisting
full conversation history to artifacts.
"""
def append(
self,
message: Dict[str, Any],
agent_id: str,
run_id: str,
) -> None:
"""
Append a message to history.
Args:
message: The message dict (role, content, etc.)
agent_id: ID of the agent
run_id: ID of the run/session
"""
...
def get_ref(self, agent_id: str, run_id: str) -> Optional[ArtifactRef]:
"""
Get artifact reference for history file.
Args:
agent_id: ID of the agent
run_id: ID of the run/session
Returns:
ArtifactRef if history exists, None otherwise
"""
...
def search(
self,
query: str,
agent_id: str,
run_id: str,
max_results: int = 20,
) -> List[Dict[str, Any]]:
"""
Search history for matching messages.
Args:
query: Search query (supports regex)
agent_id: ID of the agent
run_id: ID of the run/session
max_results: Maximum results to return
Returns:
List of matching message dicts
"""
...
def get_messages(
self,
agent_id: str,
run_id: str,
start_turn: int = 0,
end_turn: Optional[int] = None,
) -> List[Dict[str, Any]]:
"""
Get messages from history.
Args:
agent_id: ID of the agent
run_id: ID of the run/session
start_turn: Starting turn number
end_turn: Ending turn number (None for all)
Returns:
List of message dicts
"""
...
@runtime_checkable
class TerminalLoggerProtocol(Protocol):
"""
Protocol for terminal session logging.
Captures shell command outputs to artifacts for later retrieval.
"""
def log_command(
self,
command: str,
output: str,
exit_code: int,
agent_id: str,
run_id: str,
) -> ArtifactRef:
"""
Log a command execution.
Args:
command: The command that was executed
output: Combined stdout/stderr output
exit_code: Command exit code
agent_id: ID of the agent
run_id: ID of the run/session
Returns:
ArtifactRef to the logged output
"""
...
def get_session_ref(self, agent_id: str, run_id: str) -> Optional[ArtifactRef]:
"""
Get artifact reference for terminal session log.
Args:
agent_id: ID of the agent
run_id: ID of the run/session
Returns:
ArtifactRef if session exists, None otherwise
"""
...
def tail_session(
self,
agent_id: str,
run_id: str,
lines: int = 100,
) -> str:
"""
Get last N lines from terminal session.
Args:
agent_id: ID of the agent
run_id: ID of the run/session
lines: Number of lines to return
Returns:
String containing the last N lines
"""
...
def grep_session(
self,
pattern: str,
agent_id: str,
run_id: str,
max_matches: int = 50,
) -> List[GrepMatch]:
"""
Search terminal session for pattern.
Args:
pattern: Regex pattern to search for
agent_id: ID of the agent
run_id: ID of the run/session
max_matches: Maximum matches to return
Returns:
List of GrepMatch objects
"""
...
@dataclass
class QueueConfig:
"""
Configuration for tool output queuing.
Controls when and how large tool outputs are queued to artifacts
instead of being included inline in the context.
"""
enabled: bool = True
inline_max_bytes: int = 32 * 1024 # 32KB default
redact_secrets: bool = True
summary_max_chars: int = 200
checksum_algorithm: str = "sha256"
# Secret patterns to redact (regex)
secret_patterns: List[str] = field(default_factory=lambda: [
r'(?i)(api[_-]?key|apikey)["\']?\s*[:=]\s*["\']?[\w\-]+',
r'(?i)(secret|password|passwd|pwd)["\']?\s*[:=]\s*["\']?[\w\-]+',
r'(?i)(token|bearer)["\']?\s*[:=]\s*["\']?[\w\-]+',
r'(?i)(auth|authorization)["\']?\s*[:=]\s*["\']?[\w\-]+',
r'sk-[a-zA-Z0-9]{20,}', # OpenAI keys
r'ghp_[a-zA-Z0-9]{36}', # GitHub tokens
r'gho_[a-zA-Z0-9]{36}', # GitHub OAuth
r'xox[baprs]-[a-zA-Z0-9\-]+', # Slack tokens
])
def to_dict(self) -> Dict[str, Any]:
return {
"enabled": self.enabled,
"inline_max_bytes": self.inline_max_bytes,
"redact_secrets": self.redact_secrets,
"summary_max_chars": self.summary_max_chars,
"checksum_algorithm": self.checksum_algorithm,
"secret_patterns": self.secret_patterns,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "QueueConfig":
return cls(
enabled=data.get("enabled", True),
inline_max_bytes=data.get("inline_max_bytes", 32 * 1024),
redact_secrets=data.get("redact_secrets", True),
summary_max_chars=data.get("summary_max_chars", 200),
checksum_algorithm=data.get("checksum_algorithm", "sha256"),
secret_patterns=data.get("secret_patterns", cls().secret_patterns),
)
def compute_checksum(content: Union[str, bytes], algorithm: str = "sha256") -> str:
"""
Compute checksum of content.
Args:
content: String or bytes to hash
algorithm: Hash algorithm (sha256, md5, etc.)
Returns:
Hex digest of the hash
"""
if isinstance(content, str):
content = content.encode("utf-8")
hasher = hashlib.new(algorithm)
hasher.update(content)
return hasher.hexdigest()
def generate_summary(content: Any, max_chars: int = 200) -> str:
"""
Generate a brief summary of content.
Args:
content: Content to summarize
max_chars: Maximum characters in summary
Returns:
Brief summary string
"""
if isinstance(content, (dict, list)):
# For structured data, describe the structure
if isinstance(content, dict):
keys = list(content.keys())[:5]
key_str = ", ".join(str(k) for k in keys)
if len(content) > 5:
key_str += f", ... ({len(content)} total keys)"
return f"Dict with keys: {key_str}"[:max_chars]
else:
if len(content) > 0:
first_type = type(content[0]).__name__
return f"List of {len(content)} {first_type} items"[:max_chars]
return "Empty list"
# For strings, take first N chars
text = str(content)
if len(text) <= max_chars:
return text
return text[:max_chars - 3] + "..."
@@ -68,8 +68,8 @@ class LLM:
MODEL_WINDOWS = {
# OpenAI
"gpt-4": 6144, # 8,192 actual
"gpt-5-nano": 96000, # 128,000 actual
"gpt-5-nano": 96000, # 128,000 actual
"gpt-4o-mini": 96000, # 128,000 actual
"gpt-4o-mini": 96000, # 128,000 actual
"gpt-4-turbo": 96000, # 128,000 actual
"o1-preview": 96000, # 128,000 actual
"o1-mini": 96000, # 128,000 actual
@@ -51,7 +51,7 @@ class ModelRouter:
DEFAULT_MODELS = [
# Lightweight/cheap models for simple tasks
ModelProfile(
name="gpt-5-nano",
name="gpt-4o-mini",
provider="openai",
complexity_range=(TaskComplexity.SIMPLE, TaskComplexity.MODERATE),
cost_per_1k_tokens=0.00075, # Average of $0.00015 input, $0.0006 output
@@ -80,7 +80,7 @@ class ModelRouter:
# Mid-tier models for moderate complexity
ModelProfile(
name="gpt-5-nano",
name="gpt-4o-mini",
provider="openai",
complexity_range=(TaskComplexity.MODERATE, TaskComplexity.COMPLEX),
cost_per_1k_tokens=0.0075, # Average of $0.0025 input, $0.01 output
@@ -145,7 +145,7 @@ class ModelRouter:
preferred_providers: List of preferred providers in order
"""
self.models = models or self.DEFAULT_MODELS
self.default_model = default_model or os.getenv('OPENAI_MODEL_NAME', 'gpt-5-nano')
self.default_model = default_model or os.getenv('OPENAI_MODEL_NAME', 'gpt-4o-mini')
self.cost_threshold = cost_threshold
self.preferred_providers = preferred_providers or []
@@ -988,7 +988,7 @@ class OpenAIClient:
def create_completion(
self,
messages: List[Dict[str, Any]],
model: str = "gpt-5-nano",
model: str = "gpt-4o-mini",
temperature: float = 1.0,
stream: bool = False,
tools: Optional[List[Dict[str, Any]]] = None,
@@ -1033,7 +1033,7 @@ class OpenAIClient:
async def acreate_completion(
self,
messages: List[Dict[str, Any]],
model: str = "gpt-5-nano",
model: str = "gpt-4o-mini",
temperature: float = 1.0,
stream: bool = False,
tools: Optional[List[Dict[str, Any]]] = None,
@@ -1078,7 +1078,7 @@ class OpenAIClient:
def chat_completion_with_tools(
self,
messages: List[Dict[str, Any]],
model: str = "gpt-5-nano",
model: str = "gpt-4o-mini",
temperature: float = 1.0,
tools: Optional[List[Any]] = None,
execute_tool_fn: Optional[Callable] = None,
@@ -1266,7 +1266,7 @@ class OpenAIClient:
async def achat_completion_with_tools(
self,
messages: List[Dict[str, Any]],
model: str = "gpt-5-nano",
model: str = "gpt-4o-mini",
temperature: float = 1.0,
tools: Optional[List[Any]] = None,
execute_tool_fn: Optional[Callable] = None,
@@ -1450,7 +1450,7 @@ class OpenAIClient:
def chat_completion_with_tools_stream(
self,
messages: List[Dict[str, Any]],
model: str = "gpt-5-nano",
model: str = "gpt-4o-mini",
temperature: float = 1.0,
tools: Optional[List[Any]] = None,
execute_tool_fn: Optional[Callable] = None,
@@ -1602,7 +1602,7 @@ class OpenAIClient:
self,
messages: List[Dict[str, Any]],
response_format: BaseModel,
model: str = "gpt-5-nano",
model: str = "gpt-4o-mini",
temperature: float = 1.0,
**kwargs
) -> Any:
@@ -1636,7 +1636,7 @@ class OpenAIClient:
self,
messages: List[Dict[str, Any]],
response_format: BaseModel,
model: str = "gpt-5-nano",
model: str = "gpt-4o-mini",
temperature: float = 1.0,
**kwargs
) -> Any:
@@ -162,7 +162,7 @@ class MCP:
# Method 1: Using command and args separately
agent = Agent(
instructions="You are a helpful assistant...",
llm="gpt-5-nano",
llm="gpt-4o-mini",
tools=MCP(
command="/path/to/python",
args=["/path/to/app.py"]
@@ -172,14 +172,14 @@ class MCP:
# Method 2: Using a single command string
agent = Agent(
instructions="You are a helpful assistant...",
llm="gpt-5-nano",
llm="gpt-4o-mini",
tools=MCP("/path/to/python /path/to/app.py")
)
# Method 3: Using an SSE endpoint
agent = Agent(
instructions="You are a helpful assistant...",
llm="gpt-5-nano",
llm="gpt-4o-mini",
tools=MCP("http://localhost:8080/sse")
)
@@ -548,7 +548,7 @@ class MCP:
"""Convert the MCP tool to an OpenAI-compatible tool definition.
This method is specifically invoked by the Agent class when using
provider/model format (e.g., "openai/gpt-5-nano").
provider/model format (e.g., "openai/gpt-4o-mini").
Returns:
dict or list: OpenAI-compatible tool definition(s)
@@ -110,6 +110,31 @@ def __getattr__(name):
if name == "create_hooks_manager":
from .hooks import create_hooks_manager
return create_hooks_manager
# Learn module
if name == "LearnManager":
from .learn import LearnManager
return LearnManager
if name == "PersonaStore":
from .learn import PersonaStore
return PersonaStore
if name == "InsightStore":
from .learn import InsightStore
return InsightStore
if name == "ThreadStore":
from .learn import ThreadStore
return ThreadStore
if name == "PatternStore":
from .learn import PatternStore
return PatternStore
if name == "DecisionStore":
from .learn import DecisionStore
return DecisionStore
if name == "FeedbackStore":
from .learn import FeedbackStore
return FeedbackStore
if name == "ImprovementStore":
from .learn import ImprovementStore
return ImprovementStore
# DocsManager and MCPConfigManager are already imported at module level
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -153,4 +178,13 @@ __all__ = [
# MCP config management
"MCPConfigManager",
"MCPConfig",
# Learn module
"LearnManager",
"PersonaStore",
"InsightStore",
"ThreadStore",
"PatternStore",
"DecisionStore",
"FeedbackStore",
"ImprovementStore",
]
@@ -0,0 +1,61 @@
"""
Learn Module - Continuous Learning within Memory System.
Provides learning capabilities for agents to capture patterns, preferences,
and insights from interactions to improve future responses.
Usage:
from praisonaiagents import Agent, MemoryConfig, LearnConfig
# Simple enable
agent = Agent(memory=MemoryConfig(learn=True))
# With specific capabilities
agent = Agent(memory=MemoryConfig(
learn=LearnConfig(
persona=True,
insights=True,
patterns=True,
)
))
"""
__all__ = [
"LearnManager",
"PersonaStore",
"InsightStore",
"ThreadStore",
"PatternStore",
"DecisionStore",
"FeedbackStore",
"ImprovementStore",
]
def __getattr__(name: str):
"""Lazy loading for learn module components."""
if name == "LearnManager":
from .manager import LearnManager
return LearnManager
if name == "PersonaStore":
from .stores import PersonaStore
return PersonaStore
if name == "InsightStore":
from .stores import InsightStore
return InsightStore
if name == "ThreadStore":
from .stores import ThreadStore
return ThreadStore
if name == "PatternStore":
from .stores import PatternStore
return PatternStore
if name == "DecisionStore":
from .stores import DecisionStore
return DecisionStore
if name == "FeedbackStore":
from .stores import FeedbackStore
return FeedbackStore
if name == "ImprovementStore":
from .stores import ImprovementStore
return ImprovementStore
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,241 @@
"""
LearnManager - Central manager for continuous learning capabilities.
Coordinates all learning stores and provides a unified interface for
capturing and retrieving learned information.
"""
from typing import Any, Dict, List, Optional
from ...config.feature_configs import LearnConfig, LearnScope
from .stores import (
PersonaStore,
InsightStore,
ThreadStore,
PatternStore,
DecisionStore,
FeedbackStore,
ImprovementStore,
LearnEntry,
)
class LearnManager:
"""
Central manager for continuous learning within the memory system.
Coordinates all learning stores based on LearnConfig settings.
Usage:
config = LearnConfig(persona=True, insights=True)
manager = LearnManager(config, user_id="alice")
# Capture learning
manager.capture_persona("User prefers concise responses")
manager.capture_insight("User works in data science")
# Retrieve learnings
context = manager.get_learning_context()
"""
def __init__(
self,
config: Optional[LearnConfig] = None,
user_id: Optional[str] = None,
store_path: Optional[str] = None,
):
self.config = config or LearnConfig()
self.user_id = user_id or "default"
self.store_path = store_path or self.config.store_path
scope = self.config.scope
if isinstance(scope, LearnScope):
scope = scope.value
self._stores: Dict[str, Any] = {}
if self.config.persona:
self._stores["persona"] = PersonaStore(
store_path=self._get_store_path("persona"),
user_id=self.user_id,
scope=scope,
)
if self.config.insights:
self._stores["insights"] = InsightStore(
store_path=self._get_store_path("insights"),
user_id=self.user_id,
scope=scope,
)
if self.config.thread:
self._stores["threads"] = ThreadStore(
store_path=self._get_store_path("threads"),
user_id=self.user_id,
scope=scope,
)
if self.config.patterns:
self._stores["patterns"] = PatternStore(
store_path=self._get_store_path("patterns"),
user_id=self.user_id,
scope=scope,
)
if self.config.decisions:
self._stores["decisions"] = DecisionStore(
store_path=self._get_store_path("decisions"),
user_id=self.user_id,
scope=scope,
)
if self.config.feedback:
self._stores["feedback"] = FeedbackStore(
store_path=self._get_store_path("feedback"),
user_id=self.user_id,
scope=scope,
)
if self.config.improvements:
self._stores["improvements"] = ImprovementStore(
store_path=self._get_store_path("improvements"),
user_id=self.user_id,
scope=scope,
)
def _get_store_path(self, store_name: str) -> Optional[str]:
"""Get path for a specific store."""
if self.store_path:
return f"{self.store_path}/{store_name}.json"
return None
def capture_persona(self, content: str, category: str = "general") -> Optional[LearnEntry]:
"""Capture user preference or profile information."""
if "persona" in self._stores:
return self._stores["persona"].add_preference(content, category)
return None
def capture_insight(self, content: str, source: str = "interaction") -> Optional[LearnEntry]:
"""Capture an observation or learning."""
if "insights" in self._stores:
return self._stores["insights"].add_insight(content, source)
return None
def capture_thread(self, summary: str, thread_id: str) -> Optional[LearnEntry]:
"""Capture thread/session context."""
if "threads" in self._stores:
return self._stores["threads"].add_thread_summary(summary, thread_id)
return None
def capture_pattern(self, pattern: str, pattern_type: str = "general") -> Optional[LearnEntry]:
"""Capture a reusable knowledge pattern."""
if "patterns" in self._stores:
return self._stores["patterns"].add_pattern(pattern, pattern_type)
return None
def capture_decision(
self,
decision: str,
reasoning: str = "",
outcome: str = "",
) -> Optional[LearnEntry]:
"""Capture a decision record."""
if "decisions" in self._stores:
return self._stores["decisions"].add_decision(decision, reasoning, outcome)
return None
def capture_feedback(
self,
feedback: str,
rating: Optional[int] = None,
source: str = "user",
) -> Optional[LearnEntry]:
"""Capture feedback."""
if "feedback" in self._stores:
return self._stores["feedback"].add_feedback(feedback, rating, source)
return None
def capture_improvement(
self,
proposal: str,
area: str = "general",
priority: str = "medium",
) -> Optional[LearnEntry]:
"""Capture an improvement proposal."""
if "improvements" in self._stores:
return self._stores["improvements"].add_improvement(proposal, area, priority)
return None
def get_learning_context(self, limit_per_store: int = 5) -> Dict[str, List[Dict[str, Any]]]:
"""
Get learning context from all enabled stores.
Returns a dictionary with entries from each store, suitable for
injection into agent context.
"""
context = {}
for store_name, store in self._stores.items():
entries = store.list_all(limit=limit_per_store)
context[store_name] = [e.to_dict() for e in entries]
return context
def search(self, query: str, limit: int = 10) -> Dict[str, List[Dict[str, Any]]]:
"""Search across all enabled stores."""
results = {}
for store_name, store in self._stores.items():
entries = store.search(query, limit=limit)
if entries:
results[store_name] = [e.to_dict() for e in entries]
return results
def get_persona_context(self, limit: int = 10) -> List[str]:
"""Get persona entries as context strings."""
if "persona" not in self._stores:
return []
entries = self._stores["persona"].list_all(limit=limit)
return [e.content for e in entries]
def get_insights_context(self, limit: int = 10) -> List[str]:
"""Get insight entries as context strings."""
if "insights" not in self._stores:
return []
entries = self._stores["insights"].list_all(limit=limit)
return [e.content for e in entries]
def clear_all(self) -> Dict[str, int]:
"""Clear all stores."""
cleared = {}
for store_name, store in self._stores.items():
cleared[store_name] = store.clear()
return cleared
def get_stats(self) -> Dict[str, int]:
"""Get entry counts for all stores."""
return {
store_name: len(store._entries)
for store_name, store in self._stores.items()
}
def to_system_prompt_context(self) -> str:
"""
Generate context suitable for injection into system prompt.
Returns a formatted string with relevant learnings.
"""
parts = []
if "persona" in self._stores:
personas = self.get_persona_context(limit=5)
if personas:
parts.append("User Preferences:")
for p in personas:
parts.append(f"- {p}")
if "insights" in self._stores:
insights = self.get_insights_context(limit=5)
if insights:
parts.append("\nLearned Insights:")
for i in insights:
parts.append(f"- {i}")
return "\n".join(parts) if parts else ""
@@ -0,0 +1,279 @@
"""
Learning Stores - Storage backends for different learning capabilities.
Each store handles a specific type of learning data:
- PersonaStore: User preferences and profile
- InsightStore: Observations and learnings
- ThreadStore: Session/conversation context
- PatternStore: Reusable knowledge patterns
- DecisionStore: Decision logging
- FeedbackStore: Outcome signals
- ImprovementStore: Self-improvement proposals
"""
import json
import os
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
@dataclass
class LearnEntry:
"""Base entry for all learning stores."""
id: str
content: str
metadata: Dict[str, Any] = field(default_factory=dict)
created_at: str = field(default_factory=lambda: datetime.utcnow().isoformat())
updated_at: str = field(default_factory=lambda: datetime.utcnow().isoformat())
def to_dict(self) -> Dict[str, Any]:
return {
"id": self.id,
"content": self.content,
"metadata": self.metadata,
"created_at": self.created_at,
"updated_at": self.updated_at,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "LearnEntry":
return cls(
id=data["id"],
content=data["content"],
metadata=data.get("metadata", {}),
created_at=data.get("created_at", datetime.utcnow().isoformat()),
updated_at=data.get("updated_at", datetime.utcnow().isoformat()),
)
class BaseStore(ABC):
"""Abstract base class for learning stores."""
def __init__(
self,
store_path: Optional[str] = None,
user_id: Optional[str] = None,
scope: str = "private",
):
self.user_id = user_id or "default"
self.scope = scope
self.store_path = store_path or self._default_path()
self._entries: Dict[str, LearnEntry] = {}
self._load()
@property
@abstractmethod
def store_name(self) -> str:
"""Name of the store (used for file naming)."""
pass
def _default_path(self) -> str:
"""Default storage path."""
base = Path.home() / ".praison" / "learn" / self.scope / self.user_id
base.mkdir(parents=True, exist_ok=True)
return str(base / f"{self.store_name}.json")
def _load(self) -> None:
"""Load entries from storage."""
if os.path.exists(self.store_path):
try:
with open(self.store_path, "r") as f:
data = json.load(f)
self._entries = {
k: LearnEntry.from_dict(v) for k, v in data.items()
}
except (json.JSONDecodeError, IOError):
self._entries = {}
def _save(self) -> None:
"""Save entries to storage."""
os.makedirs(os.path.dirname(self.store_path), exist_ok=True)
with open(self.store_path, "w") as f:
json.dump(
{k: v.to_dict() for k, v in self._entries.items()},
f,
indent=2,
)
def add(self, content: str, metadata: Optional[Dict[str, Any]] = None) -> LearnEntry:
"""Add a new entry."""
entry_id = f"{self.store_name}_{len(self._entries)}_{datetime.utcnow().timestamp()}"
entry = LearnEntry(
id=entry_id,
content=content,
metadata=metadata or {},
)
self._entries[entry_id] = entry
self._save()
return entry
def get(self, entry_id: str) -> Optional[LearnEntry]:
"""Get entry by ID."""
return self._entries.get(entry_id)
def search(self, query: str, limit: int = 10) -> List[LearnEntry]:
"""Simple text search (can be overridden for semantic search)."""
query_lower = query.lower()
results = [
entry for entry in self._entries.values()
if query_lower in entry.content.lower()
]
return results[:limit]
def list_all(self, limit: int = 100) -> List[LearnEntry]:
"""List all entries."""
entries = list(self._entries.values())
entries.sort(key=lambda x: x.updated_at, reverse=True)
return entries[:limit]
def update(self, entry_id: str, content: str, metadata: Optional[Dict[str, Any]] = None) -> Optional[LearnEntry]:
"""Update an existing entry."""
if entry_id not in self._entries:
return None
entry = self._entries[entry_id]
entry.content = content
if metadata:
entry.metadata.update(metadata)
entry.updated_at = datetime.utcnow().isoformat()
self._save()
return entry
def delete(self, entry_id: str) -> bool:
"""Delete an entry."""
if entry_id in self._entries:
del self._entries[entry_id]
self._save()
return True
return False
def clear(self) -> int:
"""Clear all entries."""
count = len(self._entries)
self._entries = {}
self._save()
return count
class PersonaStore(BaseStore):
"""Store for user preferences and profile information."""
@property
def store_name(self) -> str:
return "persona"
def add_preference(self, preference: str, category: str = "general") -> LearnEntry:
"""Add a user preference."""
return self.add(preference, {"category": category, "type": "preference"})
def add_profile(self, profile_data: str, aspect: str = "general") -> LearnEntry:
"""Add profile information."""
return self.add(profile_data, {"aspect": aspect, "type": "profile"})
class InsightStore(BaseStore):
"""Store for observations and learnings."""
@property
def store_name(self) -> str:
return "insights"
def add_insight(self, insight: str, source: str = "interaction") -> LearnEntry:
"""Add an insight."""
return self.add(insight, {"source": source, "type": "insight"})
def add_observation(self, observation: str, context: str = "") -> LearnEntry:
"""Add an observation."""
return self.add(observation, {"context": context, "type": "observation"})
class ThreadStore(BaseStore):
"""Store for session/conversation context."""
@property
def store_name(self) -> str:
return "threads"
def add_thread_summary(self, summary: str, thread_id: str) -> LearnEntry:
"""Add a thread summary."""
return self.add(summary, {"thread_id": thread_id, "type": "summary"})
def add_context(self, context: str, thread_id: str) -> LearnEntry:
"""Add thread context."""
return self.add(context, {"thread_id": thread_id, "type": "context"})
class PatternStore(BaseStore):
"""Store for reusable knowledge patterns."""
@property
def store_name(self) -> str:
return "patterns"
def add_pattern(self, pattern: str, pattern_type: str = "general") -> LearnEntry:
"""Add a knowledge pattern."""
return self.add(pattern, {"pattern_type": pattern_type, "type": "pattern"})
class DecisionStore(BaseStore):
"""Store for decision logging."""
@property
def store_name(self) -> str:
return "decisions"
def add_decision(
self,
decision: str,
reasoning: str = "",
outcome: str = "",
) -> LearnEntry:
"""Add a decision record."""
return self.add(decision, {
"reasoning": reasoning,
"outcome": outcome,
"type": "decision",
})
class FeedbackStore(BaseStore):
"""Store for outcome signals and feedback."""
@property
def store_name(self) -> str:
return "feedback"
def add_feedback(
self,
feedback: str,
rating: Optional[int] = None,
source: str = "user",
) -> LearnEntry:
"""Add feedback."""
metadata = {"source": source, "type": "feedback"}
if rating is not None:
metadata["rating"] = rating
return self.add(feedback, metadata)
class ImprovementStore(BaseStore):
"""Store for self-improvement proposals."""
@property
def store_name(self) -> str:
return "improvements"
def add_improvement(
self,
proposal: str,
area: str = "general",
priority: str = "medium",
) -> LearnEntry:
"""Add an improvement proposal."""
return self.add(proposal, {
"area": area,
"priority": priority,
"type": "improvement",
})
@@ -109,7 +109,7 @@ class Memory:
},
"llm": {
"provider": "openai",
"config": {"model": "gpt-5-nano", "api_key": "..."}
"config": {"model": "gpt-4o-mini", "api_key": "..."}
},
"embedder": {
"provider": "openai",
@@ -146,6 +146,8 @@ class Memory:
self.use_rag = (self.provider.lower() == "rag") and CHROMADB_AVAILABLE and self.cfg.get("use_embedding", False)
self.use_mongodb = (self.provider.lower() == "mongodb") and PYMONGO_AVAILABLE
self.graph_enabled = False # Initialize graph support flag
self._learn_manager = None # Lazy-loaded LearnManager
self._learn_config = self.cfg.get("learn", None) # Learn configuration
# Extract embedding model from config
self.embedder_config = self.cfg.get("embedder", {})
@@ -1442,7 +1444,7 @@ class Memory:
import litellm
# Convert model name if it's in litellm format
model_name = llm or "gpt-5-nano"
model_name = llm or "gpt-4o-mini"
response = litellm.completion(
model=model_name,
@@ -1459,7 +1461,7 @@ class Memory:
client = OpenAI()
response = client.chat.completions.create(
model=llm or "gpt-5-nano",
model=llm or "gpt-4o-mini",
messages=[{
"role": "user",
"content": custom_prompt or default_prompt
@@ -1599,3 +1601,50 @@ class Memory:
except Exception as e:
self._log_verbose(f"Error getting all memories: {e}", logging.ERROR)
return []
# -------------------------------------------------------------------------
# Learn Integration
# -------------------------------------------------------------------------
@property
def learn(self):
"""
Get the LearnManager for continuous learning capabilities.
Returns None if learn is not enabled in config.
Usage:
memory = Memory({"learn": True})
memory.learn.capture_persona("User prefers concise responses")
memory.learn.capture_insight("User works in data science")
"""
if self._learn_manager is not None:
return self._learn_manager
if self._learn_config is None or self._learn_config is False:
return None
from .learn import LearnManager
from ..config.feature_configs import LearnConfig
if self._learn_config is True:
config = LearnConfig()
elif isinstance(self._learn_config, dict):
config = LearnConfig(**self._learn_config)
elif isinstance(self._learn_config, LearnConfig):
config = self._learn_config
else:
return None
user_id = self.cfg.get("user_id", "default")
self._learn_manager = LearnManager(config=config, user_id=user_id)
return self._learn_manager
def get_learn_context(self) -> str:
"""
Get learning context suitable for injection into system prompt.
Returns empty string if learn is not enabled.
"""
if self.learn is None:
return ""
return self.learn.to_system_prompt_context()
@@ -326,7 +326,7 @@ class Task:
if hasattr(self.agent.llm_instance, 'model'):
llm_model = self.agent.llm_instance.model
else:
llm_model = "gpt-5-nano" # Default fallback
llm_model = "gpt-4o-mini" # Default fallback
elif hasattr(self.agent, 'llm') and self.agent.llm:
# For standard model strings
llm_model = self.agent.llm
@@ -27,7 +27,7 @@ class GenerateCOT:
def __init__(
self,
qa_pairs: Optional[Dict[str, str]] = None,
model: str = "gpt-5-nano",
model: str = "gpt-4o-mini",
api_key: Optional[str] = None,
max_attempts: int = 3,
verbose: bool = True,
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "0.11.6"
version = "0.11.8"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -0,0 +1,156 @@
"""
Unit tests for Agent output parameter with verbose preset.
Tests the fix for the bug where output="verbose" would return None
because reasoning_steps=True in the verbose preset was causing the code
to try to return reasoning_content, but this attribute exists as None
for non-reasoning models.
"""
import pytest
from unittest.mock import Mock
class TestReasoningContentLogic:
"""Test the reasoning_content check logic directly."""
def test_condition_with_none_reasoning_content(self):
"""Verify the fix: condition should be False when reasoning_content is None."""
mock_message = Mock()
mock_message.reasoning_content = None
reasoning_steps = True
# Old buggy condition:
# if reasoning_steps and hasattr(mock_message, 'reasoning_content'):
old_condition = reasoning_steps and hasattr(mock_message, 'reasoning_content')
assert old_condition == True, "Old condition would be True (the bug)"
# Fixed condition:
# if reasoning_steps and hasattr(mock_message, 'reasoning_content') and mock_message.reasoning_content:
# The truthiness of the whole expression should be False
fixed_condition = reasoning_steps and hasattr(mock_message, 'reasoning_content') and mock_message.reasoning_content
assert not fixed_condition, "Fixed condition should be falsy when reasoning_content is None"
def test_condition_with_empty_string_reasoning_content(self):
"""Verify the fix: condition should be False when reasoning_content is empty string."""
mock_message = Mock()
mock_message.reasoning_content = ""
reasoning_steps = True
# Old condition would True (bug)
old_condition = reasoning_steps and hasattr(mock_message, 'reasoning_content')
assert old_condition == True
# Fixed condition should be falsy
fixed_condition = reasoning_steps and hasattr(mock_message, 'reasoning_content') and mock_message.reasoning_content
assert not fixed_condition, "Fixed condition should be falsy when reasoning_content is empty string"
def test_condition_with_actual_reasoning_content(self):
"""Verify the fix: condition should be True when reasoning_content has content."""
mock_message = Mock()
mock_message.reasoning_content = "This is my reasoning process..."
reasoning_steps = True
# Both old and new condition should be truthy
old_condition = reasoning_steps and hasattr(mock_message, 'reasoning_content')
assert old_condition
fixed_condition = reasoning_steps and hasattr(mock_message, 'reasoning_content') and mock_message.reasoning_content
assert fixed_condition, "Fixed condition should be truthy when reasoning_content has content"
def test_condition_when_reasoning_steps_false(self):
"""Verify that when reasoning_steps is False, condition is always False."""
mock_message = Mock()
mock_message.reasoning_content = "This is my reasoning process..."
reasoning_steps = False
fixed_condition = reasoning_steps and hasattr(mock_message, 'reasoning_content') and mock_message.reasoning_content
assert not fixed_condition, "Condition should be falsy when reasoning_steps is False"
def test_if_statement_behavior_with_none(self):
"""Verify that an if statement using the condition skips when reasoning_content is None."""
mock_message = Mock()
mock_message.reasoning_content = None
reasoning_steps = True
execution_path = "not_taken"
# This is how the fixed code behaves
if reasoning_steps and hasattr(mock_message, 'reasoning_content') and mock_message.reasoning_content:
execution_path = "reasoning_path"
else:
execution_path = "regular_path"
assert execution_path == "regular_path", "Should take regular path when reasoning_content is None"
def test_if_statement_behavior_with_reasoning(self):
"""Verify that an if statement using the condition enters when reasoning_content has value."""
mock_message = Mock()
mock_message.reasoning_content = "Reasoning steps here..."
reasoning_steps = True
execution_path = "not_taken"
if reasoning_steps and hasattr(mock_message, 'reasoning_content') and mock_message.reasoning_content:
execution_path = "reasoning_path"
else:
execution_path = "regular_path"
assert execution_path == "reasoning_path", "Should take reasoning path when reasoning_content has value"
class TestVerbosePresetConfig:
"""Test that the verbose preset has the expected configuration."""
def test_verbose_preset_has_reasoning_steps_true(self):
"""Verify that the verbose preset enables reasoning_steps."""
from praisonaiagents.config.presets import OUTPUT_PRESETS
verbose_preset = OUTPUT_PRESETS.get("verbose")
assert verbose_preset is not None, "verbose preset should exist"
assert verbose_preset.get("reasoning_steps") == True, "verbose preset should have reasoning_steps=True"
def test_verbose_preset_has_verbose_true(self):
"""Verify that the verbose preset enables verbose output."""
from praisonaiagents.config.presets import OUTPUT_PRESETS
verbose_preset = OUTPUT_PRESETS.get("verbose")
assert verbose_preset.get("verbose") == True, "verbose preset should have verbose=True"
def test_verbose_preset_has_stream_false(self):
"""Verify that the verbose preset does not enable streaming by default."""
from praisonaiagents.config.presets import OUTPUT_PRESETS
verbose_preset = OUTPUT_PRESETS.get("verbose")
assert verbose_preset.get("stream") == False, "verbose preset should have stream=False"
class TestChatCompletionMessageDataclass:
"""Test the ChatCompletionMessage dataclass has reasoning_content attribute."""
def test_chat_completion_message_has_reasoning_content(self):
"""Verify ChatCompletionMessage has reasoning_content attribute that defaults to None."""
from praisonaiagents.llm.openai_client import ChatCompletionMessage
# Create without reasoning_content
message = ChatCompletionMessage(content="Test", role="assistant")
# Should have the attribute
assert hasattr(message, 'reasoning_content')
# Should default to None
assert message.reasoning_content is None
def test_chat_completion_message_with_reasoning(self):
"""Verify ChatCompletionMessage can have reasoning_content set."""
from praisonaiagents.llm.openai_client import ChatCompletionMessage
message = ChatCompletionMessage(
content="Test",
role="assistant",
reasoning_content="This is my reasoning..."
)
assert message.reasoning_content == "This is my reasoning..."
@@ -0,0 +1,104 @@
"""
Tests for LearnScope enum - verifying PRIVATE/SHARED values only.
These tests verify that:
1. LearnScope only has PRIVATE and SHARED values
2. TEAM and ORG values do NOT exist
3. LearnConfig defaults to PRIVATE scope
4. BaseStore defaults to "private" scope
"""
import pytest
class TestLearnScopeValues:
"""Test LearnScope enum has exactly the right values."""
def test_learn_scope_has_private(self):
"""LearnScope should have PRIVATE value."""
from praisonaiagents.config.feature_configs import LearnScope
assert hasattr(LearnScope, 'PRIVATE')
assert LearnScope.PRIVATE.value == "private"
def test_learn_scope_has_shared(self):
"""LearnScope should have SHARED value."""
from praisonaiagents.config.feature_configs import LearnScope
assert hasattr(LearnScope, 'SHARED')
assert LearnScope.SHARED.value == "shared"
def test_learn_scope_does_not_have_team(self):
"""LearnScope should NOT have TEAM value."""
from praisonaiagents.config.feature_configs import LearnScope
assert not hasattr(LearnScope, 'TEAM'), "TEAM should be removed from LearnScope"
def test_learn_scope_does_not_have_org(self):
"""LearnScope should NOT have ORG value."""
from praisonaiagents.config.feature_configs import LearnScope
assert not hasattr(LearnScope, 'ORG'), "ORG should be removed from LearnScope"
def test_learn_scope_does_not_have_user(self):
"""LearnScope should NOT have USER value (replaced by PRIVATE)."""
from praisonaiagents.config.feature_configs import LearnScope
assert not hasattr(LearnScope, 'USER'), "USER should be replaced by PRIVATE"
def test_learn_scope_does_not_have_global(self):
"""LearnScope should NOT have GLOBAL value (replaced by SHARED)."""
from praisonaiagents.config.feature_configs import LearnScope
assert not hasattr(LearnScope, 'GLOBAL'), "GLOBAL should be replaced by SHARED"
def test_learn_scope_has_exactly_two_values(self):
"""LearnScope should have exactly 2 values: PRIVATE and SHARED."""
from praisonaiagents.config.feature_configs import LearnScope
all_values = list(LearnScope)
assert len(all_values) == 2, f"Expected 2 values, got {len(all_values)}: {all_values}"
class TestLearnConfigDefaults:
"""Test LearnConfig uses correct default scope."""
def test_learn_config_default_scope_is_private(self):
"""LearnConfig should default to PRIVATE scope."""
from praisonaiagents.config.feature_configs import LearnConfig, LearnScope
config = LearnConfig()
assert config.scope == LearnScope.PRIVATE
def test_learn_config_scope_can_be_set_to_shared(self):
"""LearnConfig scope can be set to SHARED."""
from praisonaiagents.config.feature_configs import LearnConfig, LearnScope
config = LearnConfig(scope=LearnScope.SHARED)
assert config.scope == LearnScope.SHARED
def test_learn_config_to_dict_includes_scope(self):
"""LearnConfig.to_dict() should include scope with correct value."""
from praisonaiagents.config.feature_configs import LearnConfig, LearnScope
config = LearnConfig()
d = config.to_dict()
assert d['scope'] == 'private'
config2 = LearnConfig(scope=LearnScope.SHARED)
d2 = config2.to_dict()
assert d2['scope'] == 'shared'
class TestBaseStoreDefaults:
"""Test BaseStore uses correct default scope."""
def test_base_store_default_scope_is_private(self):
"""BaseStore should default to 'private' scope."""
import inspect
from praisonaiagents.memory.learn.stores import BaseStore
sig = inspect.signature(BaseStore.__init__)
scope_param = sig.parameters.get('scope')
assert scope_param is not None
assert scope_param.default == 'private', f"Expected 'private', got '{scope_param.default}'"
@@ -0,0 +1,237 @@
"""Tests for precedence ladder resolvers."""
import pytest
from praisonaiagents.config.feature_configs import (
MemoryConfig,
MemoryBackend,
KnowledgeConfig,
PlanningConfig,
ReflectionConfig,
GuardrailConfig,
WebConfig,
OutputConfig,
ExecutionConfig,
CachingConfig,
AutonomyConfig,
resolve_memory,
resolve_knowledge,
resolve_planning,
resolve_reflection,
resolve_guardrails,
resolve_web,
resolve_output,
resolve_execution,
resolve_caching,
resolve_autonomy,
)
class TestResolveMemory:
"""Test resolve_memory precedence ladder."""
def test_default_none(self):
"""Default (None) returns None."""
assert resolve_memory(None) is None
def test_bool_false(self):
"""False returns None (disabled)."""
assert resolve_memory(False) is None
def test_bool_true(self):
"""True returns MemoryConfig with defaults."""
result = resolve_memory(True)
assert isinstance(result, MemoryConfig)
assert result.backend == MemoryBackend.FILE
def test_string_backend(self):
"""String returns MemoryConfig with that backend."""
result = resolve_memory("redis")
assert isinstance(result, MemoryConfig)
assert result.backend == MemoryBackend.REDIS
def test_string_custom_backend(self):
"""Custom string backend is preserved."""
result = resolve_memory("custom_backend")
assert isinstance(result, MemoryConfig)
assert result.backend == "custom_backend"
def test_dict_expansion(self):
"""Dict expands to MemoryConfig."""
result = resolve_memory({
"backend": "sqlite",
"user_id": "alice",
"auto_memory": True,
})
assert isinstance(result, MemoryConfig)
assert result.backend == MemoryBackend.SQLITE
assert result.user_id == "alice"
assert result.auto_memory is True
def test_config_passthrough(self):
"""MemoryConfig passes through unchanged."""
config = MemoryConfig(backend=MemoryBackend.POSTGRES, user_id="bob")
result = resolve_memory(config)
assert result is config
def test_instance_passthrough(self):
"""Instance (non-config object) passes through."""
class MockMemoryManager:
pass
instance = MockMemoryManager()
result = resolve_memory(instance)
assert result is instance
class TestResolveKnowledge:
"""Test resolve_knowledge precedence ladder."""
def test_default_none(self):
assert resolve_knowledge(None) is None
def test_bool_false(self):
assert resolve_knowledge(False) is None
def test_bool_true(self):
result = resolve_knowledge(True)
assert isinstance(result, KnowledgeConfig)
def test_string_single_source(self):
result = resolve_knowledge("docs/")
assert isinstance(result, KnowledgeConfig)
assert result.sources == ["docs/"]
def test_array_sources(self):
result = resolve_knowledge(["docs/", "data.pdf"])
assert isinstance(result, KnowledgeConfig)
assert result.sources == ["docs/", "data.pdf"]
def test_dict_expansion(self):
result = resolve_knowledge({
"sources": ["docs/"],
"rerank": True,
})
assert isinstance(result, KnowledgeConfig)
assert result.sources == ["docs/"]
assert result.rerank is True
def test_config_passthrough(self):
config = KnowledgeConfig(sources=["test/"])
result = resolve_knowledge(config)
assert result is config
class TestResolvePlanning:
"""Test resolve_planning precedence ladder."""
def test_default_none(self):
assert resolve_planning(None) is None
def test_bool_false(self):
assert resolve_planning(False) is None
def test_bool_true(self):
result = resolve_planning(True)
assert isinstance(result, PlanningConfig)
def test_config_passthrough(self):
config = PlanningConfig()
result = resolve_planning(config)
assert result is config
class TestResolveReflection:
"""Test resolve_reflection precedence ladder."""
def test_default_none(self):
assert resolve_reflection(None) is None
def test_bool_false(self):
assert resolve_reflection(False) is None
def test_bool_true(self):
result = resolve_reflection(True)
assert isinstance(result, ReflectionConfig)
class TestResolveGuardrails:
"""Test resolve_guardrails precedence ladder."""
def test_default_none(self):
assert resolve_guardrails(None) is None
def test_bool_false(self):
assert resolve_guardrails(False) is None
def test_bool_true(self):
result = resolve_guardrails(True)
assert isinstance(result, GuardrailConfig)
def test_callable_validator(self):
def my_validator(output):
return True, output
result = resolve_guardrails(my_validator)
assert isinstance(result, GuardrailConfig)
assert result.validator is my_validator
class TestResolveWeb:
"""Test resolve_web precedence ladder."""
def test_default_none(self):
assert resolve_web(None) is None
def test_bool_false(self):
assert resolve_web(False) is None
def test_bool_true(self):
result = resolve_web(True)
assert isinstance(result, WebConfig)
class TestResolveCaching:
"""Test resolve_caching precedence ladder."""
def test_default_none(self):
assert resolve_caching(None) is None
def test_bool_false(self):
assert resolve_caching(False) is None
def test_bool_true(self):
result = resolve_caching(True)
assert isinstance(result, CachingConfig)
class TestResolveAutonomy:
"""Test resolve_autonomy precedence ladder."""
def test_default_none(self):
assert resolve_autonomy(None) is None
def test_bool_false(self):
assert resolve_autonomy(False) is None
def test_bool_true(self):
result = resolve_autonomy(True)
assert isinstance(result, AutonomyConfig)
class TestNoCortexParam:
"""Test that cortex parameter does not exist."""
def test_agent_has_no_cortex_param(self):
"""Agent.__init__ should not have cortex parameter."""
import inspect
from praisonaiagents import Agent
sig = inspect.signature(Agent.__init__)
param_names = list(sig.parameters.keys())
assert "cortex" not in param_names, "cortex param should be removed from Agent"
def test_no_cortex_module_in_agents(self):
"""praisonaiagents should not have cortex module."""
import importlib.util
spec = importlib.util.find_spec("praisonaiagents.cortex")
assert spec is None, "praisonaiagents.cortex module should not exist"
@@ -0,0 +1,292 @@
"""Tests for the learn module - continuous learning within memory system."""
import os
import tempfile
import shutil
from pathlib import Path
from praisonaiagents.config.feature_configs import LearnConfig, LearnScope, MemoryConfig
from praisonaiagents.memory.learn.stores import (
PersonaStore,
InsightStore,
ThreadStore,
PatternStore,
DecisionStore,
FeedbackStore,
ImprovementStore,
LearnEntry,
)
from praisonaiagents.memory.learn.manager import LearnManager
class TestLearnConfig:
"""Test LearnConfig dataclass."""
def test_default_values(self):
"""Test default LearnConfig values."""
config = LearnConfig()
assert config.persona is True
assert config.insights is True
assert config.thread is True
assert config.patterns is False
assert config.decisions is False
assert config.feedback is False
assert config.improvements is False
assert config.scope == LearnScope.USER
assert config.auto_consolidate is True
assert config.retention_days is None
def test_custom_values(self):
"""Test LearnConfig with custom values."""
config = LearnConfig(
persona=False,
insights=True,
patterns=True,
scope=LearnScope.TEAM,
retention_days=30,
)
assert config.persona is False
assert config.patterns is True
assert config.scope == LearnScope.TEAM
assert config.retention_days == 30
def test_to_dict(self):
"""Test LearnConfig to_dict method."""
config = LearnConfig(persona=True, insights=True)
d = config.to_dict()
assert d["persona"] is True
assert d["insights"] is True
assert d["scope"] == "user"
class TestMemoryConfigWithLearn:
"""Test MemoryConfig with learn field."""
def test_learn_none_by_default(self):
"""Test that learn is None by default."""
config = MemoryConfig()
assert config.learn is None
def test_learn_bool_true(self):
"""Test learn=True."""
config = MemoryConfig(learn=True)
assert config.learn is True
d = config.to_dict()
assert d["learn"] is not None
assert d["learn"]["persona"] is True
def test_learn_config_instance(self):
"""Test learn=LearnConfig(...)."""
learn_config = LearnConfig(persona=True, patterns=True)
config = MemoryConfig(learn=learn_config)
assert config.learn is learn_config
d = config.to_dict()
assert d["learn"]["patterns"] is True
class TestLearnEntry:
"""Test LearnEntry dataclass."""
def test_create_entry(self):
"""Test creating a LearnEntry."""
entry = LearnEntry(
id="test_1",
content="Test content",
metadata={"key": "value"},
)
assert entry.id == "test_1"
assert entry.content == "Test content"
assert entry.metadata["key"] == "value"
def test_to_dict(self):
"""Test LearnEntry to_dict."""
entry = LearnEntry(id="test_1", content="Test")
d = entry.to_dict()
assert d["id"] == "test_1"
assert d["content"] == "Test"
assert "created_at" in d
def test_from_dict(self):
"""Test LearnEntry from_dict."""
data = {
"id": "test_1",
"content": "Test content",
"metadata": {"key": "value"},
}
entry = LearnEntry.from_dict(data)
assert entry.id == "test_1"
assert entry.content == "Test content"
class TestPersonaStore:
"""Test PersonaStore."""
def setup_method(self):
"""Set up test fixtures."""
self.temp_dir = tempfile.mkdtemp()
self.store_path = os.path.join(self.temp_dir, "persona.json")
def teardown_method(self):
"""Clean up test fixtures."""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_add_preference(self):
"""Test adding a preference."""
store = PersonaStore(store_path=self.store_path, user_id="test_user")
entry = store.add_preference("User prefers concise responses", "communication")
assert entry is not None
assert "concise" in entry.content
assert entry.metadata["category"] == "communication"
def test_search(self):
"""Test searching entries."""
store = PersonaStore(store_path=self.store_path, user_id="test_user")
store.add_preference("User likes Python", "programming")
store.add_preference("User prefers dark mode", "ui")
results = store.search("Python")
assert len(results) == 1
assert "Python" in results[0].content
class TestInsightStore:
"""Test InsightStore."""
def setup_method(self):
"""Set up test fixtures."""
self.temp_dir = tempfile.mkdtemp()
self.store_path = os.path.join(self.temp_dir, "insights.json")
def teardown_method(self):
"""Clean up test fixtures."""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_add_insight(self):
"""Test adding an insight."""
store = InsightStore(store_path=self.store_path, user_id="test_user")
entry = store.add_insight("User works in data science", "conversation")
assert entry is not None
assert "data science" in entry.content
class TestLearnManager:
"""Test LearnManager."""
def setup_method(self):
"""Set up test fixtures."""
self.temp_dir = tempfile.mkdtemp()
def teardown_method(self):
"""Clean up test fixtures."""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_create_manager_with_defaults(self):
"""Test creating LearnManager with default config."""
config = LearnConfig()
manager = LearnManager(config=config, user_id="test_user", store_path=self.temp_dir)
assert "persona" in manager._stores
assert "insights" in manager._stores
assert "threads" in manager._stores
def test_capture_persona(self):
"""Test capturing persona."""
config = LearnConfig(persona=True)
manager = LearnManager(config=config, user_id="test_user", store_path=self.temp_dir)
entry = manager.capture_persona("User prefers detailed explanations")
assert entry is not None
assert "detailed" in entry.content
def test_capture_insight(self):
"""Test capturing insight."""
config = LearnConfig(insights=True)
manager = LearnManager(config=config, user_id="test_user", store_path=self.temp_dir)
entry = manager.capture_insight("User is experienced with Python")
assert entry is not None
def test_get_learning_context(self):
"""Test getting learning context."""
config = LearnConfig(persona=True, insights=True)
manager = LearnManager(config=config, user_id="test_user", store_path=self.temp_dir)
manager.capture_persona("User likes concise code")
manager.capture_insight("User knows Python")
context = manager.get_learning_context()
assert "persona" in context
assert "insights" in context
assert len(context["persona"]) == 1
assert len(context["insights"]) == 1
def test_search(self):
"""Test searching across stores."""
config = LearnConfig(persona=True, insights=True)
manager = LearnManager(config=config, user_id="test_user", store_path=self.temp_dir)
manager.capture_persona("User prefers Python")
manager.capture_insight("User knows Python well")
results = manager.search("Python")
assert "persona" in results or "insights" in results
def test_clear_all(self):
"""Test clearing all stores."""
config = LearnConfig(persona=True, insights=True)
manager = LearnManager(config=config, user_id="test_user", store_path=self.temp_dir)
manager.capture_persona("Test 1")
manager.capture_insight("Test 2")
cleared = manager.clear_all()
assert cleared["persona"] == 1
assert cleared["insights"] == 1
def test_get_stats(self):
"""Test getting stats."""
config = LearnConfig(persona=True, insights=True)
manager = LearnManager(config=config, user_id="test_user", store_path=self.temp_dir)
manager.capture_persona("Test 1")
manager.capture_persona("Test 2")
manager.capture_insight("Test 3")
stats = manager.get_stats()
assert stats["persona"] == 2
assert stats["insights"] == 1
def test_to_system_prompt_context(self):
"""Test generating system prompt context."""
config = LearnConfig(persona=True, insights=True)
manager = LearnManager(config=config, user_id="test_user", store_path=self.temp_dir)
manager.capture_persona("User prefers concise responses")
manager.capture_insight("User is a Python developer")
context = manager.to_system_prompt_context()
assert "User Preferences" in context
assert "concise" in context
assert "Learned Insights" in context
assert "Python" in context
class TestNoCortexParam:
"""Test that cortex parameter does not exist."""
def test_agent_has_no_cortex_param(self):
"""Agent.__init__ should not have cortex parameter."""
import inspect
from praisonaiagents import Agent
sig = inspect.signature(Agent.__init__)
param_names = list(sig.parameters.keys())
assert "cortex" not in param_names, "cortex param should be removed from Agent"
def test_no_cortex_module_in_agents(self):
"""praisonaiagents should not have cortex module."""
import importlib.util
spec = importlib.util.find_spec("praisonaiagents.cortex")
assert spec is None, "praisonaiagents.cortex module should not exist"
+1 -1
View File
@@ -2422,7 +2422,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.11.6"
version = "0.11.8"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+2 -1
View File
@@ -1,3 +1,4 @@
chainlit.md
tests/fixtures
reports
reports
.agent
@@ -0,0 +1,257 @@
"""
Dynamic Context Discovery Example.
This example demonstrates how to use the Dynamic Context Discovery feature
to efficiently manage large tool outputs, conversation history, and terminal logs.
Features demonstrated:
1. Tool output queuing - Large outputs automatically saved as artifacts
2. Artifact tools - tail, grep, chunk operations on stored artifacts
3. History persistence - Conversation history saved for loss recovery
4. Terminal logging - Shell command outputs captured
Usage:
python dynamic_context_example.py
"""
import tempfile
def example_artifact_store():
"""Demonstrate artifact storage and retrieval."""
print("\n=== Artifact Store Example ===\n")
from praisonai.context import FileSystemArtifactStore
from praisonaiagents.context.artifacts import ArtifactMetadata
# Create a temporary store
with tempfile.TemporaryDirectory() as tmpdir:
store = FileSystemArtifactStore(base_dir=tmpdir)
# Store a large JSON response (simulating API output)
large_data = {
"records": [{"id": i, "name": f"Item {i}"} for i in range(1000)],
"metadata": {"total": 1000, "page": 1},
}
metadata = ArtifactMetadata(
agent_id="data_agent",
run_id="run_001",
tool_name="api_fetch",
)
ref = store.store(large_data, metadata)
print(f"Stored artifact: {ref.path}")
print(f"Size: {ref._format_size(ref.size_bytes)}")
print(f"Summary: {ref.summary}")
print(f"Inline reference: {ref.to_inline()}")
# Demonstrate tail operation
print("\n--- Tail (last 5 lines) ---")
tail = store.tail(ref, lines=5)
print(tail)
# Demonstrate grep operation
print("\n--- Grep for 'Item 50' ---")
matches = store.grep(ref, pattern="Item 50")
for match in matches[:3]:
print(f"Line {match.line_number}: {match.line_content[:60]}...")
# List all artifacts
print("\n--- List artifacts ---")
artifacts = store.list_artifacts(run_id="run_001")
for art in artifacts:
print(f" - {art.artifact_id}: {art.tool_name} ({art._format_size(art.size_bytes)})")
def example_output_queue():
"""Demonstrate automatic tool output queuing (previously called spooling)."""
print("\n=== Output Queue Example ===\n")
from praisonai.context import OutputQueue
from praisonaiagents.context.artifacts import ArtifactMetadata, ArtifactRef, QueueConfig
with tempfile.TemporaryDirectory() as tmpdir:
# Create output queue with 1KB threshold (for demo)
config = QueueConfig(inline_max_bytes=1024)
queue = OutputQueue(base_dir=tmpdir, config=config)
# Small output - stays inline (not queued to artifact)
small_output = "Hello, world!"
metadata = ArtifactMetadata(agent_id="agent1", run_id="run1")
result = queue.process(small_output, metadata)
print(f"Small output ({len(small_output)} bytes): {'Queued' if isinstance(result, ArtifactRef) else 'Inline'}")
# Large output - automatically queued to artifact
large_output = "x" * 2000
result = queue.process(large_output, metadata)
print(f"Large output ({len(large_output)} bytes): {'Queued' if isinstance(result, ArtifactRef) else 'Inline'}")
if isinstance(result, ArtifactRef):
print(f" Artifact path: {result.path}")
def example_history_store():
"""Demonstrate conversation history persistence."""
print("\n=== History Store Example ===\n")
from praisonai.context import HistoryStore
with tempfile.TemporaryDirectory() as tmpdir:
store = HistoryStore(base_dir=tmpdir)
# Simulate a conversation
messages = [
{"role": "user", "content": "What is the capital of France?"},
{"role": "assistant", "content": "The capital of France is Paris."},
{"role": "user", "content": "What about Germany?"},
{"role": "assistant", "content": "The capital of Germany is Berlin."},
{"role": "user", "content": "And what about the error in my code?"},
{"role": "assistant", "content": "I see an error on line 42. The variable is undefined."},
]
for msg in messages:
store.append(msg, agent_id="assistant", run_id="session_001")
# Get history reference
ref = store.get_ref(agent_id="assistant", run_id="session_001")
print(f"History stored: {ref.summary}")
# Search history
print("\n--- Search for 'capital' ---")
matches = store.search("capital", agent_id="assistant", run_id="session_001")
for msg in matches:
print(f" [{msg['role']}]: {msg['content'][:50]}...")
# Get last messages
print("\n--- Last 2 messages ---")
recent = store.get_last_messages(agent_id="assistant", run_id="session_001", count=2)
for msg in recent:
print(f" [{msg['role']}]: {msg['content'][:50]}...")
def example_terminal_logger():
"""Demonstrate terminal session logging."""
print("\n=== Terminal Logger Example ===\n")
from praisonai.context import TerminalLogger
with tempfile.TemporaryDirectory() as tmpdir:
logger = TerminalLogger(base_dir=tmpdir)
# Log some commands
commands = [
("ls -la", "total 100\ndrwxr-xr-x 5 user user 160 Jan 1 00:00 .\n-rw-r--r-- 1 user user 1234 Jan 1 00:00 file.txt", 0),
("cat file.txt", "Hello, world!\nThis is a test file.", 0),
("grep error log.txt", "Error: Something went wrong\nError: Another issue", 0),
("invalid_command", "command not found: invalid_command", 127),
]
for cmd, output, exit_code in commands:
ref = logger.log_command(
command=cmd,
output=output,
exit_code=exit_code,
agent_id="shell_agent",
run_id="session_001",
)
# Get session reference
ref = logger.get_session_ref(agent_id="shell_agent", run_id="session_001")
print(f"Terminal session: {ref.summary}")
# Tail the session
print("\n--- Tail (last 10 lines) ---")
tail = logger.tail_session(agent_id="shell_agent", run_id="session_001", lines=10)
print(tail)
# Search for errors
print("\n--- Grep for 'Error' ---")
matches = logger.grep_session("Error", agent_id="shell_agent", run_id="session_001")
for match in matches:
print(f" Line {match.line_number}: {match.line_content}")
# Get command list
print("\n--- Commands executed ---")
cmds = logger.get_commands(agent_id="shell_agent", run_id="session_001")
for cmd in cmds:
status = "✓" if cmd["exit_code"] == 0 else "✗"
print(f" {status} $ {cmd['command']}")
def example_setup_dynamic_context():
"""Demonstrate the convenience setup function."""
print("\n=== Setup Dynamic Context Example ===\n")
from praisonai.context import setup_dynamic_context
with tempfile.TemporaryDirectory() as tmpdir:
# Set up dynamic context with all features
ctx = setup_dynamic_context(
base_dir=tmpdir,
inline_max_kb=16, # 16KB threshold
redact_secrets=True,
history_enabled=True,
terminal_logging=True,
)
print(f"Run ID: {ctx.run_id}")
print(f"Base directory: {ctx.config.base_dir}")
# Get tools for agent
tools = ctx.get_tools()
print(f"\nAvailable tools ({len(tools)}):")
for tool in tools:
print(f" - {tool.__name__}: {tool.__doc__.split(chr(10))[0] if tool.__doc__ else 'No description'}")
# Get middleware
middleware = ctx.get_middleware()
print(f"\nMiddleware: {middleware.__name__}")
def example_agent_integration():
"""Show how to integrate with Agent (conceptual)."""
print("\n=== Agent Integration Example (Conceptual) ===\n")
print("""
# To integrate Dynamic Context Discovery with an Agent:
from praisonaiagents import Agent
from praisonai.context import setup_dynamic_context
# Set up dynamic context
ctx = setup_dynamic_context(
inline_max_kb=32,
history_enabled=True,
)
# Create agent with dynamic context tools and middleware
agent = Agent(
name="DataAnalyst",
instructions="You are a data analyst. Use artifact tools to explore large outputs.",
tools=ctx.get_tools(), # Adds artifact_tail, artifact_grep, etc.
hooks=[ctx.get_middleware()], # Auto-queues large tool outputs
)
# Now when the agent uses tools that produce large outputs,
# they will be automatically queued to artifacts.
# The agent can then use artifact_tail, artifact_grep, etc.
# to explore the data without loading it all into context.
""")
if __name__ == "__main__":
print("=" * 60)
print("Dynamic Context Discovery Examples")
print("=" * 60)
example_artifact_store()
example_output_queue()
example_history_store()
example_terminal_logger()
example_setup_dynamic_context()
example_agent_integration()
print("\n" + "=" * 60)
print("All examples completed successfully!")
print("=" * 60)
+2 -2
View File
@@ -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/v3.8.0.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.8.0.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.8.2.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.8.2.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
@@ -224,3 +224,191 @@ def context_export(
except ImportError as e:
typer.echo(f"Error: Context module not available: {e}", err=True)
raise typer.Exit(1)
# =============================================================================
# Artifact Commands (Dynamic Context Discovery)
# =============================================================================
artifacts_app = typer.Typer(help="Artifact management for dynamic context discovery")
app.add_typer(artifacts_app, name="artifacts")
@artifacts_app.command("list")
def artifacts_list(
run_id: str = typer.Option(None, "--run-id", "-r", help="Filter by run ID"),
agent_id: str = typer.Option(None, "--agent-id", "-a", help="Filter by agent ID"),
tool_name: str = typer.Option(None, "--tool", "-t", help="Filter by tool name"),
json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
):
"""List artifacts."""
try:
from praisonai.context import FileSystemArtifactStore
import json as json_module
store = FileSystemArtifactStore()
artifacts = store.list_artifacts(
run_id=run_id,
agent_id=agent_id,
tool_name=tool_name,
)
if json_output:
data = [a.to_dict() for a in artifacts]
print(json_module.dumps(data, indent=2))
else:
if not artifacts:
typer.echo("No artifacts found.")
return
from rich.console import Console
from rich.table import Table
console = Console()
table = Table(title=f"Artifacts ({len(artifacts)} found)")
table.add_column("ID", style="cyan")
table.add_column("Tool", style="green")
table.add_column("Size", justify="right")
table.add_column("Summary")
table.add_column("Path", style="dim")
for ref in artifacts:
size_str = ref._format_size(ref.size_bytes)
table.add_row(
ref.artifact_id,
ref.tool_name or "-",
size_str,
ref.summary[:40] + "..." if len(ref.summary) > 40 else ref.summary,
ref.path,
)
console.print(table)
except ImportError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@artifacts_app.command("show")
def artifacts_show(
artifact_path: str = typer.Argument(..., help="Path to artifact"),
lines: int = typer.Option(None, "--lines", "-n", help="Number of lines to show"),
):
"""Show artifact content."""
try:
from praisonai.context import FileSystemArtifactStore
from praisonaiagents.context.artifacts import ArtifactRef
store = FileSystemArtifactStore()
ref = ArtifactRef(path=artifact_path, summary="", size_bytes=0)
if lines:
content = store.head(ref, lines=lines)
else:
content = store.load(ref)
if not isinstance(content, str):
import json
content = json.dumps(content, indent=2)
typer.echo(content)
except FileNotFoundError:
typer.echo(f"Artifact not found: {artifact_path}", err=True)
raise typer.Exit(1)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@artifacts_app.command("tail")
def artifacts_tail(
artifact_path: str = typer.Argument(..., help="Path to artifact"),
lines: int = typer.Option(50, "--lines", "-n", help="Number of lines"),
):
"""Get last N lines of artifact."""
try:
from praisonai.context import FileSystemArtifactStore
from praisonaiagents.context.artifacts import ArtifactRef
store = FileSystemArtifactStore()
ref = ArtifactRef(path=artifact_path, summary="", size_bytes=0)
content = store.tail(ref, lines=lines)
typer.echo(content)
except FileNotFoundError:
typer.echo(f"Artifact not found: {artifact_path}", err=True)
raise typer.Exit(1)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@artifacts_app.command("grep")
def artifacts_grep(
artifact_path: str = typer.Argument(..., help="Path to artifact"),
pattern: str = typer.Argument(..., help="Search pattern (regex)"),
context_lines: int = typer.Option(2, "--context", "-C", help="Context lines"),
max_matches: int = typer.Option(50, "--max", "-m", help="Max matches"),
):
"""Search for pattern in artifact."""
try:
from praisonai.context import FileSystemArtifactStore
from praisonaiagents.context.artifacts import ArtifactRef
store = FileSystemArtifactStore()
ref = ArtifactRef(path=artifact_path, summary="", size_bytes=0)
matches = store.grep(ref, pattern=pattern, context_lines=context_lines, max_matches=max_matches)
if not matches:
typer.echo(f"No matches found for: {pattern}")
return
from rich.console import Console
console = Console()
console.print(f"[bold]Found {len(matches)} matches:[/bold]")
for match in matches:
console.print(f"\n[cyan]--- Line {match.line_number} ---[/cyan]")
for ctx in match.context_before:
console.print(f" {ctx}", style="dim")
console.print(f"[green]> {match.line_content}[/green]")
for ctx in match.context_after:
console.print(f" {ctx}", style="dim")
except FileNotFoundError:
typer.echo(f"Artifact not found: {artifact_path}", err=True)
raise typer.Exit(1)
except ValueError as e:
typer.echo(f"Invalid pattern: {e}", err=True)
raise typer.Exit(1)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@artifacts_app.command("export")
def artifacts_export(
artifact_path: str = typer.Argument(..., help="Path to artifact"),
output: str = typer.Option(None, "--output", "-o", help="Output file path"),
):
"""Export artifact to file."""
try:
from pathlib import Path
import shutil
src_path = Path(artifact_path)
if not src_path.exists():
typer.echo(f"Artifact not found: {artifact_path}", err=True)
raise typer.Exit(1)
if output:
dst_path = Path(output)
else:
dst_path = Path.cwd() / src_path.name
shutil.copy2(src_path, dst_path)
typer.echo(f"Exported to: {dst_path}")
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
+158
View File
@@ -203,6 +203,164 @@ def mcp_test(
raise typer.Exit(1)
@app.command("sync")
def mcp_sync(
server: Optional[str] = typer.Argument(None, help="Server name (None for all)"),
):
"""Sync tool schemas from MCP servers to local index."""
output = get_output_controller()
try:
from praisonai.mcp_server.tool_index import MCPToolIndex
index = MCPToolIndex()
loader = get_config_loader()
config = loader.load()
servers_to_sync = []
if server:
if server not in config.mcp.servers:
output.print_error(f"Server not found: {server}")
raise typer.Exit(1)
servers_to_sync = [server]
else:
servers_to_sync = list(config.mcp.servers.keys())
if not servers_to_sync:
output.print_info("No MCP servers configured")
return
total_tools = 0
for srv in servers_to_sync:
output.print_info(f"Syncing {srv}...")
# Note: This would need actual MCP connection to fetch tools
# For now, we just create the index structure
count = index.sync(srv, tools=[])
total_tools += count
output.print(f" Synced {count} tools")
if output.is_json_mode:
output.print_json({"synced_servers": servers_to_sync, "total_tools": total_tools})
else:
output.print_success(f"Synced {len(servers_to_sync)} servers, {total_tools} tools total")
except ImportError as e:
output.print_error(f"Error: {e}")
raise typer.Exit(1)
@app.command("tools")
def mcp_tools(
server: Optional[str] = typer.Argument(None, help="Server name (None for all)"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""List indexed MCP tools."""
output = get_output_controller()
try:
from praisonai.mcp_server.tool_index import MCPToolIndex
index = MCPToolIndex()
if server:
tools = index.list_tools(server)
if not tools:
output.print_info(f"No tools indexed for server: {server}")
output.print("\nRun 'praisonai mcp sync' to index tools")
return
else:
tools = index.get_all_tools()
if not tools:
output.print_info("No tools indexed")
output.print("\nRun 'praisonai mcp sync' to index tools")
return
if output.is_json_mode or json_output:
output.print_json({"tools": [t.to_dict() for t in tools]})
else:
headers = ["Server", "Tool", "Description"]
rows = [[t.server, t.name, t.hint[:50] + "..." if len(t.hint) > 50 else t.hint] for t in tools]
output.print_table(headers, rows, title=f"MCP Tools ({len(tools)} total)")
except ImportError as e:
output.print_error(f"Error: {e}")
raise typer.Exit(1)
@app.command("describe")
def mcp_describe(
server: str = typer.Argument(..., help="Server name"),
tool: str = typer.Argument(..., help="Tool name"),
):
"""Show full schema for an MCP tool."""
output = get_output_controller()
try:
from praisonai.mcp_server.tool_index import MCPToolIndex
import json
index = MCPToolIndex()
schema = index.describe(server, tool)
if not schema:
output.print_error(f"Tool not found: {server}/{tool}")
raise typer.Exit(1)
if output.is_json_mode:
output.print_json(schema)
else:
output.print(json.dumps(schema, indent=2))
except ImportError as e:
output.print_error(f"Error: {e}")
raise typer.Exit(1)
@app.command("status")
def mcp_status(
server: Optional[str] = typer.Argument(None, help="Server name (None for all)"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Show status of MCP servers."""
output = get_output_controller()
try:
from praisonai.mcp_server.tool_index import MCPToolIndex
from datetime import datetime
index = MCPToolIndex()
servers = [server] if server else index.list_servers()
if not servers:
output.print_info("No servers indexed")
return
statuses = []
for srv in servers:
status = index.get_status(srv)
if status:
statuses.append(status)
if output.is_json_mode or json_output:
output.print_json({"statuses": [s.to_dict() for s in statuses]})
else:
headers = ["Server", "Status", "Tools", "Last Sync"]
rows = []
for s in statuses:
status_str = "✓ Available" if s.available else "✗ Unavailable"
if s.auth_required:
status_str += " (auth required)"
last_sync = datetime.fromtimestamp(s.last_sync).strftime("%Y-%m-%d %H:%M") if s.last_sync else "Never"
rows.append([s.server, status_str, str(s.tool_count), last_sync])
output.print_table(headers, rows, title="MCP Server Status")
except ImportError as e:
output.print_error(f"Error: {e}")
raise typer.Exit(1)
@app.callback(invoke_without_command=True)
def mcp_callback(ctx: typer.Context):
"""Show MCP help or list servers."""
@@ -107,3 +107,192 @@ def memory_clear(
pass
finally:
sys.argv = original_argv
# Learn subcommand group
learn_app = typer.Typer(help="Continuous learning management")
app.add_typer(learn_app, name="learn")
@learn_app.command("status")
def learn_status(
user_id: str = typer.Option("default", "--user-id", "-u", help="User ID"),
scope: str = typer.Option("private", "--scope", "-s", help="Scope (private/shared)"),
):
"""Show learning status and statistics."""
from praisonaiagents.memory.learn import LearnManager
from praisonaiagents.config.feature_configs import LearnConfig
config = LearnConfig()
manager = LearnManager(config=config, user_id=user_id)
stats = manager.get_stats()
typer.echo(f"\n📚 Learning Status for user: {user_id}")
typer.echo(f" Scope: {scope}")
typer.echo("-" * 40)
if not stats:
typer.echo("No learning stores enabled.")
return
total = 0
for store_name, count in stats.items():
typer.echo(f" {store_name}: {count} entries")
total += count
typer.echo("-" * 40)
typer.echo(f" Total: {total} entries")
@learn_app.command("show")
def learn_show(
store: str = typer.Argument("all", help="Store to show (persona/insights/threads/patterns/decisions/feedback/improvements/all)"),
user_id: str = typer.Option("default", "--user-id", "-u", help="User ID"),
limit: int = typer.Option(10, "--limit", "-l", help="Number of entries to show"),
):
"""Show learned entries."""
from praisonaiagents.memory.learn import LearnManager
from praisonaiagents.config.feature_configs import LearnConfig
config = LearnConfig(
persona=True,
insights=True,
thread=True,
patterns=True,
decisions=True,
feedback=True,
improvements=True,
)
manager = LearnManager(config=config, user_id=user_id)
if store == "all":
context = manager.get_learning_context(limit_per_store=limit)
for store_name, entries in context.items():
if entries:
typer.echo(f"\n📁 {store_name.upper()}")
typer.echo("-" * 40)
for entry in entries:
typer.echo(f" [{entry['id'][:20]}...] {entry['content'][:60]}...")
else:
context = manager.get_learning_context(limit_per_store=limit)
if store in context:
entries = context[store]
typer.echo(f"\n📁 {store.upper()}")
typer.echo("-" * 40)
for entry in entries:
typer.echo(f" [{entry['id'][:20]}...] {entry['content']}")
else:
typer.echo(f"Store '{store}' not found or empty.")
@learn_app.command("add")
def learn_add(
content: str = typer.Argument(..., help="Content to learn"),
store: str = typer.Option("insights", "--store", "-s", help="Store type (persona/insights/patterns)"),
user_id: str = typer.Option("default", "--user-id", "-u", help="User ID"),
):
"""Add a learning entry."""
from praisonaiagents.memory.learn import LearnManager
from praisonaiagents.config.feature_configs import LearnConfig
config = LearnConfig(
persona=(store == "persona"),
insights=(store == "insights"),
patterns=(store == "patterns"),
thread=(store == "threads"),
decisions=(store == "decisions"),
feedback=(store == "feedback"),
improvements=(store == "improvements"),
)
manager = LearnManager(config=config, user_id=user_id)
entry = None
if store == "persona":
entry = manager.capture_persona(content)
elif store == "insights":
entry = manager.capture_insight(content)
elif store == "patterns":
entry = manager.capture_pattern(content)
elif store == "decisions":
entry = manager.capture_decision(content)
elif store == "feedback":
entry = manager.capture_feedback(content)
elif store == "improvements":
entry = manager.capture_improvement(content)
else:
typer.echo(f"Unknown store: {store}")
raise typer.Exit(1)
if entry:
typer.echo(f"✅ Added to {store}: {entry.id}")
else:
typer.echo(f"❌ Failed to add to {store}")
@learn_app.command("search")
def learn_search(
query: str = typer.Argument(..., help="Search query"),
user_id: str = typer.Option("default", "--user-id", "-u", help="User ID"),
limit: int = typer.Option(10, "--limit", "-l", help="Max results per store"),
):
"""Search across all learning stores."""
from praisonaiagents.memory.learn import LearnManager
from praisonaiagents.config.feature_configs import LearnConfig
config = LearnConfig(
persona=True,
insights=True,
thread=True,
patterns=True,
decisions=True,
feedback=True,
improvements=True,
)
manager = LearnManager(config=config, user_id=user_id)
results = manager.search(query, limit=limit)
if not results:
typer.echo(f"No results found for: {query}")
return
typer.echo(f"\n🔍 Search results for: {query}")
for store_name, entries in results.items():
typer.echo(f"\n📁 {store_name.upper()}")
typer.echo("-" * 40)
for entry in entries:
typer.echo(f" {entry['content'][:80]}...")
@learn_app.command("clear")
def learn_clear(
store: str = typer.Argument("all", help="Store to clear (or 'all')"),
user_id: str = typer.Option("default", "--user-id", "-u", help="User ID"),
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
):
"""Clear learning entries."""
from praisonaiagents.memory.learn import LearnManager
from praisonaiagents.config.feature_configs import LearnConfig
if not force:
confirm = typer.confirm(f"Clear {store} learning data for user {user_id}?")
if not confirm:
typer.echo("Cancelled.")
return
config = LearnConfig(
persona=True,
insights=True,
thread=True,
patterns=True,
decisions=True,
feedback=True,
improvements=True,
)
manager = LearnManager(config=config, user_id=user_id)
cleared = manager.clear_all()
typer.echo("\n🗑️ Cleared learning data:")
for store_name, count in cleared.items():
typer.echo(f" {store_name}: {count} entries")
@@ -97,3 +97,121 @@ def skills_install(
pass
finally:
sys.argv = original_argv
@app.command("search")
def skills_search(
query: str = typer.Argument(..., help="Search query"),
skill_dirs: str = typer.Option(None, "--dirs", "-d", help="Comma-separated skill directories"),
):
"""Search skills by name, description, or content."""
try:
from praisonaiagents.skills import discover_skills
import re
dirs = skill_dirs.split(",") if skill_dirs else None
skills = discover_skills(dirs, include_defaults=True)
pattern = re.compile(query, re.IGNORECASE)
matches = []
for skill in skills:
# Search in name and description
if pattern.search(skill.name) or pattern.search(skill.description):
matches.append(skill)
continue
# Search in SKILL.md content if path available
if skill.path:
skill_md = skill.path / "SKILL.md"
if skill_md.exists():
content = skill_md.read_text()
if pattern.search(content):
matches.append(skill)
if not matches:
typer.echo(f"No skills found matching: {query}")
return
from rich.console import Console
from rich.table import Table
console = Console()
table = Table(title=f"Skills matching '{query}' ({len(matches)} found)")
table.add_column("Name", style="cyan")
table.add_column("Description")
table.add_column("Path", style="dim")
for skill in matches:
table.add_row(
skill.name,
skill.description[:60] + "..." if len(skill.description) > 60 else skill.description,
str(skill.path) if skill.path else "-",
)
console.print(table)
except ImportError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@app.command("info")
def skills_info(
name: str = typer.Argument(..., help="Skill name or path"),
):
"""Show detailed information about a skill."""
try:
from praisonaiagents.skills import discover_skills, SkillLoader
from pathlib import Path
# Try as path first
path = Path(name).expanduser()
if path.exists() and path.is_dir():
loader = SkillLoader()
skill = loader.load_metadata(str(path))
if skill:
loader.activate(skill)
else:
# Search by name
skills = discover_skills(include_defaults=True)
skill = None
for s in skills:
if s.name == name:
loader = SkillLoader()
skill = loader.load_metadata(str(s.path))
if skill:
loader.activate(skill)
break
if not skill:
typer.echo(f"Skill not found: {name}", err=True)
raise typer.Exit(1)
from rich.console import Console
from rich.panel import Panel
from rich.markdown import Markdown
console = Console()
# Show metadata
console.print(f"\n[bold cyan]{skill.properties.name}[/bold cyan]")
console.print(f"[dim]{skill.properties.description}[/dim]\n")
if skill.properties.license:
console.print(f"License: {skill.properties.license}")
if skill.properties.compatibility:
console.print(f"Compatibility: {skill.properties.compatibility}")
if skill.properties.allowed_tools:
console.print(f"Allowed Tools: {skill.properties.allowed_tools}")
if skill.properties.metadata:
console.print(f"Metadata: {skill.properties.metadata}")
# Show instructions if loaded
if skill.instructions:
console.print("\n[bold]Instructions:[/bold]")
console.print(Panel(Markdown(skill.instructions[:2000])))
except ImportError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
+104
View File
@@ -0,0 +1,104 @@
"""
Dynamic Context Discovery for PraisonAI.
This module provides filesystem-backed implementations for:
- Artifact storage (tool outputs, history, terminal logs)
- Tool output queuing middleware
- History persistence with search
- Terminal session logging
Zero Performance Impact:
- All imports are lazy loaded
- Features only activate when explicitly enabled
- No overhead when not used
Usage:
from praisonai.context import (
FileSystemArtifactStore,
OutputQueue,
HistoryStore,
TerminalLogger,
)
# Create artifact store
store = FileSystemArtifactStore(base_dir="~/.praison/runs")
# Create output queue
queue = OutputQueue(store=store, inline_max_kb=32)
"""
__all__ = [
# Artifact Store
"FileSystemArtifactStore",
# Output Queue
"OutputQueue",
"create_queue_middleware",
"create_artifact_tools",
# History
"HistoryStore",
"create_history_tools",
"create_history_pointer",
# Terminal
"TerminalLogger",
"create_terminal_tools",
# Config
"DynamicContextConfig",
# Setup
"DynamicContextSetup",
"setup_dynamic_context",
]
def __getattr__(name: str):
"""Lazy load module components."""
if name == "FileSystemArtifactStore":
from .artifact_store import FileSystemArtifactStore
return FileSystemArtifactStore
# New user-friendly names
if name == "OutputQueue":
from .queue import OutputQueue
return OutputQueue
if name == "create_queue_middleware":
from .queue import create_queue_middleware
return create_queue_middleware
if name == "create_artifact_tools":
from .queue import create_artifact_tools
return create_artifact_tools
if name == "HistoryStore":
from .history_store import HistoryStore
return HistoryStore
if name == "create_history_tools":
from .history_store import create_history_tools
return create_history_tools
if name == "create_history_pointer":
from .history_store import create_history_pointer
return create_history_pointer
if name == "TerminalLogger":
from .terminal_logger import TerminalLogger
return TerminalLogger
if name == "create_terminal_tools":
from .terminal_logger import create_terminal_tools
return create_terminal_tools
if name == "DynamicContextConfig":
from .config import DynamicContextConfig
return DynamicContextConfig
if name == "DynamicContextSetup":
from .setup import DynamicContextSetup
return DynamicContextSetup
if name == "setup_dynamic_context":
from .setup import setup_dynamic_context
return setup_dynamic_context
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,513 @@
"""
Filesystem-backed Artifact Store for Dynamic Context Discovery.
Implements ArtifactStoreProtocol with:
- Deterministic storage layout per run/agent
- Checksum verification
- Secret redaction
- Efficient tail/grep/chunk operations
"""
import re
import json
import time
import uuid
import logging
from pathlib import Path
from typing import Any, List, Optional
from praisonaiagents.context.artifacts import (
ArtifactRef,
ArtifactMetadata,
GrepMatch,
QueueConfig,
compute_checksum,
generate_summary,
)
logger = logging.getLogger(__name__)
class FileSystemArtifactStore:
"""
Filesystem-backed artifact storage.
Stores artifacts in a deterministic layout:
~/.praison/runs/{run_id}/artifacts/{agent_id}/{artifact_id}.{ext}
Features:
- Automatic MIME type detection
- Secret redaction (configurable)
- Checksum verification
- Efficient tail/grep for large files
Example:
store = FileSystemArtifactStore(base_dir="~/.praison/runs")
ref = store.store(
content={"data": [1, 2, 3]},
metadata=ArtifactMetadata(agent_id="agent1", run_id="run123")
)
# Later, retrieve
content = store.load(ref)
last_lines = store.tail(ref, lines=10)
matches = store.grep(ref, pattern="error")
"""
def __init__(
self,
base_dir: str = "~/.praison/runs",
config: Optional[QueueConfig] = None,
):
"""
Initialize the artifact store.
Args:
base_dir: Base directory for artifact storage
config: Queue configuration (optional)
"""
self.base_dir = Path(base_dir).expanduser().resolve()
self.config = config or QueueConfig()
self._ensure_base_dir()
def _ensure_base_dir(self) -> None:
"""Create base directory if it doesn't exist."""
self.base_dir.mkdir(parents=True, exist_ok=True)
def _get_artifact_dir(self, run_id: str, agent_id: str) -> Path:
"""Get the directory for storing artifacts."""
artifact_dir = self.base_dir / run_id / "artifacts" / agent_id
artifact_dir.mkdir(parents=True, exist_ok=True)
return artifact_dir
def _detect_mime_type(self, content: Any) -> str:
"""Detect MIME type from content."""
if isinstance(content, dict) or isinstance(content, list):
return "application/json"
elif isinstance(content, bytes):
return "application/octet-stream"
else:
return "text/plain"
def _get_extension(self, mime_type: str) -> str:
"""Get file extension from MIME type."""
mime_to_ext = {
"application/json": ".json",
"text/plain": ".txt",
"text/html": ".html",
"text/csv": ".csv",
"application/xml": ".xml",
"application/octet-stream": ".bin",
}
return mime_to_ext.get(mime_type, ".txt")
def _serialize_content(self, content: Any, mime_type: str) -> bytes:
"""Serialize content to bytes."""
if isinstance(content, bytes):
return content
elif mime_type == "application/json":
return json.dumps(content, indent=2, default=str).encode("utf-8")
else:
return str(content).encode("utf-8")
def _deserialize_content(self, data: bytes, mime_type: str) -> Any:
"""Deserialize bytes to content."""
if mime_type == "application/json":
return json.loads(data.decode("utf-8"))
elif mime_type == "application/octet-stream":
return data
else:
return data.decode("utf-8")
def _redact_secrets(self, content: str) -> str:
"""Redact secrets from content."""
if not self.config.redact_secrets:
return content
redacted = content
for pattern in self.config.secret_patterns:
try:
redacted = re.sub(pattern, "[REDACTED]", redacted, flags=re.IGNORECASE)
except re.error:
logger.warning(f"Invalid regex pattern: {pattern}")
return redacted
def store(
self,
content: Any,
metadata: ArtifactMetadata,
) -> ArtifactRef:
"""
Store content as an artifact.
Args:
content: The content to store
metadata: Metadata about the artifact
Returns:
ArtifactRef pointing to the stored artifact
"""
# Generate artifact ID
artifact_id = str(uuid.uuid4())[:8]
# Detect MIME type
mime_type = self._detect_mime_type(content)
# Serialize content
data = self._serialize_content(content, mime_type)
# Redact secrets if text-based
if mime_type != "application/octet-stream":
data_str = data.decode("utf-8")
data_str = self._redact_secrets(data_str)
data = data_str.encode("utf-8")
# Compute checksum
checksum = compute_checksum(data, self.config.checksum_algorithm)
# Generate summary
summary = generate_summary(content, self.config.summary_max_chars)
# Determine file path
run_id = metadata.run_id or "default"
agent_id = metadata.agent_id or "default"
artifact_dir = self._get_artifact_dir(run_id, agent_id)
ext = self._get_extension(mime_type)
filename = f"{artifact_id}{ext}"
if metadata.tool_name:
filename = f"{metadata.tool_name}_{artifact_id}{ext}"
file_path = artifact_dir / filename
# Write content
file_path.write_bytes(data)
# Write metadata sidecar
meta_path = file_path.with_suffix(file_path.suffix + ".meta")
meta_data = {
**metadata.to_dict(),
"artifact_id": artifact_id,
"mime_type": mime_type,
"checksum": checksum,
"size_bytes": len(data),
"created_at": time.time(),
}
meta_path.write_text(json.dumps(meta_data, indent=2))
return ArtifactRef(
path=str(file_path),
summary=summary,
size_bytes=len(data),
mime_type=mime_type,
checksum=checksum,
created_at=meta_data["created_at"],
artifact_id=artifact_id,
agent_id=agent_id,
run_id=run_id,
tool_name=metadata.tool_name,
turn_id=metadata.turn_id,
)
def load(self, ref: ArtifactRef) -> Any:
"""
Load full content from an artifact.
Args:
ref: Reference to the artifact
Returns:
The deserialized content
"""
file_path = Path(ref.path)
if not file_path.exists():
raise FileNotFoundError(f"Artifact not found: {ref.path}")
data = file_path.read_bytes()
# Verify checksum if available
if ref.checksum:
actual_checksum = compute_checksum(data, self.config.checksum_algorithm)
if actual_checksum != ref.checksum:
logger.warning(f"Checksum mismatch for {ref.path}")
return self._deserialize_content(data, ref.mime_type)
def tail(self, ref: ArtifactRef, lines: int = 50) -> str:
"""
Get the last N lines of an artifact.
Args:
ref: Reference to the artifact
lines: Number of lines to return
Returns:
String containing the last N lines
"""
file_path = Path(ref.path)
if not file_path.exists():
raise FileNotFoundError(f"Artifact not found: {ref.path}")
# Efficient tail for large files
with open(file_path, "rb") as f:
# Seek to end
f.seek(0, 2)
file_size = f.tell()
# Read in chunks from the end
chunk_size = 8192
found_lines = []
position = file_size
while position > 0 and len(found_lines) < lines + 1:
read_size = min(chunk_size, position)
position -= read_size
f.seek(position)
chunk = f.read(read_size).decode("utf-8", errors="replace")
# Split into lines and prepend
chunk_lines = chunk.split("\n")
if found_lines:
# Merge with previous chunk
chunk_lines[-1] += found_lines[0]
found_lines = chunk_lines + found_lines[1:]
else:
found_lines = chunk_lines
# Return last N lines
return "\n".join(found_lines[-lines:])
def head(self, ref: ArtifactRef, lines: int = 50) -> str:
"""
Get the first N lines of an artifact.
Args:
ref: Reference to the artifact
lines: Number of lines to return
Returns:
String containing the first N lines
"""
file_path = Path(ref.path)
if not file_path.exists():
raise FileNotFoundError(f"Artifact not found: {ref.path}")
result_lines = []
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
for i, line in enumerate(f):
if i >= lines:
break
result_lines.append(line.rstrip("\n"))
return "\n".join(result_lines)
def grep(
self,
ref: ArtifactRef,
pattern: str,
context_lines: int = 2,
max_matches: int = 50,
) -> List[GrepMatch]:
"""
Search for pattern in artifact content.
Args:
ref: Reference to the artifact
pattern: Regex pattern to search for
context_lines: Number of context lines before/after match
max_matches: Maximum number of matches to return
Returns:
List of GrepMatch objects
"""
file_path = Path(ref.path)
if not file_path.exists():
raise FileNotFoundError(f"Artifact not found: {ref.path}")
try:
regex = re.compile(pattern, re.IGNORECASE)
except re.error as e:
raise ValueError(f"Invalid regex pattern: {e}")
matches = []
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
all_lines = f.readlines()
for i, line in enumerate(all_lines):
if regex.search(line):
# Get context
start = max(0, i - context_lines)
end = min(len(all_lines), i + context_lines + 1)
match = GrepMatch(
line_number=i + 1,
line_content=line.rstrip("\n"),
context_before=[ln.rstrip("\n") for ln in all_lines[start:i]],
context_after=[ln.rstrip("\n") for ln in all_lines[i+1:end]],
)
matches.append(match)
if len(matches) >= max_matches:
break
return matches
def chunk(
self,
ref: ArtifactRef,
start_line: int = 1,
end_line: Optional[int] = None,
) -> str:
"""
Get a chunk of lines from an artifact.
Args:
ref: Reference to the artifact
start_line: Starting line number (1-indexed)
end_line: Ending line number (inclusive), None for end of file
Returns:
String containing the requested lines
"""
file_path = Path(ref.path)
if not file_path.exists():
raise FileNotFoundError(f"Artifact not found: {ref.path}")
result_lines = []
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
for i, line in enumerate(f, start=1):
if i < start_line:
continue
if end_line is not None and i > end_line:
break
result_lines.append(line.rstrip("\n"))
return "\n".join(result_lines)
def delete(self, ref: ArtifactRef) -> bool:
"""
Delete an artifact.
Args:
ref: Reference to the artifact
Returns:
True if deleted successfully
"""
file_path = Path(ref.path)
meta_path = file_path.with_suffix(file_path.suffix + ".meta")
deleted = False
if file_path.exists():
file_path.unlink()
deleted = True
if meta_path.exists():
meta_path.unlink()
return deleted
def list_artifacts(
self,
run_id: Optional[str] = None,
agent_id: Optional[str] = None,
tool_name: Optional[str] = None,
) -> List[ArtifactRef]:
"""
List artifacts matching filters.
Args:
run_id: Filter by run ID
agent_id: Filter by agent ID
tool_name: Filter by tool name
Returns:
List of matching ArtifactRef objects
"""
artifacts = []
# Determine search path
if run_id:
search_paths = [self.base_dir / run_id / "artifacts"]
else:
search_paths = list(self.base_dir.glob("*/artifacts"))
for artifacts_dir in search_paths:
if not artifacts_dir.exists():
continue
# Search for agent directories
if agent_id:
agent_dirs = [artifacts_dir / agent_id]
else:
agent_dirs = list(artifacts_dir.iterdir())
for agent_dir in agent_dirs:
if not agent_dir.is_dir():
continue
# Find all meta files
for meta_file in agent_dir.glob("*.meta"):
try:
meta_data = json.loads(meta_file.read_text())
# Apply tool_name filter
if tool_name and meta_data.get("tool_name") != tool_name:
continue
# Get artifact file path
artifact_path = meta_file.with_suffix("")
if not artifact_path.exists():
continue
ref = ArtifactRef(
path=str(artifact_path),
summary=meta_data.get("summary", ""),
size_bytes=meta_data.get("size_bytes", 0),
mime_type=meta_data.get("mime_type", "application/octet-stream"),
checksum=meta_data.get("checksum", ""),
created_at=meta_data.get("created_at", 0),
artifact_id=meta_data.get("artifact_id", ""),
agent_id=meta_data.get("agent_id", ""),
run_id=meta_data.get("run_id", ""),
tool_name=meta_data.get("tool_name"),
turn_id=meta_data.get("turn_id", 0),
)
artifacts.append(ref)
except (json.JSONDecodeError, IOError) as e:
logger.warning(f"Failed to read metadata: {meta_file}: {e}")
# Sort by creation time (newest first)
artifacts.sort(key=lambda x: x.created_at, reverse=True)
return artifacts
def get_run_dir(self, run_id: str) -> Path:
"""Get the directory for a specific run."""
return self.base_dir / run_id
def cleanup_run(self, run_id: str) -> int:
"""
Delete all artifacts for a run.
Args:
run_id: Run ID to clean up
Returns:
Number of artifacts deleted
"""
import shutil
run_dir = self.base_dir / run_id
if not run_dir.exists():
return 0
# Count artifacts before deletion
artifacts = self.list_artifacts(run_id=run_id)
count = len(artifacts)
# Remove the entire run directory
shutil.rmtree(run_dir)
return count
+111
View File
@@ -0,0 +1,111 @@
"""
Configuration for Dynamic Context Discovery.
Provides a unified configuration class for all dynamic context features.
"""
import os
from dataclasses import dataclass, field
from typing import List, Optional
from praisonaiagents.context.artifacts import QueueConfig
@dataclass
class DynamicContextConfig:
"""
Configuration for Dynamic Context Discovery features.
Controls:
- Artifact storage location
- Tool output queuing thresholds
- History persistence
- Terminal logging
- Secret redaction
Example:
config = DynamicContextConfig(
base_dir="~/.praison/runs",
inline_max_kb=32,
redact_secrets=True,
terminal_logging=True,
)
"""
# Storage
base_dir: str = "~/.praison/runs"
# Tool output queuing
queuing_enabled: bool = True
inline_max_kb: int = 32
# History
history_enabled: bool = True
# Terminal logging
terminal_logging: bool = False # Off by default for privacy
# Security
redact_secrets: bool = True
secret_patterns: List[str] = field(default_factory=list)
# Run identification
run_id: Optional[str] = None
def __post_init__(self):
"""Apply environment variable overrides."""
self.base_dir = os.getenv(
"PRAISONAI_ARTIFACT_DIR",
self.base_dir
)
self.inline_max_kb = int(os.getenv(
"PRAISONAI_ARTIFACT_INLINE_MAX_KB",
str(self.inline_max_kb)
))
self.redact_secrets = os.getenv(
"PRAISONAI_ARTIFACT_REDACT",
str(self.redact_secrets)
).lower() in ("true", "1", "yes")
self.terminal_logging = os.getenv(
"PRAISONAI_TERMINAL_LOGGING",
str(self.terminal_logging)
).lower() in ("true", "1", "yes")
def to_queue_config(self) -> QueueConfig:
"""Convert to QueueConfig for artifact store."""
return QueueConfig(
enabled=self.queuing_enabled,
inline_max_bytes=self.inline_max_kb * 1024,
redact_secrets=self.redact_secrets,
secret_patterns=self.secret_patterns if self.secret_patterns else QueueConfig().secret_patterns,
)
def to_dict(self) -> dict:
"""Convert to dictionary."""
return {
"base_dir": self.base_dir,
"queuing_enabled": self.queuing_enabled,
"inline_max_kb": self.inline_max_kb,
"history_enabled": self.history_enabled,
"terminal_logging": self.terminal_logging,
"redact_secrets": self.redact_secrets,
"run_id": self.run_id,
}
@classmethod
def from_dict(cls, data: dict) -> "DynamicContextConfig":
"""Create from dictionary."""
return cls(
base_dir=data.get("base_dir", "~/.praison/runs"),
queuing_enabled=data.get("queuing_enabled", True),
inline_max_kb=data.get("inline_max_kb", 32),
history_enabled=data.get("history_enabled", True),
terminal_logging=data.get("terminal_logging", False),
redact_secrets=data.get("redact_secrets", True),
secret_patterns=data.get("secret_patterns", []),
run_id=data.get("run_id"),
)
@classmethod
def from_env(cls) -> "DynamicContextConfig":
"""Create configuration from environment variables."""
return cls() # __post_init__ handles env vars
@@ -0,0 +1,514 @@
"""
History Store for Dynamic Context Discovery.
Persists conversation history to artifacts for loss recovery during summarization.
Implements HistoryStoreProtocol.
Usage:
from praisonai.context import HistoryStore
store = HistoryStore(base_dir="~/.praison/runs")
# Append messages
store.append({"role": "user", "content": "Hello"}, agent_id="agent1", run_id="run123")
# Get reference for summary pointer
ref = store.get_ref(agent_id="agent1", run_id="run123")
# Search history
matches = store.search("error", agent_id="agent1", run_id="run123")
"""
import json
import re
import time
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional
from praisonaiagents.context.artifacts import (
ArtifactRef,
GrepMatch,
compute_checksum,
)
logger = logging.getLogger(__name__)
class HistoryStore:
"""
Filesystem-backed conversation history storage.
Stores history as JSONL files (one message per line) for efficient
appending and searching.
Directory layout:
~/.praison/runs/{run_id}/history/{agent_id}.jsonl
Features:
- Append-only for performance
- JSONL format for streaming reads
- Regex search support
- Turn-based retrieval
"""
def __init__(self, base_dir: str = "~/.praison/runs"):
"""
Initialize the history store.
Args:
base_dir: Base directory for history storage
"""
self.base_dir = Path(base_dir).expanduser().resolve()
def _get_history_path(self, run_id: str, agent_id: str) -> Path:
"""Get the path to the history file."""
history_dir = self.base_dir / run_id / "history"
history_dir.mkdir(parents=True, exist_ok=True)
return history_dir / f"{agent_id}.jsonl"
def append(
self,
message: Dict[str, Any],
agent_id: str,
run_id: str,
) -> None:
"""
Append a message to history.
Args:
message: The message dict (role, content, etc.)
agent_id: ID of the agent
run_id: ID of the run/session
"""
history_path = self._get_history_path(run_id, agent_id)
# Add timestamp if not present
if "timestamp" not in message:
message = {**message, "timestamp": time.time()}
# Append as JSONL
with open(history_path, "a", encoding="utf-8") as f:
f.write(json.dumps(message, default=str) + "\n")
def get_ref(self, agent_id: str, run_id: str) -> Optional[ArtifactRef]:
"""
Get artifact reference for history file.
Args:
agent_id: ID of the agent
run_id: ID of the run/session
Returns:
ArtifactRef if history exists, None otherwise
"""
history_path = self._get_history_path(run_id, agent_id)
if not history_path.exists():
return None
# Get file stats
stat = history_path.stat()
size_bytes = stat.st_size
# Count messages for summary
message_count = 0
with open(history_path, "r", encoding="utf-8") as f:
for _ in f:
message_count += 1
# Compute checksum
content = history_path.read_bytes()
checksum = compute_checksum(content)
return ArtifactRef(
path=str(history_path),
summary=f"Conversation history with {message_count} messages",
size_bytes=size_bytes,
mime_type="application/jsonl",
checksum=checksum,
created_at=stat.st_mtime,
artifact_id=f"history_{agent_id}",
agent_id=agent_id,
run_id=run_id,
)
def search(
self,
query: str,
agent_id: str,
run_id: str,
max_results: int = 20,
) -> List[Dict[str, Any]]:
"""
Search history for matching messages.
Args:
query: Search query (supports regex)
agent_id: ID of the agent
run_id: ID of the run/session
max_results: Maximum results to return
Returns:
List of matching message dicts
"""
history_path = self._get_history_path(run_id, agent_id)
if not history_path.exists():
return []
try:
pattern = re.compile(query, re.IGNORECASE)
except re.error:
# Fall back to literal search
pattern = re.compile(re.escape(query), re.IGNORECASE)
matches = []
with open(history_path, "r", encoding="utf-8") as f:
for line_num, line in enumerate(f, start=1):
try:
message = json.loads(line)
content = message.get("content", "")
if isinstance(content, str) and pattern.search(content):
message["_line_number"] = line_num
matches.append(message)
if len(matches) >= max_results:
break
except json.JSONDecodeError:
continue
return matches
def get_messages(
self,
agent_id: str,
run_id: str,
start_turn: int = 0,
end_turn: Optional[int] = None,
) -> List[Dict[str, Any]]:
"""
Get messages from history.
Args:
agent_id: ID of the agent
run_id: ID of the run/session
start_turn: Starting turn number (0-indexed)
end_turn: Ending turn number (None for all)
Returns:
List of message dicts
"""
history_path = self._get_history_path(run_id, agent_id)
if not history_path.exists():
return []
messages = []
with open(history_path, "r", encoding="utf-8") as f:
for i, line in enumerate(f):
if i < start_turn:
continue
if end_turn is not None and i >= end_turn:
break
try:
message = json.loads(line)
messages.append(message)
except json.JSONDecodeError:
continue
return messages
def get_last_messages(
self,
agent_id: str,
run_id: str,
count: int = 10,
) -> List[Dict[str, Any]]:
"""
Get the last N messages from history.
Args:
agent_id: ID of the agent
run_id: ID of the run/session
count: Number of messages to return
Returns:
List of message dicts (most recent last)
"""
history_path = self._get_history_path(run_id, agent_id)
if not history_path.exists():
return []
# Read all messages (could be optimized for large files)
all_messages = []
with open(history_path, "r", encoding="utf-8") as f:
for line in f:
try:
message = json.loads(line)
all_messages.append(message)
except json.JSONDecodeError:
continue
return all_messages[-count:]
def grep(
self,
pattern: str,
agent_id: str,
run_id: str,
context_lines: int = 0,
max_matches: int = 50,
) -> List[GrepMatch]:
"""
Search history with grep-like interface.
Args:
pattern: Regex pattern to search for
agent_id: ID of the agent
run_id: ID of the run/session
context_lines: Number of context messages before/after
max_matches: Maximum matches to return
Returns:
List of GrepMatch objects
"""
history_path = self._get_history_path(run_id, agent_id)
if not history_path.exists():
return []
try:
regex = re.compile(pattern, re.IGNORECASE)
except re.error:
regex = re.compile(re.escape(pattern), re.IGNORECASE)
# Read all lines for context
all_lines = []
with open(history_path, "r", encoding="utf-8") as f:
all_lines = f.readlines()
matches = []
for i, line in enumerate(all_lines):
try:
message = json.loads(line)
content = message.get("content", "")
if isinstance(content, str) and regex.search(content):
# Get context
start = max(0, i - context_lines)
end = min(len(all_lines), i + context_lines + 1)
match = GrepMatch(
line_number=i + 1,
line_content=line.rstrip("\n"),
context_before=[ln.rstrip("\n") for ln in all_lines[start:i]],
context_after=[ln.rstrip("\n") for ln in all_lines[i+1:end]],
)
matches.append(match)
if len(matches) >= max_matches:
break
except json.JSONDecodeError:
continue
return matches
def clear(self, agent_id: str, run_id: str) -> bool:
"""
Clear history for an agent.
Args:
agent_id: ID of the agent
run_id: ID of the run/session
Returns:
True if cleared successfully
"""
history_path = self._get_history_path(run_id, agent_id)
if history_path.exists():
history_path.unlink()
return True
return False
def export(
self,
agent_id: str,
run_id: str,
format: str = "jsonl",
) -> str:
"""
Export history in specified format.
Args:
agent_id: ID of the agent
run_id: ID of the run/session
format: Export format (jsonl, json, text)
Returns:
Exported content as string
"""
messages = self.get_messages(agent_id, run_id)
if format == "jsonl":
return "\n".join(json.dumps(m, default=str) for m in messages)
elif format == "json":
return json.dumps(messages, indent=2, default=str)
elif format == "text":
lines = []
for m in messages:
role = m.get("role", "unknown")
content = m.get("content", "")
lines.append(f"[{role}]: {content}")
return "\n\n".join(lines)
else:
raise ValueError(f"Unknown format: {format}")
def create_history_pointer(ref: ArtifactRef) -> str:
"""
Create a history pointer string for inclusion in summaries.
This pointer allows agents to recover full conversation history
after summarization.
Args:
ref: ArtifactRef to the history file
Returns:
Formatted pointer string
"""
return (
f"[Full conversation history available at: {ref.path}]\n"
f"[{ref.summary}]\n"
f"[Use history_search or history_tail tools to retrieve details]"
)
def create_history_tools(
store: Optional[HistoryStore] = None,
base_dir: str = "~/.praison/runs",
):
"""
Create tools for agents to interact with history.
Returns a list of tool functions that can be passed to Agent(tools=[...]).
Tools created:
- history_search: Search conversation history
- history_tail: Get recent messages
- history_get: Get messages by turn range
Example:
tools = create_history_tools()
agent = Agent(name="MyAgent", tools=tools)
"""
history_store = store or HistoryStore(base_dir=base_dir)
def history_search(
query: str,
agent_id: str = "default",
run_id: str = "default",
max_results: int = 10,
) -> str:
"""
Search conversation history for matching messages.
Args:
query: Search query (supports regex patterns)
agent_id: ID of the agent whose history to search
run_id: ID of the run/session
max_results: Maximum number of results to return
Returns:
Formatted string with matching messages
"""
matches = history_store.search(
query=query,
agent_id=agent_id,
run_id=run_id,
max_results=max_results,
)
if not matches:
return f"No messages found matching: {query}"
result_lines = [f"Found {len(matches)} matching messages:"]
for m in matches:
role = m.get("role", "unknown")
content = m.get("content", "")[:200]
line_num = m.get("_line_number", "?")
result_lines.append(f"\n[{line_num}] {role}: {content}...")
return "\n".join(result_lines)
def history_tail(
agent_id: str = "default",
run_id: str = "default",
count: int = 10,
) -> str:
"""
Get the most recent messages from conversation history.
Args:
agent_id: ID of the agent
run_id: ID of the run/session
count: Number of recent messages to return
Returns:
Formatted string with recent messages
"""
messages = history_store.get_last_messages(
agent_id=agent_id,
run_id=run_id,
count=count,
)
if not messages:
return "No history found."
result_lines = [f"Last {len(messages)} messages:"]
for m in messages:
role = m.get("role", "unknown")
content = m.get("content", "")
result_lines.append(f"\n[{role}]: {content}")
return "\n".join(result_lines)
def history_get(
agent_id: str = "default",
run_id: str = "default",
start_turn: int = 0,
end_turn: Optional[int] = None,
) -> str:
"""
Get messages from history by turn range.
Args:
agent_id: ID of the agent
run_id: ID of the run/session
start_turn: Starting turn number (0-indexed)
end_turn: Ending turn number (None for all remaining)
Returns:
Formatted string with messages
"""
messages = history_store.get_messages(
agent_id=agent_id,
run_id=run_id,
start_turn=start_turn,
end_turn=end_turn,
)
if not messages:
return "No messages in specified range."
result_lines = [f"Messages {start_turn} to {end_turn or 'end'}:"]
for i, m in enumerate(messages, start=start_turn):
role = m.get("role", "unknown")
content = m.get("content", "")
result_lines.append(f"\n[{i}] {role}: {content}")
return "\n".join(result_lines)
return [history_search, history_tail, history_get]
+384
View File
@@ -0,0 +1,384 @@
"""
Output Queue for Dynamic Context Discovery.
Middleware that automatically queues large tool outputs to artifacts,
replacing them with lightweight references in the context.
Usage:
from praisonai.context import OutputQueue, create_queue_middleware
# Create output queue
queue = OutputQueue(store=artifact_store)
# Process tool output
result = queue.process(tool_output, metadata)
# Returns either original output (if small) or ArtifactRef (if large)
# Or use as middleware
middleware = create_queue_middleware(store=artifact_store)
agent = Agent(hooks=[middleware])
"""
import json
import logging
from typing import Any, Callable, Optional, Union
from praisonaiagents.context.artifacts import (
ArtifactRef,
ArtifactMetadata,
QueueConfig,
)
from praisonaiagents.hooks import ToolRequest, ToolResponse
from .artifact_store import FileSystemArtifactStore
logger = logging.getLogger(__name__)
class OutputQueue:
"""
Middleware that queues large tool outputs to artifacts.
When a tool produces output larger than the configured threshold,
the queue stores it as an artifact and returns a reference instead.
Features:
- Configurable size threshold
- Automatic MIME type detection
- Secret redaction
- Checksum verification
Example:
queue = OutputQueue(
store=FileSystemArtifactStore(),
config=QueueConfig(inline_max_bytes=32*1024)
)
# Small output - returned as-is
small_result = queue.process("Hello", metadata)
# Returns: "Hello"
# Large output - queued to artifact
large_result = queue.process(large_json_data, metadata)
# Returns: ArtifactRef(path="...", summary="...", ...)
"""
def __init__(
self,
store: Optional[FileSystemArtifactStore] = None,
config: Optional[QueueConfig] = None,
base_dir: str = "~/.praison/runs",
):
"""
Initialize the output queue.
Args:
store: Artifact store to use (created if not provided)
config: Queue configuration
base_dir: Base directory for artifacts (if store not provided)
"""
self.config = config or QueueConfig()
self.store = store or FileSystemArtifactStore(
base_dir=base_dir,
config=self.config,
)
def _estimate_size(self, content: Any) -> int:
"""Estimate the serialized size of content."""
if isinstance(content, bytes):
return len(content)
elif isinstance(content, str):
return len(content.encode("utf-8"))
else:
# Serialize to JSON to estimate size
try:
return len(json.dumps(content, default=str).encode("utf-8"))
except (TypeError, ValueError):
return len(str(content).encode("utf-8"))
def should_queue(self, content: Any) -> bool:
"""
Determine if content should be queued to artifact.
Args:
content: The content to check
Returns:
True if content should be queued to artifact
"""
if not self.config.enabled:
return False
size = self._estimate_size(content)
return size > self.config.inline_max_bytes
def process(
self,
content: Any,
metadata: ArtifactMetadata,
) -> Union[Any, ArtifactRef]:
"""
Process tool output, queuing to artifact if necessary.
Args:
content: The tool output content
metadata: Metadata about the tool call
Returns:
Original content if small, ArtifactRef if queued
"""
if not self.should_queue(content):
return content
# Queue to artifact
ref = self.store.store(content, metadata)
logger.info(
f"Queued tool output to artifact: {ref.path} "
f"({ref.size_bytes} bytes)"
)
return ref
def process_tool_response(
self,
response: ToolResponse,
run_id: str = "default",
) -> ToolResponse:
"""
Process a ToolResponse, queuing result if necessary.
Args:
response: The tool response to process
run_id: Run ID for artifact storage
Returns:
Modified ToolResponse with result potentially replaced by ArtifactRef
"""
if response.error:
return response
metadata = ArtifactMetadata(
agent_id=response.context.agent_id if response.context else "default",
run_id=run_id,
tool_name=response.tool_name,
turn_id=0,
)
processed_result = self.process(response.result, metadata)
# If queued, update the response
if isinstance(processed_result, ArtifactRef):
# Replace result with inline reference
response.result = processed_result.to_inline()
response.extra["artifact_ref"] = processed_result.to_dict()
return response
def create_queue_middleware(
store: Optional[FileSystemArtifactStore] = None,
config: Optional[QueueConfig] = None,
base_dir: str = "~/.praison/runs",
run_id: str = "default",
) -> Callable:
"""
Create a wrap_tool_call middleware for automatic output queuing.
This middleware intercepts tool calls and automatically queues
large outputs to artifacts.
Args:
store: Artifact store to use
config: Queue configuration
base_dir: Base directory for artifacts
run_id: Run ID for artifact storage
Returns:
Middleware function compatible with Agent(hooks=[...])
Example:
middleware = create_queue_middleware(
config=QueueConfig(inline_max_bytes=16*1024)
)
agent = Agent(name="MyAgent", hooks=[middleware])
"""
queue = OutputQueue(store=store, config=config, base_dir=base_dir)
def queue_middleware(
request: ToolRequest,
call_next: Callable[[ToolRequest], ToolResponse],
) -> ToolResponse:
"""Middleware that queues large tool outputs."""
# Execute the tool
response = call_next(request)
# Process the response
return queue.process_tool_response(response, run_id=run_id)
# Mark as wrap_tool_call middleware
queue_middleware._middleware_type = "wrap_tool_call"
return queue_middleware
def create_artifact_tools(
store: Optional[FileSystemArtifactStore] = None,
base_dir: str = "~/.praison/runs",
):
"""
Create tools for agents to interact with artifacts.
Returns a list of tool functions that can be passed to Agent(tools=[...]).
Tools created:
- artifact_tail: Get last N lines of an artifact
- artifact_head: Get first N lines of an artifact
- artifact_grep: Search for pattern in artifact
- artifact_chunk: Get specific line range from artifact
- artifact_list: List available artifacts
Example:
tools = create_artifact_tools()
agent = Agent(name="MyAgent", tools=tools)
"""
artifact_store = store or FileSystemArtifactStore(base_dir=base_dir)
def artifact_tail(artifact_path: str, lines: int = 50) -> str:
"""
Get the last N lines of an artifact file.
Args:
artifact_path: Path to the artifact file
lines: Number of lines to return (default: 50)
Returns:
String containing the last N lines
"""
ref = ArtifactRef(path=artifact_path, summary="", size_bytes=0)
try:
return artifact_store.tail(ref, lines=lines)
except FileNotFoundError:
return f"Error: Artifact not found: {artifact_path}"
def artifact_head(artifact_path: str, lines: int = 50) -> str:
"""
Get the first N lines of an artifact file.
Args:
artifact_path: Path to the artifact file
lines: Number of lines to return (default: 50)
Returns:
String containing the first N lines
"""
ref = ArtifactRef(path=artifact_path, summary="", size_bytes=0)
try:
return artifact_store.head(ref, lines=lines)
except FileNotFoundError:
return f"Error: Artifact not found: {artifact_path}"
def artifact_grep(
artifact_path: str,
pattern: str,
context_lines: int = 2,
max_matches: int = 20,
) -> str:
"""
Search for a pattern in an artifact file.
Args:
artifact_path: Path to the artifact file
pattern: Regex pattern to search for
context_lines: Number of context lines before/after match
max_matches: Maximum number of matches to return
Returns:
Formatted string with matches and context
"""
ref = ArtifactRef(path=artifact_path, summary="", size_bytes=0)
try:
matches = artifact_store.grep(
ref,
pattern=pattern,
context_lines=context_lines,
max_matches=max_matches,
)
if not matches:
return f"No matches found for pattern: {pattern}"
result_lines = [f"Found {len(matches)} matches:"]
for match in matches:
result_lines.append(f"\n--- Line {match.line_number} ---")
for ctx_line in match.context_before:
result_lines.append(f" {ctx_line}")
result_lines.append(f"> {match.line_content}")
for ctx_line in match.context_after:
result_lines.append(f" {ctx_line}")
return "\n".join(result_lines)
except FileNotFoundError:
return f"Error: Artifact not found: {artifact_path}"
except ValueError as e:
return f"Error: {e}"
def artifact_chunk(
artifact_path: str,
start_line: int = 1,
end_line: Optional[int] = None,
) -> str:
"""
Get a specific range of lines from an artifact file.
Args:
artifact_path: Path to the artifact file
start_line: Starting line number (1-indexed)
end_line: Ending line number (inclusive), None for end of file
Returns:
String containing the requested lines
"""
ref = ArtifactRef(path=artifact_path, summary="", size_bytes=0)
try:
return artifact_store.chunk(ref, start_line=start_line, end_line=end_line)
except FileNotFoundError:
return f"Error: Artifact not found: {artifact_path}"
def artifact_list(
run_id: Optional[str] = None,
agent_id: Optional[str] = None,
tool_name: Optional[str] = None,
) -> str:
"""
List available artifacts.
Args:
run_id: Filter by run ID
agent_id: Filter by agent ID
tool_name: Filter by tool name
Returns:
Formatted list of artifacts
"""
artifacts = artifact_store.list_artifacts(
run_id=run_id,
agent_id=agent_id,
tool_name=tool_name,
)
if not artifacts:
return "No artifacts found."
result_lines = [f"Found {len(artifacts)} artifacts:"]
for ref in artifacts:
size_str = ArtifactRef._format_size(ref.size_bytes)
tool_info = f" (from {ref.tool_name})" if ref.tool_name else ""
result_lines.append(
f"- {ref.path}{tool_info}\n"
f" Size: {size_str}, Summary: {ref.summary[:50]}..."
)
return "\n".join(result_lines)
return [artifact_tail, artifact_head, artifact_grep, artifact_chunk, artifact_list]
+144
View File
@@ -0,0 +1,144 @@
"""
Setup utilities for Dynamic Context Discovery.
Provides convenience functions to set up dynamic context features
with minimal configuration.
"""
import uuid
from typing import List, Optional, Callable
from .config import DynamicContextConfig
from .artifact_store import FileSystemArtifactStore
from .queue import OutputQueue, create_queue_middleware, create_artifact_tools
from .history_store import HistoryStore, create_history_tools
from .terminal_logger import TerminalLogger, create_terminal_tools
class DynamicContextSetup:
"""
Container for dynamic context components.
Provides access to all dynamic context features:
- Artifact store
- Output queue
- History store
- Terminal logger
- Agent tools
- Middleware
"""
def __init__(
self,
config: Optional[DynamicContextConfig] = None,
run_id: Optional[str] = None,
):
"""
Initialize dynamic context setup.
Args:
config: Configuration (uses defaults if not provided)
run_id: Run ID (generated if not provided)
"""
self.config = config or DynamicContextConfig()
self.run_id = run_id or self.config.run_id or str(uuid.uuid4())[:8]
# Initialize components
self.artifact_store = FileSystemArtifactStore(
base_dir=self.config.base_dir,
config=self.config.to_queue_config(),
)
self.output_queue = OutputQueue(
store=self.artifact_store,
config=self.config.to_queue_config(),
)
self.history_store = HistoryStore(base_dir=self.config.base_dir)
self.terminal_logger = TerminalLogger(base_dir=self.config.base_dir)
def get_tools(self) -> List[Callable]:
"""
Get all dynamic context tools for agents.
Returns:
List of tool functions
"""
tools = []
# Artifact tools
tools.extend(create_artifact_tools(store=self.artifact_store))
# History tools
if self.config.history_enabled:
tools.extend(create_history_tools(store=self.history_store))
# Terminal tools
if self.config.terminal_logging:
tools.extend(create_terminal_tools(terminal_logger=self.terminal_logger))
return tools
def get_middleware(self) -> Callable:
"""
Get queue middleware for agents.
Returns:
Middleware function for Agent(hooks=[...])
"""
return create_queue_middleware(
store=self.artifact_store,
config=self.config.to_queue_config(),
run_id=self.run_id,
)
def setup_dynamic_context(
base_dir: str = "~/.praison/runs",
inline_max_kb: int = 32,
redact_secrets: bool = True,
history_enabled: bool = True,
terminal_logging: bool = False,
run_id: Optional[str] = None,
) -> DynamicContextSetup:
"""
Set up dynamic context discovery with minimal configuration.
This is the main entry point for enabling dynamic context features.
Args:
base_dir: Base directory for artifact storage
inline_max_kb: Maximum size for inline tool outputs (KB)
redact_secrets: Whether to redact secrets in artifacts
history_enabled: Whether to enable history persistence
terminal_logging: Whether to enable terminal logging
run_id: Run ID (generated if not provided)
Returns:
DynamicContextSetup with all components initialized
Example:
from praisonai.context import setup_dynamic_context
from praisonaiagents import Agent
# Set up dynamic context
ctx = setup_dynamic_context(inline_max_kb=16)
# Create agent with dynamic context tools and middleware
agent = Agent(
name="MyAgent",
tools=ctx.get_tools(),
hooks=[ctx.get_middleware()],
)
"""
config = DynamicContextConfig(
base_dir=base_dir,
inline_max_kb=inline_max_kb,
redact_secrets=redact_secrets,
history_enabled=history_enabled,
terminal_logging=terminal_logging,
run_id=run_id,
)
return DynamicContextSetup(config=config, run_id=run_id)
@@ -0,0 +1,486 @@
"""
Terminal Logger for Dynamic Context Discovery.
Captures shell command outputs to artifacts for later retrieval.
Implements TerminalLoggerProtocol.
Usage:
from praisonai.context import TerminalLogger
logger = TerminalLogger(base_dir="~/.praison/runs")
# Log a command execution
ref = logger.log_command(
command="ls -la",
output="total 100\ndrwxr-xr-x ...",
exit_code=0,
agent_id="agent1",
run_id="run123"
)
# Tail the session log
recent = logger.tail_session(agent_id="agent1", run_id="run123", lines=50)
"""
import json
import re
import time
import logging
from pathlib import Path
from typing import List, Optional
from datetime import datetime
from praisonaiagents.context.artifacts import (
ArtifactRef,
GrepMatch,
compute_checksum,
)
logger = logging.getLogger(__name__)
class TerminalLogger:
"""
Filesystem-backed terminal session logging.
Captures shell command executions to log files for later retrieval.
Directory layout:
~/.praison/runs/{run_id}/terminal/{agent_id}.log
Log format (per command):
=== COMMAND [timestamp] ===
$ command
--- OUTPUT (exit_code) ---
output content
=== END ===
Features:
- Append-only logging
- Structured command/output format
- Efficient tail/grep operations
"""
def __init__(self, base_dir: str = "~/.praison/runs"):
"""
Initialize the terminal logger.
Args:
base_dir: Base directory for terminal logs
"""
self.base_dir = Path(base_dir).expanduser().resolve()
def _get_log_path(self, run_id: str, agent_id: str) -> Path:
"""Get the path to the terminal log file."""
terminal_dir = self.base_dir / run_id / "terminal"
terminal_dir.mkdir(parents=True, exist_ok=True)
return terminal_dir / f"{agent_id}.log"
def log_command(
self,
command: str,
output: str,
exit_code: int,
agent_id: str,
run_id: str,
) -> ArtifactRef:
"""
Log a command execution.
Args:
command: The command that was executed
output: Combined stdout/stderr output
exit_code: Command exit code
agent_id: ID of the agent
run_id: ID of the run/session
Returns:
ArtifactRef to the logged output
"""
log_path = self._get_log_path(run_id, agent_id)
timestamp = datetime.now().isoformat()
# Format the log entry
entry = (
f"\n=== COMMAND [{timestamp}] ===\n"
f"$ {command}\n"
f"--- OUTPUT (exit_code={exit_code}) ---\n"
f"{output}\n"
f"=== END ===\n"
)
# Append to log
with open(log_path, "a", encoding="utf-8") as f:
f.write(entry)
# Get file stats
stat = log_path.stat()
return ArtifactRef(
path=str(log_path),
summary=f"Terminal log: {command[:50]}... (exit={exit_code})",
size_bytes=stat.st_size,
mime_type="text/plain",
checksum="", # Don't compute for append-only logs
created_at=time.time(),
artifact_id=f"terminal_{agent_id}",
agent_id=agent_id,
run_id=run_id,
tool_name="shell",
)
def get_session_ref(self, agent_id: str, run_id: str) -> Optional[ArtifactRef]:
"""
Get artifact reference for terminal session log.
Args:
agent_id: ID of the agent
run_id: ID of the run/session
Returns:
ArtifactRef if session exists, None otherwise
"""
log_path = self._get_log_path(run_id, agent_id)
if not log_path.exists():
return None
stat = log_path.stat()
# Count commands
command_count = 0
with open(log_path, "r", encoding="utf-8") as f:
for line in f:
if line.startswith("=== COMMAND"):
command_count += 1
return ArtifactRef(
path=str(log_path),
summary=f"Terminal session with {command_count} commands",
size_bytes=stat.st_size,
mime_type="text/plain",
checksum=compute_checksum(log_path.read_bytes()),
created_at=stat.st_mtime,
artifact_id=f"terminal_{agent_id}",
agent_id=agent_id,
run_id=run_id,
)
def tail_session(
self,
agent_id: str,
run_id: str,
lines: int = 100,
) -> str:
"""
Get last N lines from terminal session.
Args:
agent_id: ID of the agent
run_id: ID of the run/session
lines: Number of lines to return
Returns:
String containing the last N lines
"""
log_path = self._get_log_path(run_id, agent_id)
if not log_path.exists():
return "No terminal session found."
# Efficient tail
with open(log_path, "rb") as f:
f.seek(0, 2)
file_size = f.tell()
chunk_size = 8192
found_lines = []
position = file_size
while position > 0 and len(found_lines) < lines + 1:
read_size = min(chunk_size, position)
position -= read_size
f.seek(position)
chunk = f.read(read_size).decode("utf-8", errors="replace")
chunk_lines = chunk.split("\n")
if found_lines:
chunk_lines[-1] += found_lines[0]
found_lines = chunk_lines + found_lines[1:]
else:
found_lines = chunk_lines
return "\n".join(found_lines[-lines:])
def grep_session(
self,
pattern: str,
agent_id: str,
run_id: str,
max_matches: int = 50,
) -> List[GrepMatch]:
"""
Search terminal session for pattern.
Args:
pattern: Regex pattern to search for
agent_id: ID of the agent
run_id: ID of the run/session
max_matches: Maximum matches to return
Returns:
List of GrepMatch objects
"""
log_path = self._get_log_path(run_id, agent_id)
if not log_path.exists():
return []
try:
regex = re.compile(pattern, re.IGNORECASE)
except re.error:
regex = re.compile(re.escape(pattern), re.IGNORECASE)
matches = []
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
all_lines = f.readlines()
for i, line in enumerate(all_lines):
if regex.search(line):
start = max(0, i - 2)
end = min(len(all_lines), i + 3)
match = GrepMatch(
line_number=i + 1,
line_content=line.rstrip("\n"),
context_before=[ln.rstrip("\n") for ln in all_lines[start:i]],
context_after=[ln.rstrip("\n") for ln in all_lines[i+1:end]],
)
matches.append(match)
if len(matches) >= max_matches:
break
return matches
def get_commands(
self,
agent_id: str,
run_id: str,
last_n: Optional[int] = None,
) -> List[dict]:
"""
Get executed commands from session.
Args:
agent_id: ID of the agent
run_id: ID of the run/session
last_n: Return only last N commands (None for all)
Returns:
List of command dicts with command, output, exit_code, timestamp
"""
log_path = self._get_log_path(run_id, agent_id)
if not log_path.exists():
return []
commands = []
current_command = None
current_output_lines = []
in_output = False
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
for line in f:
if line.startswith("=== COMMAND"):
# Extract timestamp
match = re.search(r'\[(.*?)\]', line)
timestamp = match.group(1) if match else ""
current_command = {"timestamp": timestamp}
in_output = False
current_output_lines = []
elif line.startswith("$ ") and current_command:
current_command["command"] = line[2:].rstrip("\n")
elif line.startswith("--- OUTPUT"):
match = re.search(r'exit_code=(\d+)', line)
if match and current_command:
current_command["exit_code"] = int(match.group(1))
in_output = True
elif line.startswith("=== END"):
if current_command:
current_command["output"] = "\n".join(current_output_lines)
commands.append(current_command)
current_command = None
in_output = False
elif in_output:
current_output_lines.append(line.rstrip("\n"))
if last_n:
return commands[-last_n:]
return commands
def clear(self, agent_id: str, run_id: str) -> bool:
"""
Clear terminal log for an agent.
Args:
agent_id: ID of the agent
run_id: ID of the run/session
Returns:
True if cleared successfully
"""
log_path = self._get_log_path(run_id, agent_id)
if log_path.exists():
log_path.unlink()
return True
return False
def export(
self,
agent_id: str,
run_id: str,
format: str = "text",
) -> str:
"""
Export terminal log in specified format.
Args:
agent_id: ID of the agent
run_id: ID of the run/session
format: Export format (text, json)
Returns:
Exported content as string
"""
if format == "text":
log_path = self._get_log_path(run_id, agent_id)
if not log_path.exists():
return ""
return log_path.read_text(encoding="utf-8", errors="replace")
elif format == "json":
commands = self.get_commands(agent_id, run_id)
return json.dumps(commands, indent=2)
else:
raise ValueError(f"Unknown format: {format}")
def create_terminal_tools(
terminal_logger: Optional[TerminalLogger] = None,
base_dir: str = "~/.praison/runs",
):
"""
Create tools for agents to interact with terminal logs.
Returns a list of tool functions that can be passed to Agent(tools=[...]).
Tools created:
- terminal_tail: Get recent terminal output
- terminal_grep: Search terminal logs
- terminal_commands: List executed commands
Example:
tools = create_terminal_tools()
agent = Agent(name="MyAgent", tools=tools)
"""
term_logger = terminal_logger or TerminalLogger(base_dir=base_dir)
def terminal_tail(
agent_id: str = "default",
run_id: str = "default",
lines: int = 50,
) -> str:
"""
Get the most recent terminal output.
Args:
agent_id: ID of the agent
run_id: ID of the run/session
lines: Number of lines to return
Returns:
Recent terminal output
"""
return term_logger.tail_session(
agent_id=agent_id,
run_id=run_id,
lines=lines,
)
def terminal_grep(
pattern: str,
agent_id: str = "default",
run_id: str = "default",
max_matches: int = 20,
) -> str:
"""
Search terminal logs for a pattern.
Args:
pattern: Regex pattern to search for
agent_id: ID of the agent
run_id: ID of the run/session
max_matches: Maximum matches to return
Returns:
Formatted search results
"""
matches = term_logger.grep_session(
pattern=pattern,
agent_id=agent_id,
run_id=run_id,
max_matches=max_matches,
)
if not matches:
return f"No matches found for: {pattern}"
result_lines = [f"Found {len(matches)} matches:"]
for match in matches:
result_lines.append(f"\n--- Line {match.line_number} ---")
for ctx in match.context_before:
result_lines.append(f" {ctx}")
result_lines.append(f"> {match.line_content}")
for ctx in match.context_after:
result_lines.append(f" {ctx}")
return "\n".join(result_lines)
def terminal_commands(
agent_id: str = "default",
run_id: str = "default",
last_n: int = 10,
) -> str:
"""
List recently executed commands.
Args:
agent_id: ID of the agent
run_id: ID of the run/session
last_n: Number of recent commands to show
Returns:
Formatted list of commands
"""
commands = term_logger.get_commands(
agent_id=agent_id,
run_id=run_id,
last_n=last_n,
)
if not commands:
return "No commands found."
result_lines = [f"Last {len(commands)} commands:"]
for cmd in commands:
exit_code = cmd.get("exit_code", "?")
command = cmd.get("command", "?")
timestamp = cmd.get("timestamp", "?")
status = "✓" if exit_code == 0 else "✗"
result_lines.append(f"\n{status} [{timestamp}] $ {command}")
result_lines.append(f" Exit code: {exit_code}")
return "\n".join(result_lines)
return [terminal_tail, terminal_grep, terminal_commands]
+1 -1
View 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==3.8.0 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==3.8.2 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
@@ -0,0 +1,440 @@
"""
MCP Tool Index for Dynamic Context Discovery.
Provides file-backed indexing of MCP tool schemas for efficient loading.
Only tool names and hints are loaded statically; full schemas are loaded on demand.
Usage:
from praisonai.mcp_server.tool_index import MCPToolIndex
index = MCPToolIndex()
# Sync tools from a server
index.sync("brave-search")
# Get minimal static context (names + hints)
static_context = index.get_static_context()
# Load full schema on demand
schema = index.describe("brave-search", "web_search")
"""
import json
import time
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional
from dataclasses import dataclass
logger = logging.getLogger(__name__)
@dataclass
class ToolInfo:
"""Minimal tool information for static context."""
name: str
hint: str # One-line description
server: str
def to_dict(self) -> Dict[str, Any]:
return {
"name": self.name,
"hint": self.hint,
"server": self.server,
}
@dataclass
class ServerStatus:
"""Status information for an MCP server."""
server: str
available: bool = True
auth_required: bool = False
last_sync: float = 0
tool_count: int = 0
error: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
return {
"server": self.server,
"available": self.available,
"auth_required": self.auth_required,
"last_sync": self.last_sync,
"tool_count": self.tool_count,
"error": self.error,
}
class MCPToolIndex:
"""
File-backed index of MCP tool schemas.
Directory layout:
~/.praison/mcp/
├── servers/
│ ├── brave-search/
│ │ ├── _index.json # Tool names + hints
│ │ ├── _status.json # Server status
│ │ ├── web_search.json # Full schema
│ │ └── local_search.json
│ └── github/
│ └── ...
└── config.json # Server configurations
Features:
- Minimal static context (names + hints only)
- Full schemas loaded on demand
- Server status tracking
- Offline-first with sync
"""
def __init__(self, base_dir: str = "~/.praison/mcp"):
"""
Initialize the tool index.
Args:
base_dir: Base directory for MCP tool index
"""
self.base_dir = Path(base_dir).expanduser().resolve()
self.servers_dir = self.base_dir / "servers"
self._ensure_dirs()
def _ensure_dirs(self) -> None:
"""Create necessary directories."""
self.servers_dir.mkdir(parents=True, exist_ok=True)
def _get_server_dir(self, server: str) -> Path:
"""Get directory for a server's tools."""
server_dir = self.servers_dir / server
server_dir.mkdir(parents=True, exist_ok=True)
return server_dir
def sync(self, server: str, tools: Optional[List[Dict[str, Any]]] = None) -> int:
"""
Sync tool schemas from an MCP server.
Args:
server: Server name
tools: List of tool schemas (if None, tries to fetch from server)
Returns:
Number of tools synced
"""
server_dir = self._get_server_dir(server)
if tools is None:
# Try to get tools from MCP
tools = self._fetch_tools_from_server(server)
if not tools:
self._update_status(server, available=False, error="No tools found")
return 0
# Build index
index_data = []
for tool in tools:
tool_name = tool.get("name", "")
if not tool_name:
continue
# Extract hint (first line of description)
description = tool.get("description", "")
hint = description.split("\n")[0][:100] if description else ""
index_data.append({
"name": tool_name,
"hint": hint,
})
# Save full schema
schema_path = server_dir / f"{tool_name}.json"
schema_path.write_text(json.dumps(tool, indent=2))
# Save index
index_path = server_dir / "_index.json"
index_path.write_text(json.dumps(index_data, indent=2))
# Update status
self._update_status(
server,
available=True,
tool_count=len(index_data),
last_sync=time.time(),
)
logger.info(f"Synced {len(index_data)} tools from {server}")
return len(index_data)
def _fetch_tools_from_server(self, server: str) -> List[Dict[str, Any]]:
"""
Fetch tools from an MCP server.
This is a placeholder - actual implementation would use MCP client.
"""
# Try to use existing MCP infrastructure
try:
import importlib.util
if importlib.util.find_spec("praisonaiagents.mcp") is not None:
# MCP module available but would need server config to connect
pass
return []
except Exception:
return []
def _update_status(
self,
server: str,
available: bool = True,
auth_required: bool = False,
tool_count: int = 0,
last_sync: float = 0,
error: Optional[str] = None,
) -> None:
"""Update server status file."""
server_dir = self._get_server_dir(server)
status_path = server_dir / "_status.json"
status = ServerStatus(
server=server,
available=available,
auth_required=auth_required,
tool_count=tool_count,
last_sync=last_sync or time.time(),
error=error,
)
status_path.write_text(json.dumps(status.to_dict(), indent=2))
def get_status(self, server: str) -> Optional[ServerStatus]:
"""
Get status for a server.
Args:
server: Server name
Returns:
ServerStatus or None if not found
"""
server_dir = self._get_server_dir(server)
status_path = server_dir / "_status.json"
if not status_path.exists():
return None
try:
data = json.loads(status_path.read_text())
return ServerStatus(**data)
except (json.JSONDecodeError, TypeError):
return None
def list_servers(self) -> List[str]:
"""List all indexed servers."""
servers = []
for path in self.servers_dir.iterdir():
if path.is_dir():
servers.append(path.name)
return sorted(servers)
def list_tools(self, server: str) -> List[ToolInfo]:
"""
List tools for a server (from index).
Args:
server: Server name
Returns:
List of ToolInfo objects
"""
server_dir = self._get_server_dir(server)
index_path = server_dir / "_index.json"
if not index_path.exists():
return []
try:
data = json.loads(index_path.read_text())
return [
ToolInfo(name=t["name"], hint=t.get("hint", ""), server=server)
for t in data
]
except (json.JSONDecodeError, KeyError):
return []
def describe(self, server: str, tool: str) -> Optional[Dict[str, Any]]:
"""
Get full schema for a tool (loaded on demand).
Args:
server: Server name
tool: Tool name
Returns:
Full tool schema or None if not found
"""
server_dir = self._get_server_dir(server)
schema_path = server_dir / f"{tool}.json"
if not schema_path.exists():
return None
try:
return json.loads(schema_path.read_text())
except json.JSONDecodeError:
return None
def get_static_context(self, servers: Optional[List[str]] = None) -> str:
"""
Get minimal static context for all indexed tools.
This is the token-efficient representation for system prompts.
Only includes tool names and one-line hints.
Args:
servers: List of servers to include (None for all)
Returns:
Formatted string for system prompt
"""
target_servers = servers or self.list_servers()
lines = ["Available MCP Tools:"]
for server in target_servers:
tools = self.list_tools(server)
if not tools:
continue
lines.append(f"\n[{server}]")
for tool in tools:
lines.append(f" - {tool.name}: {tool.hint}")
lines.append("\nUse 'describe_tool' to get full schema for any tool.")
return "\n".join(lines)
def get_all_tools(self) -> List[ToolInfo]:
"""Get all tools from all servers."""
all_tools = []
for server in self.list_servers():
all_tools.extend(self.list_tools(server))
return all_tools
def search_tools(self, query: str) -> List[ToolInfo]:
"""
Search tools by name or hint.
Args:
query: Search query
Returns:
List of matching ToolInfo objects
"""
import re
pattern = re.compile(query, re.IGNORECASE)
matches = []
for tool in self.get_all_tools():
if pattern.search(tool.name) or pattern.search(tool.hint):
matches.append(tool)
return matches
def clear_server(self, server: str) -> bool:
"""
Clear all cached data for a server.
Args:
server: Server name
Returns:
True if cleared successfully
"""
import shutil
server_dir = self.servers_dir / server
if server_dir.exists():
shutil.rmtree(server_dir)
return True
return False
def create_mcp_tools(
index: Optional[MCPToolIndex] = None,
base_dir: str = "~/.praison/mcp",
):
"""
Create tools for agents to interact with MCP tool index.
Returns a list of tool functions that can be passed to Agent(tools=[...]).
Tools created:
- mcp_list_tools: List available MCP tools
- mcp_describe_tool: Get full schema for a tool
- mcp_search_tools: Search tools by name/description
Example:
tools = create_mcp_tools()
agent = Agent(name="MyAgent", tools=tools)
"""
tool_index = index or MCPToolIndex(base_dir=base_dir)
def mcp_list_tools(server: Optional[str] = None) -> str:
"""
List available MCP tools.
Args:
server: Server name (None for all servers)
Returns:
Formatted list of tools
"""
if server:
tools = tool_index.list_tools(server)
if not tools:
return f"No tools found for server: {server}"
lines = [f"Tools for {server}:"]
for tool in tools:
lines.append(f" - {tool.name}: {tool.hint}")
return "\n".join(lines)
else:
return tool_index.get_static_context()
def mcp_describe_tool(server: str, tool: str) -> str:
"""
Get full schema for an MCP tool.
Args:
server: Server name
tool: Tool name
Returns:
JSON schema for the tool
"""
schema = tool_index.describe(server, tool)
if not schema:
return f"Tool not found: {server}/{tool}"
return json.dumps(schema, indent=2)
def mcp_search_tools(query: str) -> str:
"""
Search MCP tools by name or description.
Args:
query: Search query
Returns:
Formatted list of matching tools
"""
matches = tool_index.search_tools(query)
if not matches:
return f"No tools found matching: {query}"
lines = [f"Found {len(matches)} tools matching '{query}':"]
for tool in matches:
lines.append(f" - [{tool.server}] {tool.name}: {tool.hint}")
return "\n".join(lines)
return [mcp_list_tools, mcp_describe_tool, mcp_search_tools]
+1 -1
View File
@@ -1 +1 @@
__version__ = "3.8.0"
__version__ = "3.8.2"
+1 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"rich>=13.7",
"markdown>=3.5",
"pyparsing>=3.0.0",
"praisonaiagents>=0.11.6",
"praisonaiagents>=0.11.8",
"python-dotenv>=0.19.0",
"instructor>=1.3.3",
"PyYAML>=6.0",
+172 -33
View File
@@ -3,26 +3,35 @@
Combined bump version and release script for PraisonAI package.
Usage:
python src/praisonai/scripts/bump_and_release.py 2.2.99
# Basic: bump praisonai to 3.8.2 with agents 0.11.8
python scripts/bump_and_release.py --agents 0.11.8 3.8.2
Or with optional praisonaiagents version:
python src/praisonai/scripts/bump_and_release.py 2.2.99 --agents 0.0.169
# Wait for agents version to propagate to PyPI (recommended after publishing agents)
python scripts/bump_and_release.py --agents 0.11.8 --wait 3.8.2
# Auto-detect latest agents version from PyPI
python scripts/bump_and_release.py --auto 3.8.2
This script:
1. Bumps version in all required files
2. Copies root README.md to package dir
3. Runs uv lock & uv build
4. Commits changes
5. Creates git tag
6. Pushes to GitHub
7. Creates GitHub release (latest)
1. Validates pre-flight conditions (clean git state, versions)
2. Optionally waits for praisonaiagents to be available on PyPI
3. Bumps version in all required files
4. Copies root README.md to package dir
5. Runs uv lock & uv build
6. Commits changes
7. Creates git tag
8. Pushes to GitHub (with rebase if needed)
9. Creates GitHub release (latest)
"""
import re
import sys
import time
import json
import argparse
import subprocess
import shutil
import urllib.request
from pathlib import Path
from typing import Optional
@@ -37,20 +46,63 @@ def get_praisonai_dir() -> Path:
return get_project_root() / "src/praisonai"
def run(cmd: list[str], cwd: Optional[Path] = None, check: bool = True) -> subprocess.CompletedProcess:
def run(cmd: list[str], cwd: Optional[Path] = None, check: bool = True, silent: bool = False) -> subprocess.CompletedProcess:
"""Run a command and print it."""
print(f"$ {' '.join(cmd)}")
if not silent:
print(f"$ {' '.join(cmd)}")
result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
if result.stdout:
print(result.stdout)
if result.stderr:
print(result.stderr, file=sys.stderr)
if not silent:
if result.stdout:
print(result.stdout)
if result.stderr:
print(result.stderr, file=sys.stderr)
if check and result.returncode != 0:
print(f"❌ Command failed with exit code {result.returncode}")
sys.exit(1)
return result
def get_pypi_version(package: str) -> Optional[str]:
"""Get the latest version of a package from PyPI."""
try:
url = f"https://pypi.org/pypi/{package}/json"
with urllib.request.urlopen(url, timeout=10) as response:
data = json.loads(response.read().decode())
return data["info"]["version"]
except Exception as e:
print(f" ⚠️ Failed to fetch PyPI version: {e}")
return None
def wait_for_pypi_version(package: str, version: str, max_wait: int = 300, interval: int = 30) -> bool:
"""Wait for a specific version to be available on PyPI."""
print(f"\n⏳ Waiting for {package}=={version} to be available on PyPI...")
start = time.time()
while time.time() - start < max_wait:
current = get_pypi_version(package)
if current == version:
print(f" ✅ {package}=={version} is now available on PyPI")
return True
elapsed = int(time.time() - start)
remaining = max_wait - elapsed
print(f" ⏳ Current: {current}, waiting for: {version} ({remaining}s remaining...)")
time.sleep(interval)
print(f" ❌ Timeout waiting for {package}=={version} on PyPI")
return False
def check_git_status() -> bool:
"""Check if there are uncommitted changes."""
root = get_project_root()
result = run(["git", "status", "--porcelain"], cwd=root, check=False, silent=True)
if result.stdout.strip():
return True # Has uncommitted changes
return False
def update_file(filepath: Path, patterns: list[tuple[str, str]], root: Path) -> bool:
"""Update a file with the given regex patterns and replacements."""
if not filepath.exists():
@@ -130,17 +182,27 @@ def bump_version(new_version: str, agents_version: Optional[str] = None):
print("\n✨ Version bump complete!")
def validate_dependencies() -> bool:
"""Validate that uv lock will succeed before making any changes."""
def validate_dependencies(max_retries: int = 3, retry_interval: int = 60) -> bool:
"""Validate that uv lock will succeed, with retry logic for PyPI propagation."""
praisonai_dir = get_praisonai_dir()
print("\n🔍 Validating dependencies (dry-run)...")
result = run(["uv", "lock", "--dry-run"], cwd=praisonai_dir, check=False)
if result.returncode != 0:
print("\n❌ Dependency validation failed. Fix the issues above before releasing.")
return False
print(" ✅ Dependencies validated successfully")
return True
for attempt in range(max_retries):
print(f"\n🔍 Validating dependencies (attempt {attempt + 1}/{max_retries})...")
# Clear cache before validation
run(["uv", "cache", "clean"], cwd=praisonai_dir, silent=True)
result = run(["uv", "lock", "--dry-run"], cwd=praisonai_dir, check=False)
if result.returncode == 0:
print(" ✅ Dependencies validated successfully")
return True
if attempt < max_retries - 1:
print(f"\n⏳ Waiting {retry_interval}s for PyPI propagation before retry...")
time.sleep(retry_interval)
print("\n❌ Dependency validation failed after all retries.")
return False
def release(version: str):
@@ -179,8 +241,17 @@ def release(version: str):
print(f"\n🏷️ Creating tag {tag}...")
run(["git", "tag", "-f", tag], cwd=root)
# 6. Push to GitHub
# 6. Pull rebase and push to GitHub
print("\n⬆️ Pushing to GitHub...")
# First fetch and rebase to handle any remote changes (e.g., auto-generated api.md)
result = run(["git", "pull", "--rebase", "origin", "main"], cwd=root, check=False)
if result.returncode != 0:
print(" ⚠️ Rebase failed, trying to continue...")
# Recreate tag after rebase (commit hash may have changed)
run(["git", "tag", "-f", tag], cwd=root)
# Push changes
run(["git", "push"], cwd=root)
run(["git", "push", "--tags", "-f"], cwd=root)
@@ -200,37 +271,105 @@ def release(version: str):
def main():
parser = argparse.ArgumentParser(
description="Bump version and release PraisonAI package"
description="Bump version and release PraisonAI package",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Bump to 3.8.2 with agents 0.11.8
python scripts/bump_and_release.py --agents 0.11.8 3.8.2
# Wait for agents to propagate to PyPI first (recommended)
python scripts/bump_and_release.py --agents 0.11.8 --wait 3.8.2
# Auto-detect latest agents version from PyPI
python scripts/bump_and_release.py --auto 3.8.2
"""
)
parser.add_argument(
"version",
help="New version number (e.g., 2.2.99)"
help="New version number (e.g., 3.8.2)"
)
parser.add_argument(
"--agents", "-a",
help="Optional: Update praisonaiagents dependency version (e.g., 0.0.169)",
help="Update praisonaiagents dependency version (e.g., 0.11.8)",
default=None
)
parser.add_argument(
"--auto",
action="store_true",
help="Auto-detect latest praisonaiagents version from PyPI"
)
parser.add_argument(
"--wait", "-w",
action="store_true",
help="Wait for the specified agents version to be available on PyPI"
)
parser.add_argument(
"--max-wait",
type=int,
default=300,
help="Maximum seconds to wait for PyPI propagation (default: 300)"
)
parser.add_argument(
"--retries", "-r",
type=int,
default=3,
help="Number of retries for dependency validation (default: 3)"
)
parser.add_argument(
"--force", "-f",
action="store_true",
help="Skip pre-flight checks (use with caution)"
)
args = parser.parse_args()
# Validate version format
if not re.match(r'^\d+\.\d+\.\d+$', args.version):
print(f"❌ Invalid version format: {args.version}")
print(" Expected format: X.Y.Z (e.g., 2.2.99)")
print(" Expected format: X.Y.Z (e.g., 3.8.2)")
sys.exit(1)
# Handle --auto flag
if args.auto:
print("\n🔍 Auto-detecting latest praisonaiagents version from PyPI...")
agents_version = get_pypi_version("praisonaiagents")
if not agents_version:
print("❌ Failed to auto-detect praisonaiagents version")
sys.exit(1)
print(f" ✅ Latest version: {agents_version}")
args.agents = agents_version
# Validate agents version format if provided
if args.agents and not re.match(r'^\d+\.\d+\.\d+$', args.agents):
print(f"❌ Invalid agents version format: {args.agents}")
print(" Expected format: X.Y.Z (e.g., 0.0.169)")
print(" Expected format: X.Y.Z (e.g., 0.11.8)")
sys.exit(1)
# Pre-flight checks
if not args.force:
print("\n🔍 Pre-flight checks...")
# Check for uncommitted changes (warning only)
if check_git_status():
print(" ⚠️ Warning: You have uncommitted changes")
else:
print(" ✅ Git working directory is clean")
# Wait for PyPI propagation if requested
if args.wait and args.agents:
if not wait_for_pypi_version("praisonaiagents", args.agents, max_wait=args.max_wait):
print("\n💡 Tip: Check if the package was published successfully")
print("💡 Tip: You can retry without --wait if the package is confirmed published")
sys.exit(1)
# Run bump version
bump_version(args.version, args.agents)
# Validate dependencies after version bump (before git operations)
if not validate_dependencies():
# Validate dependencies after version bump (with retries)
if not validate_dependencies(max_retries=args.retries):
print("\n💡 Tip: Revert changes with 'git checkout .' if needed")
print("💡 Tip: The package may need more time to propagate to PyPI")
sys.exit(1)
# Run release
@@ -0,0 +1,357 @@
"""
Unit tests for Dynamic Context Discovery - Artifacts.
Tests:
- ArtifactRef creation and serialization
- FileSystemArtifactStore operations
- OutputQueue middleware (for queuing large outputs)
- Secret redaction
"""
import tempfile
import pytest
from pathlib import Path
class TestArtifactRef:
"""Tests for ArtifactRef dataclass."""
def test_create_artifact_ref(self):
"""Test creating an ArtifactRef."""
from praisonaiagents.context.artifacts import ArtifactRef
ref = ArtifactRef(
path="/tmp/test.json",
summary="Test artifact",
size_bytes=1024,
mime_type="application/json",
)
assert ref.path == "/tmp/test.json"
assert ref.summary == "Test artifact"
assert ref.size_bytes == 1024
assert ref.mime_type == "application/json"
def test_to_inline(self):
"""Test inline representation."""
from praisonaiagents.context.artifacts import ArtifactRef
ref = ArtifactRef(
path="/tmp/test.json",
summary="API response data",
size_bytes=50000,
tool_name="api_call",
)
inline = ref.to_inline()
assert "Artifact" in inline
assert "api_call" in inline
assert "/tmp/test.json" in inline
assert "48.8 KB" in inline
def test_to_dict_and_from_dict(self):
"""Test serialization round-trip."""
from praisonaiagents.context.artifacts import ArtifactRef
ref = ArtifactRef(
path="/tmp/test.json",
summary="Test",
size_bytes=100,
artifact_id="abc123",
agent_id="agent1",
run_id="run1",
)
data = ref.to_dict()
restored = ArtifactRef.from_dict(data)
assert restored.path == ref.path
assert restored.artifact_id == ref.artifact_id
assert restored.agent_id == ref.agent_id
def test_format_size(self):
"""Test size formatting."""
from praisonaiagents.context.artifacts import ArtifactRef
assert ArtifactRef._format_size(500) == "500 B"
assert ArtifactRef._format_size(1024) == "1.0 KB"
assert ArtifactRef._format_size(1024 * 1024) == "1.0 MB"
assert ArtifactRef._format_size(1024 * 1024 * 1024) == "1.0 GB"
class TestQueueConfig:
"""Tests for QueueConfig."""
def test_default_config(self):
"""Test default configuration."""
from praisonaiagents.context.artifacts import QueueConfig
config = QueueConfig()
assert config.enabled is True
assert config.inline_max_bytes == 32 * 1024
assert config.redact_secrets is True
assert len(config.secret_patterns) > 0
def test_custom_config(self):
"""Test custom configuration."""
from praisonaiagents.context.artifacts import QueueConfig
config = QueueConfig(
enabled=False,
inline_max_bytes=16 * 1024,
redact_secrets=False,
)
assert config.enabled is False
assert config.inline_max_bytes == 16 * 1024
assert config.redact_secrets is False
class TestFileSystemArtifactStore:
"""Tests for FileSystemArtifactStore."""
@pytest.fixture
def temp_store(self):
"""Create a temporary artifact store."""
with tempfile.TemporaryDirectory() as tmpdir:
from praisonai.context import FileSystemArtifactStore
store = FileSystemArtifactStore(base_dir=tmpdir)
yield store
def test_store_json(self, temp_store):
"""Test storing JSON content."""
from praisonaiagents.context.artifacts import ArtifactMetadata
content = {"key": "value", "numbers": [1, 2, 3]}
metadata = ArtifactMetadata(
agent_id="agent1",
run_id="run1",
tool_name="test_tool",
)
ref = temp_store.store(content, metadata)
assert ref.path.endswith(".json")
assert ref.mime_type == "application/json"
assert ref.size_bytes > 0
assert ref.checksum != ""
assert "test_tool" in ref.path
def test_store_text(self, temp_store):
"""Test storing text content."""
from praisonaiagents.context.artifacts import ArtifactMetadata
content = "Hello, world!\nThis is a test."
metadata = ArtifactMetadata(agent_id="agent1", run_id="run1")
ref = temp_store.store(content, metadata)
assert ref.path.endswith(".txt")
assert ref.mime_type == "text/plain"
def test_load(self, temp_store):
"""Test loading stored content."""
from praisonaiagents.context.artifacts import ArtifactMetadata
original = {"data": [1, 2, 3]}
metadata = ArtifactMetadata(agent_id="agent1", run_id="run1")
ref = temp_store.store(original, metadata)
loaded = temp_store.load(ref)
assert loaded == original
def test_tail(self, temp_store):
"""Test tail operation."""
from praisonaiagents.context.artifacts import ArtifactMetadata
lines = "\n".join([f"Line {i}" for i in range(100)])
metadata = ArtifactMetadata(agent_id="agent1", run_id="run1")
ref = temp_store.store(lines, metadata)
tail = temp_store.tail(ref, lines=5)
assert "Line 95" in tail
assert "Line 99" in tail
def test_head(self, temp_store):
"""Test head operation."""
from praisonaiagents.context.artifacts import ArtifactMetadata
lines = "\n".join([f"Line {i}" for i in range(100)])
metadata = ArtifactMetadata(agent_id="agent1", run_id="run1")
ref = temp_store.store(lines, metadata)
head = temp_store.head(ref, lines=5)
assert "Line 0" in head
assert "Line 4" in head
assert "Line 5" not in head
def test_grep(self, temp_store):
"""Test grep operation."""
from praisonaiagents.context.artifacts import ArtifactMetadata
content = "Error: Something went wrong\nInfo: All good\nError: Another error"
metadata = ArtifactMetadata(agent_id="agent1", run_id="run1")
ref = temp_store.store(content, metadata)
matches = temp_store.grep(ref, pattern="Error")
assert len(matches) == 2
assert matches[0].line_number == 1
assert "Something went wrong" in matches[0].line_content
def test_chunk(self, temp_store):
"""Test chunk operation."""
from praisonaiagents.context.artifacts import ArtifactMetadata
lines = "\n".join([f"Line {i}" for i in range(100)])
metadata = ArtifactMetadata(agent_id="agent1", run_id="run1")
ref = temp_store.store(lines, metadata)
chunk = temp_store.chunk(ref, start_line=10, end_line=15)
assert "Line 9" in chunk
assert "Line 14" in chunk
assert "Line 15" not in chunk
def test_list_artifacts(self, temp_store):
"""Test listing artifacts."""
from praisonaiagents.context.artifacts import ArtifactMetadata
# Store multiple artifacts
for i in range(3):
metadata = ArtifactMetadata(
agent_id="agent1",
run_id="run1",
tool_name=f"tool{i}",
)
temp_store.store(f"Content {i}", metadata)
artifacts = temp_store.list_artifacts(run_id="run1")
assert len(artifacts) == 3
def test_delete(self, temp_store):
"""Test deleting artifacts."""
from praisonaiagents.context.artifacts import ArtifactMetadata
metadata = ArtifactMetadata(agent_id="agent1", run_id="run1")
ref = temp_store.store("Test content", metadata)
assert Path(ref.path).exists()
result = temp_store.delete(ref)
assert result is True
assert not Path(ref.path).exists()
def test_secret_redaction(self, temp_store):
"""Test that secrets are redacted."""
from praisonaiagents.context.artifacts import ArtifactMetadata
content = 'api_key="sk-1234567890abcdef" password="secret123"'
metadata = ArtifactMetadata(agent_id="agent1", run_id="run1")
ref = temp_store.store(content, metadata)
loaded = temp_store.load(ref)
assert "sk-1234567890abcdef" not in loaded
assert "secret123" not in loaded
assert "[REDACTED]" in loaded
class TestOutputQueue:
"""Tests for OutputQueue."""
@pytest.fixture
def temp_queue(self):
"""Create a temporary output queue."""
with tempfile.TemporaryDirectory() as tmpdir:
from praisonai.context import OutputQueue
queue = OutputQueue(base_dir=tmpdir)
yield queue
def test_small_output_not_queued(self, temp_queue):
"""Test that small outputs are not queued."""
from praisonaiagents.context.artifacts import ArtifactMetadata, ArtifactRef
content = "Small output"
metadata = ArtifactMetadata(agent_id="agent1", run_id="run1")
result = temp_queue.process(content, metadata)
assert not isinstance(result, ArtifactRef)
assert result == content
def test_large_output_queued(self, temp_queue):
"""Test that large outputs are queued."""
from praisonaiagents.context.artifacts import ArtifactMetadata, ArtifactRef
# Create content larger than threshold (32KB default)
content = "x" * (40 * 1024)
metadata = ArtifactMetadata(agent_id="agent1", run_id="run1")
result = temp_queue.process(content, metadata)
assert isinstance(result, ArtifactRef)
assert result.size_bytes > 0
def test_should_queue(self, temp_queue):
"""Test should_queue logic."""
small = "small"
large = "x" * (40 * 1024)
assert temp_queue.should_queue(small) is False
assert temp_queue.should_queue(large) is True
class TestHelperFunctions:
"""Tests for helper functions."""
def test_compute_checksum(self):
"""Test checksum computation."""
from praisonaiagents.context.artifacts import compute_checksum
content = "Hello, world!"
checksum = compute_checksum(content)
assert len(checksum) == 64 # SHA256 hex digest
# Same content should produce same checksum
assert compute_checksum(content) == checksum
# Different content should produce different checksum
assert compute_checksum("Different") != checksum
def test_generate_summary_dict(self):
"""Test summary generation for dict."""
from praisonaiagents.context.artifacts import generate_summary
content = {"key1": "value1", "key2": "value2", "key3": "value3"}
summary = generate_summary(content)
assert "Dict" in summary
assert "key1" in summary
def test_generate_summary_list(self):
"""Test summary generation for list."""
from praisonaiagents.context.artifacts import generate_summary
content = [1, 2, 3, 4, 5]
summary = generate_summary(content)
assert "List" in summary
assert "5" in summary
def test_generate_summary_string(self):
"""Test summary generation for string."""
from praisonaiagents.context.artifacts import generate_summary
content = "A" * 300
summary = generate_summary(content, max_chars=100)
assert len(summary) <= 100
assert summary.endswith("...")
+4 -4
View File
@@ -4276,7 +4276,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 = ">=0.11.6" },
{ name = "praisonaiagents", specifier = ">=0.11.8" },
{ name = "pyautogen", marker = "extra == 'autogen'", specifier = "==0.2.29" },
{ name = "pydantic", marker = "extra == 'chat'", specifier = "<=2.10.1" },
{ name = "pydantic", marker = "extra == 'code'", specifier = "<=2.10.1" },
@@ -4324,7 +4324,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.11.6"
version = "0.11.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@@ -4333,9 +4333,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e8/c1/a113cd884d9ca0747f31dc652740b5a859fdc301017f45d75382a0ceba4e/praisonaiagents-0.11.6.tar.gz", hash = "sha256:fb19b18cea0e2cccd7221e34b309dec43d6960463f39de922cd749670d1cae4f", size = 981805, upload-time = "2026-01-12T12:43:47.183Z" }
sdist = { url = "https://files.pythonhosted.org/packages/92/e8/e0a52140b3c09be369dd48dbb71addf8bb711c3267d05abf352ef6717f55/praisonaiagents-0.11.8.tar.gz", hash = "sha256:16599d391838d456bb4933c5778049ff366eb2fb812f8e5f645b2d60c9c919c5", size = 993464, upload-time = "2026-01-13T04:11:31.609Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/b5/4129fd74320c367a3d6365fb4481da3a3f7117de36dbb38ea9215e760d1f/praisonaiagents-0.11.6-py3-none-any.whl", hash = "sha256:768288047146ba9431dc23c07b222ebb97d1e05c648f6f3a37cfa71d59a30cdb", size = 964812, upload-time = "2026-01-12T12:43:44.786Z" },
{ url = "https://files.pythonhosted.org/packages/d3/4a/edf2cffa71f3f76d3f1d393ce9bff64a4873e54a8a2abed254cd7494d0bf/praisonaiagents-0.11.8-py3-none-any.whl", hash = "sha256:2c5b74d1bbe7f8f835ca698748103f5d7ab97fca2b57ec40eaec597df620e888", size = 977507, upload-time = "2026-01-13T04:11:29.481Z" },
]
[[package]]