mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-11 03:31:27 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6df3e32640 | ||
|
|
fb36e1c50c | ||
|
|
ac5454cb00 | ||
|
|
bbe95795d1 |
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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]"
|
||||
|
||||
|
||||
@@ -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]" \
|
||||
|
||||
@@ -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]"
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
Generated
+1
-1
@@ -2422,7 +2422,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "0.11.6"
|
||||
version = "0.11.8"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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 @@
|
||||
__version__ = "3.8.0"
|
||||
__version__ = "3.8.2"
|
||||
@@ -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",
|
||||
|
||||
@@ -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("...")
|
||||
Generated
+4
-4
@@ -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]]
|
||||
|
||||
Reference in new issue
Block a user