mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-10 17:29:39 -10:00
Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f59ba1b83d | ||
|
|
8c88766900 | ||
|
|
a6eaa854fe | ||
|
|
e36911e712 | ||
|
|
c3a6a39ee1 | ||
|
|
15146ff6a7 | ||
|
|
0d0c2fa762 | ||
|
|
c9010fca9f | ||
|
|
7a36352196 | ||
|
|
6ae4ebedcb | ||
|
|
3377347087 | ||
|
|
f47aa248ea | ||
|
|
0379e9f196 | ||
|
|
c841d46ae9 | ||
|
|
cd6fdd0107 | ||
|
|
d840407b12 | ||
|
|
b99ca3f0ae | ||
|
|
23c48f6cd5 | ||
|
|
d0fa48b110 | ||
|
|
0d2c1bd961 | ||
|
|
ef6b1a8913 | ||
|
|
dc9c250adb | ||
|
|
d1fb090a07 | ||
|
|
8479fffa8c | ||
|
|
8555b5ad69 | ||
|
|
16f9325176 | ||
|
|
fc9f8c5e1d | ||
|
|
f5ee866e6c | ||
|
|
af1c26c471 | ||
|
|
f20d5d95e4 | ||
|
|
ac89f8a17c | ||
|
|
abc7f09046 | ||
|
|
a0287a9919 | ||
|
|
5046e9cf88 | ||
|
|
89624c1580 | ||
|
|
cc719e5379 | ||
|
|
e854a50fd2 | ||
|
|
99fde9b9d9 | ||
|
|
3c73ca95b6 | ||
|
|
d7c274489e | ||
|
|
23f6c00e21 | ||
|
|
a293999e8b | ||
|
|
0b14ae49ef | ||
|
|
b33c649de5 | ||
|
|
02a1977651 |
No files matched your search
+5
-1
@@ -91,4 +91,8 @@ issue_details.json
|
||||
target/
|
||||
Cargo.lock
|
||||
**/*.rs.bk
|
||||
*.pdb
|
||||
*.pdb
|
||||
.windsurf
|
||||
.agent
|
||||
|
||||
src/praisonai/build
|
||||
@@ -381,7 +381,7 @@ from praisonaiagents import GuardrailAction, GuardrailConfig, LLMGuardrail
|
||||
Methods:
|
||||
|
||||
* <code title="class GuardrailConfig">GuardrailConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">to_dict</a>() -> Dict[str, Any]</code>
|
||||
* <code title="class LLMGuardrail">LLMGuardrail.<a href="./src/praisonai-agents/praisonaiagents/guardrails/llm_guardrail.py">__call__</a>(task_output: TaskOutput) -> Tuple[bool, Union[str, TaskOutput]]</code>
|
||||
* <code title="class LLMGuardrail">LLMGuardrail.<a href="./src/praisonai-agents/praisonaiagents/guardrails/llm_guardrail.py">__call__</a>(task_output) -> Tuple[bool, Union[str, 'TaskOutput']]</code>
|
||||
|
||||
# Planning
|
||||
|
||||
@@ -868,7 +868,9 @@ Methods:
|
||||
* <code title="cli">praisonai examples run <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
|
||||
* <code title="cli">praisonai examples run-all <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
|
||||
* <code title="cli">praisonai examples stats <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway channels <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway gateway-callback <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway send <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway start <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway status <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai hooks add <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
|
||||
@@ -906,6 +908,7 @@ Methods:
|
||||
* <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>
|
||||
* <code title="cli">praisonai paths show <a href="./src/praisonai/praisonai/cli/commands/paths.py">--help</a></code>
|
||||
* <code title="cli">praisonai plugins create <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
|
||||
* <code title="cli">praisonai plugins disable <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
|
||||
* <code title="cli">praisonai plugins discover <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
|
||||
@@ -923,6 +926,8 @@ Methods:
|
||||
* <code title="cli">praisonai profile snapshot <a href="./src/praisonai/praisonai/cli/commands/profile.py">--help</a></code>
|
||||
* <code title="cli">praisonai profile startup <a href="./src/praisonai/praisonai/cli/commands/profile.py">--help</a></code>
|
||||
* <code title="cli">praisonai profile suite <a href="./src/praisonai/praisonai/cli/commands/profile.py">--help</a></code>
|
||||
* <code title="cli">praisonai publish publish-main <a href="./src/praisonai/praisonai/cli/commands/publish.py">--help</a></code>
|
||||
* <code title="cli">praisonai publish pypi <a href="./src/praisonai/praisonai/cli/commands/publish.py">--help</a></code>
|
||||
* <code title="cli">praisonai rag chat <a href="./src/praisonai/praisonai/cli/commands/rag.py">--help</a></code>
|
||||
* <code title="cli">praisonai rag eval <a href="./src/praisonai/praisonai/cli/commands/rag.py">--help</a></code>
|
||||
* <code title="cli">praisonai rag index <a href="./src/praisonai/praisonai/cli/commands/rag.py">--help</a></code>
|
||||
@@ -961,6 +966,7 @@ Methods:
|
||||
* <code title="cli">praisonai sandbox recreate <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
|
||||
* <code title="cli">praisonai sandbox sandbox-callback <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
|
||||
* <code title="cli">praisonai sandbox status <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule add <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule delete <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule describe <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule list <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.5.15" \
|
||||
"praisonai>=4.5.41" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.5.15" \
|
||||
"praisonai>=4.5.41" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.5.15" \
|
||||
"praisonai>=4.5.41" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Calculator with Tests - Editor Output Example.
|
||||
|
||||
Builds a calculator module, writes unit tests, runs them, generates a
|
||||
coverage-style report, and reads it back.
|
||||
"""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools import (
|
||||
write_file, read_file, execute_command
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
instructions="You are a helpful coding assistant.",
|
||||
output="editor",
|
||||
tools=[write_file, read_file, execute_command],
|
||||
approval=True,
|
||||
)
|
||||
agent.start(
|
||||
"Build a mini calculator - create /tmp/calculator.py with add, subtract, "
|
||||
"multiply, divide functions, write unit tests in /tmp/test_calculator.py "
|
||||
"using unittest, run the tests to make sure they pass, then generate a "
|
||||
"coverage-style report in /tmp/calc_report.md listing which functions were "
|
||||
"tested and read it back"
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Fibonacci Generator - Editor Output Example.
|
||||
|
||||
Creates a Fibonacci script, runs it, writes a summary, and reads back
|
||||
both files to verify everything looks good.
|
||||
"""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools import (
|
||||
write_file, read_file, execute_command, list_directory
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
instructions="You are a helpful coding assistant.",
|
||||
output="editor",
|
||||
tools=[write_file, read_file, execute_command, list_directory],
|
||||
approval=True,
|
||||
)
|
||||
agent.start(
|
||||
"Create a Python file at /tmp/fibonacci.py that generates the first 20 "
|
||||
"Fibonacci numbers, run it to verify the output is correct, then write "
|
||||
"a summary to /tmp/fib_summary.md explaining the algorithm you used and "
|
||||
"the output, finally read back both files to confirm everything looks good"
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Multi-Step Research - Editor Output Example.
|
||||
|
||||
Researches Python frameworks, creates comparison code, runs it, writes a
|
||||
markdown report, reads it back, lists files, and gets system info.
|
||||
"""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools import (
|
||||
write_file, read_file, execute_command, list_directory, get_system_info
|
||||
)
|
||||
|
||||
try:
|
||||
from praisonaiagents.tools import web_search
|
||||
tools = [web_search, write_file, read_file, execute_command,
|
||||
list_directory, get_system_info]
|
||||
except ImportError:
|
||||
tools = [write_file, read_file, execute_command,
|
||||
list_directory, get_system_info]
|
||||
|
||||
agent = Agent(
|
||||
instructions="You are a helpful research assistant.",
|
||||
output="editor",
|
||||
tools=tools,
|
||||
approval=True,
|
||||
)
|
||||
agent.start(
|
||||
"Research the top 3 Python web frameworks (Django, FastAPI, Flask), then: "
|
||||
"1) Search the web for each framework's latest version and key features, "
|
||||
"2) Create a file called /tmp/framework_comparison.py that contains a "
|
||||
"Python dictionary with the comparison data, "
|
||||
"3) Execute the code to verify the dictionary is valid, "
|
||||
"4) Write a markdown report to /tmp/framework_report.md summarizing your "
|
||||
"findings in a table format, "
|
||||
"5) Read back the report file to verify it was written correctly, "
|
||||
"6) List the files in /tmp to confirm both files exist, "
|
||||
"7) Get system info to note what OS this report was generated on"
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""System Audit - Editor Output Example.
|
||||
|
||||
Runs system commands, gathers info, writes a report, and reads it back.
|
||||
"""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools import (
|
||||
write_file, read_file, execute_command
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
instructions="You are a helpful system administration assistant.",
|
||||
output="editor",
|
||||
tools=[write_file, read_file, execute_command],
|
||||
approval=True,
|
||||
)
|
||||
agent.start(
|
||||
"Investigate my system - get the current date, check disk usage, find out "
|
||||
"what Python version is installed, list running processes sorted by memory, "
|
||||
"then write all findings to /tmp/system_audit.md as a neat report and read "
|
||||
"it back to verify"
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Weather Search - Editor Output Example.
|
||||
|
||||
Searches for weather, creates JSON data, writes a parser script, executes
|
||||
it, and lists created files.
|
||||
"""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools import (
|
||||
write_file, execute_command, list_directory
|
||||
)
|
||||
|
||||
try:
|
||||
from praisonaiagents.tools import web_search
|
||||
tools = [web_search, write_file, execute_command, list_directory]
|
||||
except ImportError:
|
||||
tools = [write_file, execute_command, list_directory]
|
||||
|
||||
agent = Agent(
|
||||
instructions="You are a helpful research assistant.",
|
||||
output="editor",
|
||||
tools=tools,
|
||||
approval=True,
|
||||
)
|
||||
agent.start(
|
||||
"Search the web for the current weather in Tokyo, create a JSON file at "
|
||||
"/tmp/weather_data.json with the findings, then write a Python script at "
|
||||
"/tmp/parse_weather.py that reads the JSON and prints a formatted weather "
|
||||
"report, execute the script, and list all files you created in /tmp"
|
||||
)
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Calculator with tests: code + unit tests + test execution + coverage report
|
||||
# Tests: multiple file creation, test execution, report generation, file reading
|
||||
|
||||
praisonai "Build a mini calculator - create /tmp/calculator.py with add, subtract, multiply, divide functions, write unit tests in /tmp/test_calculator.py using unittest, run the tests to make sure they pass, then generate a coverage-style report in /tmp/calc_report.md listing which functions were tested and read it back" --trust
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Fibonacci generator: code creation, execution, documentation, verification
|
||||
# Tests: file creation, code execution, markdown writing, file reading
|
||||
|
||||
praisonai "Create a Python file at /tmp/fibonacci.py that generates the first 20 Fibonacci numbers, run it to verify the output is correct, then write a summary to /tmp/fib_summary.md explaining the algorithm you used and the output, finally read back both files to confirm everything looks good" --trust
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Multi-step research: web search, file creation, code execution, reporting
|
||||
# Tests: web search, file write, code execution, markdown report, file read, directory listing, system info
|
||||
|
||||
praisonai "Research the top 3 Python web frameworks (Django, FastAPI, Flask), then: 1) Search the web for each framework's latest version and key features, 2) Create a file called /tmp/framework_comparison.py that contains a Python dictionary with the comparison data, 3) Execute the code to verify the dictionary is valid, 4) Write a markdown report to /tmp/framework_report.md summarizing your findings in a table format, 5) Read back the report file to verify it was written correctly, 6) List the files in /tmp to confirm both files exist, 7) Get system info to note what OS this report was generated on, 8) Analyze the Python code you wrote for any issues, 9) Format the Python code properly, 10) Finally search for any recent news about Python 3.13 features" --trust
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
# System audit: multiple command executions, report generation
|
||||
# Tests: command execution (date, disk, python, processes), file creation, file reading
|
||||
|
||||
praisonai "Investigate my system - get the current date, check disk usage, find out what Python version is installed, list running processes sorted by memory, then write all findings to /tmp/system_audit.md as a neat report and read it back to verify" --trust
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Todo app: multi-file app creation, execution, documentation, verification
|
||||
# Tests: multiple file creation, code execution, markdown docs, directory listing
|
||||
|
||||
praisonai "Write a todo list app at /tmp/todo.py with functions to add, remove, and list tasks stored in /tmp/todos.json, run the script with some sample tasks added, then create documentation at /tmp/todo_docs.md explaining usage with examples, and verify all three files exist" --trust
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Weather search: web search, JSON creation, Python script, execution
|
||||
# Tests: web search, JSON file creation, Python scripting, code execution, directory listing
|
||||
|
||||
praisonai "Search the web for the current weather in Tokyo, create a JSON file at /tmp/weather_data.json with the findings, then write a Python script at /tmp/parse_weather.py that reads the JSON and prints a formatted weather report, execute the script, and list all files you created in /tmp" --trust
|
||||
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
Example file-based action: line-count
|
||||
|
||||
Counts lines in files matching a pattern. Demonstrates how actions
|
||||
receive step config and flags from the workflow YAML.
|
||||
|
||||
Usage in workflow:
|
||||
- name: Count lines
|
||||
action: line-count
|
||||
pattern: "*.py"
|
||||
directory: src/
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run(step, flags, cwd):
|
||||
"""Count lines in files matching a glob pattern."""
|
||||
pattern = step.get("pattern", "*.py")
|
||||
directory = step.get("directory", ".")
|
||||
target = Path(cwd) / directory
|
||||
|
||||
if not target.exists():
|
||||
return {"ok": False, "error": f"Directory not found: {target}"}
|
||||
|
||||
total_lines = 0
|
||||
file_count = 0
|
||||
|
||||
for filepath in target.rglob(pattern):
|
||||
if filepath.is_file():
|
||||
try:
|
||||
total_lines += len(filepath.read_text().splitlines())
|
||||
file_count += 1
|
||||
except (UnicodeDecodeError, PermissionError):
|
||||
continue
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"output": f"{file_count} files, {total_lines} lines ({pattern} in {directory})",
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
Example file-based action: system-info
|
||||
|
||||
Collects basic system information. Demonstrates the action file contract:
|
||||
- Must define run(step, flags, cwd) → dict
|
||||
- Return {"ok": True, "output": "..."} on success
|
||||
- Return {"ok": False, "error": "..."} on failure
|
||||
|
||||
Place in: actions/system_info.py (next to workflow)
|
||||
or: .praison/actions/system_info.py (project-level)
|
||||
"""
|
||||
|
||||
import platform
|
||||
import os
|
||||
|
||||
|
||||
def run(step, flags, cwd):
|
||||
"""Collect system information."""
|
||||
info_parts = [
|
||||
f"OS: {platform.system()} {platform.release()}",
|
||||
f"Python: {platform.python_version()}",
|
||||
f"Machine: {platform.machine()}",
|
||||
f"CWD: {cwd}",
|
||||
]
|
||||
|
||||
# Optional: include env vars if requested via step config
|
||||
if step.get("show_env"):
|
||||
env_vars = step.get("show_env", "").split(",")
|
||||
for var in env_vars:
|
||||
var = var.strip()
|
||||
info_parts.append(f"{var}: {os.environ.get(var, '(not set)')}")
|
||||
|
||||
return {"ok": True, "output": " | ".join(info_parts)}
|
||||
@@ -0,0 +1,33 @@
|
||||
# Example: File-Based Actions (Option A)
|
||||
#
|
||||
# Actions loaded from .py files in the actions/ directory next to this workflow.
|
||||
# Each action file exports: run(step, flags, cwd) → dict
|
||||
#
|
||||
# Directory structure:
|
||||
# examples/workflows/
|
||||
# ├── file-actions-demo.yaml ← this file
|
||||
# └── actions/
|
||||
# ├── system_info.py ← action: system-info
|
||||
# └── line_count.py ← action: line-count
|
||||
#
|
||||
# Usage:
|
||||
# praisonai workflow run file-actions-demo.yaml
|
||||
# praisonai workflow run file-actions-demo.yaml --dry-run
|
||||
|
||||
type: job
|
||||
name: file-actions-demo
|
||||
description: Demonstrates file-based custom actions from actions/ directory
|
||||
|
||||
steps:
|
||||
- name: System info
|
||||
action: system-info
|
||||
|
||||
- name: Count Python files
|
||||
action: line-count
|
||||
pattern: "*.py"
|
||||
directory: "."
|
||||
|
||||
- name: Count YAML files
|
||||
action: line-count
|
||||
pattern: "*.yaml"
|
||||
directory: "."
|
||||
@@ -0,0 +1,40 @@
|
||||
# Example Step Workflow: Publish to PyPI
|
||||
# Usage:
|
||||
# praisonai workflow run publish-pypi.yaml --dry-run
|
||||
# praisonai workflow run publish-pypi.yaml
|
||||
# praisonai workflow run publish-pypi.yaml --major
|
||||
# praisonai workflow run publish-pypi.yaml --minor
|
||||
# praisonai workflow run publish-pypi.yaml --no-bump
|
||||
|
||||
type: job
|
||||
name: publish-pypi
|
||||
description: Publish package to PyPI with automatic version bump
|
||||
|
||||
vars:
|
||||
token_var:
|
||||
default: PYPI_TOKEN
|
||||
description: Environment variable for PyPI token
|
||||
|
||||
flags:
|
||||
major: { description: "Bump major version (X.0.0)" }
|
||||
minor: { description: "Bump minor version (X.Y.0)" }
|
||||
no-bump: { description: "Skip version bump" }
|
||||
|
||||
steps:
|
||||
- name: Bump version
|
||||
action: bump-version
|
||||
file: pyproject.toml
|
||||
strategy: patch
|
||||
if: "{{ not flags.no_bump }}"
|
||||
|
||||
- name: Clean dist
|
||||
run: rm -rf dist
|
||||
|
||||
- name: Lock dependencies
|
||||
run: uv lock
|
||||
|
||||
- name: Build package
|
||||
run: uv build
|
||||
|
||||
- name: Publish to PyPI
|
||||
run: uv publish --token ${{ env.PYPI_TOKEN }}
|
||||
@@ -0,0 +1,40 @@
|
||||
# Example: YAML-Defined Actions (Option B)
|
||||
#
|
||||
# Actions defined inline in the workflow — self-contained, portable.
|
||||
# No external files needed.
|
||||
#
|
||||
# Usage:
|
||||
# praisonai workflow run yaml-actions-demo.yaml
|
||||
# praisonai workflow run yaml-actions-demo.yaml --dry-run
|
||||
|
||||
type: job
|
||||
name: yaml-actions-demo
|
||||
description: Demonstrates YAML-defined custom actions
|
||||
|
||||
# Define reusable actions inline
|
||||
actions:
|
||||
check-python:
|
||||
run: python3 --version
|
||||
|
||||
count-files:
|
||||
script: |
|
||||
import os
|
||||
cwd = os.getcwd()
|
||||
py_files = [f for f in os.listdir(cwd) if f.endswith('.py')]
|
||||
yaml_files = [f for f in os.listdir(cwd) if f.endswith('.yaml') or f.endswith('.yml')]
|
||||
result = f"Python: {len(py_files)}, YAML: {len(yaml_files)}"
|
||||
|
||||
timestamp:
|
||||
script: |
|
||||
from datetime import datetime
|
||||
result = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
steps:
|
||||
- name: Check Python version
|
||||
action: check-python
|
||||
|
||||
- name: Count project files
|
||||
action: count-files
|
||||
|
||||
- name: Get timestamp
|
||||
action: timestamp
|
||||
@@ -0,0 +1,70 @@
|
||||
# Agent-Centric Job Workflow Example
|
||||
#
|
||||
# This workflow demonstrates the new agent-centric step types:
|
||||
# - agent: AI agent execution
|
||||
# - judge: AI quality gate with threshold
|
||||
# - approve: AI or human approval gate
|
||||
#
|
||||
# Run with: praisonai workflow run agent-centric-job-workflow.yaml
|
||||
# Dry run: praisonai workflow run agent-centric-job-workflow.yaml --dry-run
|
||||
|
||||
type: job
|
||||
name: smart-release
|
||||
description: Intelligent release workflow with AI-powered steps
|
||||
|
||||
flags:
|
||||
major:
|
||||
description: Bump major version
|
||||
minor:
|
||||
description: Bump minor version
|
||||
skip-approval:
|
||||
description: Skip approval gate (auto-approve)
|
||||
|
||||
steps:
|
||||
# Step 1: Deterministic - Bump version
|
||||
- name: Bump version
|
||||
action: bump-version
|
||||
strategy: patch
|
||||
|
||||
# Step 2: Agent - Generate changelog
|
||||
- name: Generate changelog
|
||||
agent:
|
||||
role: Technical Writer
|
||||
instructions: |
|
||||
You are a technical writer who creates clear, concise changelogs.
|
||||
Focus on user-facing changes, bug fixes, and new features.
|
||||
prompt: |
|
||||
Generate a changelog entry for the latest release.
|
||||
Include sections for: Features, Bug Fixes, and Breaking Changes.
|
||||
Keep it concise and professional.
|
||||
model: gpt-4o-mini
|
||||
output_file: CHANGELOG_ENTRY.md
|
||||
|
||||
# Step 3: Judge - Quality check
|
||||
- name: Quality check
|
||||
judge:
|
||||
input_file: CHANGELOG_ENTRY.md
|
||||
criteria: |
|
||||
- Completeness: All sections present (Features, Bug Fixes, Breaking Changes)
|
||||
- Clarity: Easy to understand for end users
|
||||
- Formatting: Proper markdown structure
|
||||
- Conciseness: Not overly verbose
|
||||
threshold: 7.0
|
||||
on_fail: warn
|
||||
model: gpt-4o-mini
|
||||
|
||||
# Step 4: Approve - Human approval gate
|
||||
- name: Approve release
|
||||
approve:
|
||||
description: Review and approve the release
|
||||
risk_level: medium
|
||||
auto_approve: "{{ flags.skip_approval }}"
|
||||
|
||||
# Step 5: Deterministic - Build
|
||||
- name: Build package
|
||||
run: echo "Building package..."
|
||||
|
||||
# Step 6: Deterministic - Publish (conditional)
|
||||
- name: Publish to PyPI
|
||||
run: echo "Publishing to PyPI..."
|
||||
if: "{{ not flags.skip_approval }}"
|
||||
@@ -0,0 +1,89 @@
|
||||
# Hybrid Workflow Example
|
||||
#
|
||||
# This workflow demonstrates the `type: hybrid` feature that combines:
|
||||
# - Deterministic steps (run, action) from job workflows
|
||||
# - Agent-centric steps (agent, judge, approve) from job workflows
|
||||
# - Multi-agent workflow steps (workflow, parallel)
|
||||
#
|
||||
# Run with: praisonai workflow run hybrid-workflow.yaml
|
||||
# Dry run: praisonai workflow run hybrid-workflow.yaml --dry-run
|
||||
|
||||
type: hybrid
|
||||
name: hybrid-release-pipeline
|
||||
description: Hybrid workflow combining shell commands, AI agents, and multi-agent steps
|
||||
|
||||
# Define agents for multi-agent workflow steps
|
||||
agents:
|
||||
researcher:
|
||||
name: Researcher
|
||||
role: Research Analyst
|
||||
goal: Research and gather information
|
||||
instructions: Provide concise, factual research findings
|
||||
model: gpt-4o-mini
|
||||
|
||||
writer:
|
||||
name: Writer
|
||||
role: Content Writer
|
||||
goal: Write clear, engaging content
|
||||
instructions: Write professional documentation
|
||||
model: gpt-4o-mini
|
||||
|
||||
flags:
|
||||
skip-tests:
|
||||
description: Skip test execution
|
||||
auto-approve:
|
||||
description: Auto-approve deployment
|
||||
|
||||
steps:
|
||||
# Step 1: Deterministic - Check environment
|
||||
- name: Check environment
|
||||
run: echo "Checking environment..." && python --version
|
||||
|
||||
# Step 2: Agent - Generate release notes
|
||||
- name: Generate release notes
|
||||
agent:
|
||||
role: Technical Writer
|
||||
instructions: You write clear, concise release notes
|
||||
prompt: Generate release notes for version 1.2.0 with bug fixes and new features
|
||||
model: gpt-4o-mini
|
||||
output_file: RELEASE_NOTES.md
|
||||
|
||||
# Step 3: Parallel - Run multiple checks simultaneously
|
||||
- name: Parallel checks
|
||||
parallel:
|
||||
- run: echo "Running lint check..."
|
||||
- run: echo "Running type check..."
|
||||
- run: echo "Running security scan..."
|
||||
|
||||
# Step 4: Multi-agent workflow step
|
||||
- name: Research and document
|
||||
workflow:
|
||||
agent: researcher
|
||||
action: Research best practices for release management
|
||||
|
||||
# Step 5: Judge - Quality gate
|
||||
- name: Quality check
|
||||
judge:
|
||||
input_file: RELEASE_NOTES.md
|
||||
criteria: |
|
||||
- Completeness: All sections present
|
||||
- Clarity: Easy to understand
|
||||
- Professional tone
|
||||
threshold: 7.0
|
||||
on_fail: warn
|
||||
|
||||
# Step 6: Approve - Human approval gate
|
||||
- name: Approve release
|
||||
approve:
|
||||
description: Review and approve the release
|
||||
risk_level: medium
|
||||
auto_approve: "{{ flags.auto_approve }}"
|
||||
|
||||
# Step 7: Deterministic - Build
|
||||
- name: Build package
|
||||
run: echo "Building package..."
|
||||
if: "{{ not flags.skip_tests }}"
|
||||
|
||||
# Step 8: Final notification
|
||||
- name: Notify completion
|
||||
run: echo "✅ Hybrid workflow completed successfully!"
|
||||
@@ -123,6 +123,8 @@ Pattern: Each major module has a protocols.py file
|
||||
- Protocols define WHAT (interface contract)
|
||||
- Adapters implement HOW (concrete implementation)
|
||||
- Naming: XProtocol for interfaces, XAdapter for implementations
|
||||
(ALWAYS suffix with "Protocol" — never use bare concept names like TraceSink)
|
||||
- File placement: All protocols live in protocols.py within their module
|
||||
- Users can implement any protocol for custom behavior
|
||||
```
|
||||
|
||||
@@ -286,9 +288,19 @@ save() / load() # Disk persistence (NOT store)
|
||||
# Configuration
|
||||
class XConfig: # Configuration dataclass (MemoryConfig, HooksConfig)
|
||||
|
||||
# Protocols
|
||||
class XProtocol(Protocol): # Abstract interface
|
||||
class XAdapter: # Implementation
|
||||
# Protocols — ALWAYS suffix with "Protocol"
|
||||
class XProtocol(Protocol): # Abstract interface (TraceSinkProtocol, MemoryProtocol)
|
||||
class XAdapter: # Concrete implementation (ChromaAdapter, FileAdapter)
|
||||
|
||||
# ✅ CORRECT protocol naming:
|
||||
class MemoryProtocol(Protocol): ... # Clear it's a protocol
|
||||
class SessionStoreProtocol(Protocol): ... # Descriptive + Protocol suffix
|
||||
class TraceSinkProtocol(Protocol): ... # In protocols.py + suffixed
|
||||
|
||||
# ❌ WRONG protocol naming:
|
||||
class TraceSink(Protocol): ... # Missing Protocol suffix — ambiguous
|
||||
class IMemory(Protocol): ... # TypeScript convention, not Python
|
||||
class MemoryBase(Protocol): ... # "Base" implies ABC, not Protocol
|
||||
|
||||
# Execution
|
||||
run() # Synchronous, blocking
|
||||
|
||||
@@ -344,6 +344,14 @@ _LAZY_IMPORTS = {
|
||||
'ObservabilityEventType': ('praisonaiagents.escalation.observability', 'EventType'),
|
||||
'DoomLoopDetector': ('praisonaiagents.escalation.doom_loop', 'DoomLoopDetector'),
|
||||
'MemoryBackend': ('praisonaiagents.config.feature_configs', 'MemoryBackend'),
|
||||
'LearnConfig': ('praisonaiagents.config.feature_configs', 'LearnConfig'),
|
||||
'LearnScope': ('praisonaiagents.config.feature_configs', 'LearnScope'),
|
||||
'LearnMode': ('praisonaiagents.config.feature_configs', 'LearnMode'),
|
||||
'LearnBackend': ('praisonaiagents.config.feature_configs', 'LearnBackend'),
|
||||
'LearnProtocol': ('praisonaiagents.memory.learn.protocols', 'LearnProtocol'),
|
||||
'AsyncLearnProtocol': ('praisonaiagents.memory.learn.protocols', 'AsyncLearnProtocol'),
|
||||
'LearnManagerProtocol': ('praisonaiagents.memory.learn.protocols', 'LearnManagerProtocol'),
|
||||
'LearnManager': ('praisonaiagents.memory.learn', 'LearnManager'),
|
||||
'ChunkingStrategy': ('praisonaiagents.config.feature_configs', 'ChunkingStrategy'),
|
||||
'GuardrailAction': ('praisonaiagents.config.feature_configs', 'GuardrailAction'),
|
||||
'WebSearchProvider': ('praisonaiagents.config.feature_configs', 'WebSearchProvider'),
|
||||
|
||||
@@ -438,6 +438,8 @@ class Agent:
|
||||
hooks: Optional[Union[List[Any], Any]] = None, # Union[list, HooksConfig]
|
||||
skills: Optional[Union[List[str], Any]] = None, # Union[list, SkillsConfig]
|
||||
approval: Optional[Union[bool, Any]] = None, # Union[bool, ApprovalProtocol backend]
|
||||
tool_timeout: Optional[int] = None, # P8/G11: Timeout in seconds for each tool call
|
||||
learn: Optional[Union[bool, Any]] = None, # Union[bool, LearnConfig] - Continuous learning (peer to memory)
|
||||
):
|
||||
"""Initialize an Agent instance.
|
||||
|
||||
@@ -508,6 +510,11 @@ class Agent:
|
||||
skills: Agent skills. Accepts:
|
||||
- List[str]: Skill directory paths
|
||||
- SkillsConfig: Custom configuration
|
||||
learn: Continuous learning configuration. Accepts:
|
||||
- bool: True enables with defaults, False disables
|
||||
- LearnConfig: Custom configuration
|
||||
Learning is a first-class citizen, peer to memory. It captures patterns,
|
||||
preferences, and insights from interactions to improve future responses.
|
||||
|
||||
Raises:
|
||||
ValueError: If all of name, role, goal, backstory, and instructions are None.
|
||||
@@ -713,6 +720,7 @@ class Agent:
|
||||
json_output = getattr(_output_config, 'json_output', False)
|
||||
status_trace = getattr(_output_config, 'status_trace', False) # New: clean inline status
|
||||
simple_output = getattr(_output_config, 'simple_output', False) # status preset: no timestamps
|
||||
editor_output = getattr(_output_config, 'editor_output', False) # Editor: Step N display
|
||||
output_file = getattr(_output_config, 'output_file', None) # Auto-save to file
|
||||
output_template = getattr(_output_config, 'template', None) # Response template
|
||||
else:
|
||||
@@ -722,10 +730,20 @@ class Agent:
|
||||
json_output = False
|
||||
status_trace = False
|
||||
simple_output = False
|
||||
editor_output = False
|
||||
|
||||
# Enable trace output mode if configured (takes priority)
|
||||
# Enable editor output mode if configured (beginner-friendly, takes priority)
|
||||
# Shows: Step 1: 📄 Creating file: path → ✓ Done
|
||||
if editor_output:
|
||||
try:
|
||||
from ..output.editor import enable_editor_output, is_editor_output_enabled
|
||||
if not is_editor_output_enabled():
|
||||
enable_editor_output(use_color=True)
|
||||
except ImportError:
|
||||
pass # Editor module not available
|
||||
# Enable trace output mode if configured
|
||||
# This provides timestamped inline status with duration
|
||||
if status_trace:
|
||||
elif status_trace:
|
||||
try:
|
||||
from ..output.trace import enable_trace_output, is_trace_output_enabled
|
||||
if not is_trace_output_enabled():
|
||||
@@ -957,6 +975,49 @@ class Agent:
|
||||
elif memory is False:
|
||||
memory = None
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Resolve LEARN param - FAST PATH (top-level, peer to memory)
|
||||
# learn= is a first-class citizen, independent of memory=
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
from ..config.feature_configs import LearnConfig
|
||||
|
||||
_learn_config = None
|
||||
|
||||
# Top-level learn= takes precedence over memory.learn
|
||||
if learn is not None:
|
||||
# Explicit top-level learn= param
|
||||
if learn is False:
|
||||
_learn_config = None
|
||||
elif learn is True:
|
||||
# learn=True shorthand enables AGENTIC mode (auto-learning)
|
||||
from ..memory.learn.protocols import LearnMode
|
||||
_learn_config = LearnConfig(mode=LearnMode.AGENTIC)
|
||||
elif isinstance(learn, LearnConfig):
|
||||
_learn_config = learn
|
||||
elif isinstance(learn, dict):
|
||||
_learn_config = LearnConfig(**learn)
|
||||
else:
|
||||
_learn_config = learn # Pass through
|
||||
elif _memory_config is not None and isinstance(_memory_config, MemoryConfig):
|
||||
# Fallback to memory.learn for backward compatibility
|
||||
if _memory_config.learn:
|
||||
if _memory_config.learn is True:
|
||||
# learn=True shorthand enables AGENTIC mode
|
||||
from ..memory.learn.protocols import LearnMode
|
||||
_learn_config = LearnConfig(mode=LearnMode.AGENTIC)
|
||||
elif isinstance(_memory_config.learn, LearnConfig):
|
||||
_learn_config = _memory_config.learn
|
||||
elif isinstance(_memory_config.learn, dict):
|
||||
_learn_config = LearnConfig(**_memory_config.learn)
|
||||
|
||||
# If learn is enabled but memory is not, we need to enable memory for learn to work
|
||||
if _learn_config is not None and memory is None:
|
||||
# Enable minimal memory for learn to work
|
||||
memory = {"learn": _learn_config.to_dict() if hasattr(_learn_config, 'to_dict') else _learn_config}
|
||||
elif _learn_config is not None and isinstance(memory, dict):
|
||||
# Merge learn config into memory dict
|
||||
memory["learn"] = _learn_config.to_dict() if hasattr(_learn_config, 'to_dict') else _learn_config
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Resolve HISTORY from MemoryConfig - FAST PATH
|
||||
# History is enabled via memory="history" preset or MemoryConfig(history=True)
|
||||
@@ -1343,6 +1404,37 @@ class Agent:
|
||||
self.tools = list(tools)
|
||||
else:
|
||||
self.tools = tools or []
|
||||
|
||||
# Inject default tools for autonomy mode (after self.tools is initialized)
|
||||
# ONLY inject if caller didn't provide tools - avoid duplicates with CLI/wrapper tools
|
||||
if self.autonomy_enabled and hasattr(self, '_autonomy_config_obj'):
|
||||
config = self._autonomy_config_obj
|
||||
if config.default_tools is not None:
|
||||
# User provided custom default tools via AutonomyConfig
|
||||
self.tools.extend(config.default_tools)
|
||||
elif not self.tools:
|
||||
# Only inject AUTONOMY_PROFILE if no tools were provided by caller
|
||||
# This prevents duplicates when CLI/wrapper already provides tools
|
||||
try:
|
||||
from ..tools.profiles import AUTONOMY_PROFILE
|
||||
from .. import tools as tools_module
|
||||
resolved_tools = []
|
||||
for tool_name in AUTONOMY_PROFILE.tools:
|
||||
try:
|
||||
tool = getattr(tools_module, tool_name)
|
||||
if tool is not None:
|
||||
resolved_tools.append(tool)
|
||||
except AttributeError:
|
||||
pass # Tool not available, skip
|
||||
self.tools.extend(resolved_tools)
|
||||
except ImportError:
|
||||
# Fallback to ast-grep tools if profiles not available
|
||||
try:
|
||||
from ..tools.ast_grep_tool import get_ast_grep_tools
|
||||
self.tools.extend(get_ast_grep_tools())
|
||||
except ImportError:
|
||||
pass # No default tools available
|
||||
|
||||
self.max_iter = max_iter
|
||||
self.max_rpm = max_rpm
|
||||
self.max_execution_time = max_execution_time
|
||||
@@ -1463,6 +1555,9 @@ Your Goal: {self.goal}
|
||||
# Pending approvals for async (non-blocking) mode
|
||||
self._pending_approvals = {}
|
||||
|
||||
# P8/G11: Tool timeout - prevent slow tools from blocking
|
||||
self._tool_timeout = tool_timeout
|
||||
|
||||
# Cache for system prompts and formatted tools with lazy thread-safe lock
|
||||
self._system_prompt_cache = {}
|
||||
self._formatted_tools_cache = {}
|
||||
@@ -1529,6 +1624,10 @@ Your Goal: {self.goal}
|
||||
self._history_enabled = _history_enabled
|
||||
self._history_limit = _history_limit
|
||||
self._history_session_id = _history_session_id
|
||||
|
||||
# Learning configuration (top-level, peer to memory)
|
||||
# Stored for access via agent._learn_config
|
||||
self._learn_config = _learn_config
|
||||
|
||||
# Agent-centric feature instances (lazy loaded for zero performance impact)
|
||||
self._auto_memory = auto_memory
|
||||
@@ -1962,6 +2061,7 @@ Summary:"""
|
||||
"clear_context": config.clear_context,
|
||||
"track_changes": config.effective_track_changes,
|
||||
"snapshot_dir": config.snapshot_dir,
|
||||
"default_tools": config.default_tools,
|
||||
}
|
||||
# Also preserve any extra user-provided keys from dict input
|
||||
if isinstance(autonomy, dict):
|
||||
@@ -2270,13 +2370,16 @@ Summary:"""
|
||||
)
|
||||
|
||||
# Take initial snapshot before autonomous execution starts
|
||||
if self._file_snapshot is not None:
|
||||
# NOTE: Snapshot tracking is disabled by default for performance (G12 fix)
|
||||
# FileSnapshot.track() walks entire project and is too slow for large codebases
|
||||
# Enable with autonomy={"snapshot": True} if needed
|
||||
if self._file_snapshot is not None and self.autonomy_config.get("snapshot", False):
|
||||
try:
|
||||
snap_info = self._file_snapshot.track(message="pre-autonomous")
|
||||
self._snapshot_stack.append(snap_info.commit_hash)
|
||||
self._redo_stack.clear()
|
||||
except Exception as e:
|
||||
logger.debug(f"Pre-autonomous snapshot failed: {e}")
|
||||
logging.debug(f"Pre-autonomous snapshot failed: {e}")
|
||||
|
||||
start_time = time_module.time()
|
||||
started_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
@@ -2303,11 +2406,21 @@ Summary:"""
|
||||
# Reset doom loop tracker for new task
|
||||
self._reset_doom_loop()
|
||||
|
||||
# P3/G2: Import callback helper for autonomy events
|
||||
from ..main import execute_sync_callback
|
||||
|
||||
try:
|
||||
# Execute the autonomous loop
|
||||
while iterations < effective_max_iter:
|
||||
iterations += 1
|
||||
|
||||
# P3/G2: Emit autonomy_iteration callback for CLI visibility
|
||||
execute_sync_callback('autonomy_iteration',
|
||||
iteration=iterations,
|
||||
max_iterations=effective_max_iter,
|
||||
stage=stage
|
||||
)
|
||||
|
||||
# Check timeout
|
||||
if timeout_seconds and (time_module.time() - start_time) > timeout_seconds:
|
||||
return AutonomyResult(
|
||||
@@ -2324,6 +2437,13 @@ Summary:"""
|
||||
# Check doom loop with graduated recovery (G-RECOVERY-2 fix)
|
||||
if self._is_doom_loop():
|
||||
recovery = self._get_doom_recovery()
|
||||
|
||||
# P3/G2: Emit doom loop callback for CLI visibility
|
||||
execute_sync_callback('autonomy_doom_loop',
|
||||
iteration=iterations,
|
||||
recovery_action=recovery
|
||||
)
|
||||
|
||||
obs = getattr(self, '_observability_hooks', None)
|
||||
if obs is not None:
|
||||
from ..escalation.observability import EventType as _EvtType
|
||||
@@ -2337,6 +2457,12 @@ Summary:"""
|
||||
if self._doom_loop_tracker is not None:
|
||||
self._doom_loop_tracker.clear_actions()
|
||||
continue
|
||||
elif recovery == "escalate_model":
|
||||
# Give the agent one more try with explicit error guidance
|
||||
prompt = prompt + "\n\n[System: You are stuck in a loop. CRITICAL: Check that all tool argument names exactly match the function signature. Do NOT add '=' to argument names. Use only the documented parameter names.]"
|
||||
if self._doom_loop_tracker is not None:
|
||||
self._doom_loop_tracker.clear_actions()
|
||||
continue
|
||||
elif recovery == "request_help":
|
||||
return AutonomyResult(
|
||||
success=False,
|
||||
@@ -2380,8 +2506,13 @@ Summary:"""
|
||||
response_str = str(response)
|
||||
|
||||
# Record the action for doom loop tracking (G1 fix: was missing)
|
||||
# Use iteration number + response hash to avoid false positives
|
||||
# when same prompt is re-injected (G8 fix: doom loop false positive)
|
||||
self._record_action(
|
||||
"chat", {"prompt": prompt}, response_str[:200], True
|
||||
"chat",
|
||||
{"iteration": iterations, "response_hash": hash(response_str[:500])},
|
||||
response_str[:200],
|
||||
True
|
||||
)
|
||||
|
||||
# Record for action history
|
||||
@@ -2417,14 +2548,28 @@ Summary:"""
|
||||
stage_order = ["direct", "heuristic", "planned", "autonomous"]
|
||||
idx = stage_order.index(stage) if stage in stage_order else 0
|
||||
if idx < len(stage_order) - 1:
|
||||
prev_stage = stage
|
||||
stage = stage_order[idx + 1]
|
||||
|
||||
# P3/G2: Emit stage change callback for CLI visibility
|
||||
execute_sync_callback('autonomy_stage_change',
|
||||
from_stage=prev_stage,
|
||||
to_stage=stage
|
||||
)
|
||||
|
||||
if obs is not None:
|
||||
obs.emit(_EvtType.STAGE_ESCALATE, {"from": stage_order[idx], "to": stage})
|
||||
obs.emit(_EvtType.STAGE_ESCALATE, {"from": prev_stage, "to": stage})
|
||||
|
||||
# Check for completion promise FIRST (structured signal)
|
||||
if effective_promise:
|
||||
promise_tag = f"<promise>{effective_promise}</promise>"
|
||||
if promise_tag in response_str:
|
||||
# P3/G2: Emit completion callback
|
||||
execute_sync_callback('autonomy_complete',
|
||||
completion_reason="promise",
|
||||
iterations=iterations,
|
||||
duration_seconds=time_module.time() - start_time
|
||||
)
|
||||
return AutonomyResult(
|
||||
success=True,
|
||||
output=response_str,
|
||||
@@ -2438,6 +2583,12 @@ Summary:"""
|
||||
|
||||
# Check for keyword-based completion signals (word-boundary, G-COMPLETION-1 fix)
|
||||
if self._is_completion_signal(response_str):
|
||||
# P3/G2: Emit completion callback
|
||||
execute_sync_callback('autonomy_complete',
|
||||
completion_reason="goal",
|
||||
iterations=iterations,
|
||||
duration_seconds=time_module.time() - start_time
|
||||
)
|
||||
return AutonomyResult(
|
||||
success=True,
|
||||
output=response_str,
|
||||
@@ -2451,6 +2602,12 @@ Summary:"""
|
||||
|
||||
# For DIRECT stage, complete after first response
|
||||
if stage == "direct":
|
||||
# P3/G2: Emit completion callback
|
||||
execute_sync_callback('autonomy_complete',
|
||||
completion_reason="goal",
|
||||
iterations=iterations,
|
||||
duration_seconds=time_module.time() - start_time
|
||||
)
|
||||
return AutonomyResult(
|
||||
success=True,
|
||||
output=response_str,
|
||||
@@ -2467,6 +2624,12 @@ Summary:"""
|
||||
self.clear_history()
|
||||
|
||||
# Max iterations reached
|
||||
# P3/G2: Emit completion callback for max iterations
|
||||
execute_sync_callback('autonomy_complete',
|
||||
completion_reason="max_iterations",
|
||||
iterations=iterations,
|
||||
duration_seconds=time_module.time() - start_time
|
||||
)
|
||||
return AutonomyResult(
|
||||
success=False,
|
||||
output="Max iterations reached",
|
||||
@@ -2620,6 +2783,11 @@ Summary:"""
|
||||
if self._doom_loop_tracker is not None:
|
||||
self._doom_loop_tracker.clear_actions()
|
||||
continue
|
||||
elif recovery == "escalate_model":
|
||||
prompt = prompt + "\n\n[System: You are stuck in a loop. CRITICAL: Check that all tool argument names exactly match the function signature. Do NOT add '=' to argument names. Use only the documented parameter names.]"
|
||||
if self._doom_loop_tracker is not None:
|
||||
self._doom_loop_tracker.clear_actions()
|
||||
continue
|
||||
elif recovery == "request_help":
|
||||
return AutonomyResult(
|
||||
success=False,
|
||||
@@ -2663,8 +2831,13 @@ Summary:"""
|
||||
response_str = str(response)
|
||||
|
||||
# Record the action for doom loop tracking (G2 fix: was missing)
|
||||
# Use iteration number + response hash to avoid false positives
|
||||
# when same prompt is re-injected (G8 fix: doom loop false positive)
|
||||
self._record_action(
|
||||
"chat", {"prompt": prompt}, response_str[:200], True
|
||||
"chat",
|
||||
{"iteration": iterations, "response_hash": hash(response_str[:500])},
|
||||
response_str[:200],
|
||||
True
|
||||
)
|
||||
|
||||
# Record for action history
|
||||
@@ -3302,10 +3475,17 @@ Summary:"""
|
||||
if not self._memory_instance:
|
||||
return
|
||||
|
||||
if memory_type == "short_term" and hasattr(self._memory_instance, 'add_short_term'):
|
||||
self._memory_instance.add_short_term(content, **kwargs)
|
||||
elif memory_type == "long_term" and hasattr(self._memory_instance, 'add_long_term'):
|
||||
self._memory_instance.add_long_term(content, **kwargs)
|
||||
# Use protocol names first (store_*), fallback to legacy names (add_*)
|
||||
if memory_type == "short_term":
|
||||
if hasattr(self._memory_instance, 'store_short_term'):
|
||||
self._memory_instance.store_short_term(content, **kwargs)
|
||||
elif hasattr(self._memory_instance, 'add_short_term'):
|
||||
self._memory_instance.add_short_term(content, **kwargs)
|
||||
elif memory_type == "long_term":
|
||||
if hasattr(self._memory_instance, 'store_long_term'):
|
||||
self._memory_instance.store_long_term(content, **kwargs)
|
||||
elif hasattr(self._memory_instance, 'add_long_term'):
|
||||
self._memory_instance.add_long_term(content, **kwargs)
|
||||
elif memory_type == "entity" and hasattr(self._memory_instance, 'add_entity'):
|
||||
self._memory_instance.add_entity(content, **kwargs)
|
||||
elif memory_type == "episodic" and hasattr(self._memory_instance, 'add_episodic'):
|
||||
@@ -4274,8 +4454,26 @@ Your Goal: {self.goal}"""
|
||||
|
||||
try:
|
||||
sig = inspect.signature(func)
|
||||
valid_params = set(sig.parameters.keys()) - {'self'}
|
||||
casted_args = {}
|
||||
|
||||
# Sanitize argument names: strip trailing '=', whitespace, and
|
||||
# other invalid chars that LLMs sometimes hallucinate in kwarg names
|
||||
sanitized = {}
|
||||
for raw_name, arg_value in arguments.items():
|
||||
clean = raw_name.strip().rstrip('=').strip()
|
||||
# If the cleaned name matches a valid param, use it;
|
||||
# otherwise try case-insensitive match
|
||||
if clean in valid_params:
|
||||
sanitized[clean] = arg_value
|
||||
elif clean.lower() in {p.lower() for p in valid_params}:
|
||||
# Case-insensitive fuzzy match
|
||||
matched = next(p for p in valid_params if p.lower() == clean.lower())
|
||||
sanitized[matched] = arg_value
|
||||
else:
|
||||
sanitized[clean] = arg_value
|
||||
arguments = sanitized
|
||||
|
||||
for param_name, arg_value in arguments.items():
|
||||
if param_name in sig.parameters:
|
||||
param = sig.parameters[param_name]
|
||||
@@ -4362,7 +4560,19 @@ Your Goal: {self.goal}"""
|
||||
arguments.update(res.output.modified_data)
|
||||
|
||||
with with_injection_context(state):
|
||||
result = self._execute_tool_impl(function_name, arguments)
|
||||
# P8/G11: Apply tool timeout if configured
|
||||
tool_timeout = getattr(self, '_tool_timeout', None)
|
||||
if tool_timeout and tool_timeout > 0:
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
future = executor.submit(self._execute_tool_impl, function_name, arguments)
|
||||
try:
|
||||
result = future.result(timeout=tool_timeout)
|
||||
except concurrent.futures.TimeoutError:
|
||||
logging.warning(f"Tool {function_name} timed out after {tool_timeout}s")
|
||||
result = {"error": f"Tool timed out after {tool_timeout}s", "timeout": True}
|
||||
else:
|
||||
result = self._execute_tool_impl(function_name, arguments)
|
||||
|
||||
# Apply tool output truncation to prevent context overflow
|
||||
# Uses context manager budget if enabled, otherwise applies default limit
|
||||
@@ -4415,6 +4625,13 @@ Your Goal: {self.goal}"""
|
||||
)
|
||||
self._hook_runner.execute_sync(HookEvent.AFTER_TOOL, after_tool_input, target=function_name)
|
||||
|
||||
# G10 fix: Mark progress after successful tool execution
|
||||
# This prevents false doom loop detection when tools succeed
|
||||
if self._doom_loop_tracker is not None and result is not None:
|
||||
is_error = isinstance(result, dict) and result.get('error')
|
||||
if not is_error:
|
||||
self._doom_loop_tracker.mark_progress(f"tool:{function_name}")
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
# Emit tool call end with error
|
||||
@@ -4446,6 +4663,36 @@ Your Goal: {self.goal}"""
|
||||
prompt_str = prompt if isinstance(prompt, str) else str(prompt)
|
||||
self._process_auto_memory(prompt_str, str(response))
|
||||
|
||||
# Auto-learning extraction (opt-in via LearnConfig(mode=LearnMode.AGENTIC))
|
||||
self._process_auto_learning()
|
||||
|
||||
return response
|
||||
|
||||
async def _atrigger_after_agent_hook(self, prompt, response, start_time, tools_used=None):
|
||||
"""Async version: Trigger AFTER_AGENT hook and return response."""
|
||||
from ..hooks import HookEvent, AfterAgentInput
|
||||
after_agent_input = AfterAgentInput(
|
||||
session_id=getattr(self, '_session_id', 'default'),
|
||||
cwd=os.getcwd(),
|
||||
event_name=HookEvent.AFTER_AGENT,
|
||||
timestamp=str(time.time()),
|
||||
agent_name=self.name,
|
||||
prompt=prompt if isinstance(prompt, str) else str(prompt),
|
||||
response=response or "",
|
||||
tools_used=tools_used or [],
|
||||
total_tokens=0,
|
||||
execution_time_ms=(time.time() - start_time) * 1000
|
||||
)
|
||||
await self._hook_runner.execute(HookEvent.AFTER_AGENT, after_agent_input)
|
||||
|
||||
# Auto-memory extraction (opt-in via MemoryConfig(auto_memory=True))
|
||||
if response:
|
||||
prompt_str = prompt if isinstance(prompt, str) else str(prompt)
|
||||
self._process_auto_memory(prompt_str, str(response))
|
||||
|
||||
# Auto-learning extraction (opt-in via LearnConfig(mode=LearnMode.AGENTIC))
|
||||
self._process_auto_learning()
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@@ -4566,6 +4813,9 @@ Your Goal: {self.goal}"""
|
||||
error_msg = f"Tool execution denied: {decision.reason}"
|
||||
logging.warning(error_msg)
|
||||
return {"error": error_msg, "approval_denied": True}
|
||||
# Bridge: mark tool as approved in global registry context so
|
||||
# @require_approval decorator wrappers skip their own check.
|
||||
get_approval_registry().mark_approved(function_name)
|
||||
if decision.modified_args:
|
||||
arguments = decision.modified_args
|
||||
logging.info(f"Using modified arguments: {arguments}")
|
||||
@@ -4703,7 +4953,14 @@ Your Goal: {self.goal}"""
|
||||
return {"error": error_msg}
|
||||
|
||||
def clear_history(self):
|
||||
"""Clear all chat history.
|
||||
|
||||
Also resets _auto_save_last_index to prevent silent message loss
|
||||
when auto_save is enabled.
|
||||
"""
|
||||
self.chat_history = []
|
||||
# Reset auto-save index to prevent stale index causing message loss
|
||||
self._auto_save_last_index = 0
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# History Management Methods
|
||||
@@ -4728,6 +4985,8 @@ Your Goal: {self.goal}"""
|
||||
|
||||
deleted_count = len(self.chat_history) - keep_last
|
||||
self.chat_history = self.chat_history[-keep_last:]
|
||||
# Reset auto-save index to match new history length
|
||||
self._auto_save_last_index = len(self.chat_history)
|
||||
return deleted_count
|
||||
|
||||
def delete_history(self, index: int) -> bool:
|
||||
@@ -4745,6 +5004,12 @@ Your Goal: {self.goal}"""
|
||||
with self._history_lock:
|
||||
try:
|
||||
del self.chat_history[index]
|
||||
# Adjust auto-save index if deletion affects saved range
|
||||
if hasattr(self, '_auto_save_last_index'):
|
||||
self._auto_save_last_index = min(
|
||||
self._auto_save_last_index,
|
||||
len(self.chat_history)
|
||||
)
|
||||
return True
|
||||
except IndexError:
|
||||
return False
|
||||
@@ -4767,7 +5032,14 @@ Your Goal: {self.goal}"""
|
||||
msg for msg in self.chat_history
|
||||
if pattern.lower() not in msg.get("content", "").lower()
|
||||
]
|
||||
return original_len - len(self.chat_history)
|
||||
deleted_count = original_len - len(self.chat_history)
|
||||
# Adjust auto-save index if deletion affects saved range
|
||||
if deleted_count > 0 and hasattr(self, '_auto_save_last_index'):
|
||||
self._auto_save_last_index = min(
|
||||
self._auto_save_last_index,
|
||||
len(self.chat_history)
|
||||
)
|
||||
return deleted_count
|
||||
|
||||
def get_history_size(self) -> int:
|
||||
"""Get the current number of messages in chat history."""
|
||||
@@ -5006,7 +5278,9 @@ Your Goal: {self.goal}"""
|
||||
"display_fn": self._display_generating if self.verbose else None,
|
||||
"reasoning_steps": reasoning_steps,
|
||||
"verbose": self.verbose,
|
||||
"max_iterations": 10
|
||||
"max_iterations": 10,
|
||||
"stream_callback": self.stream_emitter.emit,
|
||||
"emit_events": True,
|
||||
}
|
||||
if response_format:
|
||||
chat_kwargs["response_format"] = response_format
|
||||
@@ -5825,7 +6099,7 @@ Your Goal: {self.goal}"""
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return validated_response
|
||||
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for JSON output: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
@@ -5857,7 +6131,7 @@ Your Goal: {self.goal}"""
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return validated_response
|
||||
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
@@ -5927,7 +6201,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
self._end_run(validated_response, "completed", {"duration_ms": (time.time() - start_time) * 1000})
|
||||
return validated_response
|
||||
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed after reflection: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
@@ -5946,7 +6220,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return validated_response
|
||||
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed after max reflections: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
@@ -6134,7 +6408,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(normalized_content, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return validated_response
|
||||
return await self._atrigger_after_agent_hook(prompt, validated_response, start_time)
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for custom LLM: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
@@ -6212,7 +6486,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
logging.debug(f"Agent.achat completed in {total_time:.2f} seconds")
|
||||
# Execute callback after tool completion
|
||||
self._execute_callback_and_display(original_prompt, result, time.time() - start_time, task_name, task_description, task_id)
|
||||
return result
|
||||
return await self._atrigger_after_agent_hook(original_prompt, result, start_time)
|
||||
elif output_json or output_pydantic:
|
||||
response = await self._openai_client.async_client.chat.completions.create(
|
||||
model=self.llm,
|
||||
@@ -6226,7 +6500,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
logging.debug(f"Agent.achat completed in {total_time:.2f} seconds")
|
||||
# Execute callback after JSON/Pydantic completion
|
||||
self._execute_callback_and_display(original_prompt, response_text, time.time() - start_time, task_name, task_description, task_id)
|
||||
return response_text
|
||||
return await self._atrigger_after_agent_hook(original_prompt, response_text, start_time)
|
||||
else:
|
||||
response = await self._openai_client.async_client.chat.completions.create(
|
||||
model=self.llm,
|
||||
@@ -6266,7 +6540,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if logging.getLogger().getEffectiveLevel() == logging.DEBUG:
|
||||
total_time = time.time() - start_time
|
||||
logging.debug(f"Agent.achat completed in {total_time:.2f} seconds")
|
||||
return response_text
|
||||
return await self._atrigger_after_agent_hook(original_prompt, response_text, start_time)
|
||||
|
||||
reflection_response = await self._openai_client.async_client.beta.chat.completions.parse(
|
||||
model=self.reflect_llm if self.reflect_llm else self.llm,
|
||||
@@ -6324,7 +6598,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return validated_response
|
||||
return await self._atrigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for OpenAI client: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
@@ -7045,9 +7319,88 @@ Write the complete compiled report:"""
|
||||
except Exception as e:
|
||||
logging.debug(f"Auto-memory extraction failed: {e}")
|
||||
|
||||
def _process_auto_learning(self):
|
||||
"""Process auto-learning extraction after agent response.
|
||||
|
||||
Called after each agent response when LearnMode.AGENTIC is set.
|
||||
Uses LearnManager.process_conversation() to extract and store learnings
|
||||
(persona, insights, patterns) from the conversation. No-op when learning
|
||||
is disabled or mode is not AGENTIC.
|
||||
"""
|
||||
# Quick exit if no learn config or mode is not AGENTIC
|
||||
if not self._learn_config:
|
||||
return
|
||||
|
||||
# Check if mode is AGENTIC (auto-extract learnings)
|
||||
from ..memory.learn.protocols import LearnMode
|
||||
mode = getattr(self._learn_config, 'mode', None)
|
||||
|
||||
# Handle PROPOSE mode — extract but store as pending for user approval
|
||||
if (isinstance(mode, LearnMode) and mode == LearnMode.PROPOSE) or \
|
||||
(isinstance(mode, str) and mode == 'propose'):
|
||||
learn_manager = None
|
||||
if self._memory_instance and hasattr(self._memory_instance, 'learn'):
|
||||
learn_manager = self._memory_instance.learn
|
||||
if learn_manager:
|
||||
try:
|
||||
recent_messages = self.chat_history[-2:] if len(self.chat_history) >= 2 else self.chat_history
|
||||
if recent_messages:
|
||||
# Use same extraction as AGENTIC, but don't auto-store
|
||||
result = learn_manager.process_conversation(
|
||||
messages=recent_messages,
|
||||
llm=getattr(self._learn_config, 'llm', None) or self.llm,
|
||||
extract_only=True,
|
||||
)
|
||||
# Move extracted items to pending queue
|
||||
if result:
|
||||
for p in result.get("persona", []):
|
||||
if p:
|
||||
learn_manager.add_pending(p, category="persona")
|
||||
for i in result.get("insights", []):
|
||||
if i:
|
||||
learn_manager.add_pending(i, category="insights")
|
||||
for pt in result.get("patterns", []):
|
||||
if pt:
|
||||
learn_manager.add_pending(pt, category="patterns")
|
||||
except Exception as e:
|
||||
logging.debug(f"PROPOSE mode learning extraction failed: {e}")
|
||||
return
|
||||
|
||||
if mode is None or (isinstance(mode, LearnMode) and mode != LearnMode.AGENTIC):
|
||||
return
|
||||
if isinstance(mode, str) and mode != 'agentic':
|
||||
return
|
||||
|
||||
# Get LearnManager from memory instance
|
||||
learn_manager = None
|
||||
if self._memory_instance and hasattr(self._memory_instance, 'learn'):
|
||||
learn_manager = self._memory_instance.learn
|
||||
|
||||
if not learn_manager:
|
||||
return
|
||||
|
||||
try:
|
||||
# Process recent conversation (last 2 messages: user + assistant)
|
||||
recent_messages = self.chat_history[-2:] if len(self.chat_history) >= 2 else self.chat_history
|
||||
if recent_messages:
|
||||
learn_manager.process_conversation(
|
||||
messages=recent_messages,
|
||||
llm=getattr(self._learn_config, 'llm', None) or self.llm,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.debug(f"Auto-learning extraction failed: {e}")
|
||||
|
||||
def _auto_save_session(self):
|
||||
"""Auto-save session if auto_save is enabled."""
|
||||
if not self.auto_save or not self._memory_instance:
|
||||
"""Auto-save session if auto_save is enabled.
|
||||
|
||||
G-1 FIX: Routes to SessionStore instead of Memory.save_session() to
|
||||
maintain clean separation between conversation history (SessionStore)
|
||||
and semantic memory (Memory).
|
||||
|
||||
Issue 1 FIX: Track last saved index to avoid duplicate insertion when
|
||||
called multiple times in the same session.
|
||||
"""
|
||||
if not self.auto_save:
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -7057,12 +7410,36 @@ Write the complete compiled report:"""
|
||||
for msg in self.chat_history
|
||||
]
|
||||
|
||||
self._memory_instance.save_session(
|
||||
name=self.auto_save,
|
||||
conversation_history=clean_history,
|
||||
metadata={"agent_name": self.name, "user_id": self.user_id}
|
||||
)
|
||||
logging.debug(f"Auto-saved session: {self.auto_save}")
|
||||
# Issue 1 FIX: Only save NEW messages since last save
|
||||
# Track last saved index to avoid duplicates
|
||||
last_saved = getattr(self, '_auto_save_last_index', 0)
|
||||
new_messages = clean_history[last_saved:]
|
||||
|
||||
if not new_messages:
|
||||
return # Nothing new to save
|
||||
|
||||
# G-1 FIX: Use SessionStore for conversation history persistence
|
||||
# This maintains clean separation: Memory = facts, SessionStore = turns
|
||||
if self._session_store is not None:
|
||||
# Persist only NEW messages to SessionStore
|
||||
for msg in new_messages:
|
||||
self._session_store.add_message(
|
||||
self.auto_save, # Use auto_save name as session_id
|
||||
role=msg.get("role", "user"),
|
||||
content=msg.get("content", ""),
|
||||
)
|
||||
# Update last saved index
|
||||
self._auto_save_last_index = len(clean_history)
|
||||
logging.debug(f"Auto-saved {len(new_messages)} new messages to SessionStore: {self.auto_save}")
|
||||
elif self._memory_instance and hasattr(self._memory_instance, 'save_session'):
|
||||
# Fallback to Memory.save_session() for backward compatibility
|
||||
# Memory.save_session() replaces entire history, so no duplicate issue
|
||||
self._memory_instance.save_session(
|
||||
name=self.auto_save,
|
||||
conversation_history=clean_history,
|
||||
metadata={"agent_name": self.name, "user_id": self.user_id}
|
||||
)
|
||||
logging.debug(f"Auto-saved session to Memory: {self.auto_save}")
|
||||
except Exception as e:
|
||||
logging.debug(f"Error auto-saving session: {e}")
|
||||
|
||||
@@ -7259,11 +7636,31 @@ Write the complete compiled report:"""
|
||||
if formatted_tools:
|
||||
completion_args["tools"] = formatted_tools
|
||||
|
||||
# Import StreamEvent types for event emission
|
||||
from ..streaming.events import StreamEvent, StreamEventType
|
||||
import time as time_module
|
||||
|
||||
# Emit REQUEST_START event
|
||||
request_start_perf = time_module.perf_counter()
|
||||
self.stream_emitter.emit(StreamEvent(
|
||||
type=StreamEventType.REQUEST_START,
|
||||
timestamp=request_start_perf,
|
||||
metadata={"model": self.llm, "message_count": len(messages)}
|
||||
))
|
||||
|
||||
completion = self._openai_client.sync_client.chat.completions.create(**completion_args)
|
||||
|
||||
# Emit HEADERS_RECEIVED event
|
||||
self.stream_emitter.emit(StreamEvent(
|
||||
type=StreamEventType.HEADERS_RECEIVED,
|
||||
timestamp=time_module.perf_counter()
|
||||
))
|
||||
|
||||
# Stream the response chunks without display
|
||||
response_text = ""
|
||||
tool_calls_data = []
|
||||
first_token_emitted = False
|
||||
last_content_time = None
|
||||
|
||||
for chunk in completion:
|
||||
delta = chunk.choices[0].delta
|
||||
@@ -7272,6 +7669,24 @@ Write the complete compiled report:"""
|
||||
if delta.content is not None:
|
||||
chunk_content = delta.content
|
||||
response_text += chunk_content
|
||||
last_content_time = time_module.perf_counter()
|
||||
|
||||
# Emit FIRST_TOKEN on first content
|
||||
if not first_token_emitted:
|
||||
self.stream_emitter.emit(StreamEvent(
|
||||
type=StreamEventType.FIRST_TOKEN,
|
||||
timestamp=last_content_time,
|
||||
content=chunk_content
|
||||
))
|
||||
first_token_emitted = True
|
||||
else:
|
||||
# Emit DELTA_TEXT for subsequent tokens
|
||||
self.stream_emitter.emit(StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
timestamp=last_content_time,
|
||||
content=chunk_content
|
||||
))
|
||||
|
||||
yield chunk_content
|
||||
|
||||
# Handle tool calls (accumulate but don't yield as chunks)
|
||||
@@ -7289,6 +7704,18 @@ Write the complete compiled report:"""
|
||||
if tool_call_delta.function.arguments:
|
||||
tool_calls_data[tool_call_delta.index]['function']['arguments'] += tool_call_delta.function.arguments
|
||||
|
||||
# Emit LAST_TOKEN and STREAM_END events after streaming loop
|
||||
if last_content_time:
|
||||
self.stream_emitter.emit(StreamEvent(
|
||||
type=StreamEventType.LAST_TOKEN,
|
||||
timestamp=last_content_time
|
||||
))
|
||||
self.stream_emitter.emit(StreamEvent(
|
||||
type=StreamEventType.STREAM_END,
|
||||
timestamp=time_module.perf_counter(),
|
||||
metadata={"response_length": len(response_text)}
|
||||
))
|
||||
|
||||
# Handle any tool calls that were accumulated
|
||||
if tool_calls_data:
|
||||
# Add assistant message with tool calls to chat history
|
||||
@@ -7521,6 +7948,10 @@ Write the complete compiled report:"""
|
||||
error_msg = f"Tool execution denied: {decision.reason}"
|
||||
logging.warning(error_msg)
|
||||
return {"error": error_msg, "approval_denied": True}
|
||||
# Bridge: mark tool as approved in global registry context so
|
||||
# @require_approval decorator wrappers skip their own check.
|
||||
from ..approval import get_approval_registry as _get_reg
|
||||
_get_reg().mark_approved(function_name)
|
||||
if decision.modified_args:
|
||||
arguments = decision.modified_args
|
||||
logging.info(f"Using modified arguments: {arguments}")
|
||||
|
||||
@@ -122,6 +122,8 @@ class AutonomyConfig:
|
||||
track_changes: Optional[bool] = None # None = auto (True for full_auto)
|
||||
sandbox: Optional[Any] = None # SandboxConfig (lazy import to avoid circular)
|
||||
snapshot_dir: Optional[str] = None # Defaults to ~/.praisonai/snapshots
|
||||
# Default tools for autonomy mode (auto-populated with code intelligence tools)
|
||||
default_tools: Optional[List[Any]] = None # None = auto (uses get_autonomy_default_tools())
|
||||
|
||||
def __post_init__(self):
|
||||
if self.level not in VALID_AUTONOMY_LEVELS:
|
||||
@@ -134,10 +136,8 @@ class AutonomyConfig:
|
||||
self.track_changes = (self.level == "full_auto")
|
||||
# Default snapshot directory
|
||||
if self.snapshot_dir is None:
|
||||
import os
|
||||
self.snapshot_dir = os.path.join(
|
||||
os.path.expanduser("~"), ".praisonai", "snapshots"
|
||||
)
|
||||
from ..paths import get_snapshots_dir
|
||||
self.snapshot_dir = str(get_snapshots_dir())
|
||||
|
||||
@property
|
||||
def effective_track_changes(self) -> bool:
|
||||
@@ -166,6 +166,7 @@ class AutonomyConfig:
|
||||
track_changes=data.get("track_changes"),
|
||||
sandbox=data.get("sandbox"),
|
||||
snapshot_dir=data.get("snapshot_dir"),
|
||||
default_tools=data.get("default_tools"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -162,7 +162,8 @@ class ContextAgent(Agent):
|
||||
|
||||
# Enhanced logging and output management
|
||||
self.debug_mode = os.getenv('LOGLEVEL', '').lower() == 'debug'
|
||||
self.output_dir = Path(".praisonai/prp") # Save in .praisonai/prp folder
|
||||
from ..paths import get_project_prp_dir
|
||||
self.output_dir = get_project_prp_dir() # Save in .praisonai/prp folder
|
||||
self.setup_output_directories() # Create directories first
|
||||
self.setup_logging() # Then setup logging
|
||||
|
||||
|
||||
@@ -220,6 +220,7 @@ class AgentTeam:
|
||||
web: Optional[Any] = None, # Union[bool, WebConfig] - web search/fetch
|
||||
reflection: Optional[Any] = None, # Union[bool, ReflectionConfig] - self-reflection
|
||||
caching: Optional[Any] = None, # Union[bool, CachingConfig] - caching
|
||||
learn: Optional[Any] = None, # Union[bool, LearnConfig] - continuous learning
|
||||
):
|
||||
"""
|
||||
Initialize AgentManager with consolidated feature parameters.
|
||||
@@ -243,8 +244,10 @@ class AgentTeam:
|
||||
guardrails: Guardrails configuration (bool | Callable | GuardrailConfig)
|
||||
web: Web search/fetch configuration (bool | WebConfig)
|
||||
reflection: Self-reflection configuration (bool | ReflectionConfig)
|
||||
learn: Continuous learning configuration (bool | LearnConfig)
|
||||
"""
|
||||
# Store new params for propagation to agents
|
||||
self._learn = learn
|
||||
self.llm = llm # Store default LLM for API consistency
|
||||
self._autonomy = autonomy
|
||||
self._knowledge = knowledge
|
||||
@@ -512,14 +515,16 @@ class AgentTeam:
|
||||
if not mem_cfg:
|
||||
mem_cfg = next((t.config.get('memory_config') for t in tasks if hasattr(t, 'config') and t.config), None)
|
||||
if not mem_cfg:
|
||||
from ..paths import get_project_data_dir
|
||||
_pd = str(get_project_data_dir())
|
||||
mem_cfg = {
|
||||
"provider": "rag",
|
||||
"use_embedding": True,
|
||||
"storage": {
|
||||
"type": "sqlite",
|
||||
"path": "./.praisonai/memory.db"
|
||||
"path": f"{_pd}/memory.db"
|
||||
},
|
||||
"rag_db_path": "./.praisonai/chroma_db"
|
||||
"rag_db_path": f"{_pd}/chroma_db"
|
||||
}
|
||||
if _embedder:
|
||||
if isinstance(_embedder, dict):
|
||||
|
||||
@@ -57,6 +57,34 @@ class LearnScope(str, Enum):
|
||||
SHARED = "shared" # Shared with all agents
|
||||
|
||||
|
||||
class LearnMode(str, Enum):
|
||||
"""Learning mode for automatic learning extraction.
|
||||
|
||||
DISABLED: No automatic learning (manual capture only)
|
||||
AGENTIC: Agent autonomously extracts and stores learnings after each conversation
|
||||
PROPOSE: (Future) Agent proposes learnings for user approval before storing.
|
||||
Note: PROPOSE mode is defined but not yet implemented. Using it will
|
||||
behave the same as DISABLED until the approval workflow is added.
|
||||
"""
|
||||
DISABLED = "disabled"
|
||||
AGENTIC = "agentic"
|
||||
PROPOSE = "propose"
|
||||
|
||||
|
||||
class LearnBackend(str, Enum):
|
||||
"""Storage backend for learning data.
|
||||
|
||||
FILE: JSON files (default, zero dependencies)
|
||||
SQLITE: SQLite database
|
||||
REDIS: Redis (requires redis package)
|
||||
MONGODB: MongoDB (requires pymongo)
|
||||
"""
|
||||
FILE = "file"
|
||||
SQLITE = "sqlite"
|
||||
REDIS = "redis"
|
||||
MONGODB = "mongodb"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LearnConfig:
|
||||
"""
|
||||
@@ -66,16 +94,21 @@ class LearnConfig:
|
||||
to improve future responses. All learning data is stored within the memory system.
|
||||
|
||||
Usage:
|
||||
# Simple enable
|
||||
Agent(memory=MemoryConfig(learn=True))
|
||||
# Simple enable (top-level param)
|
||||
Agent(learn=True)
|
||||
|
||||
# With specific capabilities
|
||||
Agent(memory=MemoryConfig(
|
||||
learn=LearnConfig(
|
||||
persona=True, # User preferences
|
||||
insights=True, # Observations
|
||||
patterns=True, # Reusable knowledge
|
||||
)
|
||||
Agent(learn=LearnConfig(
|
||||
persona=True, # User preferences
|
||||
insights=True, # Observations
|
||||
patterns=True, # Reusable knowledge
|
||||
mode="agentic", # Auto-extract learnings
|
||||
))
|
||||
|
||||
# With database backend
|
||||
Agent(learn=LearnConfig(
|
||||
backend="sqlite",
|
||||
db_url="sqlite:///learn.db",
|
||||
))
|
||||
"""
|
||||
# Learning capabilities
|
||||
@@ -87,15 +120,19 @@ class LearnConfig:
|
||||
feedback: bool = False # Outcome signals
|
||||
improvements: bool = False # Self-improvement proposals
|
||||
|
||||
# Learning mode
|
||||
mode: Union[str, LearnMode] = LearnMode.DISABLED # How to extract learnings
|
||||
|
||||
# Scope configuration
|
||||
scope: Union[str, LearnScope] = LearnScope.PRIVATE
|
||||
|
||||
# Storage configuration
|
||||
store_path: Optional[str] = None # Custom storage path
|
||||
# Storage backend configuration
|
||||
backend: Union[str, LearnBackend] = LearnBackend.FILE # Storage backend
|
||||
db_url: Optional[str] = None # Database connection URL (for non-file backends)
|
||||
store_path: Optional[str] = None # Custom storage path (for file backend)
|
||||
|
||||
# Maintenance settings
|
||||
auto_consolidate: bool = True # Auto-consolidate learnings
|
||||
retention_days: Optional[int] = None # Days to retain (None = forever)
|
||||
# LLM for learning extraction
|
||||
llm: Optional[str] = None # LLM model for extracting learnings (defaults to agent's LLM)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
@@ -107,10 +144,12 @@ class LearnConfig:
|
||||
"decisions": self.decisions,
|
||||
"feedback": self.feedback,
|
||||
"improvements": self.improvements,
|
||||
"mode": self.mode.value if isinstance(self.mode, LearnMode) else self.mode,
|
||||
"scope": self.scope.value if isinstance(self.scope, LearnScope) else self.scope,
|
||||
"backend": self.backend.value if isinstance(self.backend, LearnBackend) else self.backend,
|
||||
"db_url": self.db_url,
|
||||
"store_path": self.store_path,
|
||||
"auto_consolidate": self.auto_consolidate,
|
||||
"retention_days": self.retention_days,
|
||||
"llm": self.llm,
|
||||
}
|
||||
|
||||
|
||||
@@ -574,6 +613,10 @@ class OutputConfig:
|
||||
# Shows: [timestamp] Calling LLM..., Executing tool..., Response: ...
|
||||
status_trace: bool = False
|
||||
|
||||
# Editor output mode - beginner-friendly numbered steps
|
||||
# Shows: Step 1: 📄 Creating file: path → ✓ Done
|
||||
editor_output: bool = False
|
||||
|
||||
# Output file - save agent response to file automatically
|
||||
# When set, saves the response to the specified file path
|
||||
output_file: Optional[str] = None
|
||||
@@ -596,6 +639,7 @@ class OutputConfig:
|
||||
"simple_output": self.simple_output,
|
||||
"show_parameters": self.show_parameters,
|
||||
"status_trace": self.status_trace,
|
||||
"editor_output": self.editor_output,
|
||||
"output_file": self.output_file,
|
||||
"template": self.template,
|
||||
}
|
||||
|
||||
@@ -85,14 +85,32 @@ _config_cache: Optional[Dict[str, Any]] = None
|
||||
_config_loaded: bool = False
|
||||
|
||||
|
||||
def _default_project_plugins_dir():
|
||||
"""Return project-local plugins directory via paths.py."""
|
||||
from ..paths import get_project_data_dir
|
||||
return get_project_data_dir() / "plugins"
|
||||
|
||||
|
||||
def _default_global_plugins_dir():
|
||||
"""Return user-global plugins directory via paths.py."""
|
||||
from ..paths import get_plugins_dir
|
||||
return get_plugins_dir()
|
||||
|
||||
|
||||
def get_project_data_dir():
|
||||
"""Re-export for local use in config search."""
|
||||
from ..paths import get_project_data_dir as _gpd
|
||||
return _gpd()
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginsConfig:
|
||||
"""Configuration for the plugin system."""
|
||||
enabled: Union[bool, List[str]] = False # True, False, or list of plugin names
|
||||
auto_discover: bool = True
|
||||
directories: List[str] = field(default_factory=lambda: [
|
||||
"./.praisonai/plugins/",
|
||||
"~/.praisonai/plugins/"
|
||||
str(_default_project_plugins_dir()),
|
||||
str(_default_global_plugins_dir())
|
||||
])
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
@@ -174,7 +192,7 @@ def _find_config_file() -> Optional[Path]:
|
||||
# Project-local locations
|
||||
cwd = Path.cwd()
|
||||
local_paths = [
|
||||
cwd / ".praisonai" / "config.toml",
|
||||
get_project_data_dir() / "config.toml",
|
||||
cwd / "praisonai.toml",
|
||||
]
|
||||
|
||||
@@ -183,8 +201,8 @@ def _find_config_file() -> Optional[Path]:
|
||||
return path
|
||||
|
||||
# User global location
|
||||
home = Path.home()
|
||||
global_path = home / ".praisonai" / "config.toml"
|
||||
from ..paths import get_data_dir
|
||||
global_path = get_data_dir() / "config.toml"
|
||||
if global_path.exists():
|
||||
return global_path
|
||||
|
||||
@@ -245,8 +263,8 @@ def _dict_to_plugins_config(data: Dict[str, Any]) -> PluginsConfig:
|
||||
enabled=data.get("enabled", False),
|
||||
auto_discover=data.get("auto_discover", True),
|
||||
directories=data.get("directories", [
|
||||
"./.praisonai/plugins/",
|
||||
"~/.praisonai/plugins/"
|
||||
str(_default_project_plugins_dir()),
|
||||
str(_default_global_plugins_dir())
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
@@ -133,6 +133,17 @@ OUTPUT_PRESETS: Dict[str, Dict[str, Any]] = {
|
||||
"actions_trace": True,
|
||||
"json_output": True, # Output as JSONL
|
||||
},
|
||||
# Editor preset - beginner-friendly numbered steps
|
||||
# Shows: Step 1: 📄 Creating file: path → ✓ Done
|
||||
# Best for non-programmers and interactive scripts
|
||||
"editor": {
|
||||
"verbose": False,
|
||||
"markdown": False,
|
||||
"stream": False,
|
||||
"metrics": False,
|
||||
"reasoning_steps": False,
|
||||
"editor_output": True, # Enable editor display mode
|
||||
},
|
||||
|
||||
# ==========================================================================
|
||||
# ALIASES (for backward compatibility)
|
||||
|
||||
@@ -6,6 +6,9 @@ implementations that coordinate multi-agent deployments.
|
||||
|
||||
This module contains only protocols and lightweight utilities.
|
||||
Heavy implementations live in the praisonai wrapper package.
|
||||
|
||||
Gap S2: WebSocketGateway is re-exported here for convenience but requires
|
||||
the praisonai wrapper package to be installed.
|
||||
"""
|
||||
|
||||
from .protocols import (
|
||||
@@ -18,15 +21,59 @@ from .protocols import (
|
||||
)
|
||||
from .config import GatewayConfig, SessionConfig, ChannelRouteConfig, MultiChannelGatewayConfig
|
||||
|
||||
# Lazy loading cache
|
||||
_lazy_cache = {}
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy load heavy gateway implementations from praisonai wrapper.
|
||||
|
||||
Gap S2: Re-export WebSocketGateway from praisonai wrapper for convenience.
|
||||
This allows downstream packages to import from praisonaiagents.gateway
|
||||
without needing to know about the praisonai wrapper.
|
||||
"""
|
||||
if name in _lazy_cache:
|
||||
return _lazy_cache[name]
|
||||
|
||||
if name == "WebSocketGateway":
|
||||
try:
|
||||
from praisonai.gateway import WebSocketGateway
|
||||
_lazy_cache[name] = WebSocketGateway
|
||||
return WebSocketGateway
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"WebSocketGateway requires the praisonai wrapper package. "
|
||||
"Install with: pip install praisonai"
|
||||
)
|
||||
|
||||
if name == "GatewaySession":
|
||||
try:
|
||||
from praisonai.gateway import GatewaySession
|
||||
_lazy_cache[name] = GatewaySession
|
||||
return GatewaySession
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"GatewaySession requires the praisonai wrapper package. "
|
||||
"Install with: pip install praisonai"
|
||||
)
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
__all__ = [
|
||||
# Protocols (always available)
|
||||
"GatewayProtocol",
|
||||
"GatewaySessionProtocol",
|
||||
"GatewayClientProtocol",
|
||||
"GatewayEvent",
|
||||
"GatewayMessage",
|
||||
"EventType",
|
||||
# Config (always available)
|
||||
"GatewayConfig",
|
||||
"SessionConfig",
|
||||
"ChannelRouteConfig",
|
||||
"MultiChannelGatewayConfig",
|
||||
# Implementations (lazy loaded from praisonai wrapper)
|
||||
"WebSocketGateway",
|
||||
"GatewaySession",
|
||||
]
|
||||
@@ -67,16 +67,22 @@ class LLMGuardrail:
|
||||
self.logger.warning(f"Unknown LLM type: {type(llm)}, treating as-is")
|
||||
return llm
|
||||
|
||||
def __call__(self, task_output: TaskOutput) -> Tuple[bool, Union[str, TaskOutput]]:
|
||||
def __call__(self, task_output) -> Tuple[bool, Union[str, "TaskOutput"]]:
|
||||
"""Validate the task output using the LLM.
|
||||
|
||||
Args:
|
||||
task_output: The task output to validate
|
||||
task_output: The task output to validate (TaskOutput or plain str)
|
||||
|
||||
Returns:
|
||||
Tuple of (success, result) where result is the output or error message
|
||||
"""
|
||||
try:
|
||||
# Accept plain str for convenience (e.g. chat-input screening)
|
||||
if isinstance(task_output, str):
|
||||
raw_text = task_output
|
||||
else:
|
||||
raw_text = task_output.raw
|
||||
|
||||
if not self.llm:
|
||||
self.logger.warning("No LLM provided for guardrail validation")
|
||||
return True, task_output
|
||||
@@ -88,7 +94,7 @@ You are a quality assurance validator. Your task is to evaluate the following ou
|
||||
Validation Criteria: {self.description}
|
||||
|
||||
Output to Validate:
|
||||
{task_output.raw}
|
||||
{raw_text}
|
||||
|
||||
Please evaluate if this output meets the criteria. Respond with:
|
||||
1. "PASS" if the output meets all criteria
|
||||
|
||||
@@ -20,34 +20,59 @@ class Chunking:
|
||||
@cached_property
|
||||
def SUPPORTED_CHUNKERS(self) -> Dict[str, Any]:
|
||||
"""Lazy load chunker classes."""
|
||||
chunkers = {}
|
||||
try:
|
||||
from chonkie import (
|
||||
TokenChunker,
|
||||
SentenceChunker,
|
||||
SemanticChunker,
|
||||
SDPMChunker,
|
||||
LateChunker,
|
||||
RecursiveChunker,
|
||||
CodeChunker,
|
||||
NeuralChunker,
|
||||
SlumberChunker
|
||||
)
|
||||
from chonkie import TokenChunker
|
||||
chunkers['token'] = TokenChunker
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from chonkie import SentenceChunker
|
||||
chunkers['sentence'] = SentenceChunker
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from chonkie import SemanticChunker
|
||||
chunkers['semantic'] = SemanticChunker
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from chonkie import SDPMChunker
|
||||
chunkers['sdpm'] = SDPMChunker
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from chonkie import LateChunker
|
||||
chunkers['late'] = LateChunker
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from chonkie import RecursiveChunker
|
||||
chunkers['recursive'] = RecursiveChunker
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from chonkie import CodeChunker
|
||||
chunkers['code'] = CodeChunker
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from chonkie import NeuralChunker
|
||||
chunkers['neural'] = NeuralChunker
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from chonkie import SlumberChunker
|
||||
chunkers['slumber'] = SlumberChunker
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if not chunkers:
|
||||
raise ImportError(
|
||||
"chonkie package not found. Please install it using: pip install 'praisonaiagents[knowledge]'"
|
||||
)
|
||||
|
||||
return {
|
||||
'token': TokenChunker,
|
||||
'sentence': SentenceChunker,
|
||||
'semantic': SemanticChunker,
|
||||
'sdpm': SDPMChunker,
|
||||
'late': LateChunker,
|
||||
'recursive': RecursiveChunker,
|
||||
'code': CodeChunker,
|
||||
'neural': NeuralChunker,
|
||||
'slumber': SlumberChunker
|
||||
}
|
||||
|
||||
return chunkers
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -116,7 +141,18 @@ class Chunking:
|
||||
if self._chunker is None:
|
||||
chunker_cls = self.SUPPORTED_CHUNKERS[self.chunker_type]
|
||||
common_params = self._get_chunker_params()
|
||||
self._chunker = chunker_cls(**common_params)
|
||||
try:
|
||||
self._chunker = chunker_cls(**common_params)
|
||||
except TypeError:
|
||||
# Chonkie API may have changed — retry with just chunk_size
|
||||
fallback_params = {'chunk_size': self.chunk_size}
|
||||
if 'embedding_model' in common_params:
|
||||
fallback_params['embedding_model'] = common_params['embedding_model']
|
||||
try:
|
||||
self._chunker = chunker_cls(**fallback_params)
|
||||
except TypeError:
|
||||
# Last resort — no params
|
||||
self._chunker = chunker_cls()
|
||||
|
||||
return self._chunker
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import os
|
||||
import logging
|
||||
import uuid
|
||||
import time
|
||||
from datetime import datetime
|
||||
from .chunking import Chunking
|
||||
from functools import cached_property
|
||||
@@ -414,9 +412,11 @@ class Knowledge:
|
||||
|
||||
@cached_property
|
||||
def config(self):
|
||||
# Generate unique collection name for each instance (only if not provided in config)
|
||||
default_collection = f"test_{int(time.time())}_{str(uuid.uuid4())[:8]}"
|
||||
persist_dir = ".praisonai"
|
||||
# Use deterministic collection name for persistence across restarts
|
||||
# Only generate unique name if explicitly requested via config
|
||||
default_collection = "praisonai_knowledge"
|
||||
from ..paths import get_project_data_dir
|
||||
persist_dir = str(get_project_data_dir())
|
||||
|
||||
# Create persistent client config
|
||||
base_config = {
|
||||
@@ -862,7 +862,8 @@ class Knowledge:
|
||||
result = IndexResult()
|
||||
|
||||
# Initialize file tracker for incremental indexing
|
||||
state_dir = os.path.join(os.path.dirname(path) if os.path.isfile(path) else path, ".praisonai")
|
||||
from ..paths import DEFAULT_DIR_NAME
|
||||
state_dir = os.path.join(os.path.dirname(path) if os.path.isfile(path) else path, DEFAULT_DIR_NAME)
|
||||
os.makedirs(state_dir, exist_ok=True)
|
||||
state_file = os.path.join(state_dir, ".index_state.json")
|
||||
|
||||
|
||||
@@ -592,6 +592,18 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
f"waiting {retry_delay:.1f}s before retry..."
|
||||
)
|
||||
|
||||
# P5: Emit retry callback for CLI visibility
|
||||
try:
|
||||
from ..main import execute_sync_callback
|
||||
execute_sync_callback('retry',
|
||||
attempt=attempt + 1,
|
||||
max_attempts=self._max_retries + 1,
|
||||
error=error_str[:200],
|
||||
retry_in_seconds=retry_delay
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if self._rate_limiter is not None:
|
||||
self._rate_limiter.wait_for_retry(retry_delay)
|
||||
else:
|
||||
|
||||
@@ -1156,6 +1156,8 @@ class OpenAIClient:
|
||||
reasoning_steps: bool = False,
|
||||
verbose: bool = True,
|
||||
max_iterations: int = 10,
|
||||
stream_callback: Optional[Callable] = None,
|
||||
emit_events: bool = False,
|
||||
**kwargs
|
||||
) -> Optional[ChatCompletion]:
|
||||
"""
|
||||
@@ -1179,6 +1181,8 @@ class OpenAIClient:
|
||||
reasoning_steps: Whether to show reasoning
|
||||
verbose: Whether to show verbose output
|
||||
max_iterations: Maximum tool calling iterations
|
||||
stream_callback: Optional callback for StreamEvent emission
|
||||
emit_events: Whether to emit StreamEvents (requires stream_callback)
|
||||
**kwargs: Additional API parameters
|
||||
|
||||
Returns:
|
||||
@@ -1208,6 +1212,8 @@ class OpenAIClient:
|
||||
console=console,
|
||||
display_fn=display_fn,
|
||||
reasoning_steps=reasoning_steps,
|
||||
stream_callback=stream_callback,
|
||||
emit_events=emit_events,
|
||||
**kwargs
|
||||
)
|
||||
else:
|
||||
@@ -1377,6 +1383,8 @@ class OpenAIClient:
|
||||
reasoning_steps: bool = False,
|
||||
verbose: bool = True,
|
||||
max_iterations: int = 10,
|
||||
stream_callback: Optional[Callable] = None,
|
||||
emit_events: bool = False,
|
||||
**kwargs
|
||||
) -> Optional[ChatCompletion]:
|
||||
"""
|
||||
@@ -1394,6 +1402,8 @@ class OpenAIClient:
|
||||
reasoning_steps: Whether to show reasoning
|
||||
verbose: Whether to show verbose output
|
||||
max_iterations: Maximum tool calling iterations
|
||||
stream_callback: Optional callback for StreamEvent emission (can be sync or async)
|
||||
emit_events: Whether to emit StreamEvents (requires stream_callback)
|
||||
**kwargs: Additional API parameters
|
||||
|
||||
Returns:
|
||||
@@ -1419,6 +1429,8 @@ class OpenAIClient:
|
||||
console=console,
|
||||
display_fn=display_fn,
|
||||
reasoning_steps=reasoning_steps,
|
||||
stream_callback=stream_callback,
|
||||
emit_events=emit_events,
|
||||
**kwargs
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -103,6 +103,20 @@ __all__ = [
|
||||
'display_self_reflection',
|
||||
]
|
||||
|
||||
# P3/G2: Supported callback types documentation
|
||||
# Standard callbacks: tool_call, interaction, error, llm_start, llm_end
|
||||
# Autonomy callbacks (PraisonAI unique):
|
||||
# - autonomy_iteration: {iteration, max_iterations, stage}
|
||||
# - autonomy_stage_change: {from_stage, to_stage}
|
||||
# - autonomy_doom_loop: {iteration, recovery_action}
|
||||
# - autonomy_complete: {completion_reason, iterations, duration_seconds}
|
||||
# - retry: {attempt, max_attempts, error, retry_in_seconds}
|
||||
SUPPORTED_CALLBACK_TYPES = [
|
||||
'tool_call', 'interaction', 'error', 'llm_start', 'llm_end',
|
||||
'autonomy_iteration', 'autonomy_stage_change', 'autonomy_doom_loop', 'autonomy_complete',
|
||||
'retry',
|
||||
]
|
||||
|
||||
def register_display_callback(display_type: str, callback_fn, is_async: bool = False):
|
||||
"""Register a synchronous or asynchronous callback function for a specific display type.
|
||||
|
||||
|
||||
@@ -245,7 +245,7 @@ class AutoMemory:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
memory: "FileMemory",
|
||||
memory: Any,
|
||||
enabled: bool = True,
|
||||
extractor: Optional[AutoMemoryExtractor] = None,
|
||||
min_importance: float = 0.6,
|
||||
|
||||
@@ -80,7 +80,8 @@ class DocsManager:
|
||||
- Priority-based ordering
|
||||
"""
|
||||
|
||||
DOCS_DIR_NAME = ".praisonai/docs"
|
||||
from ..paths import DEFAULT_DIR_NAME as _DIR_NAME
|
||||
DOCS_DIR_NAME = f"{_DIR_NAME}/docs"
|
||||
SUPPORTED_EXTENSIONS = [".md", ".txt", ".rst"]
|
||||
|
||||
# Maximum characters per doc (context window management)
|
||||
@@ -101,7 +102,11 @@ class DocsManager:
|
||||
verbose: Verbosity level
|
||||
"""
|
||||
self.workspace_path = Path(workspace_path) if workspace_path else Path.cwd()
|
||||
self.global_docs_path = Path(global_docs_path) if global_docs_path else Path.home() / ".praisonai" / "docs"
|
||||
if global_docs_path:
|
||||
self.global_docs_path = Path(global_docs_path)
|
||||
else:
|
||||
from ..paths import get_docs_dir
|
||||
self.global_docs_path = get_docs_dir()
|
||||
self.verbose = verbose
|
||||
|
||||
self._docs: Dict[str, Doc] = {}
|
||||
|
||||
@@ -140,7 +140,8 @@ class FileMemory:
|
||||
if base_path:
|
||||
self.base_path = Path(base_path)
|
||||
else:
|
||||
self.base_path = Path(".praisonai/memory")
|
||||
from ..paths import get_project_data_dir
|
||||
self.base_path = get_project_data_dir() / "memory"
|
||||
|
||||
self.user_path = self.base_path / user_id
|
||||
self.episodic_path = self.user_path / "episodic"
|
||||
@@ -1065,6 +1066,55 @@ class FileMemory:
|
||||
"storage_path": str(self.user_path)
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# MemoryProtocol Compliance Aliases
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def store_short_term(
|
||||
self,
|
||||
text: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs
|
||||
) -> str:
|
||||
"""Protocol-compliant alias for add_short_term."""
|
||||
return self.add_short_term(text, metadata)
|
||||
|
||||
def store_long_term(
|
||||
self,
|
||||
text: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs
|
||||
) -> str:
|
||||
"""Protocol-compliant alias for add_long_term."""
|
||||
return self.add_long_term(text, metadata)
|
||||
|
||||
def search_short_term(
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 5,
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Protocol-compliant alias for search (short_term only)."""
|
||||
return self.search(query, memory_types=["short_term"], limit=limit)
|
||||
|
||||
def search_long_term(
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 5,
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Protocol-compliant alias for search (long_term only)."""
|
||||
return self.search(query, memory_types=["long_term"], limit=limit)
|
||||
|
||||
def get_all_memories(self, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""Get all memories from short-term and long-term stores."""
|
||||
all_memories = []
|
||||
for item in self._short_term:
|
||||
all_memories.append({"type": "short_term", **item.to_dict()})
|
||||
for item in self._long_term:
|
||||
all_memories.append({"type": "long_term", **item.to_dict()})
|
||||
return all_memories
|
||||
|
||||
def export(self) -> Dict[str, Any]:
|
||||
"""Export all memory data."""
|
||||
return {
|
||||
|
||||
@@ -103,7 +103,8 @@ class HooksManager:
|
||||
```
|
||||
"""
|
||||
|
||||
CONFIG_FILE = ".praisonai/hooks.json"
|
||||
from ..paths import DEFAULT_DIR_NAME as _DIR_NAME
|
||||
CONFIG_FILE = f"{_DIR_NAME}/hooks.json"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -384,7 +385,8 @@ class HooksManager:
|
||||
timeout: Global timeout in seconds
|
||||
enabled: Whether hooks are enabled
|
||||
"""
|
||||
config_dir = self.workspace_path / ".praisonai"
|
||||
from ..paths import DEFAULT_DIR_NAME
|
||||
config_dir = self.workspace_path / DEFAULT_DIR_NAME
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
config = {
|
||||
|
||||
@@ -5,23 +5,30 @@ Provides learning capabilities for agents to capture patterns, preferences,
|
||||
and insights from interactions to improve future responses.
|
||||
|
||||
Usage:
|
||||
from praisonaiagents import Agent, MemoryConfig, LearnConfig
|
||||
from praisonaiagents import Agent, LearnConfig
|
||||
|
||||
# Simple enable
|
||||
agent = Agent(memory=MemoryConfig(learn=True))
|
||||
# Simple enable (top-level param)
|
||||
agent = Agent(learn=True)
|
||||
|
||||
# With specific capabilities
|
||||
agent = Agent(memory=MemoryConfig(
|
||||
learn=LearnConfig(
|
||||
persona=True,
|
||||
insights=True,
|
||||
patterns=True,
|
||||
)
|
||||
# With specific capabilities and auto-learning
|
||||
agent = Agent(learn=LearnConfig(
|
||||
persona=True,
|
||||
insights=True,
|
||||
patterns=True,
|
||||
mode="agentic", # Auto-extract learnings
|
||||
))
|
||||
|
||||
# With database backend
|
||||
agent = Agent(learn=LearnConfig(
|
||||
backend="sqlite",
|
||||
db_url="sqlite:///learn.db",
|
||||
))
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
# Manager
|
||||
"LearnManager",
|
||||
# Stores
|
||||
"PersonaStore",
|
||||
"InsightStore",
|
||||
"ThreadStore",
|
||||
@@ -29,14 +36,22 @@ __all__ = [
|
||||
"DecisionStore",
|
||||
"FeedbackStore",
|
||||
"ImprovementStore",
|
||||
# Protocols
|
||||
"LearnMode",
|
||||
"LearnProtocol",
|
||||
"AsyncLearnProtocol",
|
||||
"LearnManagerProtocol",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy loading for learn module components."""
|
||||
# Manager
|
||||
if name == "LearnManager":
|
||||
from .manager import LearnManager
|
||||
return LearnManager
|
||||
|
||||
# Stores
|
||||
if name == "PersonaStore":
|
||||
from .stores import PersonaStore
|
||||
return PersonaStore
|
||||
@@ -58,4 +73,19 @@ def __getattr__(name: str):
|
||||
if name == "ImprovementStore":
|
||||
from .stores import ImprovementStore
|
||||
return ImprovementStore
|
||||
|
||||
# Protocols
|
||||
if name == "LearnMode":
|
||||
from .protocols import LearnMode
|
||||
return LearnMode
|
||||
if name == "LearnProtocol":
|
||||
from .protocols import LearnProtocol
|
||||
return LearnProtocol
|
||||
if name == "AsyncLearnProtocol":
|
||||
from .protocols import AsyncLearnProtocol
|
||||
return AsyncLearnProtocol
|
||||
if name == "LearnManagerProtocol":
|
||||
from .protocols import LearnManagerProtocol
|
||||
return LearnManagerProtocol
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -6,8 +6,9 @@ capturing and retrieving learned information.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
import logging
|
||||
|
||||
from ...config.feature_configs import LearnConfig, LearnScope
|
||||
from ...config.feature_configs import LearnConfig, LearnScope, LearnBackend
|
||||
from .stores import (
|
||||
PersonaStore,
|
||||
InsightStore,
|
||||
@@ -43,65 +44,163 @@ class LearnManager:
|
||||
config: Optional[LearnConfig] = None,
|
||||
user_id: Optional[str] = None,
|
||||
store_path: Optional[str] = None,
|
||||
custom_stores: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
"""
|
||||
Initialize LearnManager.
|
||||
|
||||
Args:
|
||||
config: LearnConfig for enabling/disabling learning capabilities
|
||||
user_id: User identifier for scoping learnings
|
||||
store_path: Custom path for storing learning data
|
||||
custom_stores: Optional dict of custom store implementations.
|
||||
Keys are store names (e.g., "domain", "skills").
|
||||
Values must implement LearnProtocol (add, search, list_all, etc.)
|
||||
|
||||
Example:
|
||||
```python
|
||||
class MyDomainStore:
|
||||
def add(self, content, metadata=None): ...
|
||||
def search(self, query, limit=10): ...
|
||||
def list_all(self, limit=100): ...
|
||||
def get(self, entry_id): ...
|
||||
def update(self, entry_id, content, metadata=None): ...
|
||||
def delete(self, entry_id): ...
|
||||
def clear(self): ...
|
||||
|
||||
manager = LearnManager(
|
||||
config=LearnConfig(persona=True),
|
||||
custom_stores={"domain": MyDomainStore()}
|
||||
)
|
||||
```
|
||||
"""
|
||||
self.config = config or LearnConfig()
|
||||
self.user_id = user_id or "default"
|
||||
self.store_path = store_path or self.config.store_path
|
||||
|
||||
# Resolve backend from config
|
||||
self._backend = self._create_backend()
|
||||
if self._backend is not None:
|
||||
logging.info(
|
||||
f"LearnManager using {self.config.backend} backend"
|
||||
)
|
||||
|
||||
scope = self.config.scope
|
||||
if isinstance(scope, LearnScope):
|
||||
scope = scope.value
|
||||
|
||||
self._stores: Dict[str, Any] = {}
|
||||
self._custom_stores = custom_stores or {}
|
||||
|
||||
if self.config.persona:
|
||||
# Built-in stores are added first, custom stores can override them
|
||||
if self.config.persona and "persona" not in self._custom_stores:
|
||||
self._stores["persona"] = PersonaStore(
|
||||
store_path=self._get_store_path("persona"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
if self.config.insights:
|
||||
if self.config.insights and "insights" not in self._custom_stores:
|
||||
self._stores["insights"] = InsightStore(
|
||||
store_path=self._get_store_path("insights"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
if self.config.thread:
|
||||
if self.config.thread and "threads" not in self._custom_stores:
|
||||
self._stores["threads"] = ThreadStore(
|
||||
store_path=self._get_store_path("threads"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
if self.config.patterns:
|
||||
if self.config.patterns and "patterns" not in self._custom_stores:
|
||||
self._stores["patterns"] = PatternStore(
|
||||
store_path=self._get_store_path("patterns"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
if self.config.decisions:
|
||||
if self.config.decisions and "decisions" not in self._custom_stores:
|
||||
self._stores["decisions"] = DecisionStore(
|
||||
store_path=self._get_store_path("decisions"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
if self.config.feedback:
|
||||
if self.config.feedback and "feedback" not in self._custom_stores:
|
||||
self._stores["feedback"] = FeedbackStore(
|
||||
store_path=self._get_store_path("feedback"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
if self.config.improvements:
|
||||
if self.config.improvements and "improvements" not in self._custom_stores:
|
||||
self._stores["improvements"] = ImprovementStore(
|
||||
store_path=self._get_store_path("improvements"),
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
)
|
||||
|
||||
# Add custom stores last (they override built-in stores)
|
||||
if self._custom_stores:
|
||||
for name, store in self._custom_stores.items():
|
||||
self._stores[name] = store
|
||||
|
||||
# PROPOSE mode: pending learnings queue
|
||||
self._pending: List[LearnEntry] = []
|
||||
|
||||
def _create_backend(self) -> Any:
|
||||
"""Create a storage backend from config.
|
||||
|
||||
Returns backend instance or None for FILE (default) backend.
|
||||
Falls back to None (FILE) on error.
|
||||
"""
|
||||
backend = self.config.backend
|
||||
backend_str = backend.value if isinstance(backend, LearnBackend) else str(backend)
|
||||
|
||||
if backend_str == "file":
|
||||
return None # FILE uses BaseJSONStore default
|
||||
|
||||
if backend_str == "sqlite":
|
||||
try:
|
||||
from ...storage.backends import SQLiteBackend
|
||||
db_path = self.config.db_url or f"{self.store_path or 'learn'}/learn.db"
|
||||
# Strip sqlite:/// prefix if present
|
||||
if db_path.startswith("sqlite:///"):
|
||||
db_path = db_path[len("sqlite:///"):]
|
||||
return SQLiteBackend(db_path=db_path, table_name="learn_store")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to create SQLite backend: {e}. Falling back to FILE.")
|
||||
return None
|
||||
|
||||
if backend_str == "redis":
|
||||
try:
|
||||
from ...storage.backends import RedisBackend
|
||||
url = self.config.db_url or "redis://localhost:6379"
|
||||
return RedisBackend(url=url, prefix="praison:learn:")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to create Redis backend: {e}. Falling back to FILE.")
|
||||
return None
|
||||
|
||||
if backend_str == "mongodb":
|
||||
try:
|
||||
from ...storage.backends import MongoDBBackend
|
||||
url = self.config.db_url or "mongodb://localhost:27017/"
|
||||
return MongoDBBackend(url=url, collection="praison_learn")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to create MongoDB backend: {e}. Falling back to FILE.")
|
||||
return None
|
||||
|
||||
logging.warning(f"Unknown learn backend '{backend_str}'. Falling back to FILE.")
|
||||
return None
|
||||
|
||||
def _get_store_path(self, store_name: str) -> Optional[str]:
|
||||
"""Get path for a specific store."""
|
||||
@@ -216,6 +315,88 @@ class LearnManager:
|
||||
for store_name, store in self._stores.items()
|
||||
}
|
||||
|
||||
@property
|
||||
def pending_learnings(self) -> List[LearnEntry]:
|
||||
"""Get list of pending learnings awaiting approval (PROPOSE mode)."""
|
||||
return list(self._pending)
|
||||
|
||||
def add_pending(
|
||||
self,
|
||||
content: str,
|
||||
category: str = "persona",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> LearnEntry:
|
||||
"""Add a learning to the pending queue for user approval.
|
||||
|
||||
Args:
|
||||
content: The learning content
|
||||
category: Store category (persona, insights, patterns, etc.)
|
||||
metadata: Optional metadata
|
||||
|
||||
Returns:
|
||||
The pending LearnEntry
|
||||
"""
|
||||
from datetime import datetime
|
||||
entry_id = f"pending_{category}_{len(self._pending)}_{datetime.utcnow().timestamp()}"
|
||||
entry = LearnEntry(
|
||||
id=entry_id,
|
||||
content=content,
|
||||
metadata={**(metadata or {}), "category": category, "status": "pending"},
|
||||
)
|
||||
self._pending.append(entry)
|
||||
return entry
|
||||
|
||||
def approve_learning(self, entry_id: str) -> bool:
|
||||
"""Approve a pending learning — move it to the appropriate store.
|
||||
|
||||
Args:
|
||||
entry_id: ID of the pending learning to approve
|
||||
|
||||
Returns:
|
||||
True if approved and stored, False if not found
|
||||
"""
|
||||
for i, entry in enumerate(self._pending):
|
||||
if entry.id == entry_id:
|
||||
category = entry.metadata.get("category", "persona")
|
||||
# Route to appropriate store
|
||||
if category in self._stores:
|
||||
self._stores[category].add(entry.content, entry.metadata)
|
||||
elif "persona" in self._stores:
|
||||
self._stores["persona"].add(entry.content, entry.metadata)
|
||||
self._pending.pop(i)
|
||||
return True
|
||||
return False
|
||||
|
||||
def reject_learning(self, entry_id: str) -> bool:
|
||||
"""Reject a pending learning — discard it.
|
||||
|
||||
Args:
|
||||
entry_id: ID of the pending learning to reject
|
||||
|
||||
Returns:
|
||||
True if found and rejected, False if not found
|
||||
"""
|
||||
for i, entry in enumerate(self._pending):
|
||||
if entry.id == entry_id:
|
||||
self._pending.pop(i)
|
||||
return True
|
||||
return False
|
||||
|
||||
def approve_all_learnings(self) -> int:
|
||||
"""Approve all pending learnings at once.
|
||||
|
||||
Returns:
|
||||
Number of learnings approved
|
||||
"""
|
||||
count = 0
|
||||
while self._pending:
|
||||
entry = self._pending[0]
|
||||
if self.approve_learning(entry.id):
|
||||
count += 1
|
||||
else:
|
||||
break # Safety
|
||||
return count
|
||||
|
||||
def to_system_prompt_context(self) -> str:
|
||||
"""
|
||||
Generate context suitable for injection into system prompt.
|
||||
@@ -275,3 +456,211 @@ class LearnManager:
|
||||
# Note: threads are session-specific and not included in system prompt
|
||||
|
||||
return "\n".join(parts) if parts else ""
|
||||
|
||||
def process_conversation(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
llm: Optional[str] = None,
|
||||
extract_only: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Process a conversation to extract learnings automatically.
|
||||
|
||||
Uses LLM to analyze the conversation and extract:
|
||||
- User preferences (persona)
|
||||
- Insights and observations
|
||||
- Patterns in user behavior
|
||||
|
||||
Args:
|
||||
messages: Conversation messages (list of {"role": ..., "content": ...})
|
||||
llm: Optional LLM model to use for extraction (defaults to gpt-4o-mini)
|
||||
|
||||
Returns:
|
||||
Dictionary with extracted learnings:
|
||||
{
|
||||
"persona": [...],
|
||||
"insights": [...],
|
||||
"patterns": [...],
|
||||
"stored": {"persona": N, "insights": N, "patterns": N}
|
||||
}
|
||||
"""
|
||||
if not messages:
|
||||
return {"persona": [], "insights": [], "patterns": [], "stored": {}}
|
||||
|
||||
# Build conversation text
|
||||
conversation_text = "\n".join([
|
||||
f"{msg.get('role', 'unknown')}: {msg.get('content', '')}"
|
||||
for msg in messages
|
||||
])
|
||||
|
||||
# Use LLM to extract learnings
|
||||
extraction_prompt = f"""Analyze this conversation and extract learnings.
|
||||
|
||||
CONVERSATION:
|
||||
{conversation_text}
|
||||
|
||||
Extract the following (if present):
|
||||
1. USER PREFERENCES: Things the user likes, dislikes, prefers, or their style
|
||||
2. INSIGHTS: Observations about the user's domain, work, or context
|
||||
3. PATTERNS: Recurring behaviors or request patterns
|
||||
|
||||
Return JSON:
|
||||
{{
|
||||
"persona": ["preference 1", "preference 2"],
|
||||
"insights": ["insight 1", "insight 2"],
|
||||
"patterns": ["pattern 1", "pattern 2"]
|
||||
}}
|
||||
|
||||
Only include items that are clearly evident from the conversation.
|
||||
Return empty arrays if nothing is found for a category."""
|
||||
|
||||
try:
|
||||
# Lazy import to avoid circular dependency
|
||||
from ...llm import LLM
|
||||
import json
|
||||
|
||||
model = llm or "gpt-4o-mini"
|
||||
llm_instance = LLM(model=model)
|
||||
response = llm_instance.get_response(
|
||||
prompt=extraction_prompt,
|
||||
output_json=True,
|
||||
)
|
||||
|
||||
# Parse response - handle both string and dict responses
|
||||
if isinstance(response, str):
|
||||
extracted = json.loads(response)
|
||||
elif isinstance(response, dict):
|
||||
extracted = response
|
||||
else:
|
||||
extracted = {"persona": [], "insights": [], "patterns": []}
|
||||
|
||||
# Store extracted learnings (unless extract_only)
|
||||
stored = {"persona": 0, "insights": 0, "patterns": 0}
|
||||
|
||||
if not extract_only:
|
||||
for preference in extracted.get("persona", []):
|
||||
if preference and "persona" in self._stores:
|
||||
self.capture_persona(preference)
|
||||
stored["persona"] += 1
|
||||
|
||||
for insight in extracted.get("insights", []):
|
||||
if insight and "insights" in self._stores:
|
||||
self.capture_insight(insight, source="auto_extraction")
|
||||
stored["insights"] += 1
|
||||
|
||||
for pattern in extracted.get("patterns", []):
|
||||
if pattern and "patterns" in self._stores:
|
||||
self.capture_pattern(pattern, pattern_type="auto_extracted")
|
||||
stored["patterns"] += 1
|
||||
|
||||
return {
|
||||
"persona": extracted.get("persona", []),
|
||||
"insights": extracted.get("insights", []),
|
||||
"patterns": extracted.get("patterns", []),
|
||||
"stored": stored,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.warning(f"Failed to extract learnings: {e}")
|
||||
return {"persona": [], "insights": [], "patterns": [], "stored": {}, "error": str(e)}
|
||||
|
||||
async def aprocess_conversation(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
llm: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Async version of process_conversation.
|
||||
|
||||
Args:
|
||||
messages: Conversation messages
|
||||
llm: Optional LLM model
|
||||
|
||||
Returns:
|
||||
Dictionary with extracted learnings
|
||||
"""
|
||||
if not messages:
|
||||
return {"persona": [], "insights": [], "patterns": [], "stored": {}}
|
||||
|
||||
# Build conversation text
|
||||
conversation_text = "\n".join([
|
||||
f"{msg.get('role', 'unknown')}: {msg.get('content', '')}"
|
||||
for msg in messages
|
||||
])
|
||||
|
||||
extraction_prompt = f"""Analyze this conversation and extract learnings.
|
||||
|
||||
CONVERSATION:
|
||||
{conversation_text}
|
||||
|
||||
Extract the following (if present):
|
||||
1. USER PREFERENCES: Things the user likes, dislikes, prefers, or their style
|
||||
2. INSIGHTS: Observations about the user's domain, work, or context
|
||||
3. PATTERNS: Recurring behaviors or request patterns
|
||||
|
||||
Return JSON:
|
||||
{{
|
||||
"persona": ["preference 1", "preference 2"],
|
||||
"insights": ["insight 1", "insight 2"],
|
||||
"patterns": ["pattern 1", "pattern 2"]
|
||||
}}
|
||||
|
||||
Only include items that are clearly evident from the conversation.
|
||||
Return empty arrays if nothing is found for a category."""
|
||||
|
||||
try:
|
||||
# Lazy import to avoid circular dependency
|
||||
from ...llm import LLM
|
||||
import json
|
||||
import asyncio
|
||||
|
||||
model = llm or "gpt-4o-mini"
|
||||
llm_instance = LLM(model=model)
|
||||
|
||||
# Run sync LLM call in executor for async compatibility
|
||||
loop = asyncio.get_event_loop()
|
||||
response = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: llm_instance.get_response(
|
||||
prompt=extraction_prompt,
|
||||
output_json=True,
|
||||
)
|
||||
)
|
||||
|
||||
# Parse response - handle both string and dict responses
|
||||
if isinstance(response, str):
|
||||
extracted = json.loads(response)
|
||||
elif isinstance(response, dict):
|
||||
extracted = response
|
||||
else:
|
||||
extracted = {"persona": [], "insights": [], "patterns": []}
|
||||
|
||||
stored = {"persona": 0, "insights": 0, "patterns": 0}
|
||||
|
||||
for preference in extracted.get("persona", []):
|
||||
if preference and "persona" in self._stores:
|
||||
self.capture_persona(preference)
|
||||
stored["persona"] += 1
|
||||
|
||||
for insight in extracted.get("insights", []):
|
||||
if insight and "insights" in self._stores:
|
||||
self.capture_insight(insight, source="auto_extraction")
|
||||
stored["insights"] += 1
|
||||
|
||||
for pattern in extracted.get("patterns", []):
|
||||
if pattern and "patterns" in self._stores:
|
||||
self.capture_pattern(pattern, pattern_type="auto_extracted")
|
||||
stored["patterns"] += 1
|
||||
|
||||
return {
|
||||
"persona": extracted.get("persona", []),
|
||||
"insights": extracted.get("insights", []),
|
||||
"patterns": extracted.get("patterns", []),
|
||||
"stored": stored,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.warning(f"Failed to extract learnings: {e}")
|
||||
return {"persona": [], "insights": [], "patterns": [], "stored": {}, "error": str(e)}
|
||||
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
Learn Protocol Definitions.
|
||||
|
||||
Provides Protocol interfaces for learning implementations.
|
||||
Enables custom learning backends and extensibility.
|
||||
"""
|
||||
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
|
||||
|
||||
# Re-export LearnMode from feature_configs (canonical location)
|
||||
# This avoids duplication while maintaining backward compatibility
|
||||
from ...config.feature_configs import LearnMode
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class LearnProtocol(Protocol):
|
||||
"""
|
||||
Protocol for learning store implementations.
|
||||
|
||||
Enables custom backends (file, SQLite, Redis, MongoDB, etc.)
|
||||
while maintaining a consistent interface.
|
||||
|
||||
Example:
|
||||
```python
|
||||
class RedisLearnStore:
|
||||
def add(self, content: str, metadata=None) -> Dict[str, Any]:
|
||||
# Store in Redis
|
||||
return {"id": "...", "content": content}
|
||||
|
||||
def search(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
# Search Redis
|
||||
return []
|
||||
|
||||
def list_all(self, limit: int = 100) -> List[Dict[str, Any]]:
|
||||
return []
|
||||
|
||||
def delete(self, entry_id: str) -> bool:
|
||||
return True
|
||||
|
||||
def clear(self) -> int:
|
||||
return 0
|
||||
```
|
||||
"""
|
||||
|
||||
def add(
|
||||
self,
|
||||
content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Add a new entry to the store.
|
||||
|
||||
Args:
|
||||
content: The content to store
|
||||
metadata: Optional metadata dictionary
|
||||
|
||||
Returns:
|
||||
Dictionary with entry details including 'id'
|
||||
"""
|
||||
...
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search entries by query.
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
limit: Maximum results to return
|
||||
|
||||
Returns:
|
||||
List of matching entries
|
||||
"""
|
||||
...
|
||||
|
||||
def list_all(
|
||||
self,
|
||||
limit: int = 100,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List all entries.
|
||||
|
||||
Args:
|
||||
limit: Maximum entries to return
|
||||
|
||||
Returns:
|
||||
List of all entries (most recent first)
|
||||
"""
|
||||
...
|
||||
|
||||
def get(
|
||||
self,
|
||||
entry_id: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get entry by ID.
|
||||
|
||||
Args:
|
||||
entry_id: Entry identifier
|
||||
|
||||
Returns:
|
||||
Entry dict or None if not found
|
||||
"""
|
||||
...
|
||||
|
||||
def update(
|
||||
self,
|
||||
entry_id: str,
|
||||
content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Update an existing entry.
|
||||
|
||||
Args:
|
||||
entry_id: Entry identifier
|
||||
content: New content
|
||||
metadata: Optional metadata to merge
|
||||
|
||||
Returns:
|
||||
Updated entry or None if not found
|
||||
"""
|
||||
...
|
||||
|
||||
def delete(
|
||||
self,
|
||||
entry_id: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Delete an entry.
|
||||
|
||||
Args:
|
||||
entry_id: Entry identifier
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
...
|
||||
|
||||
def clear(self) -> int:
|
||||
"""
|
||||
Clear all entries.
|
||||
|
||||
Returns:
|
||||
Number of entries cleared
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AsyncLearnProtocol(Protocol):
|
||||
"""
|
||||
Async Protocol for learning store implementations.
|
||||
|
||||
For backends that support async operations.
|
||||
"""
|
||||
|
||||
async def aadd(
|
||||
self,
|
||||
content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Async add entry."""
|
||||
...
|
||||
|
||||
async def asearch(
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Async search entries."""
|
||||
...
|
||||
|
||||
async def alist_all(
|
||||
self,
|
||||
limit: int = 100,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Async list all entries."""
|
||||
...
|
||||
|
||||
async def aget(
|
||||
self,
|
||||
entry_id: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Async get entry by ID."""
|
||||
...
|
||||
|
||||
async def aupdate(
|
||||
self,
|
||||
entry_id: str,
|
||||
content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Async update entry."""
|
||||
...
|
||||
|
||||
async def adelete(
|
||||
self,
|
||||
entry_id: str,
|
||||
) -> bool:
|
||||
"""Async delete entry."""
|
||||
...
|
||||
|
||||
async def aclear(self) -> int:
|
||||
"""Async clear all entries."""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class LearnManagerProtocol(Protocol):
|
||||
"""
|
||||
Protocol for LearnManager implementations.
|
||||
|
||||
Defines the interface for managing learning across multiple stores.
|
||||
"""
|
||||
|
||||
def capture_persona(self, content: str, category: str = "general") -> Optional[Dict[str, Any]]:
|
||||
"""Capture user preference or profile information."""
|
||||
...
|
||||
|
||||
def capture_insight(self, content: str, source: str = "interaction") -> Optional[Dict[str, Any]]:
|
||||
"""Capture an observation or learning."""
|
||||
...
|
||||
|
||||
def capture_pattern(self, pattern: str, pattern_type: str = "general") -> Optional[Dict[str, Any]]:
|
||||
"""Capture a reusable knowledge pattern."""
|
||||
...
|
||||
|
||||
def get_learning_context(self, limit_per_store: int = 5) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Get learning context from all enabled stores."""
|
||||
...
|
||||
|
||||
def search(self, query: str, limit: int = 10) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Search across all enabled stores."""
|
||||
...
|
||||
|
||||
def to_system_prompt_context(self) -> str:
|
||||
"""Generate context for system prompt injection."""
|
||||
...
|
||||
|
||||
def process_conversation(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
llm: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Process a conversation to extract learnings automatically.
|
||||
|
||||
Args:
|
||||
messages: Conversation messages
|
||||
llm: Optional LLM model to use for extraction
|
||||
|
||||
Returns:
|
||||
Dictionary with extracted learnings
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
__all__ = [
|
||||
'LearnMode',
|
||||
'LearnProtocol',
|
||||
'AsyncLearnProtocol',
|
||||
'LearnManagerProtocol',
|
||||
]
|
||||
@@ -73,12 +73,23 @@ class BaseStore(ABC):
|
||||
self._backend = backend
|
||||
self.store_path = store_path or self._default_path()
|
||||
self._entries: Dict[str, LearnEntry] = {}
|
||||
self._was_updated: bool = False
|
||||
|
||||
# DRY: Use BaseJSONStore for thread-safe storage
|
||||
self._store = BaseJSONStore(
|
||||
storage_path=self.store_path,
|
||||
backend=backend,
|
||||
)
|
||||
# Fall back to FILE backend if connection fails (e.g., Redis unavailable)
|
||||
try:
|
||||
self._store = BaseJSONStore(
|
||||
storage_path=self.store_path,
|
||||
backend=backend,
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.warning(f"Failed to connect to backend: {e}. Falling back to FILE.")
|
||||
self._backend = None
|
||||
self._store = BaseJSONStore(
|
||||
storage_path=self.store_path,
|
||||
backend=None,
|
||||
)
|
||||
self._load()
|
||||
|
||||
@property
|
||||
@@ -87,6 +98,15 @@ class BaseStore(ABC):
|
||||
"""Name of the store (used for file naming)."""
|
||||
pass
|
||||
|
||||
@property
|
||||
def was_updated(self) -> bool:
|
||||
"""Whether this store has been modified since last reset."""
|
||||
return self._was_updated
|
||||
|
||||
def reset_updated(self) -> None:
|
||||
"""Reset the was_updated flag."""
|
||||
self._was_updated = False
|
||||
|
||||
def _default_path(self) -> str:
|
||||
"""Default storage path (uses centralized paths - DRY)."""
|
||||
from ...paths import get_learn_dir, ensure_dir
|
||||
@@ -106,7 +126,24 @@ class BaseStore(ABC):
|
||||
self._store.save({k: v.to_dict() for k, v in self._entries.items()})
|
||||
|
||||
def add(self, content: str, metadata: Optional[Dict[str, Any]] = None) -> LearnEntry:
|
||||
"""Add a new entry."""
|
||||
"""Add a new entry with deduplication.
|
||||
|
||||
Checks for exact content matches to prevent duplicate learnings.
|
||||
If a duplicate is found, returns the existing entry instead of creating a new one.
|
||||
"""
|
||||
# Deduplication: Check for exact content match
|
||||
content_normalized = content.strip().lower()
|
||||
for existing_entry in self._entries.values():
|
||||
if existing_entry.content.strip().lower() == content_normalized:
|
||||
# Update metadata if provided, but don't create duplicate
|
||||
if metadata:
|
||||
existing_entry.metadata.update(metadata)
|
||||
existing_entry.updated_at = datetime.utcnow().isoformat()
|
||||
self._save()
|
||||
self._was_updated = True
|
||||
return existing_entry
|
||||
|
||||
# No duplicate found, create new entry
|
||||
entry_id = f"{self.store_name}_{len(self._entries)}_{datetime.utcnow().timestamp()}"
|
||||
entry = LearnEntry(
|
||||
id=entry_id,
|
||||
@@ -115,6 +152,7 @@ class BaseStore(ABC):
|
||||
)
|
||||
self._entries[entry_id] = entry
|
||||
self._save()
|
||||
self._was_updated = True
|
||||
return entry
|
||||
|
||||
def get(self, entry_id: str) -> Optional[LearnEntry]:
|
||||
@@ -146,6 +184,7 @@ class BaseStore(ABC):
|
||||
entry.metadata.update(metadata)
|
||||
entry.updated_at = datetime.utcnow().isoformat()
|
||||
self._save()
|
||||
self._was_updated = True
|
||||
return entry
|
||||
|
||||
def delete(self, entry_id: str) -> bool:
|
||||
@@ -153,6 +192,7 @@ class BaseStore(ABC):
|
||||
if entry_id in self._entries:
|
||||
del self._entries[entry_id]
|
||||
self._save()
|
||||
self._was_updated = True
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -161,6 +201,7 @@ class BaseStore(ABC):
|
||||
count = len(self._entries)
|
||||
self._entries = {}
|
||||
self._save()
|
||||
self._was_updated = True
|
||||
return count
|
||||
|
||||
|
||||
|
||||
@@ -113,7 +113,8 @@ class MCPConfigManager:
|
||||
- Easy server management
|
||||
"""
|
||||
|
||||
MCP_DIR_NAME = ".praisonai/mcp"
|
||||
from ..paths import DEFAULT_DIR_NAME as _DIR_NAME
|
||||
MCP_DIR_NAME = f"{_DIR_NAME}/mcp"
|
||||
SUPPORTED_EXTENSIONS = [".json", ".mcpjson"]
|
||||
|
||||
def __init__(
|
||||
@@ -131,7 +132,11 @@ class MCPConfigManager:
|
||||
verbose: Verbosity level
|
||||
"""
|
||||
self.workspace_path = Path(workspace_path) if workspace_path else Path.cwd()
|
||||
self.global_mcp_path = Path(global_mcp_path) if global_mcp_path else Path.home() / ".praisonai" / "mcp"
|
||||
if global_mcp_path:
|
||||
self.global_mcp_path = Path(global_mcp_path)
|
||||
else:
|
||||
from ..paths import get_mcp_dir
|
||||
self.global_mcp_path = get_mcp_dir()
|
||||
self.verbose = verbose
|
||||
|
||||
self._configs: Dict[str, MCPConfig] = {}
|
||||
|
||||
@@ -226,15 +226,17 @@ class Memory:
|
||||
self.embedding_dimensions = self._get_embedding_dimensions(self.embedding_model)
|
||||
self._log_verbose(f"Using embedding dimensions: {self.embedding_dimensions}")
|
||||
|
||||
# Create .praisonai directory if it doesn't exist
|
||||
os.makedirs(".praisonai", exist_ok=True)
|
||||
# Create project data directory if it doesn't exist
|
||||
from ..paths import get_project_data_dir
|
||||
_project_data = str(get_project_data_dir())
|
||||
os.makedirs(_project_data, exist_ok=True)
|
||||
|
||||
# Short-term DB
|
||||
self.short_db = self.cfg.get("short_db", ".praisonai/short_term.db")
|
||||
self.short_db = self.cfg.get("short_db", os.path.join(_project_data, "short_term.db"))
|
||||
self._init_stm()
|
||||
|
||||
# Long-term DB
|
||||
self.long_db = self.cfg.get("long_db", ".praisonai/long_term.db")
|
||||
self.long_db = self.cfg.get("long_db", os.path.join(_project_data, "long_term.db"))
|
||||
self._init_ltm()
|
||||
|
||||
# Conditionally init Mem0, MongoDB, or local RAG
|
||||
|
||||
@@ -40,6 +40,9 @@ class MemoryProtocol(Protocol):
|
||||
|
||||
def search_long_term(self, query: str, limit: int = 5, **kwargs):
|
||||
return self._data[:limit]
|
||||
|
||||
def get_all_memories(self, **kwargs):
|
||||
return self._data
|
||||
|
||||
# Use in agent
|
||||
memory: MemoryProtocol = InMemoryStore()
|
||||
@@ -122,6 +125,15 @@ class MemoryProtocol(Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
def get_all_memories(self, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all memories from all backends.
|
||||
|
||||
Returns:
|
||||
List of all memory entries (short-term and long-term combined)
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ResettableMemoryProtocol(MemoryProtocol, Protocol):
|
||||
|
||||
@@ -122,7 +122,8 @@ class RulesManager:
|
||||
```
|
||||
"""
|
||||
|
||||
RULES_DIR_NAME = ".praisonai/rules"
|
||||
from ..paths import DEFAULT_DIR_NAME as _DIR_NAME
|
||||
RULES_DIR_NAME = f"{_DIR_NAME}/rules"
|
||||
SUPPORTED_EXTENSIONS = [".md", ".mdc", ".txt"]
|
||||
|
||||
# Root-level instruction files (like Claude, Codex, Gemini, Cursor, Windsurf)
|
||||
@@ -165,7 +166,11 @@ class RulesManager:
|
||||
verbose: Verbosity level
|
||||
"""
|
||||
self.workspace_path = Path(workspace_path) if workspace_path else Path.cwd()
|
||||
self.global_rules_path = Path(global_rules_path) if global_rules_path else Path.home() / ".praisonai" / "rules"
|
||||
if global_rules_path:
|
||||
self.global_rules_path = Path(global_rules_path)
|
||||
else:
|
||||
from ..paths import get_rules_dir
|
||||
self.global_rules_path = get_rules_dir()
|
||||
self.verbose = verbose
|
||||
|
||||
self._rules: Dict[str, Rule] = {}
|
||||
|
||||
@@ -782,7 +782,8 @@ class WorkflowManager:
|
||||
```
|
||||
"""
|
||||
|
||||
WORKFLOWS_DIR = ".praisonai/workflows"
|
||||
from ..paths import DEFAULT_DIR_NAME as _DIR_NAME
|
||||
WORKFLOWS_DIR = f"{_DIR_NAME}/workflows"
|
||||
SUPPORTED_EXTENSIONS = [".md", ".yaml", ".yml"]
|
||||
|
||||
def __init__(
|
||||
@@ -1857,7 +1858,8 @@ class WorkflowManager:
|
||||
|
||||
def _get_checkpoints_dir(self) -> Path:
|
||||
"""Get the checkpoints directory path."""
|
||||
checkpoints_dir = self.workspace_path / ".praisonai" / "checkpoints"
|
||||
from ..paths import DEFAULT_DIR_NAME
|
||||
checkpoints_dir = self.workspace_path / DEFAULT_DIR_NAME / "checkpoints"
|
||||
checkpoints_dir.mkdir(parents=True, exist_ok=True)
|
||||
return checkpoints_dir
|
||||
|
||||
|
||||
@@ -49,6 +49,15 @@ __all__ = [
|
||||
"disable_trace_output",
|
||||
"is_trace_output_enabled",
|
||||
"get_trace_output",
|
||||
# Editor output (for editor preset - beginner-friendly numbered steps)
|
||||
"EditorOutput",
|
||||
"enable_editor_output",
|
||||
"disable_editor_output",
|
||||
"is_editor_output_enabled",
|
||||
"get_editor_output",
|
||||
"TOOL_LABELS",
|
||||
"BlockType",
|
||||
"DisplayBlock",
|
||||
]
|
||||
|
||||
|
||||
@@ -112,4 +121,37 @@ def __getattr__(name: str):
|
||||
from .trace import get_trace_output
|
||||
return get_trace_output
|
||||
|
||||
# Editor output (for editor preset - beginner-friendly numbered steps)
|
||||
if name == "EditorOutput":
|
||||
from .editor import EditorOutput
|
||||
return EditorOutput
|
||||
|
||||
if name == "enable_editor_output":
|
||||
from .editor import enable_editor_output
|
||||
return enable_editor_output
|
||||
|
||||
if name == "disable_editor_output":
|
||||
from .editor import disable_editor_output
|
||||
return disable_editor_output
|
||||
|
||||
if name == "is_editor_output_enabled":
|
||||
from .editor import is_editor_output_enabled
|
||||
return is_editor_output_enabled
|
||||
|
||||
if name == "get_editor_output":
|
||||
from .editor import get_editor_output
|
||||
return get_editor_output
|
||||
|
||||
if name == "TOOL_LABELS":
|
||||
from .editor import TOOL_LABELS
|
||||
return TOOL_LABELS
|
||||
|
||||
if name == "BlockType":
|
||||
from .editor import BlockType
|
||||
return BlockType
|
||||
|
||||
if name == "DisplayBlock":
|
||||
from .editor import DisplayBlock
|
||||
return DisplayBlock
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -80,6 +80,7 @@ class StatusOutput:
|
||||
use_color: bool = True,
|
||||
show_timestamps: bool = True, # NEW: control timestamp display
|
||||
show_metrics: bool = False, # Enable metrics display for debug mode
|
||||
show_tool_output: bool = True, # Show tool result output inline
|
||||
):
|
||||
self._file = file or sys.stderr # Use stderr to not interfere with agent output
|
||||
self._format = format
|
||||
@@ -87,6 +88,7 @@ class StatusOutput:
|
||||
self._use_color = use_color
|
||||
self._show_timestamps = show_timestamps
|
||||
self._show_metrics = show_metrics
|
||||
self._show_tool_output = show_tool_output
|
||||
self._console = None
|
||||
self._tool_start_times: Dict[str, float] = {}
|
||||
self._lock = threading.Lock() # Per-sink lock for thread safety
|
||||
@@ -120,12 +122,12 @@ class StatusOutput:
|
||||
parts = []
|
||||
for k, v in (redacted or {}).items():
|
||||
if isinstance(v, str):
|
||||
v_str = f'"{_truncate(v, 30)}"'
|
||||
v_str = f'"{_truncate(v, 80)}"'
|
||||
else:
|
||||
v_str = _truncate(str(v), 30)
|
||||
v_str = _truncate(str(v), 80)
|
||||
parts.append(f"{k}={v_str}")
|
||||
|
||||
return _truncate(", ".join(parts), 80)
|
||||
return _truncate(", ".join(parts), 200)
|
||||
|
||||
def agent_start(self, agent_name: str) -> None:
|
||||
"""Record agent start."""
|
||||
@@ -185,8 +187,8 @@ class StatusOutput:
|
||||
"""Record LLM call end with optional metrics for debug mode."""
|
||||
ts = time.time()
|
||||
|
||||
# Use latency_ms if provided, otherwise calculate from start time
|
||||
if latency_ms is not None:
|
||||
# Use latency_ms if provided and positive, otherwise calculate from start time
|
||||
if latency_ms is not None and latency_ms > 0:
|
||||
duration_ms = latency_ms
|
||||
elif duration_ms is None and hasattr(self, '_llm_start_time'):
|
||||
start_ts = self._llm_start_time
|
||||
@@ -267,9 +269,13 @@ class StatusOutput:
|
||||
args_str = self._format_args(self._pending_tool_args)
|
||||
|
||||
result_str = ""
|
||||
if result_summary:
|
||||
result_str = f" → {_truncate(result_summary, 50)}"
|
||||
if self._show_tool_output:
|
||||
if result_summary:
|
||||
result_str = f" → {_truncate(result_summary, 50)}"
|
||||
elif error_message:
|
||||
result_str = f" → ✗ {_truncate(error_message, 50)}"
|
||||
elif error_message:
|
||||
# Always show errors even when output is hidden
|
||||
result_str = f" → ✗ {_truncate(error_message, 50)}"
|
||||
|
||||
color = "cyan" if status == "ok" else "red"
|
||||
@@ -285,9 +291,9 @@ class StatusOutput:
|
||||
separator = "─" * 50
|
||||
self._emit_text(separator, ts, "dim", show_timestamp=False)
|
||||
self._emit_text("Final Output:", ts, "bold", show_timestamp=False)
|
||||
# Print actual content without truncation for final output
|
||||
# Print actual content to stdout (not stderr) so users see it
|
||||
with self._lock: # Thread-safe output
|
||||
print(content, file=self._file)
|
||||
print(content)
|
||||
|
||||
def _emit_text(self, message: str, ts: float, style: str = None, show_timestamp: bool = True) -> None:
|
||||
"""Emit a text line. Thread-safe for multi-agent execution."""
|
||||
@@ -320,6 +326,7 @@ def enable_status_output(
|
||||
use_color: bool = True,
|
||||
show_timestamps: bool = True,
|
||||
show_metrics: bool = False, # Enable metrics for debug mode
|
||||
show_tool_output: bool = True, # Show tool result output inline
|
||||
) -> StatusOutput:
|
||||
"""
|
||||
Enable actions output mode globally.
|
||||
@@ -334,6 +341,7 @@ def enable_status_output(
|
||||
use_color: Whether to use colored output (default: True)
|
||||
show_timestamps: Whether to show timestamps (default: True)
|
||||
show_metrics: Whether to show token/cost metrics (default: False)
|
||||
show_tool_output: Whether to show tool result output inline (default: True)
|
||||
|
||||
Returns:
|
||||
StatusOutput instance for programmatic access
|
||||
@@ -350,6 +358,7 @@ def enable_status_output(
|
||||
use_color=use_color,
|
||||
show_timestamps=show_timestamps,
|
||||
show_metrics=show_metrics,
|
||||
show_tool_output=show_tool_output,
|
||||
)
|
||||
_status_output_enabled = True
|
||||
|
||||
|
||||
@@ -309,6 +309,78 @@ def get_project_data_dir(project_path: Optional[Union[str, Path]] = None) -> Pat
|
||||
return base / DEFAULT_DIR_NAME
|
||||
|
||||
|
||||
def get_project_sessions_dir(project_path: Optional[Union[str, Path]] = None) -> Path:
|
||||
"""
|
||||
Get project-level sessions directory.
|
||||
|
||||
Args:
|
||||
project_path: Project root (defaults to cwd)
|
||||
|
||||
Returns:
|
||||
Path to .praisonai/sessions/ in project
|
||||
"""
|
||||
return get_project_data_dir(project_path) / "sessions"
|
||||
|
||||
|
||||
def get_project_knowledge_dir(project_path: Optional[Union[str, Path]] = None) -> Path:
|
||||
"""
|
||||
Get project-level knowledge directory.
|
||||
|
||||
Args:
|
||||
project_path: Project root (defaults to cwd)
|
||||
|
||||
Returns:
|
||||
Path to .praisonai/knowledge/ in project
|
||||
"""
|
||||
return get_project_data_dir(project_path) / "knowledge"
|
||||
|
||||
|
||||
def get_project_summaries_dir(project_path: Optional[Union[str, Path]] = None) -> Path:
|
||||
"""
|
||||
Get project-level summaries directory for RAG.
|
||||
|
||||
Args:
|
||||
project_path: Project root (defaults to cwd)
|
||||
|
||||
Returns:
|
||||
Path to .praisonai/summaries/ in project
|
||||
"""
|
||||
return get_project_data_dir(project_path) / "summaries"
|
||||
|
||||
|
||||
def get_project_prp_dir(project_path: Optional[Union[str, Path]] = None) -> Path:
|
||||
"""
|
||||
Get project-level PRP output directory.
|
||||
|
||||
Args:
|
||||
project_path: Project root (defaults to cwd)
|
||||
|
||||
Returns:
|
||||
Path to .praisonai/prp/ in project
|
||||
"""
|
||||
return get_project_data_dir(project_path) / "prp"
|
||||
|
||||
|
||||
def get_schedules_dir() -> Path:
|
||||
"""
|
||||
Get schedules directory.
|
||||
|
||||
Returns:
|
||||
Path to ~/.praisonai/schedules/
|
||||
"""
|
||||
return get_data_dir() / "schedules"
|
||||
|
||||
|
||||
def get_storage_path() -> Path:
|
||||
"""
|
||||
Get default SQLite storage database path.
|
||||
|
||||
Returns:
|
||||
Path to ~/.praisonai/storage.db
|
||||
"""
|
||||
return get_data_dir() / "storage.db"
|
||||
|
||||
|
||||
def ensure_dir(path: Union[str, Path]) -> Path:
|
||||
"""
|
||||
Ensure a directory exists, creating it if necessary.
|
||||
@@ -352,6 +424,8 @@ def get_all_paths() -> Dict[str, Path]:
|
||||
"rules": get_rules_dir(),
|
||||
"permissions": get_permissions_dir(),
|
||||
"storage": get_storage_dir(),
|
||||
"storage_db": get_storage_path(),
|
||||
"schedules": get_schedules_dir(),
|
||||
"checkpoints": get_checkpoints_dir(),
|
||||
"snapshots": get_snapshots_dir(),
|
||||
"learn": get_learn_dir(),
|
||||
|
||||
@@ -15,8 +15,9 @@ from typing import Optional, List, Any, Dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default rules file path (follows ~/.praisonai/ convention)
|
||||
DEFAULT_RULES_DIR = os.path.join(os.path.expanduser("~"), ".praisonai", "rules")
|
||||
# Default rules file path (uses centralized paths - DRY)
|
||||
from ..paths import get_rules_dir as _get_rules_dir
|
||||
DEFAULT_RULES_DIR = str(_get_rules_dir())
|
||||
DEFAULT_RULES_FILE = os.path.join(DEFAULT_RULES_DIR, "default.rules")
|
||||
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ class RetrievalConfig:
|
||||
vector_store_provider: str = "chroma"
|
||||
vector_store_config: Dict[str, Any] = field(default_factory=dict)
|
||||
collection_name: Optional[str] = None
|
||||
persist_path: str = ".praisonai"
|
||||
persist_path: str = None # Resolved to str(get_project_data_dir()) in __post_init__
|
||||
|
||||
# Embedding configuration
|
||||
embedder_provider: Optional[str] = None
|
||||
@@ -112,6 +112,11 @@ class RetrievalConfig:
|
||||
{context}
|
||||
</retrieved_context>"""
|
||||
system_separation: bool = True # Use system prompt separation for safety
|
||||
|
||||
def __post_init__(self):
|
||||
if self.persist_path is None:
|
||||
from ..paths import get_project_data_dir
|
||||
self.persist_path = str(get_project_data_dir())
|
||||
|
||||
def get_token_budget(self, model_name: Optional[str] = None):
|
||||
"""
|
||||
@@ -233,7 +238,7 @@ class RetrievalConfig:
|
||||
vector_store_provider=data.get("vector_store_provider", "chroma"),
|
||||
vector_store_config=data.get("vector_store_config", {}),
|
||||
collection_name=data.get("collection_name"),
|
||||
persist_path=data.get("persist_path", ".praisonai"),
|
||||
persist_path=data.get("persist_path"),
|
||||
embedder_provider=data.get("embedder_provider"),
|
||||
embedder_model=data.get("embedder_model"),
|
||||
auto_keywords=auto_keywords,
|
||||
|
||||
@@ -99,7 +99,7 @@ class HierarchicalSummarizer:
|
||||
def __init__(
|
||||
self,
|
||||
llm=None,
|
||||
persist_path: str = ".praisonai/summaries",
|
||||
persist_path: str = None,
|
||||
verbose: bool = False,
|
||||
):
|
||||
"""
|
||||
@@ -111,6 +111,9 @@ class HierarchicalSummarizer:
|
||||
verbose: Enable verbose logging
|
||||
"""
|
||||
self._llm = llm
|
||||
if persist_path is None:
|
||||
from ..paths import get_project_summaries_dir
|
||||
persist_path = str(get_project_summaries_dir())
|
||||
self._persist_path = persist_path
|
||||
self._verbose = verbose
|
||||
self._hierarchy: Dict[str, SummaryNode] = {}
|
||||
|
||||
@@ -20,7 +20,7 @@ Default storage: ~/.praisonai/schedules/jobs.json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .models import Schedule, ScheduleJob
|
||||
from .models import Schedule, ScheduleJob, DeliveryTarget
|
||||
from .store import FileScheduleStore
|
||||
from .parser import parse_schedule
|
||||
from .runner import ScheduleRunner
|
||||
@@ -33,10 +33,11 @@ def __getattr__(name: str):
|
||||
if name in _module_cache:
|
||||
return _module_cache[name]
|
||||
|
||||
if name in ("Schedule", "ScheduleJob"):
|
||||
from .models import Schedule, ScheduleJob
|
||||
if name in ("Schedule", "ScheduleJob", "DeliveryTarget"):
|
||||
from .models import Schedule, ScheduleJob, DeliveryTarget
|
||||
_module_cache["Schedule"] = Schedule
|
||||
_module_cache["ScheduleJob"] = ScheduleJob
|
||||
_module_cache["DeliveryTarget"] = DeliveryTarget
|
||||
return _module_cache[name]
|
||||
|
||||
if name == "FileScheduleStore":
|
||||
@@ -60,6 +61,7 @@ def __getattr__(name: str):
|
||||
__all__ = [
|
||||
"Schedule",
|
||||
"ScheduleJob",
|
||||
"DeliveryTarget",
|
||||
"FileScheduleStore",
|
||||
"parse_schedule",
|
||||
"ScheduleRunner",
|
||||
|
||||
@@ -51,6 +51,45 @@ class Schedule:
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeliveryTarget:
|
||||
"""Where to deliver the result when a scheduled job fires.
|
||||
|
||||
Attributes:
|
||||
channel: Platform name (``"telegram"``, ``"discord"``,
|
||||
``"slack"``, ``"whatsapp"``).
|
||||
channel_id: Platform-specific chat / channel / group ID.
|
||||
thread_id: Optional thread ID for threaded delivery.
|
||||
session_id: Optional session ID to preserve conversation
|
||||
context when the cron fires.
|
||||
"""
|
||||
|
||||
channel: str = ""
|
||||
channel_id: str = ""
|
||||
thread_id: Optional[str] = None
|
||||
session_id: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
d: Dict[str, Any] = {
|
||||
"channel": self.channel,
|
||||
"channel_id": self.channel_id,
|
||||
}
|
||||
if self.thread_id is not None:
|
||||
d["thread_id"] = self.thread_id
|
||||
if self.session_id is not None:
|
||||
d["session_id"] = self.session_id
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict[str, Any]) -> "DeliveryTarget":
|
||||
return cls(
|
||||
channel=d.get("channel", ""),
|
||||
channel_id=d.get("channel_id", ""),
|
||||
thread_id=d.get("thread_id"),
|
||||
session_id=d.get("session_id"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScheduleJob:
|
||||
"""A persisted scheduled job.
|
||||
@@ -67,6 +106,8 @@ class ScheduleJob:
|
||||
delete_after_run: Auto-remove after first execution (one-shot).
|
||||
created_at: Epoch timestamp.
|
||||
last_run_at: Epoch timestamp of most recent execution.
|
||||
delivery: Optional delivery target for routing results back
|
||||
to a channel bot (e.g. Telegram chat).
|
||||
"""
|
||||
|
||||
name: str = ""
|
||||
@@ -79,11 +120,12 @@ class ScheduleJob:
|
||||
delete_after_run: bool = False
|
||||
created_at: float = field(default_factory=time.time)
|
||||
last_run_at: Optional[float] = None
|
||||
delivery: Optional[DeliveryTarget] = None
|
||||
|
||||
# ── serialisation ────────────────────────────────────────────────
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
d: Dict[str, Any] = {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"schedule": self.schedule.to_dict(),
|
||||
@@ -95,10 +137,14 @@ class ScheduleJob:
|
||||
"created_at": self.created_at,
|
||||
"last_run_at": self.last_run_at,
|
||||
}
|
||||
if self.delivery is not None:
|
||||
d["delivery"] = self.delivery.to_dict()
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict[str, Any]) -> "ScheduleJob":
|
||||
sched_data = d.get("schedule", {})
|
||||
delivery_data = d.get("delivery")
|
||||
return cls(
|
||||
id=d.get("id", uuid.uuid4().hex[:12]),
|
||||
name=d.get("name", ""),
|
||||
@@ -110,4 +156,5 @@ class ScheduleJob:
|
||||
delete_after_run=d.get("delete_after_run", False),
|
||||
created_at=d.get("created_at", time.time()),
|
||||
last_run_at=d.get("last_run_at"),
|
||||
delivery=DeliveryTarget.from_dict(delivery_data) if isinstance(delivery_data, dict) else None,
|
||||
)
|
||||
@@ -15,7 +15,8 @@ from .models import ScheduleJob
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_DIR = os.path.join(os.path.expanduser("~"), ".praisonai", "schedules")
|
||||
from ..paths import get_schedules_dir as _get_schedules_dir
|
||||
_DEFAULT_DIR = str(_get_schedules_dir())
|
||||
_JOBS_FILE = "jobs.json"
|
||||
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ class Session:
|
||||
default_memory_config = {
|
||||
"provider": "rag",
|
||||
"use_embedding": False, # Disable embeddings to avoid OpenAI API key requirement
|
||||
"rag_db_path": f".praisonai/sessions/{self.session_id}/chroma_db"
|
||||
"rag_db_path": str(self._get_session_dir() / "chroma_db")
|
||||
}
|
||||
if memory_config:
|
||||
default_memory_config.update(memory_config)
|
||||
@@ -94,7 +94,7 @@ class Session:
|
||||
self.knowledge_config = default_knowledge_config
|
||||
|
||||
# Create session directory
|
||||
os.makedirs(f".praisonai/sessions/{self.session_id}", exist_ok=True)
|
||||
self._get_session_dir().mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Initialize components lazily
|
||||
self._memory = None
|
||||
@@ -110,6 +110,12 @@ class Session:
|
||||
self._agents_instance = None
|
||||
self._agents = {} # Track agents and their chat histories
|
||||
|
||||
def _get_session_dir(self):
|
||||
"""Return session-specific directory using paths.py."""
|
||||
from pathlib import Path
|
||||
from .paths import get_project_sessions_dir
|
||||
return get_project_sessions_dir() / self.session_id
|
||||
|
||||
@property
|
||||
def memory(self) -> Memory:
|
||||
"""Lazy-loaded memory instance"""
|
||||
@@ -323,7 +329,11 @@ class Session:
|
||||
|
||||
def _save_agent_chat_histories(self) -> None:
|
||||
"""
|
||||
Save all agent chat histories to memory.
|
||||
Save all agent chat histories.
|
||||
|
||||
G-2 FIX: Routes to SessionStore instead of Memory.store_short_term() to
|
||||
maintain clean separation between conversation history (SessionStore)
|
||||
and semantic memory (Memory). Falls back to Memory for backward compat.
|
||||
"""
|
||||
if self.is_remote:
|
||||
return
|
||||
@@ -340,18 +350,37 @@ class Session:
|
||||
chat_history = agent_data.get("chat_history")
|
||||
|
||||
if chat_history is not None:
|
||||
# Save to memory
|
||||
history_text = f"Agent chat history for {agent_key}"
|
||||
self.memory.store_short_term(
|
||||
text=history_text,
|
||||
metadata={
|
||||
"type": "agent_chat_history",
|
||||
"session_id": self.session_id,
|
||||
"user_id": self.user_id,
|
||||
"agent_key": agent_key,
|
||||
"chat_history": chat_history
|
||||
}
|
||||
)
|
||||
# G-2 FIX: Try SessionStore first for clean separation
|
||||
session_store = None
|
||||
try:
|
||||
from .session import get_default_session_store
|
||||
session_store = get_default_session_store()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if session_store is not None:
|
||||
# Use SessionStore for conversation history
|
||||
session_id = f"{self.session_id}_{agent_key}"
|
||||
for msg in chat_history:
|
||||
if isinstance(msg, dict):
|
||||
session_store.add_message(
|
||||
session_id,
|
||||
role=msg.get("role", "user"),
|
||||
content=msg.get("content", ""),
|
||||
)
|
||||
else:
|
||||
# Fallback to Memory.store_short_term() for backward compatibility
|
||||
history_text = f"Agent chat history for {agent_key}"
|
||||
self.memory.store_short_term(
|
||||
text=history_text,
|
||||
metadata={
|
||||
"type": "agent_chat_history",
|
||||
"session_id": self.session_id,
|
||||
"user_id": self.user_id,
|
||||
"agent_key": agent_key,
|
||||
"chat_history": chat_history
|
||||
}
|
||||
)
|
||||
|
||||
def get_state(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a specific state value"""
|
||||
|
||||
@@ -86,7 +86,7 @@ class Session:
|
||||
default_memory_config = {
|
||||
"provider": "rag",
|
||||
"use_embedding": False,
|
||||
"rag_db_path": f".praisonai/sessions/{self.session_id}/chroma_db"
|
||||
"rag_db_path": str(self._get_session_dir() / "chroma_db")
|
||||
}
|
||||
if memory_config:
|
||||
default_memory_config.update(memory_config)
|
||||
@@ -95,7 +95,7 @@ class Session:
|
||||
default_knowledge_config = knowledge_config or {}
|
||||
self.knowledge_config = default_knowledge_config
|
||||
|
||||
os.makedirs(f".praisonai/sessions/{self.session_id}", exist_ok=True)
|
||||
self._get_session_dir().mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._memory = None
|
||||
self._knowledge = None
|
||||
@@ -109,6 +109,12 @@ class Session:
|
||||
self._agents_instance = None
|
||||
self._agents = {}
|
||||
|
||||
def _get_session_dir(self):
|
||||
"""Get session-specific directory using centralized paths."""
|
||||
from ..paths import get_project_sessions_dir
|
||||
from pathlib import Path
|
||||
return get_project_sessions_dir() / self.session_id
|
||||
|
||||
@property
|
||||
def memory(self) -> "Memory":
|
||||
"""Lazy-loaded memory instance"""
|
||||
@@ -305,7 +311,12 @@ class Session:
|
||||
}
|
||||
|
||||
def _save_agent_chat_histories(self) -> None:
|
||||
"""Save all agent chat histories to memory."""
|
||||
"""Save all agent chat histories.
|
||||
|
||||
G-2 FIX: Routes to SessionStore instead of Memory.store_short_term() to
|
||||
maintain clean separation between conversation history (SessionStore)
|
||||
and semantic memory (Memory). Falls back to Memory for backward compat.
|
||||
"""
|
||||
if self.is_remote:
|
||||
return
|
||||
|
||||
@@ -320,17 +331,37 @@ class Session:
|
||||
chat_history = agent_data.get("chat_history")
|
||||
|
||||
if chat_history is not None:
|
||||
history_text = f"Agent chat history for {agent_key}"
|
||||
self.memory.store_short_term(
|
||||
text=history_text,
|
||||
metadata={
|
||||
"type": "agent_chat_history",
|
||||
"session_id": self.session_id,
|
||||
"user_id": self.user_id,
|
||||
"agent_key": agent_key,
|
||||
"chat_history": chat_history
|
||||
}
|
||||
)
|
||||
# G-2 FIX: Try SessionStore first for clean separation
|
||||
session_store = None
|
||||
try:
|
||||
from . import get_default_session_store
|
||||
session_store = get_default_session_store()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if session_store is not None:
|
||||
# Use SessionStore for conversation history
|
||||
session_id = f"{self.session_id}_{agent_key}"
|
||||
for msg in chat_history:
|
||||
if isinstance(msg, dict):
|
||||
session_store.add_message(
|
||||
session_id,
|
||||
role=msg.get("role", "user"),
|
||||
content=msg.get("content", ""),
|
||||
)
|
||||
else:
|
||||
# Fallback to Memory.store_short_term() for backward compatibility
|
||||
history_text = f"Agent chat history for {agent_key}"
|
||||
self.memory.store_short_term(
|
||||
text=history_text,
|
||||
metadata={
|
||||
"type": "agent_chat_history",
|
||||
"session_id": self.session_id,
|
||||
"user_id": self.user_id,
|
||||
"agent_key": agent_key,
|
||||
"chat_history": chat_history
|
||||
}
|
||||
)
|
||||
|
||||
def get_state(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a specific state value"""
|
||||
|
||||
@@ -67,6 +67,9 @@ class SessionData:
|
||||
agent_name: Optional[str] = None
|
||||
user_id: Optional[str] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
# Gap S3: Gateway integration - link session to gateway session
|
||||
gateway_session_id: Optional[str] = None
|
||||
agent_id: Optional[str] = None # Gateway agent ID (different from agent_name)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
@@ -78,6 +81,8 @@ class SessionData:
|
||||
"agent_name": self.agent_name,
|
||||
"user_id": self.user_id,
|
||||
"metadata": self.metadata,
|
||||
"gateway_session_id": self.gateway_session_id,
|
||||
"agent_id": self.agent_id,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -95,6 +100,8 @@ class SessionData:
|
||||
agent_name=data.get("agent_name"),
|
||||
user_id=data.get("user_id"),
|
||||
metadata=data.get("metadata", {}),
|
||||
gateway_session_id=data.get("gateway_session_id"),
|
||||
agent_id=data.get("agent_id"),
|
||||
)
|
||||
|
||||
def get_chat_history(self, max_messages: Optional[int] = None) -> List[Dict[str, str]]:
|
||||
@@ -489,6 +496,177 @@ class DefaultSessionStore:
|
||||
sessions.sort(key=lambda x: x.get("updated_at", ""), reverse=True)
|
||||
return sessions[:limit]
|
||||
|
||||
# ── Agent-Level Queries (Gap S4) ──────────────────────────────────
|
||||
|
||||
def list_sessions_by_agent(self, agent_name: str, limit: int = 50) -> List[str]:
|
||||
"""List session IDs for a specific agent.
|
||||
|
||||
Gap S4: Enables querying sessions by agent instead of just session ID.
|
||||
|
||||
Args:
|
||||
agent_name: The agent name to filter by
|
||||
limit: Maximum number of session IDs to return
|
||||
|
||||
Returns:
|
||||
List of session IDs belonging to the specified agent
|
||||
"""
|
||||
session_ids = []
|
||||
|
||||
try:
|
||||
for filename in os.listdir(self.session_dir):
|
||||
if filename.endswith(".json"):
|
||||
filepath = os.path.join(self.session_dir, filename)
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if data.get("agent_name") == agent_name:
|
||||
session_ids.append(data.get("session_id", filename[:-5]))
|
||||
except (json.JSONDecodeError, IOError):
|
||||
continue
|
||||
except (IOError, OSError):
|
||||
pass
|
||||
|
||||
return session_ids[:limit]
|
||||
|
||||
def get_sessions_by_agent(
|
||||
self,
|
||||
agent_name: str,
|
||||
limit: int = 10,
|
||||
) -> List[SessionData]:
|
||||
"""Get full session data for a specific agent.
|
||||
|
||||
Gap S4: Enables retrieving all sessions for an agent.
|
||||
|
||||
Args:
|
||||
agent_name: The agent name to filter by
|
||||
limit: Maximum number of sessions to return
|
||||
|
||||
Returns:
|
||||
List of SessionData objects for the specified agent
|
||||
"""
|
||||
session_ids = self.list_sessions_by_agent(agent_name, limit)
|
||||
return [self._load_session(sid) for sid in session_ids]
|
||||
|
||||
def get_agent_chat_history(
|
||||
self,
|
||||
agent_name: str,
|
||||
max_messages: Optional[int] = None,
|
||||
max_sessions: int = 5,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get combined chat history across all sessions for an agent.
|
||||
|
||||
Gap S4: Enables retrieving conversation history by agent.
|
||||
|
||||
Args:
|
||||
agent_name: The agent name to filter by
|
||||
max_messages: Maximum messages per session
|
||||
max_sessions: Maximum number of sessions to include
|
||||
|
||||
Returns:
|
||||
List of messages with session context
|
||||
"""
|
||||
sessions = self.get_sessions_by_agent(agent_name, max_sessions)
|
||||
messages = []
|
||||
|
||||
for session in sessions:
|
||||
history = session.get_chat_history(max_messages)
|
||||
for msg in history:
|
||||
messages.append({
|
||||
**msg,
|
||||
"session_id": session.session_id,
|
||||
"agent_name": agent_name,
|
||||
})
|
||||
|
||||
return messages
|
||||
|
||||
# ── Gateway Integration (Gap S3) ──────────────────────────────────
|
||||
|
||||
def set_gateway_info(
|
||||
self,
|
||||
session_id: str,
|
||||
gateway_session_id: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Link a session to a gateway session.
|
||||
|
||||
Gap S3: Enables memory to be scoped to gateway agents/sessions.
|
||||
|
||||
Args:
|
||||
session_id: The local session ID
|
||||
gateway_session_id: The gateway session ID to link to
|
||||
agent_id: The gateway agent ID
|
||||
|
||||
Returns:
|
||||
True if saved successfully
|
||||
"""
|
||||
session = self._load_session(session_id)
|
||||
|
||||
with self._lock:
|
||||
if gateway_session_id:
|
||||
session.gateway_session_id = gateway_session_id
|
||||
if agent_id:
|
||||
session.agent_id = agent_id
|
||||
self._cache[session_id] = session
|
||||
|
||||
return self._save_session(session)
|
||||
|
||||
def get_by_gateway_session(self, gateway_session_id: str) -> Optional[SessionData]:
|
||||
"""Get session data linked to a gateway session.
|
||||
|
||||
Gap S3: Enables retrieving memory by gateway session ID.
|
||||
|
||||
Args:
|
||||
gateway_session_id: The gateway session ID to look up
|
||||
|
||||
Returns:
|
||||
SessionData if found, None otherwise
|
||||
"""
|
||||
try:
|
||||
for filename in os.listdir(self.session_dir):
|
||||
if filename.endswith(".json"):
|
||||
filepath = os.path.join(self.session_dir, filename)
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if data.get("gateway_session_id") == gateway_session_id:
|
||||
return SessionData.from_dict(data)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
continue
|
||||
except (IOError, OSError):
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def list_sessions_by_gateway_agent(self, agent_id: str, limit: int = 50) -> List[str]:
|
||||
"""List session IDs linked to a gateway agent.
|
||||
|
||||
Gap S3: Enables querying sessions by gateway agent ID.
|
||||
|
||||
Args:
|
||||
agent_id: The gateway agent ID to filter by
|
||||
limit: Maximum number of session IDs to return
|
||||
|
||||
Returns:
|
||||
List of session IDs linked to the gateway agent
|
||||
"""
|
||||
session_ids = []
|
||||
|
||||
try:
|
||||
for filename in os.listdir(self.session_dir):
|
||||
if filename.endswith(".json"):
|
||||
filepath = os.path.join(self.session_dir, filename)
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if data.get("agent_id") == agent_id:
|
||||
session_ids.append(data.get("session_id", filename[:-5]))
|
||||
except (json.JSONDecodeError, IOError):
|
||||
continue
|
||||
except (IOError, OSError):
|
||||
pass
|
||||
|
||||
return session_ids[:limit]
|
||||
|
||||
def session_exists(self, session_id: str) -> bool:
|
||||
"""Check if a session exists."""
|
||||
filepath = self._get_session_path(session_id)
|
||||
|
||||
@@ -29,6 +29,7 @@ __all__ = [
|
||||
"FileBackend",
|
||||
"SQLiteBackend",
|
||||
"RedisBackend",
|
||||
"MongoDBBackend",
|
||||
"get_backend",
|
||||
]
|
||||
|
||||
@@ -47,6 +48,7 @@ _LAZY_IMPORTS = {
|
||||
"FileBackend": ("backends", "FileBackend"),
|
||||
"SQLiteBackend": ("backends", "SQLiteBackend"),
|
||||
"RedisBackend": ("backends", "RedisBackend"),
|
||||
"MongoDBBackend": ("backends", "MongoDBBackend"),
|
||||
"get_backend": ("backends", "get_backend"),
|
||||
}
|
||||
|
||||
|
||||
@@ -13,13 +13,14 @@ DRY: All backends implement StorageBackendProtocol for consistent interface.
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
import tempfile
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..paths import get_storage_dir
|
||||
from ..paths import get_storage_dir, get_storage_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -175,7 +176,7 @@ class SQLiteBackend:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_path: str = "~/.praisonai/storage.db",
|
||||
db_path: str = None,
|
||||
table_name: str = "praison_storage",
|
||||
auto_create: bool = True,
|
||||
):
|
||||
@@ -187,7 +188,7 @@ class SQLiteBackend:
|
||||
table_name: Name of the storage table
|
||||
auto_create: Create table if it doesn't exist
|
||||
"""
|
||||
self.db_path = os.path.expanduser(db_path)
|
||||
self.db_path = os.path.expanduser(db_path) if db_path else str(get_storage_path())
|
||||
self.table_name = table_name
|
||||
self._local = threading.local()
|
||||
|
||||
@@ -456,6 +457,114 @@ class RedisBackend:
|
||||
self._client = None
|
||||
|
||||
|
||||
class MongoDBBackend:
|
||||
"""
|
||||
MongoDB-based storage backend.
|
||||
|
||||
Uses MongoDB for document-oriented data storage.
|
||||
Requires the `pymongo` package (optional dependency).
|
||||
|
||||
Example:
|
||||
```python
|
||||
backend = MongoDBBackend(url="mongodb://localhost:27017/")
|
||||
backend.save("session_123", {"messages": []})
|
||||
data = backend.load("session_123")
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str = "mongodb://localhost:27017/",
|
||||
database: str = "praisonai",
|
||||
collection: str = "praison_storage",
|
||||
max_pool_size: int = 50,
|
||||
):
|
||||
self.url = url
|
||||
self.database = database
|
||||
self.collection_name = collection
|
||||
self.max_pool_size = max_pool_size
|
||||
self._client = None
|
||||
self._collection = None
|
||||
|
||||
def _get_collection(self):
|
||||
"""Lazy initialize MongoDB client and collection."""
|
||||
if self._collection is None:
|
||||
try:
|
||||
import pymongo
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"MongoDB backend requires the 'pymongo' package. "
|
||||
"Install with: pip install pymongo"
|
||||
)
|
||||
self._client = pymongo.MongoClient(
|
||||
self.url,
|
||||
maxPoolSize=self.max_pool_size,
|
||||
retryWrites=True,
|
||||
retryReads=True,
|
||||
serverSelectionTimeoutMS=5000, # 5 second timeout for connection
|
||||
)
|
||||
db = self._client[self.database]
|
||||
self._collection = db[self.collection_name]
|
||||
return self._collection
|
||||
|
||||
def save(self, key: str, data: Dict[str, Any]) -> None:
|
||||
"""Save data with the given key (upsert)."""
|
||||
collection = self._get_collection()
|
||||
json_data = json.dumps(data, default=str, ensure_ascii=False)
|
||||
collection.replace_one(
|
||||
{"_id": key},
|
||||
{"_id": key, "data": json_data, "updated_at": time.time()},
|
||||
upsert=True,
|
||||
)
|
||||
|
||||
def load(self, key: str) -> Optional[Dict[str, Any]]:
|
||||
"""Load data by key."""
|
||||
collection = self._get_collection()
|
||||
doc = collection.find_one({"_id": key})
|
||||
if doc and "data" in doc:
|
||||
try:
|
||||
return json.loads(doc["data"])
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return None
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""Delete data by key."""
|
||||
collection = self._get_collection()
|
||||
result = collection.delete_one({"_id": key})
|
||||
return result.deleted_count > 0
|
||||
|
||||
def list_keys(self, prefix: str = "") -> List[str]:
|
||||
"""List all keys, optionally filtered by prefix."""
|
||||
collection = self._get_collection()
|
||||
if prefix:
|
||||
cursor = collection.find(
|
||||
{"_id": {"$regex": f"^{prefix}"}},
|
||||
{"_id": 1}
|
||||
)
|
||||
else:
|
||||
cursor = collection.find({}, {"_id": 1})
|
||||
return sorted([doc["_id"] for doc in cursor])
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
"""Check if a key exists."""
|
||||
collection = self._get_collection()
|
||||
return collection.count_documents({"_id": key}, limit=1) > 0
|
||||
|
||||
def clear(self) -> int:
|
||||
"""Clear all data. Returns number of items deleted."""
|
||||
collection = self._get_collection()
|
||||
result = collection.delete_many({})
|
||||
return result.deleted_count
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the MongoDB connection."""
|
||||
if self._client:
|
||||
self._client.close()
|
||||
self._client = None
|
||||
self._collection = None
|
||||
|
||||
|
||||
def get_backend(
|
||||
backend_type: str = "file",
|
||||
**kwargs
|
||||
@@ -488,6 +597,8 @@ def get_backend(
|
||||
return SQLiteBackend(**kwargs)
|
||||
elif backend_type == "redis":
|
||||
return RedisBackend(**kwargs)
|
||||
elif backend_type == "mongodb":
|
||||
return MongoDBBackend(**kwargs)
|
||||
else:
|
||||
raise ValueError(f"Unknown backend type: {backend_type}")
|
||||
|
||||
@@ -496,5 +607,6 @@ __all__ = [
|
||||
'FileBackend',
|
||||
'SQLiteBackend',
|
||||
'RedisBackend',
|
||||
'MongoDBBackend',
|
||||
'get_backend',
|
||||
]
|
||||
@@ -155,12 +155,44 @@ TOOL_MAPPINGS = {
|
||||
'schedule_list': ('.schedule_tools', None),
|
||||
'schedule_remove': ('.schedule_tools', None),
|
||||
'schedule_tools': ('.schedule_tools', None),
|
||||
|
||||
# AST-Grep Tools (structural code search/rewrite)
|
||||
'ast_grep_search': ('.ast_grep_tool', None),
|
||||
'ast_grep_rewrite': ('.ast_grep_tool', None),
|
||||
'ast_grep_scan': ('.ast_grep_tool', None),
|
||||
'is_ast_grep_available': ('.ast_grep_tool', None),
|
||||
'get_ast_grep_tools': ('.ast_grep_tool', None),
|
||||
'ast_grep_tools': ('.ast_grep_tool', None),
|
||||
}
|
||||
|
||||
_instances = {} # Cache for class instances
|
||||
|
||||
# Profile exports (lazy loaded)
|
||||
_PROFILE_EXPORTS = frozenset({
|
||||
'ToolProfile', 'AUTONOMY_PROFILE', 'BUILTIN_PROFILES',
|
||||
'register_profile', 'get_profile', 'resolve_profiles', 'list_profiles'
|
||||
})
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
"""Smart lazy loading of tools with class method support."""
|
||||
"""Smart lazy loading of tools and profiles."""
|
||||
# Handle profile imports first
|
||||
if name in _PROFILE_EXPORTS:
|
||||
from .profiles import (
|
||||
ToolProfile, AUTONOMY_PROFILE, BUILTIN_PROFILES,
|
||||
register_profile, get_profile, resolve_profiles, list_profiles
|
||||
)
|
||||
_profile_map = {
|
||||
'ToolProfile': ToolProfile,
|
||||
'AUTONOMY_PROFILE': AUTONOMY_PROFILE,
|
||||
'BUILTIN_PROFILES': BUILTIN_PROFILES,
|
||||
'register_profile': register_profile,
|
||||
'get_profile': get_profile,
|
||||
'resolve_profiles': resolve_profiles,
|
||||
'list_profiles': list_profiles,
|
||||
}
|
||||
return _profile_map[name]
|
||||
|
||||
# Handle tool mappings
|
||||
if name not in TOOL_MAPPINGS:
|
||||
raise AttributeError(f"module '{__package__}' has no attribute '{name}'")
|
||||
|
||||
@@ -188,10 +220,11 @@ def __getattr__(name: str) -> Any:
|
||||
'search_web', 'web_search', 'get_available_providers',
|
||||
'web_crawl', 'crawl_web', 'get_available_crawl_providers',
|
||||
'run_skill_script', 'read_skill_file', 'list_skill_scripts', 'create_skill_tools',
|
||||
'schedule_add', 'schedule_list', 'schedule_remove'
|
||||
'schedule_add', 'schedule_list', 'schedule_remove',
|
||||
'ast_grep_search', 'ast_grep_rewrite', 'ast_grep_scan', 'is_ast_grep_available', 'get_ast_grep_tools'
|
||||
]:
|
||||
return getattr(module, name)
|
||||
if name in ['file_tools', 'spider_tools', 'python_tools', 'shell_tools', 'cot_tools', 'tavily_tools', 'youdotcom_tools', 'exa_tools', 'crawl4ai_tools', 'skill_tools', 'schedule_tools']:
|
||||
if name in ['file_tools', 'spider_tools', 'python_tools', 'shell_tools', 'cot_tools', 'tavily_tools', 'youdotcom_tools', 'exa_tools', 'crawl4ai_tools', 'skill_tools', 'schedule_tools', 'ast_grep_tools']:
|
||||
return module # Returns the callable module
|
||||
return getattr(module, name)
|
||||
else:
|
||||
@@ -214,4 +247,7 @@ __all__ = list(TOOL_MAPPINGS.keys()) + [
|
||||
# Validation and retry protocols
|
||||
'ValidationResult', 'ToolValidatorProtocol', 'AsyncToolValidatorProtocol', 'PassthroughValidator',
|
||||
'RetryPolicy', 'FallbackChain', 'ToolExecutionConfig',
|
||||
# Tool profiles (DRY tool sets for autonomy/interactive modes)
|
||||
'ToolProfile', 'AUTONOMY_PROFILE', 'BUILTIN_PROFILES',
|
||||
'register_profile', 'get_profile', 'resolve_profiles', 'list_profiles',
|
||||
]
|
||||
@@ -0,0 +1,338 @@
|
||||
"""
|
||||
AST-Grep Tool for PraisonAI Agents.
|
||||
|
||||
Provides AST-based structural code search and rewrite capabilities
|
||||
using the ast-grep (sg) CLI tool. Gracefully handles cases where
|
||||
ast-grep is not installed.
|
||||
|
||||
Features:
|
||||
- Structural code search using AST patterns
|
||||
- Structural code rewrite (dry-run by default)
|
||||
- YAML rule scanning
|
||||
- Graceful fallback when not installed
|
||||
- Zero performance impact when not used (lazy loading)
|
||||
|
||||
Installation:
|
||||
pip install ast-grep-cli
|
||||
|
||||
Usage:
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools import ast_grep_search
|
||||
|
||||
agent = Agent(
|
||||
name="coder",
|
||||
tools=[ast_grep_search],
|
||||
)
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Availability cache for performance (checked once per process)
|
||||
_availability_cache: Optional[bool] = None
|
||||
|
||||
|
||||
def is_ast_grep_available() -> bool:
|
||||
"""Check if ast-grep (sg) CLI is available.
|
||||
|
||||
Returns:
|
||||
True if sg binary is found in PATH, False otherwise.
|
||||
|
||||
Note:
|
||||
Result is cached for performance. First call checks PATH,
|
||||
subsequent calls return cached result.
|
||||
"""
|
||||
global _availability_cache
|
||||
|
||||
if _availability_cache is not None:
|
||||
return _availability_cache
|
||||
|
||||
# Check if sg binary is in PATH
|
||||
sg_path = shutil.which('sg')
|
||||
_availability_cache = sg_path is not None
|
||||
|
||||
if _availability_cache:
|
||||
logger.debug(f"ast-grep found at: {sg_path}")
|
||||
else:
|
||||
logger.debug("ast-grep (sg) not found in PATH")
|
||||
|
||||
return _availability_cache
|
||||
|
||||
|
||||
def _get_not_installed_message() -> str:
|
||||
"""Get helpful error message when ast-grep is not installed."""
|
||||
return (
|
||||
"ast-grep is not installed or not available in PATH.\n\n"
|
||||
"To install ast-grep, run:\n"
|
||||
" pip install ast-grep-cli\n\n"
|
||||
"Or install via npm:\n"
|
||||
" npm install -g @ast-grep/cli\n\n"
|
||||
"For more information, visit: https://ast-grep.github.io/"
|
||||
)
|
||||
|
||||
|
||||
def ast_grep_search(
|
||||
pattern: str,
|
||||
lang: str,
|
||||
path: str = ".",
|
||||
json_output: bool = True,
|
||||
) -> str:
|
||||
"""Search code using AST patterns.
|
||||
|
||||
Performs structural code search using ast-grep's pattern matching.
|
||||
Unlike regex, this understands code structure and won't match
|
||||
patterns in comments or strings.
|
||||
|
||||
Args:
|
||||
pattern: AST pattern to search for. Use $VAR for single nodes,
|
||||
$$$ for multiple nodes. Example: "def $FN($$$)" matches
|
||||
all Python function definitions.
|
||||
lang: Programming language (python, javascript, typescript,
|
||||
rust, go, java, c, cpp, etc.)
|
||||
path: Directory or file to search in. Defaults to current directory.
|
||||
json_output: Whether to return JSON format. Defaults to True.
|
||||
|
||||
Returns:
|
||||
Search results as JSON string (if json_output=True) or plain text.
|
||||
Returns error message if ast-grep is not installed.
|
||||
|
||||
Example:
|
||||
# Find all function definitions
|
||||
result = ast_grep_search("def $FN($$$)", lang="python", path="./src")
|
||||
|
||||
# Find all console.log calls
|
||||
result = ast_grep_search("console.log($$$)", lang="javascript")
|
||||
|
||||
# Find all async functions
|
||||
result = ast_grep_search("async def $FN($$$)", lang="python")
|
||||
"""
|
||||
if not is_ast_grep_available():
|
||||
return _get_not_installed_message()
|
||||
|
||||
if not pattern:
|
||||
return "Error: Pattern cannot be empty"
|
||||
|
||||
try:
|
||||
cmd = ['sg', '--pattern', pattern, '--lang', lang]
|
||||
|
||||
if json_output:
|
||||
cmd.append('--json')
|
||||
|
||||
cmd.append(path)
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
if result.returncode != 0 and result.stderr:
|
||||
return f"Error: {result.stderr}"
|
||||
|
||||
output = result.stdout.strip()
|
||||
if not output:
|
||||
return "No matches found"
|
||||
|
||||
return output
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Error: Search timed out after 60 seconds"
|
||||
except subprocess.SubprocessError as e:
|
||||
return f"Error executing ast-grep: {e}"
|
||||
except Exception as e:
|
||||
logger.exception("Unexpected error in ast_grep_search")
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
def ast_grep_rewrite(
|
||||
pattern: str,
|
||||
replacement: str,
|
||||
lang: str,
|
||||
path: str = ".",
|
||||
dry_run: bool = True,
|
||||
) -> str:
|
||||
"""Rewrite code using AST patterns.
|
||||
|
||||
Performs structural code transformation using ast-grep's pattern
|
||||
matching and rewriting. By default, runs in dry-run mode to show
|
||||
what would be changed without modifying files.
|
||||
|
||||
Args:
|
||||
pattern: AST pattern to match. Use $VAR for single nodes,
|
||||
$$$ for multiple nodes.
|
||||
replacement: Replacement pattern. Can reference captured
|
||||
variables from the pattern.
|
||||
lang: Programming language (python, javascript, typescript,
|
||||
rust, go, java, c, cpp, etc.)
|
||||
path: Directory or file to rewrite. Defaults to current directory.
|
||||
dry_run: If True (default), show changes without modifying files.
|
||||
Set to False to actually modify files.
|
||||
|
||||
Returns:
|
||||
Rewrite preview/results as string.
|
||||
Returns error message if ast-grep is not installed.
|
||||
|
||||
Example:
|
||||
# Preview renaming a function (dry run)
|
||||
result = ast_grep_rewrite(
|
||||
"def old_name($$$)",
|
||||
"def new_name($$$)",
|
||||
lang="python",
|
||||
path="./src"
|
||||
)
|
||||
|
||||
# Actually apply the rewrite
|
||||
result = ast_grep_rewrite(
|
||||
"console.log($MSG)",
|
||||
"logger.info($MSG)",
|
||||
lang="javascript",
|
||||
dry_run=False
|
||||
)
|
||||
"""
|
||||
if not is_ast_grep_available():
|
||||
return _get_not_installed_message()
|
||||
|
||||
if not pattern:
|
||||
return "Error: Pattern cannot be empty"
|
||||
|
||||
if not replacement:
|
||||
return "Error: Replacement cannot be empty"
|
||||
|
||||
try:
|
||||
cmd = [
|
||||
'sg', '--pattern', pattern,
|
||||
'--rewrite', replacement,
|
||||
'--lang', lang,
|
||||
]
|
||||
|
||||
if not dry_run:
|
||||
cmd.append('--update-all')
|
||||
|
||||
cmd.append(path)
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
if result.returncode != 0 and result.stderr:
|
||||
return f"Error: {result.stderr}"
|
||||
|
||||
output = result.stdout.strip()
|
||||
if not output:
|
||||
if dry_run:
|
||||
return "No matches found for rewrite"
|
||||
else:
|
||||
return "No changes made"
|
||||
|
||||
prefix = "[DRY RUN] " if dry_run else ""
|
||||
return f"{prefix}{output}"
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Error: Rewrite timed out after 120 seconds"
|
||||
except subprocess.SubprocessError as e:
|
||||
return f"Error executing ast-grep: {e}"
|
||||
except Exception as e:
|
||||
logger.exception("Unexpected error in ast_grep_rewrite")
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
def ast_grep_scan(
|
||||
path: str = ".",
|
||||
rule_file: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Scan code using YAML lint rules.
|
||||
|
||||
Runs ast-grep's rule-based scanning to find code patterns
|
||||
defined in YAML rule files. Useful for enforcing coding
|
||||
standards and finding anti-patterns.
|
||||
|
||||
Args:
|
||||
path: Directory or file to scan. Defaults to current directory.
|
||||
rule_file: Optional path to a specific YAML rule file.
|
||||
If not provided, looks for sgconfig.yml in the path.
|
||||
|
||||
Returns:
|
||||
Scan results as string.
|
||||
Returns error message if ast-grep is not installed.
|
||||
|
||||
Example:
|
||||
# Scan with default rules
|
||||
result = ast_grep_scan(path="./src")
|
||||
|
||||
# Scan with specific rule file
|
||||
result = ast_grep_scan(
|
||||
path="./src",
|
||||
rule_file="./rules/security.yml"
|
||||
)
|
||||
"""
|
||||
if not is_ast_grep_available():
|
||||
return _get_not_installed_message()
|
||||
|
||||
try:
|
||||
cmd = ['sg', 'scan']
|
||||
|
||||
if rule_file:
|
||||
cmd.extend(['--rule', rule_file])
|
||||
|
||||
cmd.append(path)
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
# scan returns non-zero if issues found, which is expected
|
||||
output = result.stdout.strip()
|
||||
if result.stderr and not output:
|
||||
return f"Error: {result.stderr}"
|
||||
|
||||
if not output:
|
||||
return "No issues found"
|
||||
|
||||
return output
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Error: Scan timed out after 120 seconds"
|
||||
except subprocess.SubprocessError as e:
|
||||
return f"Error executing ast-grep: {e}"
|
||||
except Exception as e:
|
||||
logger.exception("Unexpected error in ast_grep_scan")
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
# Convenience function to get all ast-grep tools
|
||||
def get_ast_grep_tools() -> List:
|
||||
"""Get all ast-grep tools as a list.
|
||||
|
||||
Returns:
|
||||
List of ast-grep tool functions.
|
||||
|
||||
Example:
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools.ast_grep_tool import get_ast_grep_tools
|
||||
|
||||
agent = Agent(
|
||||
name="coder",
|
||||
tools=get_ast_grep_tools(),
|
||||
)
|
||||
"""
|
||||
return [ast_grep_search, ast_grep_rewrite, ast_grep_scan]
|
||||
|
||||
|
||||
__all__ = [
|
||||
'is_ast_grep_available',
|
||||
'ast_grep_search',
|
||||
'ast_grep_rewrite',
|
||||
'ast_grep_scan',
|
||||
'get_ast_grep_tools',
|
||||
]
|
||||
@@ -93,6 +93,12 @@ class FileTools:
|
||||
safe_path = FileTools._validate_path(filepath)
|
||||
# Create directory if it doesn't exist
|
||||
os.makedirs(os.path.dirname(safe_path), exist_ok=True)
|
||||
# Auto-coerce non-string content (LLMs sometimes pass dicts/lists)
|
||||
if not isinstance(content, str):
|
||||
if isinstance(content, (dict, list)):
|
||||
content = json.dumps(content, indent=2, ensure_ascii=False)
|
||||
else:
|
||||
content = str(content)
|
||||
with open(safe_path, 'w', encoding=encoding) as f:
|
||||
f.write(content)
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
ToolProfile system for composable, extensible tool sets.
|
||||
|
||||
Provides named profiles of tools that can be combined and extended.
|
||||
This eliminates DRY violations across interactive/autonomy modes.
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.tools.profiles import resolve_profiles, AUTONOMY_PROFILE
|
||||
|
||||
# Get tools for autonomy mode
|
||||
tools = resolve_profiles("autonomy")
|
||||
|
||||
# Combine multiple profiles
|
||||
tools = resolve_profiles("file_ops", "web", "shell")
|
||||
|
||||
# Register custom profile (wrapper/plugins)
|
||||
from praisonaiagents.tools.profiles import register_profile, ToolProfile
|
||||
register_profile(ToolProfile(name="custom", tools=["my_tool"]))
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolProfile:
|
||||
"""Named, extensible collection of tool names.
|
||||
|
||||
Attributes:
|
||||
name: Profile identifier (e.g., "file_ops", "shell", "autonomy")
|
||||
tools: List of tool name strings (resolved via TOOL_MAPPINGS)
|
||||
description: Human-readable description of the profile
|
||||
|
||||
Example:
|
||||
profile = ToolProfile(
|
||||
name="file_ops",
|
||||
tools=["read_file", "write_file", "list_files"],
|
||||
description="File system operations"
|
||||
)
|
||||
"""
|
||||
name: str
|
||||
tools: List[str] = field(default_factory=list)
|
||||
description: str = ""
|
||||
|
||||
|
||||
# Built-in profiles (SDK ships with these)
|
||||
BUILTIN_PROFILES: Dict[str, ToolProfile] = {
|
||||
"code_intelligence": ToolProfile(
|
||||
name="code_intelligence",
|
||||
tools=["ast_grep_search", "ast_grep_rewrite", "ast_grep_scan"],
|
||||
description="Structural code search and rewrite using ast-grep",
|
||||
),
|
||||
"file_ops": ToolProfile(
|
||||
name="file_ops",
|
||||
tools=[
|
||||
"read_file", "write_file", "list_files",
|
||||
"copy_file", "move_file", "delete_file", "get_file_info"
|
||||
],
|
||||
description="File system operations",
|
||||
),
|
||||
"shell": ToolProfile(
|
||||
name="shell",
|
||||
tools=["execute_command", "list_processes", "get_system_info"],
|
||||
description="Shell and system tools",
|
||||
),
|
||||
"web": ToolProfile(
|
||||
name="web",
|
||||
tools=["internet_search", "search_web", "web_crawl"],
|
||||
description="Web search and crawling",
|
||||
),
|
||||
"code_exec": ToolProfile(
|
||||
name="code_exec",
|
||||
tools=["execute_code", "analyze_code", "format_code", "lint_code"],
|
||||
description="Python code execution and analysis",
|
||||
),
|
||||
"schedule": ToolProfile(
|
||||
name="schedule",
|
||||
tools=["schedule_add", "schedule_list", "schedule_remove"],
|
||||
description="Agent-centric scheduling",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _build_autonomy_tools() -> List[str]:
|
||||
"""Build the autonomy profile tools list (deduped)."""
|
||||
tools = []
|
||||
for profile_name in ["code_intelligence", "file_ops", "shell", "web"]:
|
||||
profile = BUILTIN_PROFILES[profile_name]
|
||||
for tool in profile.tools:
|
||||
if tool not in tools:
|
||||
tools.append(tool)
|
||||
return tools
|
||||
|
||||
|
||||
# Composite autonomy profile - combines multiple profiles
|
||||
AUTONOMY_PROFILE = ToolProfile(
|
||||
name="autonomy",
|
||||
tools=_build_autonomy_tools(),
|
||||
description="Default tools for autonomous agents (file_ops + shell + web + code_intelligence)",
|
||||
)
|
||||
|
||||
|
||||
# Extension registry for custom profiles (wrapper/plugins use this)
|
||||
_custom_profiles: Dict[str, ToolProfile] = {}
|
||||
|
||||
|
||||
def register_profile(profile: ToolProfile) -> None:
|
||||
"""Register a custom tool profile.
|
||||
|
||||
Custom profiles override built-in profiles with the same name.
|
||||
This allows the wrapper to extend or replace SDK profiles.
|
||||
|
||||
Args:
|
||||
profile: ToolProfile to register
|
||||
|
||||
Example:
|
||||
# Wrapper registers ACP/LSP tools
|
||||
register_profile(ToolProfile(
|
||||
name="acp",
|
||||
tools=["acp_create_file", "acp_edit_file"],
|
||||
))
|
||||
"""
|
||||
_custom_profiles[profile.name] = profile
|
||||
|
||||
|
||||
def get_profile(name: str) -> ToolProfile:
|
||||
"""Get a tool profile by name.
|
||||
|
||||
Custom profiles take precedence over built-in profiles.
|
||||
|
||||
Args:
|
||||
name: Profile name
|
||||
|
||||
Returns:
|
||||
ToolProfile instance
|
||||
|
||||
Raises:
|
||||
KeyError: If profile not found
|
||||
"""
|
||||
if name in _custom_profiles:
|
||||
return _custom_profiles[name]
|
||||
if name in BUILTIN_PROFILES:
|
||||
return BUILTIN_PROFILES[name]
|
||||
if name == "autonomy":
|
||||
return AUTONOMY_PROFILE
|
||||
raise KeyError(f"Unknown tool profile: {name}")
|
||||
|
||||
|
||||
def resolve_profiles(*names: str) -> List[str]:
|
||||
"""Resolve multiple profile names to a flat tool list.
|
||||
|
||||
Tools are deduplicated - each tool appears only once.
|
||||
|
||||
Args:
|
||||
*names: Profile names to resolve
|
||||
|
||||
Returns:
|
||||
List of tool name strings (deduped)
|
||||
|
||||
Raises:
|
||||
KeyError: If any profile not found
|
||||
|
||||
Example:
|
||||
# Get tools for autonomy + custom ACP
|
||||
tools = resolve_profiles("autonomy", "acp")
|
||||
"""
|
||||
tools: List[str] = []
|
||||
for name in names:
|
||||
profile = get_profile(name)
|
||||
for tool in profile.tools:
|
||||
if tool not in tools:
|
||||
tools.append(tool)
|
||||
return tools
|
||||
|
||||
|
||||
def list_profiles() -> List[str]:
|
||||
"""List all available profile names.
|
||||
|
||||
Returns:
|
||||
List of profile names (built-in + custom + autonomy)
|
||||
"""
|
||||
names = set(BUILTIN_PROFILES.keys())
|
||||
names.update(_custom_profiles.keys())
|
||||
names.add("autonomy")
|
||||
return sorted(names)
|
||||
@@ -41,6 +41,10 @@ def schedule_add(
|
||||
name: str,
|
||||
schedule: str,
|
||||
message: str = "",
|
||||
channel: str = "",
|
||||
channel_id: str = "",
|
||||
agent_id: str = "",
|
||||
session_id: str = "",
|
||||
) -> str:
|
||||
"""Add a new scheduled job.
|
||||
|
||||
@@ -53,16 +57,39 @@ def schedule_add(
|
||||
- "at:2026-03-01T09:00:00" (one-shot ISO timestamp)
|
||||
- "in 20 minutes" (relative one-shot)
|
||||
message: The prompt or reminder text to deliver when triggered.
|
||||
channel: Delivery platform ("telegram", "discord", "slack",
|
||||
"whatsapp"). If set with channel_id, the result will
|
||||
be sent back to that chat when the job fires.
|
||||
channel_id: Target chat/channel/group ID on the platform.
|
||||
agent_id: Which agent should execute this job. If empty, the
|
||||
executor uses its default agent.
|
||||
session_id: Optional session ID to preserve conversation context.
|
||||
If set, the agent will have access to prior chat history.
|
||||
|
||||
Returns:
|
||||
Confirmation string with the job id.
|
||||
"""
|
||||
try:
|
||||
from ..scheduler.parser import parse_schedule
|
||||
from ..scheduler.models import ScheduleJob
|
||||
from ..scheduler.models import ScheduleJob, DeliveryTarget
|
||||
|
||||
sched = parse_schedule(schedule)
|
||||
job = ScheduleJob(name=name, schedule=sched, message=message)
|
||||
|
||||
delivery = None
|
||||
if channel and channel_id:
|
||||
delivery = DeliveryTarget(
|
||||
channel=channel,
|
||||
channel_id=channel_id,
|
||||
session_id=session_id or None,
|
||||
)
|
||||
|
||||
job = ScheduleJob(
|
||||
name=name,
|
||||
schedule=sched,
|
||||
message=message,
|
||||
agent_id=agent_id or None,
|
||||
delivery=delivery,
|
||||
)
|
||||
|
||||
store = _get_store()
|
||||
|
||||
@@ -72,7 +99,9 @@ def schedule_add(
|
||||
return f"A schedule named '{name}' already exists (id: {existing.id}). Remove it first or choose a different name."
|
||||
|
||||
store.add(job)
|
||||
return f"Schedule '{name}' added (id: {job.id}, {schedule})."
|
||||
delivery_note = f" → deliver to {channel}:{channel_id}" if delivery else ""
|
||||
agent_note = f" agent={agent_id}" if agent_id else ""
|
||||
return f"Schedule '{name}' added (id: {job.id}, {schedule}{agent_note}{delivery_note})."
|
||||
except ValueError as e:
|
||||
return f"Error adding schedule: {e}"
|
||||
except Exception as e:
|
||||
@@ -105,10 +134,15 @@ def schedule_list() -> str:
|
||||
sched_str = f"at: {sched.at}"
|
||||
else:
|
||||
sched_str = str(sched.kind)
|
||||
lines.append(
|
||||
f" - {j.name} (id: {j.id}) [{status}] — {sched_str}"
|
||||
+ (f" — \"{j.message}\"" if j.message else "")
|
||||
)
|
||||
|
||||
parts = [f" - {j.name} (id: {j.id}) [{status}] — {sched_str}"]
|
||||
if j.agent_id:
|
||||
parts.append(f" agent={j.agent_id}")
|
||||
if j.delivery:
|
||||
parts.append(f" → {j.delivery.channel}:{j.delivery.channel_id}")
|
||||
if j.message:
|
||||
parts.append(f' — "{j.message}"')
|
||||
lines.append("".join(parts))
|
||||
return "\n".join(lines)
|
||||
except Exception as e:
|
||||
logger.error("schedule_list failed: %s", e, exc_info=True)
|
||||
|
||||
@@ -569,6 +569,7 @@ class AgentFlow:
|
||||
reflection: Optional[Any] = None # Union[bool, ReflectionConfig] - self-reflection
|
||||
execution: Optional[Any] = None # Union[str, ExecutionConfig] - execution control
|
||||
caching: Optional[Any] = None # Union[bool, CachingConfig] - caching
|
||||
learn: Optional[Any] = None # Union[bool, LearnConfig] - continuous learning
|
||||
|
||||
# ============================================================
|
||||
# ROBUSTNESS PARAMS (debugging & audit trail)
|
||||
@@ -1901,14 +1902,46 @@ Create a brief execution plan (2-3 sentences) describing how to best accomplish
|
||||
# Only pass output_config if it has values
|
||||
output_json_model = output_config.get('json_model') if output_config else None
|
||||
output_pydantic_model = output_config.get('pydantic_model') if output_config else None
|
||||
return Task(
|
||||
# Extract remaining YAML step-level attributes (previously dead stores)
|
||||
yaml_guardrail = getattr(step, '_yaml_guardrail', None)
|
||||
yaml_max_retries = getattr(step, '_yaml_max_retries', None)
|
||||
yaml_expected_output = getattr(step, '_yaml_expected_output', None)
|
||||
yaml_output_file = getattr(step, '_yaml_output_file', None)
|
||||
yaml_skip_on_failure = getattr(step, '_yaml_skip_on_failure', None)
|
||||
yaml_retry_delay = getattr(step, '_yaml_retry_delay', None)
|
||||
yaml_create_directory = getattr(step, '_yaml_create_directory', None)
|
||||
yaml_callback = getattr(step, '_yaml_callback', None)
|
||||
yaml_async_execution = getattr(step, '_yaml_async_execution', None)
|
||||
|
||||
# Build Task kwargs — only include YAML overrides when present
|
||||
task_kwargs = dict(
|
||||
name=yaml_step_name or getattr(step, 'name', f'agent_{index+1}'),
|
||||
agent=step,
|
||||
tools=agent_tools,
|
||||
action=action,
|
||||
output_json=output_json_model,
|
||||
output_pydantic=output_pydantic_model
|
||||
output_pydantic=output_pydantic_model,
|
||||
)
|
||||
if yaml_guardrail is not None:
|
||||
task_kwargs['guardrails'] = yaml_guardrail
|
||||
if yaml_max_retries is not None:
|
||||
task_kwargs['max_retries'] = yaml_max_retries
|
||||
if yaml_expected_output is not None:
|
||||
task_kwargs['expected_output'] = yaml_expected_output
|
||||
if yaml_output_file is not None:
|
||||
task_kwargs['output_file'] = yaml_output_file
|
||||
if yaml_skip_on_failure is not None:
|
||||
task_kwargs['skip_on_failure'] = yaml_skip_on_failure
|
||||
if yaml_retry_delay is not None:
|
||||
task_kwargs['retry_delay'] = yaml_retry_delay
|
||||
if yaml_create_directory is not None:
|
||||
task_kwargs['create_directory'] = yaml_create_directory
|
||||
if yaml_callback is not None:
|
||||
task_kwargs['callback'] = yaml_callback
|
||||
if yaml_async_execution is not None:
|
||||
task_kwargs['async_execution'] = yaml_async_execution
|
||||
|
||||
return Task(**task_kwargs)
|
||||
elif callable(step):
|
||||
return Task(
|
||||
name=getattr(step, '__name__', f'step_{index+1}'),
|
||||
@@ -2955,7 +2988,8 @@ class WorkflowManager:
|
||||
```
|
||||
"""
|
||||
|
||||
WORKFLOWS_DIR = ".praisonai/workflows"
|
||||
from ..paths import DEFAULT_DIR_NAME as _DIR_NAME
|
||||
WORKFLOWS_DIR = f"{_DIR_NAME}/workflows"
|
||||
SUPPORTED_EXTENSIONS = [".md", ".yaml", ".yml"]
|
||||
|
||||
def __init__(
|
||||
@@ -4246,7 +4280,8 @@ class WorkflowManager:
|
||||
|
||||
def _get_checkpoints_dir(self) -> Path:
|
||||
"""Get the checkpoints directory path."""
|
||||
checkpoints_dir = self.workspace_path / ".praisonai" / "checkpoints"
|
||||
from ..paths import DEFAULT_DIR_NAME
|
||||
checkpoints_dir = self.workspace_path / DEFAULT_DIR_NAME / "checkpoints"
|
||||
checkpoints_dir.mkdir(parents=True, exist_ok=True)
|
||||
return checkpoints_dir
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "1.5.15"
|
||||
version = "1.5.41"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -83,6 +83,11 @@ auth = [
|
||||
"python-multipart>=0.0.6"
|
||||
]
|
||||
|
||||
# Autonomy mode dependencies (code intelligence tools)
|
||||
autonomy = [
|
||||
"ast-grep-cli>=0.30.0"
|
||||
]
|
||||
|
||||
# Combined features
|
||||
all = [
|
||||
"praisonaiagents[memory]",
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
Integration tests for directory consistency with real API calls.
|
||||
|
||||
Tests that PRAISONAI_HOME overrides actually redirect all data,
|
||||
and that a real agentic call works end-to-end with the wired paths.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestPraisonaiHomeOverride:
|
||||
"""Tests that PRAISONAI_HOME env var redirects all global paths."""
|
||||
|
||||
def test_all_global_paths_honor_override(self):
|
||||
"""Every global helper should resolve under PRAISONAI_HOME."""
|
||||
from praisonaiagents.paths import (
|
||||
_clear_cache,
|
||||
get_data_dir,
|
||||
get_sessions_dir,
|
||||
get_skills_dir,
|
||||
get_plugins_dir,
|
||||
get_mcp_dir,
|
||||
get_docs_dir,
|
||||
get_rules_dir,
|
||||
get_permissions_dir,
|
||||
get_storage_dir,
|
||||
get_checkpoints_dir,
|
||||
get_snapshots_dir,
|
||||
get_learn_dir,
|
||||
get_cache_dir,
|
||||
get_mcp_auth_path,
|
||||
get_memory_dir,
|
||||
get_workflows_dir,
|
||||
get_summaries_dir,
|
||||
get_prp_dir,
|
||||
get_runs_dir,
|
||||
get_schedules_dir,
|
||||
get_storage_path,
|
||||
)
|
||||
|
||||
custom_home = tempfile.mkdtemp(prefix="praisonai_test_")
|
||||
try:
|
||||
_clear_cache()
|
||||
with patch.dict(os.environ, {"PRAISONAI_HOME": custom_home}):
|
||||
base = Path(custom_home)
|
||||
assert get_data_dir() == base
|
||||
assert get_sessions_dir() == base / "sessions"
|
||||
assert get_skills_dir() == base / "skills"
|
||||
assert get_plugins_dir() == base / "plugins"
|
||||
assert get_mcp_dir() == base / "mcp"
|
||||
assert get_docs_dir() == base / "docs"
|
||||
assert get_rules_dir() == base / "rules"
|
||||
assert get_permissions_dir() == base / "permissions"
|
||||
assert get_storage_dir() == base / "storage"
|
||||
assert get_checkpoints_dir() == base / "checkpoints"
|
||||
assert get_snapshots_dir() == base / "snapshots"
|
||||
assert get_learn_dir() == base / "learn"
|
||||
assert get_cache_dir() == base / "cache"
|
||||
assert get_mcp_auth_path() == base / "mcp-auth.json"
|
||||
assert get_memory_dir() == base / "memory"
|
||||
assert get_workflows_dir() == base / "workflows"
|
||||
assert get_summaries_dir() == base / "summaries"
|
||||
assert get_prp_dir() == base / "prp"
|
||||
assert get_runs_dir() == base / "runs"
|
||||
assert get_schedules_dir() == base / "schedules"
|
||||
assert get_storage_path() == base / "storage.db"
|
||||
finally:
|
||||
_clear_cache()
|
||||
shutil.rmtree(custom_home, ignore_errors=True)
|
||||
|
||||
def test_autonomy_config_respects_override(self):
|
||||
"""AutonomyConfig.snapshot_dir should redirect with PRAISONAI_HOME."""
|
||||
from praisonaiagents.paths import _clear_cache
|
||||
|
||||
custom_home = tempfile.mkdtemp(prefix="praisonai_test_")
|
||||
try:
|
||||
_clear_cache()
|
||||
with patch.dict(os.environ, {"PRAISONAI_HOME": custom_home}):
|
||||
from praisonaiagents.agent.autonomy import AutonomyConfig
|
||||
|
||||
config = AutonomyConfig()
|
||||
assert config.snapshot_dir == str(Path(custom_home) / "snapshots")
|
||||
finally:
|
||||
_clear_cache()
|
||||
shutil.rmtree(custom_home, ignore_errors=True)
|
||||
|
||||
def test_retrieval_config_defaults_to_project_local(self):
|
||||
"""RetrievalConfig.persist_path should default to project-local .praisonai."""
|
||||
from praisonaiagents.rag.retrieval_config import RetrievalConfig
|
||||
from praisonaiagents.paths import get_project_data_dir
|
||||
|
||||
rc = RetrievalConfig()
|
||||
assert rc.persist_path == str(get_project_data_dir())
|
||||
|
||||
def test_summarizer_defaults_to_project_local(self):
|
||||
"""HierarchicalSummarizer._persist_path should default to project-local .praisonai/summaries."""
|
||||
from praisonaiagents.rag.summarizer import HierarchicalSummarizer
|
||||
from praisonaiagents.paths import get_project_summaries_dir
|
||||
|
||||
hs = HierarchicalSummarizer()
|
||||
assert hs._persist_path == str(get_project_summaries_dir())
|
||||
|
||||
|
||||
class TestRealAgenticCall:
|
||||
"""Real agentic test — creates Agent, calls LLM, verifies path consistency."""
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not os.getenv("OPENAI_API_KEY"),
|
||||
reason="OPENAI_API_KEY not set — skipping real LLM call"
|
||||
)
|
||||
def test_agent_start_with_custom_home(self):
|
||||
"""
|
||||
MANDATORY REAL AGENTIC TEST.
|
||||
Creates Agent, calls agent.start(), verifies LLM responds.
|
||||
"""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="path-test",
|
||||
instructions="You are a helpful assistant. Respond in one sentence.",
|
||||
)
|
||||
result = agent.start("Say hello in one sentence")
|
||||
|
||||
# Agent must produce a non-empty text response from the LLM
|
||||
assert result is not None
|
||||
result_str = str(result)
|
||||
assert len(result_str) > 0, "Agent returned empty response"
|
||||
print(f"\n✓ Real agentic response: {result_str[:200]}")
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not os.getenv("OPENAI_API_KEY"),
|
||||
reason="OPENAI_API_KEY not set — skipping real LLM call"
|
||||
)
|
||||
def test_agent_with_praisonai_home_override(self):
|
||||
"""
|
||||
Run agent with PRAISONAI_HOME override and verify data paths work.
|
||||
"""
|
||||
from praisonaiagents.paths import _clear_cache
|
||||
|
||||
custom_home = tempfile.mkdtemp(prefix="praisonai_agent_test_")
|
||||
try:
|
||||
_clear_cache()
|
||||
os.environ["PRAISONAI_HOME"] = custom_home
|
||||
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="home-test",
|
||||
instructions="You are a helpful assistant.",
|
||||
)
|
||||
result = agent.start("Say 'path test passed' in one sentence")
|
||||
|
||||
assert result is not None
|
||||
print(f"\n✓ Agent with custom PRAISONAI_HOME: {str(result)[:200]}")
|
||||
finally:
|
||||
os.environ.pop("PRAISONAI_HOME", None)
|
||||
_clear_cache()
|
||||
shutil.rmtree(custom_home, ignore_errors=True)
|
||||
|
||||
|
||||
class TestNoHardcodedPaths:
|
||||
"""Verify no remaining hardcoded paths in the fixed modules."""
|
||||
|
||||
def test_no_expanduser_in_policy_config(self):
|
||||
"""policy/config.py should not expanduser for rules."""
|
||||
import inspect
|
||||
from praisonaiagents.policy import config
|
||||
|
||||
source = inspect.getsource(config)
|
||||
assert 'expanduser("~"), ".praisonai", "rules"' not in source
|
||||
|
||||
def test_no_expanduser_in_autonomy(self):
|
||||
"""autonomy.py should not expanduser for snapshots."""
|
||||
import inspect
|
||||
from praisonaiagents.agent import autonomy
|
||||
|
||||
source = inspect.getsource(autonomy.AutonomyConfig.__post_init__)
|
||||
assert 'expanduser("~"), ".praisonai", "snapshots"' not in source
|
||||
|
||||
def test_no_expanduser_in_scheduler_store(self):
|
||||
"""scheduler/store.py should not expanduser for schedules."""
|
||||
import inspect
|
||||
from praisonaiagents.scheduler import store
|
||||
|
||||
source = inspect.getsource(store)
|
||||
assert 'expanduser("~"), ".praisonai", "schedules"' not in source
|
||||
|
||||
def test_no_hardcoded_string_in_backends(self):
|
||||
"""storage/backends.py should not have hardcoded ~/.praisonai/storage.db default."""
|
||||
import inspect
|
||||
from praisonaiagents.storage.backends import SQLiteBackend
|
||||
|
||||
source = inspect.getsource(SQLiteBackend.__init__)
|
||||
assert '"~/.praisonai/storage.db"' not in source
|
||||
@@ -434,7 +434,7 @@ callbacks:
|
||||
|
||||
|
||||
class TestYAMLWorkflowParserGuardrails:
|
||||
"""Tests for guardrails parsing."""
|
||||
"""Tests for guardrails parsing and wiring to Task objects."""
|
||||
|
||||
def test_parse_step_guardrail(self):
|
||||
"""Test parsing guardrail on a step."""
|
||||
@@ -459,6 +459,167 @@ steps:
|
||||
workflow = parser.parse_string(yaml_content)
|
||||
|
||||
assert workflow is not None
|
||||
|
||||
def test_guardrail_wired_to_task(self):
|
||||
"""Test that guardrail string from YAML is wired into the Task object."""
|
||||
from praisonaiagents.workflows import YAMLWorkflowParser
|
||||
|
||||
yaml_content = """
|
||||
name: Guardrail Wiring Test
|
||||
agents:
|
||||
writer:
|
||||
name: Writer
|
||||
role: Writer
|
||||
goal: Write
|
||||
instructions: "Write content"
|
||||
|
||||
steps:
|
||||
- agent: writer
|
||||
action: "Write professional content"
|
||||
guardrail: "Ensure the content is professional and error-free"
|
||||
"""
|
||||
parser = YAMLWorkflowParser()
|
||||
workflow = parser.parse_string(yaml_content)
|
||||
|
||||
# Normalize the step to a Task (this is what run() does internally)
|
||||
task = workflow._normalize_single_step(workflow.steps[0], 0)
|
||||
|
||||
# The guardrail string must be set on the Task
|
||||
assert task.guardrail == "Ensure the content is professional and error-free"
|
||||
# _setup_guardrail should have created an LLMGuardrail instance
|
||||
assert task._guardrail_fn is not None
|
||||
|
||||
def test_max_retries_wired_to_task(self):
|
||||
"""Test that max_retries from YAML is wired into the Task object."""
|
||||
from praisonaiagents.workflows import YAMLWorkflowParser
|
||||
|
||||
yaml_content = """
|
||||
name: Retries Wiring Test
|
||||
agents:
|
||||
writer:
|
||||
name: Writer
|
||||
role: Writer
|
||||
goal: Write
|
||||
instructions: "Write content"
|
||||
|
||||
steps:
|
||||
- agent: writer
|
||||
action: "Write content"
|
||||
max_retries: 7
|
||||
"""
|
||||
parser = YAMLWorkflowParser()
|
||||
workflow = parser.parse_string(yaml_content)
|
||||
task = workflow._normalize_single_step(workflow.steps[0], 0)
|
||||
|
||||
assert task.max_retries == 7
|
||||
|
||||
def test_expected_output_wired_to_task(self):
|
||||
"""Test that expected_output from YAML is wired into the Task object."""
|
||||
from praisonaiagents.workflows import YAMLWorkflowParser
|
||||
|
||||
yaml_content = """
|
||||
name: Expected Output Wiring Test
|
||||
agents:
|
||||
writer:
|
||||
name: Writer
|
||||
role: Writer
|
||||
goal: Write
|
||||
instructions: "Write content"
|
||||
|
||||
steps:
|
||||
- agent: writer
|
||||
action: "Write a blog post"
|
||||
expected_output: "A well-structured blog post with introduction, body, and conclusion"
|
||||
"""
|
||||
parser = YAMLWorkflowParser()
|
||||
workflow = parser.parse_string(yaml_content)
|
||||
task = workflow._normalize_single_step(workflow.steps[0], 0)
|
||||
|
||||
assert task.expected_output == "A well-structured blog post with introduction, body, and conclusion"
|
||||
|
||||
def test_skip_on_failure_wired_to_task(self):
|
||||
"""Test that skip_on_failure from YAML is wired into the Task object."""
|
||||
from praisonaiagents.workflows import YAMLWorkflowParser
|
||||
|
||||
yaml_content = """
|
||||
name: Skip Failure Wiring Test
|
||||
agents:
|
||||
writer:
|
||||
name: Writer
|
||||
role: Writer
|
||||
goal: Write
|
||||
instructions: "Write content"
|
||||
|
||||
steps:
|
||||
- agent: writer
|
||||
action: "Write content"
|
||||
skip_on_failure: true
|
||||
"""
|
||||
parser = YAMLWorkflowParser()
|
||||
workflow = parser.parse_string(yaml_content)
|
||||
task = workflow._normalize_single_step(workflow.steps[0], 0)
|
||||
|
||||
assert task.skip_on_failure == True
|
||||
|
||||
def test_output_file_wired_to_task(self):
|
||||
"""Test that output_file from YAML is wired into the Task object."""
|
||||
from praisonaiagents.workflows import YAMLWorkflowParser
|
||||
|
||||
yaml_content = """
|
||||
name: Output File Wiring Test
|
||||
agents:
|
||||
writer:
|
||||
name: Writer
|
||||
role: Writer
|
||||
goal: Write
|
||||
instructions: "Write content"
|
||||
|
||||
steps:
|
||||
- agent: writer
|
||||
action: "Write content"
|
||||
output_file: "output/result.txt"
|
||||
"""
|
||||
parser = YAMLWorkflowParser()
|
||||
workflow = parser.parse_string(yaml_content)
|
||||
task = workflow._normalize_single_step(workflow.steps[0], 0)
|
||||
|
||||
assert task.output_file == "output/result.txt"
|
||||
|
||||
def test_all_yaml_step_attrs_wired(self):
|
||||
"""Test that ALL YAML step-level attributes are wired through to Task."""
|
||||
from praisonaiagents.workflows import YAMLWorkflowParser
|
||||
|
||||
yaml_content = """
|
||||
name: Full Wiring Test
|
||||
agents:
|
||||
writer:
|
||||
name: Writer
|
||||
role: Writer
|
||||
goal: Write
|
||||
instructions: "Write content"
|
||||
|
||||
steps:
|
||||
- agent: writer
|
||||
action: "Write content"
|
||||
guardrail: "Check quality"
|
||||
max_retries: 5
|
||||
expected_output: "High quality content"
|
||||
output_file: "out.txt"
|
||||
skip_on_failure: true
|
||||
retry_delay: 2.5
|
||||
create_directory: true
|
||||
"""
|
||||
parser = YAMLWorkflowParser()
|
||||
workflow = parser.parse_string(yaml_content)
|
||||
task = workflow._normalize_single_step(workflow.steps[0], 0)
|
||||
|
||||
assert task.guardrail == "Check quality"
|
||||
assert task.max_retries == 5
|
||||
assert task.expected_output == "High quality content"
|
||||
assert task.output_file == "out.txt"
|
||||
assert task.skip_on_failure == True
|
||||
assert task.retry_delay == 2.5
|
||||
assert task.create_directory == True
|
||||
|
||||
|
||||
class TestYAMLWorkflowParserVariables:
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""TDD Tests for top-level learn= parameter on Agent and related classes.
|
||||
|
||||
Tests that learn= is a first-class citizen, peer to memory=, not nested inside it.
|
||||
"""
|
||||
|
||||
|
||||
class TestAgentLearnParam:
|
||||
"""Test Agent(learn=...) top-level parameter."""
|
||||
|
||||
def test_agent_accepts_learn_false(self):
|
||||
"""Agent should accept learn=False (default, no learning)."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
learn=False,
|
||||
)
|
||||
|
||||
assert agent._learn_config is None or agent._learn_config is False
|
||||
|
||||
def test_agent_accepts_learn_true(self):
|
||||
"""Agent should accept learn=True (enable with defaults)."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
learn=True,
|
||||
)
|
||||
|
||||
# Should have learn config enabled
|
||||
assert agent._learn_config is not None
|
||||
assert agent._learn_config is not False
|
||||
|
||||
def test_agent_accepts_learn_config(self):
|
||||
"""Agent should accept learn=LearnConfig(...)."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.config.feature_configs import LearnConfig
|
||||
|
||||
config = LearnConfig(persona=True, insights=True, patterns=False)
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
learn=config,
|
||||
)
|
||||
|
||||
# Should store the config
|
||||
assert agent._learn_config is not None
|
||||
if hasattr(agent._learn_config, 'persona'):
|
||||
assert agent._learn_config.persona is True
|
||||
assert agent._learn_config.insights is True
|
||||
|
||||
def test_learn_param_independent_of_memory(self):
|
||||
"""learn= should work without memory= being set."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# learn=True without memory=True should still work
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
learn=True,
|
||||
memory=None, # No memory explicitly
|
||||
)
|
||||
|
||||
# Learn should be enabled even without explicit memory
|
||||
assert agent._learn_config is not None
|
||||
|
||||
def test_learn_and_memory_both_work(self):
|
||||
"""learn= and memory= should work together."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
learn=True,
|
||||
memory=True,
|
||||
)
|
||||
|
||||
# Both should be enabled
|
||||
assert agent._learn_config is not None
|
||||
assert agent._memory_instance is not None
|
||||
|
||||
|
||||
class TestLearnConfigExport:
|
||||
"""Test that LearnConfig is exported from top-level package."""
|
||||
|
||||
def test_learn_config_exported(self):
|
||||
"""LearnConfig should be importable from praisonaiagents."""
|
||||
from praisonaiagents import LearnConfig
|
||||
|
||||
assert LearnConfig is not None
|
||||
|
||||
# Should be able to instantiate
|
||||
config = LearnConfig()
|
||||
assert config.persona is True # Default
|
||||
assert config.insights is True # Default
|
||||
|
||||
def test_learn_scope_exported(self):
|
||||
"""LearnScope should be importable from praisonaiagents."""
|
||||
from praisonaiagents import LearnScope
|
||||
|
||||
assert LearnScope is not None
|
||||
assert LearnScope.PRIVATE.value == "private"
|
||||
assert LearnScope.SHARED.value == "shared"
|
||||
|
||||
|
||||
class TestAgentTeamLearnParam:
|
||||
"""Test AgentTeam(learn=...) parameter."""
|
||||
|
||||
def test_agent_team_accepts_learn_param(self):
|
||||
"""AgentTeam should accept learn= parameter."""
|
||||
from praisonaiagents import Agent, AgentTeam
|
||||
|
||||
agent1 = Agent(name="agent1", instructions="Agent 1")
|
||||
agent2 = Agent(name="agent2", instructions="Agent 2")
|
||||
|
||||
team = AgentTeam(
|
||||
agents=[agent1, agent2],
|
||||
learn=True,
|
||||
)
|
||||
|
||||
# Should store learn config
|
||||
assert hasattr(team, '_learn') or hasattr(team, 'learn')
|
||||
|
||||
|
||||
class TestWorkflowLearnParam:
|
||||
"""Test Workflow/AgentFlow(learn=...) parameter."""
|
||||
|
||||
def test_workflow_accepts_learn_param(self):
|
||||
"""Workflow should accept learn= parameter."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.workflows import Workflow
|
||||
|
||||
agent = Agent(name="test", instructions="Test")
|
||||
|
||||
workflow = Workflow(
|
||||
steps=[agent], # Workflow uses steps=, not agents=
|
||||
learn=True,
|
||||
)
|
||||
|
||||
# Should store learn config (dataclass field)
|
||||
assert workflow.learn is True
|
||||
|
||||
|
||||
class TestLearnParamDocstring:
|
||||
"""Test that learn= param is documented."""
|
||||
|
||||
def test_agent_docstring_mentions_learn(self):
|
||||
"""Agent.__init__ docstring should mention learn parameter."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
docstring = Agent.__init__.__doc__ or ""
|
||||
|
||||
# Should mention learn parameter
|
||||
assert "learn" in docstring.lower()
|
||||
|
||||
|
||||
class TestBackwardCompatibility:
|
||||
"""Test backward compatibility with memory=MemoryConfig(learn=...)."""
|
||||
|
||||
def test_memory_config_learn_still_works(self):
|
||||
"""memory=MemoryConfig(learn=True) should still work."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.config.feature_configs import MemoryConfig
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
memory=MemoryConfig(learn=True),
|
||||
)
|
||||
|
||||
# Should have memory with learn enabled
|
||||
assert agent._memory_instance is not None
|
||||
# Learn context should be accessible
|
||||
if hasattr(agent._memory_instance, 'get_learn_context'):
|
||||
context = agent._memory_instance.get_learn_context()
|
||||
assert isinstance(context, str)
|
||||
|
||||
def test_top_level_learn_takes_precedence(self):
|
||||
"""Top-level learn= should take precedence over memory.learn."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.config.feature_configs import MemoryConfig
|
||||
|
||||
# Top-level learn=False should override memory.learn=True
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
memory=MemoryConfig(learn=True),
|
||||
learn=False, # This should take precedence
|
||||
)
|
||||
|
||||
# Learn should be disabled due to top-level param
|
||||
assert agent._learn_config is None or agent._learn_config is False
|
||||
@@ -16,6 +16,17 @@ requires_knowledge = pytest.mark.skipif(
|
||||
)
|
||||
|
||||
|
||||
def _make_knowledge_or_skip():
|
||||
"""Create a Knowledge() instance, skipping if OpenAI API key is unavailable."""
|
||||
from praisonaiagents import Knowledge
|
||||
try:
|
||||
return Knowledge()
|
||||
except Exception as e:
|
||||
if "api_key" in str(e).lower() or "openai" in str(e).lower():
|
||||
pytest.skip(f"Skipping: OpenAI API key required for embeddings: {e}")
|
||||
raise
|
||||
|
||||
|
||||
@requires_knowledge
|
||||
class TestDirectoryIngestion:
|
||||
"""Test that directories are properly processed and file contents stored."""
|
||||
@@ -42,10 +53,14 @@ class TestDirectoryIngestion:
|
||||
|
||||
def test_directory_ingestion_stores_text_not_path(self, temp_knowledge_dir):
|
||||
"""Test that ingesting a directory stores file contents, not the directory path."""
|
||||
from praisonaiagents import Knowledge
|
||||
knowledge = _make_knowledge_or_skip()
|
||||
|
||||
knowledge = Knowledge()
|
||||
result = knowledge.add(temp_knowledge_dir, user_id='test_user')
|
||||
try:
|
||||
result = knowledge.add(temp_knowledge_dir, user_id='test_user')
|
||||
except Exception as e:
|
||||
if "api_key" in str(e).lower() or "openai" in str(e).lower():
|
||||
pytest.skip(f"Skipping: OpenAI API key required: {e}")
|
||||
raise
|
||||
|
||||
# Should have results from files
|
||||
assert 'results' in result
|
||||
@@ -65,10 +80,14 @@ class TestDirectoryIngestion:
|
||||
|
||||
def test_directory_ingestion_processes_multiple_files(self, temp_knowledge_dir):
|
||||
"""Test that all files in a directory are processed."""
|
||||
from praisonaiagents import Knowledge
|
||||
knowledge = _make_knowledge_or_skip()
|
||||
|
||||
knowledge = Knowledge()
|
||||
result = knowledge.add(temp_knowledge_dir, user_id='test_user')
|
||||
try:
|
||||
knowledge.add(temp_knowledge_dir, user_id='test_user')
|
||||
except Exception as e:
|
||||
if "api_key" in str(e).lower() or "openai" in str(e).lower():
|
||||
pytest.skip(f"Skipping: OpenAI API key required: {e}")
|
||||
raise
|
||||
|
||||
# Search for content from both files
|
||||
search1 = knowledge.search('ZEBRA-71', user_id='test_user')
|
||||
@@ -80,10 +99,14 @@ class TestDirectoryIngestion:
|
||||
|
||||
def test_directory_ingestion_sets_metadata(self, temp_knowledge_dir):
|
||||
"""Test that file metadata is properly set."""
|
||||
from praisonaiagents import Knowledge
|
||||
knowledge = _make_knowledge_or_skip()
|
||||
|
||||
knowledge = Knowledge()
|
||||
knowledge.add(temp_knowledge_dir, user_id='test_user')
|
||||
try:
|
||||
knowledge.add(temp_knowledge_dir, user_id='test_user')
|
||||
except Exception as e:
|
||||
if "api_key" in str(e).lower() or "openai" in str(e).lower():
|
||||
pytest.skip(f"Skipping: OpenAI API key required: {e}")
|
||||
raise
|
||||
|
||||
search_result = knowledge.search('ZEBRA-71', user_id='test_user')
|
||||
|
||||
@@ -114,15 +137,20 @@ class TestContextBuilderUsesText:
|
||||
"""Test that Agent._get_knowledge_context returns text, not path."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name='TestAgent',
|
||||
instructions='Answer based on knowledge.',
|
||||
knowledge=[temp_knowledge_dir],
|
||||
output='silent',
|
||||
)
|
||||
|
||||
# Ensure knowledge is processed
|
||||
agent._ensure_knowledge_processed()
|
||||
try:
|
||||
agent = Agent(
|
||||
name='TestAgent',
|
||||
instructions='Answer based on knowledge.',
|
||||
knowledge=[temp_knowledge_dir],
|
||||
output='silent',
|
||||
)
|
||||
|
||||
# Ensure knowledge is processed
|
||||
agent._ensure_knowledge_processed()
|
||||
except Exception as e:
|
||||
if "api_key" in str(e).lower() or "openai" in str(e).lower():
|
||||
pytest.skip(f"Skipping: OpenAI API key required: {e}")
|
||||
raise
|
||||
|
||||
# Get context
|
||||
context, _ = agent._get_knowledge_context('What is the password?', use_rag=True)
|
||||
|
||||
@@ -33,8 +33,6 @@ class TestLearnConfig:
|
||||
assert config.feedback is False
|
||||
assert config.improvements is False
|
||||
assert config.scope == LearnScope.PRIVATE
|
||||
assert config.auto_consolidate is True
|
||||
assert config.retention_days is None
|
||||
|
||||
def test_custom_values(self):
|
||||
"""Test LearnConfig with custom values."""
|
||||
@@ -43,12 +41,10 @@ class TestLearnConfig:
|
||||
insights=True,
|
||||
patterns=True,
|
||||
scope=LearnScope.SHARED,
|
||||
retention_days=30,
|
||||
)
|
||||
assert config.persona is False
|
||||
assert config.patterns is True
|
||||
assert config.scope == LearnScope.SHARED
|
||||
assert config.retention_days == 30
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test LearnConfig to_dict method."""
|
||||
@@ -271,6 +267,198 @@ class TestLearnManager:
|
||||
assert "Python" in context
|
||||
|
||||
|
||||
class TestBackendWiring:
|
||||
"""P3-A: Test LearnBackend wiring — SQLite, Redis, FILE."""
|
||||
|
||||
def setup_method(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
|
||||
def teardown_method(self):
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_sqlite_backend_round_trip(self):
|
||||
"""SQLite backend stores and retrieves learnings."""
|
||||
from praisonaiagents.config.feature_configs import LearnBackend
|
||||
config = LearnConfig(
|
||||
persona=True, insights=True, patterns=False,
|
||||
backend=LearnBackend.SQLITE,
|
||||
)
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
|
||||
# Store
|
||||
entry = manager.capture_persona("Prefers dark mode")
|
||||
assert entry is not None
|
||||
assert "dark mode" in entry.content
|
||||
|
||||
# Retrieve
|
||||
context = manager.get_persona_context()
|
||||
assert any("dark mode" in c for c in context)
|
||||
|
||||
def test_file_backend_unchanged(self):
|
||||
"""FILE backend works as before (default)."""
|
||||
config = LearnConfig(persona=True)
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
entry = manager.capture_persona("Prefers light mode")
|
||||
assert entry is not None
|
||||
|
||||
# Verify JSON file exists
|
||||
store_files = list(Path(self.temp_dir).rglob("*.json"))
|
||||
assert len(store_files) > 0
|
||||
|
||||
def test_redis_fallback_when_unavailable(self):
|
||||
"""Redis backend falls back to FILE when Redis not running."""
|
||||
import warnings
|
||||
from praisonaiagents.config.feature_configs import LearnBackend
|
||||
config = LearnConfig(
|
||||
persona=True,
|
||||
backend=LearnBackend.REDIS,
|
||||
db_url="redis://localhost:59999", # unlikely port
|
||||
)
|
||||
with warnings.catch_warnings(record=True):
|
||||
warnings.simplefilter("always")
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
|
||||
# Should still work with FILE fallback
|
||||
entry = manager.capture_persona("Redis fallback test")
|
||||
assert entry is not None
|
||||
|
||||
def test_mongodb_warns_and_falls_back(self):
|
||||
"""MongoDB warns and falls back to FILE."""
|
||||
from praisonaiagents.config.feature_configs import LearnBackend
|
||||
config = LearnConfig(
|
||||
persona=True,
|
||||
backend=LearnBackend.MONGODB,
|
||||
)
|
||||
# Should not crash, falls back to FILE
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
entry = manager.capture_persona("Mongo fallback test")
|
||||
assert entry is not None
|
||||
|
||||
|
||||
class TestWasUpdated:
|
||||
"""P3-B: Test was_updated tracking on BaseStore."""
|
||||
|
||||
def setup_method(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
|
||||
def teardown_method(self):
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_was_updated_false_initially(self):
|
||||
"""New store starts with was_updated=False."""
|
||||
store = PersonaStore(store_path=os.path.join(self.temp_dir, "persona.json"))
|
||||
assert store.was_updated is False
|
||||
|
||||
def test_was_updated_true_after_add(self):
|
||||
"""was_updated becomes True after add()."""
|
||||
store = PersonaStore(store_path=os.path.join(self.temp_dir, "persona.json"))
|
||||
store.add("test preference")
|
||||
assert store.was_updated is True
|
||||
|
||||
def test_was_updated_true_after_update(self):
|
||||
"""was_updated becomes True after update()."""
|
||||
store = PersonaStore(store_path=os.path.join(self.temp_dir, "persona.json"))
|
||||
entry = store.add("preference")
|
||||
store.reset_updated()
|
||||
store.update(entry.id, "updated preference")
|
||||
assert store.was_updated is True
|
||||
|
||||
def test_was_updated_true_after_delete(self):
|
||||
"""was_updated becomes True after delete()."""
|
||||
store = PersonaStore(store_path=os.path.join(self.temp_dir, "persona.json"))
|
||||
entry = store.add("to delete")
|
||||
store.reset_updated()
|
||||
store.delete(entry.id)
|
||||
assert store.was_updated is True
|
||||
|
||||
def test_was_updated_true_after_clear(self):
|
||||
"""was_updated becomes True after clear()."""
|
||||
store = PersonaStore(store_path=os.path.join(self.temp_dir, "persona.json"))
|
||||
store.add("something")
|
||||
store.reset_updated()
|
||||
store.clear()
|
||||
assert store.was_updated is True
|
||||
|
||||
def test_reset_updated(self):
|
||||
"""reset_updated() sets flag back to False."""
|
||||
store = PersonaStore(store_path=os.path.join(self.temp_dir, "persona.json"))
|
||||
store.add("test")
|
||||
assert store.was_updated is True
|
||||
store.reset_updated()
|
||||
assert store.was_updated is False
|
||||
|
||||
|
||||
class TestProposeMode:
|
||||
"""P3-C: Test PROPOSE mode — extract but don't auto-store."""
|
||||
|
||||
def setup_method(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
|
||||
def teardown_method(self):
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_pending_learnings_initially_empty(self):
|
||||
"""Pending learnings list starts empty."""
|
||||
config = LearnConfig(persona=True, mode="propose")
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
assert manager.pending_learnings == []
|
||||
|
||||
def test_add_pending_learning(self):
|
||||
"""Can add a pending learning manually."""
|
||||
config = LearnConfig(persona=True, mode="propose")
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
manager.add_pending("User prefers Python", category="persona")
|
||||
assert len(manager.pending_learnings) == 1
|
||||
assert "Python" in manager.pending_learnings[0].content
|
||||
|
||||
def test_approve_learning_moves_to_store(self):
|
||||
"""Approving a pending learning moves it to the main store."""
|
||||
config = LearnConfig(persona=True, mode="propose")
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
manager.add_pending("User likes concise answers", category="persona")
|
||||
pending_id = manager.pending_learnings[0].id
|
||||
|
||||
result = manager.approve_learning(pending_id)
|
||||
assert result is True
|
||||
assert len(manager.pending_learnings) == 0
|
||||
# Should now be in persona store
|
||||
personas = manager.get_persona_context()
|
||||
assert any("concise" in p for p in personas)
|
||||
|
||||
def test_reject_learning_discards(self):
|
||||
"""Rejecting a pending learning discards it."""
|
||||
config = LearnConfig(persona=True, mode="propose")
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
manager.add_pending("Bad learning", category="persona")
|
||||
pending_id = manager.pending_learnings[0].id
|
||||
|
||||
result = manager.reject_learning(pending_id)
|
||||
assert result is True
|
||||
assert len(manager.pending_learnings) == 0
|
||||
# Should NOT be in any store
|
||||
personas = manager.get_persona_context()
|
||||
assert not any("Bad learning" in p for p in personas)
|
||||
|
||||
def test_approve_all_learnings(self):
|
||||
"""Bulk approve all pending learnings."""
|
||||
config = LearnConfig(persona=True, insights=True, mode="propose")
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
manager.add_pending("Preference A", category="persona")
|
||||
manager.add_pending("Insight B", category="insights")
|
||||
assert len(manager.pending_learnings) == 2
|
||||
|
||||
count = manager.approve_all_learnings()
|
||||
assert count == 2
|
||||
assert len(manager.pending_learnings) == 0
|
||||
|
||||
def test_reject_nonexistent_learning(self):
|
||||
"""Rejecting a nonexistent ID returns False."""
|
||||
config = LearnConfig(persona=True, mode="propose")
|
||||
manager = LearnManager(config=config, user_id="test", store_path=self.temp_dir)
|
||||
result = manager.reject_learning("nonexistent_id")
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestNoCortexParam:
|
||||
"""Test that cortex parameter does not exist."""
|
||||
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
"""
|
||||
TDD Tests for Learn Module Gap Fixes.
|
||||
|
||||
Tests for:
|
||||
- Gap 1: process_conversation() auto-learning
|
||||
- Gap 2: LearnProtocol for extensibility
|
||||
- Gap 3: Learning modes (AGENTIC, PROPOSE)
|
||||
- Gap 4: Async in learn module
|
||||
- Gap 5: DB backend support in LearnConfig
|
||||
"""
|
||||
|
||||
# Tests for Learn Module Gap Fixes
|
||||
|
||||
|
||||
class TestLearnProtocols:
|
||||
"""Test LearnProtocol and related protocols."""
|
||||
|
||||
def test_learn_protocol_exported(self):
|
||||
"""LearnProtocol should be exported from top-level."""
|
||||
from praisonaiagents import LearnProtocol
|
||||
assert LearnProtocol is not None
|
||||
|
||||
def test_async_learn_protocol_exported(self):
|
||||
"""AsyncLearnProtocol should be exported from top-level."""
|
||||
from praisonaiagents import AsyncLearnProtocol
|
||||
assert AsyncLearnProtocol is not None
|
||||
|
||||
def test_learn_manager_protocol_exported(self):
|
||||
"""LearnManagerProtocol should be exported from top-level."""
|
||||
from praisonaiagents import LearnManagerProtocol
|
||||
assert LearnManagerProtocol is not None
|
||||
|
||||
def test_learn_manager_exported(self):
|
||||
"""LearnManager should be exported from top-level."""
|
||||
from praisonaiagents import LearnManager
|
||||
assert LearnManager is not None
|
||||
|
||||
def test_custom_store_satisfies_protocol(self):
|
||||
"""Custom store should satisfy LearnProtocol."""
|
||||
from praisonaiagents import LearnProtocol
|
||||
|
||||
class CustomLearnStore:
|
||||
def add(self, content, metadata=None):
|
||||
return {"id": "test", "content": content}
|
||||
|
||||
def search(self, query, limit=10):
|
||||
return []
|
||||
|
||||
def list_all(self, limit=100):
|
||||
return []
|
||||
|
||||
def get(self, entry_id):
|
||||
return None
|
||||
|
||||
def update(self, entry_id, content, metadata=None):
|
||||
return None
|
||||
|
||||
def delete(self, entry_id):
|
||||
return True
|
||||
|
||||
def clear(self):
|
||||
return 0
|
||||
|
||||
store = CustomLearnStore()
|
||||
assert isinstance(store, LearnProtocol)
|
||||
|
||||
|
||||
class TestLearnModes:
|
||||
"""Test LearnMode enum and configuration."""
|
||||
|
||||
def test_learn_mode_exported(self):
|
||||
"""LearnMode should be exported from top-level."""
|
||||
from praisonaiagents import LearnMode
|
||||
assert LearnMode is not None
|
||||
|
||||
def test_learn_mode_values(self):
|
||||
"""LearnMode should have DISABLED, AGENTIC, PROPOSE values."""
|
||||
from praisonaiagents import LearnMode
|
||||
|
||||
assert LearnMode.DISABLED.value == "disabled"
|
||||
assert LearnMode.AGENTIC.value == "agentic"
|
||||
assert LearnMode.PROPOSE.value == "propose"
|
||||
|
||||
def test_learn_config_has_mode(self):
|
||||
"""LearnConfig should have mode parameter."""
|
||||
from praisonaiagents import LearnConfig, LearnMode
|
||||
|
||||
config = LearnConfig(mode=LearnMode.AGENTIC)
|
||||
assert config.mode == LearnMode.AGENTIC
|
||||
|
||||
def test_learn_config_mode_default_disabled(self):
|
||||
"""LearnConfig mode should default to DISABLED."""
|
||||
from praisonaiagents import LearnConfig, LearnMode
|
||||
|
||||
config = LearnConfig()
|
||||
assert config.mode == LearnMode.DISABLED
|
||||
|
||||
def test_learn_config_mode_string(self):
|
||||
"""LearnConfig should accept mode as string."""
|
||||
from praisonaiagents import LearnConfig
|
||||
|
||||
config = LearnConfig(mode="agentic")
|
||||
assert config.mode == "agentic"
|
||||
|
||||
|
||||
class TestLearnBackend:
|
||||
"""Test LearnBackend enum and configuration."""
|
||||
|
||||
def test_learn_backend_exported(self):
|
||||
"""LearnBackend should be exported from top-level."""
|
||||
from praisonaiagents import LearnBackend
|
||||
assert LearnBackend is not None
|
||||
|
||||
def test_learn_backend_values(self):
|
||||
"""LearnBackend should have FILE, SQLITE, REDIS, MONGODB values."""
|
||||
from praisonaiagents import LearnBackend
|
||||
|
||||
assert LearnBackend.FILE.value == "file"
|
||||
assert LearnBackend.SQLITE.value == "sqlite"
|
||||
assert LearnBackend.REDIS.value == "redis"
|
||||
assert LearnBackend.MONGODB.value == "mongodb"
|
||||
|
||||
def test_learn_config_has_backend(self):
|
||||
"""LearnConfig should have backend parameter."""
|
||||
from praisonaiagents import LearnConfig, LearnBackend
|
||||
|
||||
config = LearnConfig(backend=LearnBackend.SQLITE)
|
||||
assert config.backend == LearnBackend.SQLITE
|
||||
|
||||
def test_learn_config_has_db_url(self):
|
||||
"""LearnConfig should have db_url parameter."""
|
||||
from praisonaiagents import LearnConfig
|
||||
|
||||
config = LearnConfig(
|
||||
backend="sqlite",
|
||||
db_url="sqlite:///learn.db"
|
||||
)
|
||||
assert config.db_url == "sqlite:///learn.db"
|
||||
|
||||
def test_learn_config_has_llm(self):
|
||||
"""LearnConfig should have llm parameter for extraction."""
|
||||
from praisonaiagents import LearnConfig
|
||||
|
||||
config = LearnConfig(llm="gpt-4o")
|
||||
assert config.llm == "gpt-4o"
|
||||
|
||||
|
||||
class TestProcessConversation:
|
||||
"""Test process_conversation() auto-learning method."""
|
||||
|
||||
def test_learn_manager_has_process_conversation(self):
|
||||
"""LearnManager should have process_conversation method."""
|
||||
from praisonaiagents.memory.learn import LearnManager
|
||||
|
||||
manager = LearnManager()
|
||||
assert hasattr(manager, 'process_conversation')
|
||||
assert callable(manager.process_conversation)
|
||||
|
||||
def test_learn_manager_has_aprocess_conversation(self):
|
||||
"""LearnManager should have async aprocess_conversation method."""
|
||||
from praisonaiagents.memory.learn import LearnManager
|
||||
|
||||
manager = LearnManager()
|
||||
assert hasattr(manager, 'aprocess_conversation')
|
||||
assert callable(manager.aprocess_conversation)
|
||||
|
||||
def test_process_conversation_empty_messages(self):
|
||||
"""process_conversation should handle empty messages."""
|
||||
from praisonaiagents.memory.learn import LearnManager
|
||||
|
||||
manager = LearnManager()
|
||||
result = manager.process_conversation([])
|
||||
|
||||
assert result == {"persona": [], "insights": [], "patterns": [], "stored": {}}
|
||||
|
||||
def test_process_conversation_signature(self):
|
||||
"""process_conversation should have correct signature."""
|
||||
from praisonaiagents.memory.learn import LearnManager
|
||||
import inspect
|
||||
|
||||
manager = LearnManager()
|
||||
sig = inspect.signature(manager.process_conversation)
|
||||
params = list(sig.parameters.keys())
|
||||
|
||||
assert "messages" in params
|
||||
assert "llm" in params
|
||||
|
||||
|
||||
class TestLearnConfigToDict:
|
||||
"""Test LearnConfig.to_dict() includes new fields."""
|
||||
|
||||
def test_to_dict_includes_mode(self):
|
||||
"""to_dict should include mode."""
|
||||
from praisonaiagents import LearnConfig, LearnMode
|
||||
|
||||
config = LearnConfig(mode=LearnMode.AGENTIC)
|
||||
d = config.to_dict()
|
||||
|
||||
assert "mode" in d
|
||||
assert d["mode"] == "agentic"
|
||||
|
||||
def test_to_dict_includes_backend(self):
|
||||
"""to_dict should include backend."""
|
||||
from praisonaiagents import LearnConfig, LearnBackend
|
||||
|
||||
config = LearnConfig(backend=LearnBackend.SQLITE)
|
||||
d = config.to_dict()
|
||||
|
||||
assert "backend" in d
|
||||
assert d["backend"] == "sqlite"
|
||||
|
||||
def test_to_dict_includes_db_url(self):
|
||||
"""to_dict should include db_url."""
|
||||
from praisonaiagents import LearnConfig
|
||||
|
||||
config = LearnConfig(db_url="sqlite:///test.db")
|
||||
d = config.to_dict()
|
||||
|
||||
assert "db_url" in d
|
||||
assert d["db_url"] == "sqlite:///test.db"
|
||||
|
||||
def test_to_dict_includes_llm(self):
|
||||
"""to_dict should include llm."""
|
||||
from praisonaiagents import LearnConfig
|
||||
|
||||
config = LearnConfig(llm="gpt-4o")
|
||||
d = config.to_dict()
|
||||
|
||||
assert "llm" in d
|
||||
assert d["llm"] == "gpt-4o"
|
||||
|
||||
|
||||
class TestAgentWithLearnMode:
|
||||
"""Test Agent with learn mode configuration."""
|
||||
|
||||
def test_agent_accepts_learn_config_with_mode(self):
|
||||
"""Agent should accept LearnConfig with mode."""
|
||||
from praisonaiagents import Agent, LearnConfig, LearnMode
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
learn=LearnConfig(
|
||||
mode=LearnMode.AGENTIC,
|
||||
persona=True,
|
||||
insights=True,
|
||||
),
|
||||
)
|
||||
|
||||
assert agent._learn_config is not None
|
||||
assert agent._learn_config.mode == LearnMode.AGENTIC
|
||||
|
||||
def test_agent_accepts_learn_config_with_backend(self):
|
||||
"""Agent should accept LearnConfig with backend."""
|
||||
from praisonaiagents import Agent, LearnConfig, LearnBackend
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
learn=LearnConfig(
|
||||
backend=LearnBackend.SQLITE,
|
||||
db_url="sqlite:///test.db",
|
||||
),
|
||||
)
|
||||
|
||||
assert agent._learn_config is not None
|
||||
assert agent._learn_config.backend == LearnBackend.SQLITE
|
||||
|
||||
def test_agent_has_process_auto_learning_method(self):
|
||||
"""Agent should have _process_auto_learning method."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(name="test", instructions="Test agent")
|
||||
assert hasattr(agent, '_process_auto_learning')
|
||||
assert callable(agent._process_auto_learning)
|
||||
|
||||
|
||||
class TestCustomStores:
|
||||
"""Test custom_stores support in LearnManager."""
|
||||
|
||||
def test_learn_manager_accepts_custom_stores(self):
|
||||
"""LearnManager should accept custom_stores parameter."""
|
||||
from praisonaiagents import LearnManager, LearnConfig
|
||||
|
||||
class MyCustomStore:
|
||||
def __init__(self):
|
||||
self.data = []
|
||||
def add(self, content, metadata=None):
|
||||
self.data.append(content)
|
||||
return {"id": "1", "content": content}
|
||||
def search(self, query, limit=10):
|
||||
return []
|
||||
def list_all(self, limit=100):
|
||||
return self.data
|
||||
def get(self, entry_id):
|
||||
return None
|
||||
def update(self, entry_id, content, metadata=None):
|
||||
return None
|
||||
def delete(self, entry_id):
|
||||
return True
|
||||
def clear(self):
|
||||
return 0
|
||||
|
||||
custom_store = MyCustomStore()
|
||||
manager = LearnManager(
|
||||
config=LearnConfig(persona=True),
|
||||
custom_stores={"domain": custom_store}
|
||||
)
|
||||
|
||||
assert "domain" in manager._stores
|
||||
assert manager._stores["domain"] is custom_store
|
||||
|
||||
def test_custom_stores_can_override_builtin(self):
|
||||
"""Custom stores can override built-in stores."""
|
||||
from praisonaiagents import LearnManager, LearnConfig
|
||||
|
||||
class MyPersonaStore:
|
||||
def add(self, content, metadata=None):
|
||||
return {"id": "custom", "content": content}
|
||||
def search(self, query, limit=10):
|
||||
return []
|
||||
def list_all(self, limit=100):
|
||||
return []
|
||||
def get(self, entry_id):
|
||||
return None
|
||||
def update(self, entry_id, content, metadata=None):
|
||||
return None
|
||||
def delete(self, entry_id):
|
||||
return True
|
||||
def clear(self):
|
||||
return 0
|
||||
|
||||
custom_persona = MyPersonaStore()
|
||||
manager = LearnManager(
|
||||
config=LearnConfig(persona=True),
|
||||
custom_stores={"persona": custom_persona}
|
||||
)
|
||||
|
||||
# Custom stores should override built-in stores
|
||||
assert "persona" in manager._stores
|
||||
assert manager._stores["persona"] is custom_persona
|
||||
@@ -23,14 +23,18 @@ from unittest.mock import patch, MagicMock, PropertyMock
|
||||
class TestDoomLoopFiring:
|
||||
"""G1/G2: Doom loop detection must actually fire in run_autonomous()."""
|
||||
|
||||
def test_doom_loop_fires_when_agent_repeats_same_response(self):
|
||||
@patch("praisonaiagents.agent.agent.Agent.get_recommended_stage", return_value="heuristic")
|
||||
def test_doom_loop_fires_when_agent_repeats_same_response(self, mock_stage):
|
||||
"""run_autonomous() must call _record_action() so DoomLoopTracker gets data."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="test_doom",
|
||||
instructions="You are a test agent",
|
||||
autonomy={"max_iterations": 10, "doom_loop_threshold": 3},
|
||||
# Need enough iterations for graduated recovery to exhaust all 4 stages:
|
||||
# threshold=3 means 3 identical actions per doom-loop cycle, 4 recovery
|
||||
# stages, plus check iterations = ~16 iterations minimum.
|
||||
autonomy={"max_iterations": 25, "doom_loop_threshold": 3},
|
||||
)
|
||||
|
||||
# Mock chat to return the same response every time
|
||||
@@ -43,16 +47,16 @@ class TestDoomLoopFiring:
|
||||
|
||||
agent.chat = mock_chat
|
||||
|
||||
# Use a prompt that triggers autonomous stage (multi-step)
|
||||
# so the loop runs multiple iterations instead of exiting after 1 (direct)
|
||||
result = agent.run_autonomous("First refactor the auth module, then test it step by step")
|
||||
|
||||
# Doom loop should have been detected before max_iterations
|
||||
assert result.completion_reason == "doom_loop", (
|
||||
f"Expected doom_loop but got {result.completion_reason} after {result.iterations} iterations"
|
||||
# Graduated recovery produces needs_help (stage 3) or doom_loop (stage 4).
|
||||
# Both are valid doom-loop-family abort outcomes.
|
||||
assert result.completion_reason in ("doom_loop", "needs_help"), (
|
||||
f"Expected doom_loop or needs_help but got {result.completion_reason} "
|
||||
f"after {result.iterations} iterations"
|
||||
)
|
||||
assert result.success is False
|
||||
assert result.iterations < 10 # Should stop well before max
|
||||
assert result.iterations < 25 # Should stop well before max
|
||||
|
||||
def test_doom_loop_does_not_fire_with_varied_responses(self):
|
||||
"""Varied responses should NOT trigger doom loop."""
|
||||
|
||||
@@ -157,7 +157,11 @@ class TestDoomLoopRecovery:
|
||||
agent = self._make_agent(threshold=2)
|
||||
result = agent.run_autonomous("Do something")
|
||||
assert result.success is False
|
||||
assert result.completion_reason == "doom_loop"
|
||||
# Graduated recovery produces needs_help (stage 3) or doom_loop (stage 4).
|
||||
# Both are valid doom-loop-family abort outcomes.
|
||||
assert result.completion_reason in ("doom_loop", "needs_help"), (
|
||||
f"Expected doom_loop or needs_help but got {result.completion_reason}"
|
||||
)
|
||||
|
||||
@patch("praisonaiagents.agent.agent.Agent.chat")
|
||||
@patch("praisonaiagents.agent.agent.Agent.get_recommended_stage", return_value="heuristic")
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
Tests for bot framework fixes.
|
||||
|
||||
Tests cover:
|
||||
1. WhatsApp health() uses correct session manager attribute
|
||||
2. asyncio.get_running_loop() usage (not deprecated get_event_loop)
|
||||
3. Rate limiter functionality
|
||||
4. Background task tracking
|
||||
5. Deprecation warning for legacy approval
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import time
|
||||
|
||||
|
||||
class TestRateLimiter:
|
||||
"""Tests for the RateLimiter utility."""
|
||||
|
||||
def test_rate_limiter_import(self):
|
||||
"""Test that RateLimiter can be imported."""
|
||||
from praisonai.bots._rate_limit import RateLimiter, RateLimitConfig
|
||||
assert RateLimiter is not None
|
||||
assert RateLimitConfig is not None
|
||||
|
||||
def test_rate_limiter_for_platform(self):
|
||||
"""Test platform-specific rate limiter creation."""
|
||||
from praisonai.bots._rate_limit import RateLimiter, PLATFORM_LIMITS
|
||||
|
||||
for platform in ["telegram", "discord", "slack", "whatsapp"]:
|
||||
limiter = RateLimiter.for_platform(platform)
|
||||
assert limiter is not None
|
||||
assert limiter._config == PLATFORM_LIMITS[platform]
|
||||
|
||||
def test_rate_limiter_unknown_platform_uses_defaults(self):
|
||||
"""Test that unknown platform uses default config."""
|
||||
from praisonai.bots._rate_limit import RateLimiter, RateLimitConfig
|
||||
|
||||
limiter = RateLimiter.for_platform("unknown_platform")
|
||||
default = RateLimitConfig()
|
||||
assert limiter._config.messages_per_second == default.messages_per_second
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limiter_acquire_no_wait_with_tokens(self):
|
||||
"""Test that acquire doesn't wait when tokens available."""
|
||||
from praisonai.bots._rate_limit import RateLimiter, RateLimitConfig
|
||||
|
||||
config = RateLimitConfig(messages_per_second=100, burst_size=10)
|
||||
limiter = RateLimiter(config)
|
||||
|
||||
start = time.monotonic()
|
||||
await limiter.acquire()
|
||||
elapsed = time.monotonic() - start
|
||||
|
||||
# Should be nearly instant
|
||||
assert elapsed < 0.1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limiter_acquire_waits_when_exhausted(self):
|
||||
"""Test that acquire waits when tokens exhausted."""
|
||||
from praisonai.bots._rate_limit import RateLimiter, RateLimitConfig
|
||||
|
||||
config = RateLimitConfig(messages_per_second=10, burst_size=1)
|
||||
limiter = RateLimiter(config)
|
||||
|
||||
# Exhaust the single token
|
||||
await limiter.acquire()
|
||||
|
||||
start = time.monotonic()
|
||||
await limiter.acquire()
|
||||
elapsed = time.monotonic() - start
|
||||
|
||||
# Should wait ~0.1s for token refill (1/10 = 0.1s)
|
||||
assert elapsed >= 0.05 # Allow some tolerance
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limiter_per_channel_delay(self):
|
||||
"""Test per-channel delay enforcement."""
|
||||
from praisonai.bots._rate_limit import RateLimiter, RateLimitConfig
|
||||
|
||||
config = RateLimitConfig(
|
||||
messages_per_second=100,
|
||||
per_channel_delay=0.1,
|
||||
burst_size=10
|
||||
)
|
||||
limiter = RateLimiter(config)
|
||||
|
||||
channel = "test_channel"
|
||||
|
||||
# First message - no delay
|
||||
await limiter.acquire(channel)
|
||||
|
||||
# Second message to same channel - should delay
|
||||
start = time.monotonic()
|
||||
await limiter.acquire(channel)
|
||||
elapsed = time.monotonic() - start
|
||||
|
||||
assert elapsed >= 0.05 # Allow tolerance
|
||||
|
||||
def test_rate_limiter_reset(self):
|
||||
"""Test rate limiter reset."""
|
||||
from praisonai.bots._rate_limit import RateLimiter, RateLimitConfig
|
||||
|
||||
config = RateLimitConfig(burst_size=5)
|
||||
limiter = RateLimiter(config)
|
||||
limiter._tokens = 0
|
||||
limiter._channel_last_send["test"] = time.monotonic()
|
||||
|
||||
limiter.reset()
|
||||
|
||||
assert limiter._tokens == 5.0
|
||||
assert len(limiter._channel_last_send) == 0
|
||||
|
||||
|
||||
class TestAsyncioGetRunningLoop:
|
||||
"""Tests to verify get_running_loop is used instead of deprecated get_event_loop."""
|
||||
|
||||
def test_session_manager_uses_get_running_loop(self):
|
||||
"""Verify _session.py uses get_running_loop."""
|
||||
import inspect
|
||||
from praisonai.bots._session import BotSessionManager
|
||||
|
||||
source = inspect.getsource(BotSessionManager)
|
||||
assert "get_running_loop" in source
|
||||
assert "get_event_loop()" not in source
|
||||
|
||||
def test_approval_uses_get_running_loop(self):
|
||||
"""Verify _approval.py uses get_running_loop."""
|
||||
# Import will trigger deprecation warning, that's expected
|
||||
import warnings
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
from praisonai.bots._approval import BotApprovalBackend
|
||||
|
||||
import inspect
|
||||
source = inspect.getsource(BotApprovalBackend)
|
||||
assert "get_running_loop" in source
|
||||
|
||||
|
||||
class TestWhatsAppSessionManagerAttribute:
|
||||
"""Tests for WhatsApp bot session manager attribute fix."""
|
||||
|
||||
def test_whatsapp_health_uses_session_mgr(self):
|
||||
"""Verify WhatsApp health() uses _session_mgr not _session."""
|
||||
import inspect
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
|
||||
source = inspect.getsource(WhatsAppBot.health)
|
||||
# Should use _session_mgr
|
||||
assert "_session_mgr" in source
|
||||
# Should NOT use bare _session (except as part of _session_mgr)
|
||||
# Count occurrences
|
||||
session_mgr_count = source.count("_session_mgr")
|
||||
bare_session_count = source.count("self._session") - source.count("self._session_mgr")
|
||||
assert bare_session_count == 0, "health() should not use self._session"
|
||||
assert session_mgr_count > 0, "health() should use self._session_mgr"
|
||||
|
||||
|
||||
class TestWhatsAppHttpSession:
|
||||
"""Tests for WhatsApp shared HTTP session."""
|
||||
|
||||
def test_whatsapp_has_http_session_attribute(self):
|
||||
"""Verify WhatsApp bot has _http_session attribute."""
|
||||
import inspect
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
|
||||
source = inspect.getsource(WhatsAppBot.__init__)
|
||||
assert "_http_session" in source
|
||||
|
||||
def test_whatsapp_creates_session_on_start(self):
|
||||
"""Verify WhatsApp creates ClientSession on start."""
|
||||
import inspect
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
|
||||
source = inspect.getsource(WhatsAppBot._start_cloud_mode)
|
||||
assert "self._http_session = aiohttp.ClientSession" in source
|
||||
|
||||
def test_whatsapp_closes_session_on_stop(self):
|
||||
"""Verify WhatsApp closes ClientSession on stop."""
|
||||
import inspect
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
|
||||
source = inspect.getsource(WhatsAppBot.stop)
|
||||
assert "_http_session.close()" in source
|
||||
|
||||
|
||||
class TestWhatsAppBackgroundTasks:
|
||||
"""Tests for WhatsApp background task tracking."""
|
||||
|
||||
def test_whatsapp_has_background_tasks_set(self):
|
||||
"""Verify WhatsApp bot has _background_tasks set."""
|
||||
import inspect
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
|
||||
source = inspect.getsource(WhatsAppBot.__init__)
|
||||
assert "_background_tasks" in source
|
||||
|
||||
def test_whatsapp_tracks_webhook_tasks(self):
|
||||
"""Verify webhook processing tasks are tracked."""
|
||||
import inspect
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
|
||||
source = inspect.getsource(WhatsAppBot._handle_webhook)
|
||||
assert "self._background_tasks.add(task)" in source
|
||||
assert "add_done_callback" in source
|
||||
|
||||
def test_whatsapp_cancels_tasks_on_stop(self):
|
||||
"""Verify background tasks are cancelled on stop."""
|
||||
import inspect
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
|
||||
source = inspect.getsource(WhatsAppBot.stop)
|
||||
assert "task.cancel()" in source or "for task in self._background_tasks" in source
|
||||
|
||||
|
||||
class TestLegacyApprovalDeprecation:
|
||||
"""Tests for legacy approval deprecation warning."""
|
||||
|
||||
def test_legacy_approval_emits_deprecation_warning(self):
|
||||
"""Verify importing _approval.py emits DeprecationWarning."""
|
||||
import warnings
|
||||
import sys
|
||||
import importlib
|
||||
|
||||
# Remove from cache to force re-import
|
||||
if 'praisonai.bots._approval' in sys.modules:
|
||||
del sys.modules['praisonai.bots._approval']
|
||||
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
# Use importlib to avoid lint warning about unused import
|
||||
importlib.import_module('praisonai.bots._approval')
|
||||
|
||||
# Check that a DeprecationWarning was raised
|
||||
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
|
||||
assert len(deprecation_warnings) >= 1
|
||||
assert "deprecated" in str(deprecation_warnings[0].message).lower()
|
||||
|
||||
|
||||
class TestAckReactorIntegration:
|
||||
"""Tests for AckReactor integration across adapters."""
|
||||
|
||||
def test_discord_has_ack_reactor(self):
|
||||
"""Verify Discord adapter has AckReactor."""
|
||||
import inspect
|
||||
from praisonai.bots.discord import DiscordBot
|
||||
|
||||
source = inspect.getsource(DiscordBot.__init__)
|
||||
assert "_ack" in source
|
||||
assert "AckReactor" in source
|
||||
|
||||
def test_discord_uses_ack_in_message_handling(self):
|
||||
"""Verify Discord uses AckReactor in message handling."""
|
||||
import inspect
|
||||
from praisonai.bots.discord import DiscordBot
|
||||
|
||||
# Get the start method source which contains on_message
|
||||
source = inspect.getsource(DiscordBot.start)
|
||||
assert "self._ack.enabled" in source
|
||||
assert "add_reaction" in source
|
||||
assert "self._ack.done" in source
|
||||
|
||||
def test_slack_has_ack_reactor(self):
|
||||
"""Verify Slack adapter has AckReactor."""
|
||||
import inspect
|
||||
from praisonai.bots.slack import SlackBot
|
||||
|
||||
source = inspect.getsource(SlackBot.__init__)
|
||||
assert "_ack" in source
|
||||
assert "AckReactor" in source
|
||||
|
||||
def test_slack_uses_ack_in_message_handling(self):
|
||||
"""Verify Slack uses AckReactor in message handling."""
|
||||
import inspect
|
||||
from praisonai.bots.slack import SlackBot
|
||||
|
||||
source = inspect.getsource(SlackBot.start)
|
||||
assert "self._ack.enabled" in source
|
||||
assert "reactions_add" in source
|
||||
assert "self._ack.done" in source
|
||||
|
||||
def test_whatsapp_has_ack_reactor(self):
|
||||
"""Verify WhatsApp adapter has AckReactor."""
|
||||
import inspect
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
|
||||
source = inspect.getsource(WhatsAppBot.__init__)
|
||||
assert "_ack" in source
|
||||
assert "AckReactor" in source
|
||||
|
||||
def test_whatsapp_uses_ack_in_message_handling(self):
|
||||
"""Verify WhatsApp uses AckReactor in message handling."""
|
||||
import inspect
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
|
||||
source = inspect.getsource(WhatsAppBot._handle_incoming_message)
|
||||
assert "self._ack.enabled" in source
|
||||
assert "send_reaction" in source
|
||||
assert "self._ack.done" in source
|
||||
|
||||
|
||||
class TestWhatsAppRateLimiterIntegration:
|
||||
"""Tests for WhatsApp rate limiter integration."""
|
||||
|
||||
def test_whatsapp_has_rate_limiter(self):
|
||||
"""Verify WhatsApp adapter has rate limiter."""
|
||||
import inspect
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
|
||||
source = inspect.getsource(WhatsAppBot.__init__)
|
||||
assert "_rate_limiter" in source
|
||||
assert "RateLimiter.for_platform" in source
|
||||
|
||||
def test_whatsapp_send_message_uses_rate_limiter(self):
|
||||
"""Verify WhatsApp send_message uses rate limiter."""
|
||||
import inspect
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
|
||||
source = inspect.getsource(WhatsAppBot.send_message)
|
||||
assert "self._rate_limiter.acquire" in source
|
||||
|
||||
def test_whatsapp_send_template_uses_rate_limiter(self):
|
||||
"""Verify WhatsApp send_template uses rate limiter."""
|
||||
import inspect
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
|
||||
source = inspect.getsource(WhatsAppBot.send_template)
|
||||
assert "self._rate_limiter.acquire" in source
|
||||
|
||||
|
||||
class TestPlatformLimits:
|
||||
"""Tests for platform rate limit configurations."""
|
||||
|
||||
def test_telegram_limits(self):
|
||||
"""Test Telegram rate limits are configured correctly."""
|
||||
from praisonai.bots._rate_limit import PLATFORM_LIMITS
|
||||
|
||||
telegram = PLATFORM_LIMITS["telegram"]
|
||||
assert telegram.messages_per_second >= 20 # ~30 per Telegram docs
|
||||
assert telegram.burst_size >= 20
|
||||
|
||||
def test_discord_limits(self):
|
||||
"""Test Discord rate limits are configured correctly."""
|
||||
from praisonai.bots._rate_limit import PLATFORM_LIMITS
|
||||
|
||||
discord = PLATFORM_LIMITS["discord"]
|
||||
assert discord.messages_per_second <= 2 # 5 per 5 seconds = 1/sec
|
||||
assert discord.per_channel_delay >= 0.5
|
||||
|
||||
def test_slack_limits(self):
|
||||
"""Test Slack rate limits are configured correctly."""
|
||||
from praisonai.bots._rate_limit import PLATFORM_LIMITS
|
||||
|
||||
slack = PLATFORM_LIMITS["slack"]
|
||||
assert slack.messages_per_second <= 2 # 1 per second per channel
|
||||
assert slack.per_channel_delay >= 0.5
|
||||
|
||||
def test_whatsapp_limits(self):
|
||||
"""Test WhatsApp rate limits are configured correctly."""
|
||||
from praisonai.bots._rate_limit import PLATFORM_LIMITS
|
||||
|
||||
whatsapp = PLATFORM_LIMITS["whatsapp"]
|
||||
assert whatsapp.messages_per_second >= 30 # ~80 per Cloud API docs
|
||||
@@ -0,0 +1,322 @@
|
||||
"""
|
||||
Unit tests for EditorOutput module.
|
||||
|
||||
Tests the user-friendly editor-style output mode with:
|
||||
- Step numbering
|
||||
- Human-readable tool labels
|
||||
- Multi-agent prefix support
|
||||
- Markdown export
|
||||
- Thread safety
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import threading
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
class TestEditorOutputImports:
|
||||
"""Test that EditorOutput can be imported correctly."""
|
||||
|
||||
def test_import_from_output_module(self):
|
||||
"""Test lazy import from output module."""
|
||||
from praisonaiagents.output import EditorOutput
|
||||
assert EditorOutput is not None
|
||||
|
||||
def test_import_enable_disable(self):
|
||||
"""Test enable/disable functions can be imported."""
|
||||
from praisonaiagents.output import enable_editor_output, disable_editor_output
|
||||
assert callable(enable_editor_output)
|
||||
assert callable(disable_editor_output)
|
||||
|
||||
def test_import_tool_labels(self):
|
||||
"""Test TOOL_LABELS can be imported."""
|
||||
from praisonaiagents.output import TOOL_LABELS
|
||||
assert isinstance(TOOL_LABELS, dict)
|
||||
assert 'internet_search' in TOOL_LABELS
|
||||
|
||||
def test_import_block_types(self):
|
||||
"""Test BlockType and DisplayBlock can be imported."""
|
||||
from praisonaiagents.output import BlockType, DisplayBlock
|
||||
assert BlockType.NARRATIVE.value == "narrative"
|
||||
assert BlockType.COMMAND.value == "command"
|
||||
|
||||
|
||||
class TestEditorOutputBasic:
|
||||
"""Test basic EditorOutput functionality."""
|
||||
|
||||
def test_init_default(self):
|
||||
"""Test default initialization."""
|
||||
from praisonaiagents.output.editor import EditorOutput
|
||||
editor = EditorOutput(use_rich=False)
|
||||
assert editor._step_count == 0
|
||||
assert editor._blocks == []
|
||||
assert editor._multi_agent_mode is False
|
||||
|
||||
def test_init_with_agent_name(self):
|
||||
"""Test initialization with agent name."""
|
||||
from praisonaiagents.output.editor import EditorOutput
|
||||
editor = EditorOutput(use_rich=False, agent_name="TestAgent")
|
||||
assert editor._agent_name == "TestAgent"
|
||||
|
||||
def test_elapsed_time(self):
|
||||
"""Test elapsed time tracking."""
|
||||
from praisonaiagents.output.editor import EditorOutput
|
||||
import time
|
||||
editor = EditorOutput(use_rich=False)
|
||||
time.sleep(0.1)
|
||||
elapsed = editor.elapsed_time()
|
||||
assert elapsed >= 0.1
|
||||
|
||||
|
||||
class TestToolCall:
|
||||
"""Test tool_call method."""
|
||||
|
||||
def test_tool_call_increments_step(self):
|
||||
"""Test that tool_call increments step counter."""
|
||||
from praisonaiagents.output.editor import EditorOutput
|
||||
editor = EditorOutput(use_rich=False)
|
||||
|
||||
editor.tool_call("test_tool")
|
||||
assert editor._step_count == 1
|
||||
|
||||
editor.tool_call("test_tool")
|
||||
assert editor._step_count == 2
|
||||
|
||||
def test_tool_call_with_known_tool(self):
|
||||
"""Test tool_call with a known tool label."""
|
||||
from praisonaiagents.output.editor import EditorOutput, TOOL_LABELS
|
||||
editor = EditorOutput(use_rich=False)
|
||||
|
||||
# internet_search should have a label
|
||||
assert 'internet_search' in TOOL_LABELS
|
||||
editor.tool_call("internet_search", args={"query": "test"})
|
||||
|
||||
blocks = editor.get_blocks()
|
||||
assert len(blocks) == 1
|
||||
assert "Searching the web" in blocks[0].content
|
||||
|
||||
def test_tool_call_with_unknown_tool(self):
|
||||
"""Test tool_call with an unknown tool."""
|
||||
from praisonaiagents.output.editor import EditorOutput
|
||||
editor = EditorOutput(use_rich=False)
|
||||
|
||||
editor.tool_call("my_custom_tool")
|
||||
blocks = editor.get_blocks()
|
||||
assert len(blocks) == 1
|
||||
assert "my_custom_tool" in blocks[0].content
|
||||
|
||||
def test_tool_call_result_formatting(self):
|
||||
"""Test that JSON results are formatted nicely."""
|
||||
from praisonaiagents.output.editor import EditorOutput
|
||||
editor = EditorOutput(use_rich=False)
|
||||
|
||||
# Test list result
|
||||
editor.tool_call("test", result='[{"a": 1}, {"b": 2}]')
|
||||
blocks = editor.get_blocks()
|
||||
assert "Found 2 items" in blocks[0].output
|
||||
|
||||
def test_tool_call_success_result(self):
|
||||
"""Test success result formatting."""
|
||||
from praisonaiagents.output.editor import EditorOutput
|
||||
editor = EditorOutput(use_rich=False)
|
||||
|
||||
editor.tool_call("test", result='{"success": true}')
|
||||
blocks = editor.get_blocks()
|
||||
assert "Success" in blocks[0].output
|
||||
|
||||
|
||||
class TestMultiAgentPrefix:
|
||||
"""Test multi-agent prefix support."""
|
||||
|
||||
def test_no_prefix_by_default(self):
|
||||
"""Test that no prefix is shown by default."""
|
||||
from praisonaiagents.output.editor import EditorOutput
|
||||
editor = EditorOutput(use_rich=False, agent_name="Agent1")
|
||||
|
||||
prefix = editor._get_prefix()
|
||||
assert prefix == ""
|
||||
|
||||
def test_prefix_when_multi_agent_enabled(self):
|
||||
"""Test prefix is shown when multi-agent mode is enabled."""
|
||||
from praisonaiagents.output.editor import EditorOutput
|
||||
editor = EditorOutput(use_rich=False, agent_name="Agent1")
|
||||
editor.enable_multi_agent_mode()
|
||||
|
||||
prefix = editor._get_prefix()
|
||||
assert prefix == "[Agent1] "
|
||||
|
||||
def test_set_agent_name(self):
|
||||
"""Test setting agent name."""
|
||||
from praisonaiagents.output.editor import EditorOutput
|
||||
editor = EditorOutput(use_rich=False)
|
||||
editor.set_agent_name("NewAgent")
|
||||
editor.enable_multi_agent_mode()
|
||||
|
||||
prefix = editor._get_prefix()
|
||||
assert prefix == "[NewAgent] "
|
||||
|
||||
def test_prefix_override(self):
|
||||
"""Test prefix can be overridden per-call."""
|
||||
from praisonaiagents.output.editor import EditorOutput
|
||||
editor = EditorOutput(use_rich=False, agent_name="Agent1")
|
||||
editor.enable_multi_agent_mode()
|
||||
|
||||
prefix = editor._get_prefix("Agent2")
|
||||
assert prefix == "[Agent2] "
|
||||
|
||||
|
||||
class TestAdditionalMethods:
|
||||
"""Test additional display methods."""
|
||||
|
||||
def test_narrative(self):
|
||||
"""Test narrative method."""
|
||||
from praisonaiagents.output.editor import EditorOutput, BlockType
|
||||
editor = EditorOutput(use_rich=False)
|
||||
|
||||
editor.narrative("This is a narrative block")
|
||||
blocks = editor.get_blocks()
|
||||
assert len(blocks) == 1
|
||||
assert blocks[0].type == BlockType.NARRATIVE
|
||||
assert blocks[0].content == "This is a narrative block"
|
||||
|
||||
def test_code(self):
|
||||
"""Test code method."""
|
||||
from praisonaiagents.output.editor import EditorOutput, BlockType
|
||||
editor = EditorOutput(use_rich=False)
|
||||
|
||||
editor.code("print('hello')", language="python")
|
||||
blocks = editor.get_blocks()
|
||||
assert len(blocks) == 1
|
||||
assert blocks[0].type == BlockType.CODE
|
||||
assert blocks[0].metadata["language"] == "python"
|
||||
|
||||
def test_action(self):
|
||||
"""Test action method."""
|
||||
from praisonaiagents.output.editor import EditorOutput, BlockType
|
||||
editor = EditorOutput(use_rich=False)
|
||||
|
||||
editor.action("File created", details=["path: /tmp/test.py"])
|
||||
blocks = editor.get_blocks()
|
||||
assert len(blocks) == 1
|
||||
assert blocks[0].type == BlockType.ACTION
|
||||
assert blocks[0].title == "File created"
|
||||
|
||||
def test_list_items(self):
|
||||
"""Test list_items method."""
|
||||
from praisonaiagents.output.editor import EditorOutput, BlockType
|
||||
editor = EditorOutput(use_rich=False)
|
||||
|
||||
editor.list_items(["Item 1", "Item 2"], title="My List")
|
||||
blocks = editor.get_blocks()
|
||||
assert len(blocks) == 1
|
||||
assert blocks[0].type == BlockType.LIST
|
||||
assert blocks[0].items == ["Item 1", "Item 2"]
|
||||
|
||||
def test_error(self):
|
||||
"""Test error method."""
|
||||
from praisonaiagents.output.editor import EditorOutput
|
||||
editor = EditorOutput(use_rich=False)
|
||||
|
||||
# Just verify it doesn't crash
|
||||
editor.error("Something went wrong")
|
||||
|
||||
def test_summary(self):
|
||||
"""Test summary method."""
|
||||
from praisonaiagents.output.editor import EditorOutput, BlockType
|
||||
editor = EditorOutput(use_rich=False)
|
||||
|
||||
editor.summary("Completed", items=["Duration: 5s", "Steps: 3"])
|
||||
blocks = editor.get_blocks()
|
||||
assert len(blocks) == 1
|
||||
assert blocks[0].type == BlockType.SUMMARY
|
||||
|
||||
|
||||
class TestToMarkdown:
|
||||
"""Test to_markdown export."""
|
||||
|
||||
def test_to_markdown_empty(self):
|
||||
"""Test to_markdown with no blocks."""
|
||||
from praisonaiagents.output.editor import EditorOutput
|
||||
editor = EditorOutput(use_rich=False)
|
||||
|
||||
md = editor.to_markdown()
|
||||
assert md == ""
|
||||
|
||||
def test_to_markdown_with_blocks(self):
|
||||
"""Test to_markdown with various blocks."""
|
||||
from praisonaiagents.output.editor import EditorOutput
|
||||
editor = EditorOutput(use_rich=False)
|
||||
|
||||
editor.narrative("Hello world")
|
||||
editor.tool_call("test_tool", result="Done")
|
||||
editor.summary("Complete", items=["Step 1"])
|
||||
|
||||
md = editor.to_markdown()
|
||||
assert "Hello world" in md
|
||||
assert "Complete" in md
|
||||
assert "Step 1" in md
|
||||
|
||||
|
||||
class TestThreadSafety:
|
||||
"""Test thread safety of EditorOutput."""
|
||||
|
||||
def test_concurrent_tool_calls(self):
|
||||
"""Test that concurrent tool_call is thread-safe."""
|
||||
from praisonaiagents.output.editor import EditorOutput
|
||||
editor = EditorOutput(use_rich=False)
|
||||
|
||||
def call_tool(n):
|
||||
for _ in range(10):
|
||||
editor.tool_call(f"tool_{n}")
|
||||
|
||||
threads = [threading.Thread(target=call_tool, args=(i,)) for i in range(5)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# Should have 50 steps total (5 threads * 10 calls)
|
||||
assert editor._step_count == 50
|
||||
assert len(editor.get_blocks()) == 50
|
||||
|
||||
|
||||
class TestEnableDisable:
|
||||
"""Test enable/disable functions."""
|
||||
|
||||
def test_enable_returns_editor(self):
|
||||
"""Test that enable_editor_output returns an EditorOutput instance."""
|
||||
from praisonaiagents.output.editor import enable_editor_output, disable_editor_output, EditorOutput
|
||||
|
||||
editor = enable_editor_output(use_color=False)
|
||||
assert isinstance(editor, EditorOutput)
|
||||
|
||||
disable_editor_output()
|
||||
|
||||
def test_is_enabled(self):
|
||||
"""Test is_editor_output_enabled function."""
|
||||
from praisonaiagents.output.editor import (
|
||||
enable_editor_output, disable_editor_output, is_editor_output_enabled
|
||||
)
|
||||
|
||||
disable_editor_output()
|
||||
assert is_editor_output_enabled() is False
|
||||
|
||||
enable_editor_output(use_color=False)
|
||||
assert is_editor_output_enabled() is True
|
||||
|
||||
disable_editor_output()
|
||||
assert is_editor_output_enabled() is False
|
||||
|
||||
def test_get_editor_output(self):
|
||||
"""Test get_editor_output function."""
|
||||
from praisonaiagents.output.editor import (
|
||||
enable_editor_output, disable_editor_output, get_editor_output
|
||||
)
|
||||
|
||||
disable_editor_output()
|
||||
assert get_editor_output() is None
|
||||
|
||||
editor = enable_editor_output(use_color=False)
|
||||
assert get_editor_output() is editor
|
||||
|
||||
disable_editor_output()
|
||||
@@ -232,7 +232,10 @@ class TestMultiChannelGatewayConfig:
|
||||
"""Test MultiChannelGatewayConfig with defaults."""
|
||||
from praisonaiagents.gateway import MultiChannelGatewayConfig
|
||||
config = MultiChannelGatewayConfig()
|
||||
assert isinstance(config.gateway, GatewayConfig)
|
||||
# Use qualname check to be robust against module reimport identity issues
|
||||
assert type(config.gateway).__qualname__ == "GatewayConfig", (
|
||||
f"Expected GatewayConfig, got {type(config.gateway).__qualname__}"
|
||||
)
|
||||
assert config.agents == {}
|
||||
assert config.channels == {}
|
||||
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
"""TDD Tests for Memory vs History Separation Fixes.
|
||||
|
||||
Tests G-1 (auto_save_session routes to SessionStore) and
|
||||
G-2 (Session._save_agent_chat_histories routes to SessionStore).
|
||||
|
||||
These tests verify the clean separation between:
|
||||
- Memory = semantic facts/entities (injected into system prompt)
|
||||
- History = conversation turns (injected into message array)
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
class TestG1AutoSaveSessionRouting:
|
||||
"""G-1: _auto_save_session() should route to SessionStore first."""
|
||||
|
||||
def test_auto_save_uses_session_store_when_available(self):
|
||||
"""When SessionStore is available, use it instead of Memory.save_session()."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Create agent with auto_save
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
auto_save="test_session",
|
||||
)
|
||||
|
||||
# Mock session store
|
||||
mock_session_store = MagicMock()
|
||||
agent._session_store = mock_session_store
|
||||
|
||||
# Add some chat history
|
||||
agent.chat_history = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there!"},
|
||||
]
|
||||
|
||||
# Call _auto_save_session
|
||||
agent._auto_save_session()
|
||||
|
||||
# Verify SessionStore.add_message was called
|
||||
assert mock_session_store.add_message.call_count == 2
|
||||
|
||||
def test_auto_save_falls_back_to_memory_when_no_session_store(self):
|
||||
"""When SessionStore is not available, fall back to Memory.save_session()."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Create agent with auto_save and memory
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
auto_save="test_session",
|
||||
)
|
||||
|
||||
# No session store
|
||||
agent._session_store = None
|
||||
|
||||
# Mock memory instance
|
||||
mock_memory = MagicMock()
|
||||
mock_memory.save_session = MagicMock()
|
||||
agent._memory_instance = mock_memory
|
||||
|
||||
# Add some chat history
|
||||
agent.chat_history = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
|
||||
# Call _auto_save_session
|
||||
agent._auto_save_session()
|
||||
|
||||
# Verify Memory.save_session was called as fallback
|
||||
mock_memory.save_session.assert_called_once()
|
||||
|
||||
def test_auto_save_does_nothing_when_disabled(self):
|
||||
"""When auto_save is not set, do nothing."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
)
|
||||
|
||||
# No auto_save set
|
||||
agent.auto_save = None
|
||||
|
||||
# Mock session store
|
||||
mock_session_store = MagicMock()
|
||||
agent._session_store = mock_session_store
|
||||
|
||||
# Call _auto_save_session
|
||||
agent._auto_save_session()
|
||||
|
||||
# Verify nothing was called
|
||||
mock_session_store.add_message.assert_not_called()
|
||||
|
||||
|
||||
class TestG2SessionSaveAgentChatHistories:
|
||||
"""G-2: Session._save_agent_chat_histories() should route to SessionStore first."""
|
||||
|
||||
def test_save_agent_histories_routes_to_session_store(self):
|
||||
"""Verify _save_agent_chat_histories has SessionStore routing logic."""
|
||||
from praisonaiagents.session import Session
|
||||
import inspect
|
||||
|
||||
# Verify the method exists and has the G-2 fix comment
|
||||
source = inspect.getsource(Session._save_agent_chat_histories)
|
||||
|
||||
# Check that the G-2 fix is present
|
||||
assert "G-2 FIX" in source, "G-2 fix should be documented in the method"
|
||||
assert "SessionStore" in source, "Method should reference SessionStore"
|
||||
assert "get_default_session_store" in source, "Method should try to get SessionStore"
|
||||
|
||||
def test_save_agent_histories_behavioral(self):
|
||||
"""Issue 3 FIX: Behavioral test - verify actual routing to SessionStore."""
|
||||
from praisonaiagents.session import Session
|
||||
|
||||
# Create session
|
||||
session = Session(session_id="test_behavioral", user_id="test_user")
|
||||
|
||||
# Create mock agent with chat history
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.chat_history = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi!"},
|
||||
]
|
||||
|
||||
# Add agent to session
|
||||
session._agents["test_agent"] = {
|
||||
"agent": mock_agent,
|
||||
"chat_history": []
|
||||
}
|
||||
|
||||
# Call the method - it should not raise an exception
|
||||
# This tests the actual behavior, not just source code
|
||||
session._save_agent_chat_histories()
|
||||
|
||||
|
||||
class TestIssue1DuplicatePrevention:
|
||||
"""Issue 1: Verify duplicate history insertion is prevented."""
|
||||
|
||||
def test_auto_save_tracks_last_saved_index(self):
|
||||
"""Verify _auto_save_session tracks last saved index to prevent duplicates."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
auto_save="test_session",
|
||||
)
|
||||
|
||||
# Mock session store
|
||||
mock_session_store = MagicMock()
|
||||
agent._session_store = mock_session_store
|
||||
|
||||
# Add initial chat history
|
||||
agent.chat_history = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there!"},
|
||||
]
|
||||
|
||||
# First save - should save 2 messages
|
||||
agent._auto_save_session()
|
||||
assert mock_session_store.add_message.call_count == 2
|
||||
|
||||
# Second save with same history - should save 0 new messages
|
||||
mock_session_store.reset_mock()
|
||||
agent._auto_save_session()
|
||||
assert mock_session_store.add_message.call_count == 0, "Should not re-save existing messages"
|
||||
|
||||
# Add new message
|
||||
agent.chat_history.append({"role": "user", "content": "How are you?"})
|
||||
|
||||
# Third save - should save only 1 new message
|
||||
mock_session_store.reset_mock()
|
||||
agent._auto_save_session()
|
||||
assert mock_session_store.add_message.call_count == 1, "Should only save new message"
|
||||
|
||||
|
||||
class TestEdgeCaseClearHistoryResetIndex:
|
||||
"""Edge case: clear_history() must reset _auto_save_last_index."""
|
||||
|
||||
def test_clear_history_resets_auto_save_index(self):
|
||||
"""Verify clear_history() resets _auto_save_last_index to prevent silent message loss."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
auto_save="test_session",
|
||||
)
|
||||
|
||||
mock_store = MagicMock()
|
||||
agent._session_store = mock_store
|
||||
|
||||
# Save initial messages
|
||||
agent.chat_history = [{"role": "user", "content": "msg1"}]
|
||||
agent._auto_save_session()
|
||||
assert mock_store.add_message.call_count == 1
|
||||
assert agent._auto_save_last_index == 1
|
||||
|
||||
# Clear history - index should reset
|
||||
agent.clear_history()
|
||||
assert agent._auto_save_last_index == 0, "Index should reset to 0 after clear_history()"
|
||||
|
||||
# Add new message after clear
|
||||
agent.chat_history = [{"role": "user", "content": "msg2"}]
|
||||
mock_store.reset_mock()
|
||||
agent._auto_save_session()
|
||||
|
||||
# Should save the new message (not skip due to stale index)
|
||||
assert mock_store.add_message.call_count == 1, "New message should be saved after clear"
|
||||
|
||||
def test_prune_history_adjusts_auto_save_index(self):
|
||||
"""Verify prune_history() adjusts _auto_save_last_index."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
auto_save="test_session",
|
||||
)
|
||||
|
||||
mock_store = MagicMock()
|
||||
agent._session_store = mock_store
|
||||
|
||||
# Save 5 messages
|
||||
agent.chat_history = [
|
||||
{"role": "user", "content": f"msg{i}"} for i in range(5)
|
||||
]
|
||||
agent._auto_save_session()
|
||||
assert agent._auto_save_last_index == 5
|
||||
|
||||
# Prune to keep last 2
|
||||
agent.prune_history(keep_last=2)
|
||||
assert len(agent.chat_history) == 2
|
||||
assert agent._auto_save_last_index == 2, "Index should match pruned history length"
|
||||
|
||||
def test_delete_history_adjusts_auto_save_index(self):
|
||||
"""Verify delete_history() adjusts _auto_save_last_index."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
auto_save="test_session",
|
||||
)
|
||||
|
||||
mock_store = MagicMock()
|
||||
agent._session_store = mock_store
|
||||
|
||||
# Save 3 messages
|
||||
agent.chat_history = [
|
||||
{"role": "user", "content": f"msg{i}"} for i in range(3)
|
||||
]
|
||||
agent._auto_save_session()
|
||||
assert agent._auto_save_last_index == 3
|
||||
|
||||
# Delete one message
|
||||
agent.delete_history(0)
|
||||
assert len(agent.chat_history) == 2
|
||||
assert agent._auto_save_last_index == 2, "Index should be adjusted after deletion"
|
||||
|
||||
|
||||
class TestMemoryHistorySeparation:
|
||||
"""Test that Memory and History are properly separated."""
|
||||
|
||||
def test_memory_context_does_not_include_conversation_turns(self):
|
||||
"""Memory.get_context() should return facts/entities, not conversation turns."""
|
||||
from praisonaiagents.memory import FileMemory
|
||||
|
||||
memory = FileMemory(user_id="test_user")
|
||||
|
||||
# Store some facts using correct API
|
||||
memory.add_long_term("User prefers Python programming")
|
||||
memory.add_entity("Alice", "person", {"role": "developer"})
|
||||
|
||||
# Get context
|
||||
context = memory.get_context()
|
||||
|
||||
# Should contain facts and entities
|
||||
assert "Important Facts" in context or "Known Entities" in context or context == ""
|
||||
|
||||
# Should NOT contain conversation turn markers
|
||||
assert "role" not in context.lower() or "user:" not in context.lower()
|
||||
|
||||
def test_session_store_stores_conversation_turns(self):
|
||||
"""SessionStore should store conversation turns with role/content."""
|
||||
from praisonaiagents.session import get_default_session_store
|
||||
|
||||
store = get_default_session_store()
|
||||
|
||||
# Add messages
|
||||
store.add_message("test_session", role="user", content="Hello")
|
||||
store.add_message("test_session", role="assistant", content="Hi!")
|
||||
|
||||
# Get history
|
||||
history = store.get_chat_history("test_session")
|
||||
|
||||
# Should have conversation turns
|
||||
assert len(history) >= 2
|
||||
assert any(msg.get("role") == "user" for msg in history)
|
||||
assert any(msg.get("role") == "assistant" for msg in history)
|
||||
|
||||
|
||||
class TestG3PerTurnPersist:
|
||||
"""G-3: Verify per-turn persist exists via _persist_message()."""
|
||||
|
||||
def test_persist_message_exists(self):
|
||||
"""Agent should have _persist_message method for per-turn persistence."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(name="test", instructions="Test")
|
||||
|
||||
# Verify method exists
|
||||
assert hasattr(agent, '_persist_message')
|
||||
assert callable(agent._persist_message)
|
||||
|
||||
def test_persist_message_uses_session_store(self):
|
||||
"""_persist_message should use SessionStore when available."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Create agent without session_id (it's set via memory config)
|
||||
agent = Agent(name="test", instructions="Test")
|
||||
|
||||
# Mock session store
|
||||
mock_store = MagicMock()
|
||||
agent._session_store = mock_store
|
||||
agent._session_id = "test_session"
|
||||
agent._db = None # No DB adapter
|
||||
|
||||
# Call _persist_message
|
||||
agent._persist_message("user", "Hello")
|
||||
|
||||
# Verify SessionStore was used
|
||||
mock_store.add_user_message.assert_called_once_with("test_session", "Hello")
|
||||
@@ -322,3 +322,146 @@ class TestImportPerformance:
|
||||
|
||||
# Should import in under 10ms (no heavy deps)
|
||||
assert elapsed < 0.01, f"Import took {elapsed*1000:.2f}ms, expected < 10ms"
|
||||
|
||||
|
||||
class TestProjectLocalHelpers:
|
||||
"""Tests for project-local path helpers (get_project_*_dir)."""
|
||||
|
||||
def test_project_sessions_dir(self):
|
||||
"""get_project_sessions_dir returns .praisonai/sessions/ in cwd."""
|
||||
from praisonaiagents.paths import get_project_sessions_dir
|
||||
|
||||
result = get_project_sessions_dir()
|
||||
assert result == Path.cwd() / ".praisonai" / "sessions"
|
||||
|
||||
def test_project_sessions_dir_custom_path(self, tmp_path):
|
||||
"""get_project_sessions_dir accepts custom project path."""
|
||||
from praisonaiagents.paths import get_project_sessions_dir
|
||||
|
||||
result = get_project_sessions_dir(tmp_path)
|
||||
assert result == tmp_path / ".praisonai" / "sessions"
|
||||
|
||||
def test_project_knowledge_dir(self):
|
||||
"""get_project_knowledge_dir returns .praisonai/knowledge/ in cwd."""
|
||||
from praisonaiagents.paths import get_project_knowledge_dir
|
||||
|
||||
result = get_project_knowledge_dir()
|
||||
assert result == Path.cwd() / ".praisonai" / "knowledge"
|
||||
|
||||
def test_project_knowledge_dir_custom_path(self, tmp_path):
|
||||
"""get_project_knowledge_dir accepts custom project path."""
|
||||
from praisonaiagents.paths import get_project_knowledge_dir
|
||||
|
||||
result = get_project_knowledge_dir(tmp_path)
|
||||
assert result == tmp_path / ".praisonai" / "knowledge"
|
||||
|
||||
def test_project_summaries_dir(self):
|
||||
"""get_project_summaries_dir returns .praisonai/summaries/ in cwd."""
|
||||
from praisonaiagents.paths import get_project_summaries_dir
|
||||
|
||||
result = get_project_summaries_dir()
|
||||
assert result == Path.cwd() / ".praisonai" / "summaries"
|
||||
|
||||
def test_project_prp_dir(self):
|
||||
"""get_project_prp_dir returns .praisonai/prp/ in cwd."""
|
||||
from praisonaiagents.paths import get_project_prp_dir
|
||||
|
||||
result = get_project_prp_dir()
|
||||
assert result == Path.cwd() / ".praisonai" / "prp"
|
||||
|
||||
|
||||
class TestGetSchedulesDir:
|
||||
"""Tests for get_schedules_dir() function."""
|
||||
|
||||
def test_returns_schedules_subdir(self):
|
||||
"""Should return schedules subdirectory of data dir."""
|
||||
from praisonaiagents.paths import get_schedules_dir
|
||||
|
||||
result = get_schedules_dir()
|
||||
assert result == Path.home() / ".praisonai" / "schedules"
|
||||
|
||||
def test_respects_env_override(self):
|
||||
"""Should respect PRAISONAI_HOME override."""
|
||||
from praisonaiagents.paths import get_schedules_dir
|
||||
|
||||
with patch.dict(os.environ, {"PRAISONAI_HOME": "/custom"}):
|
||||
result = get_schedules_dir()
|
||||
assert result == Path("/custom/schedules")
|
||||
|
||||
|
||||
class TestGetStoragePath:
|
||||
"""Tests for get_storage_path() function."""
|
||||
|
||||
def test_returns_storage_db(self):
|
||||
"""Should return storage.db in data dir."""
|
||||
from praisonaiagents.paths import get_storage_path
|
||||
|
||||
result = get_storage_path()
|
||||
assert result == Path.home() / ".praisonai" / "storage.db"
|
||||
|
||||
def test_respects_env_override(self):
|
||||
"""Should respect PRAISONAI_HOME override."""
|
||||
from praisonaiagents.paths import get_storage_path
|
||||
|
||||
with patch.dict(os.environ, {"PRAISONAI_HOME": "/custom"}):
|
||||
result = get_storage_path()
|
||||
assert result == Path("/custom/storage.db")
|
||||
|
||||
|
||||
class TestModuleWiring:
|
||||
"""Tests verifying that modules use paths.py instead of hardcoded paths."""
|
||||
|
||||
def test_policy_config_uses_paths(self):
|
||||
"""policy/config.py should resolve rules dir via paths.py."""
|
||||
from praisonaiagents.policy.config import DEFAULT_RULES_DIR
|
||||
from praisonaiagents.paths import get_rules_dir
|
||||
|
||||
assert DEFAULT_RULES_DIR == str(get_rules_dir())
|
||||
|
||||
def test_policy_config_no_hardcoded_expanduser(self):
|
||||
"""policy/config.py should not have hardcoded expanduser for rules."""
|
||||
import inspect
|
||||
from praisonaiagents.policy import config
|
||||
|
||||
source = inspect.getsource(config)
|
||||
# The module should not contain expanduser + .praisonai for rules
|
||||
assert 'expanduser("~"), ".praisonai", "rules"' not in source
|
||||
|
||||
def test_autonomy_snapshot_dir_defaults_via_paths(self):
|
||||
"""autonomy.py AutonomyConfig should default snapshot_dir via paths.py."""
|
||||
from praisonaiagents.agent.autonomy import AutonomyConfig
|
||||
from praisonaiagents.paths import get_snapshots_dir
|
||||
|
||||
config = AutonomyConfig()
|
||||
assert config.snapshot_dir == str(get_snapshots_dir())
|
||||
|
||||
def test_autonomy_snapshot_dir_respects_env(self):
|
||||
"""AutonomyConfig snapshot_dir should respect PRAISONAI_HOME."""
|
||||
from praisonaiagents.paths import _clear_cache
|
||||
|
||||
_clear_cache()
|
||||
with patch.dict(os.environ, {"PRAISONAI_HOME": "/custom"}):
|
||||
from praisonaiagents.agent.autonomy import AutonomyConfig
|
||||
|
||||
config = AutonomyConfig()
|
||||
assert config.snapshot_dir == str(Path("/custom/snapshots"))
|
||||
_clear_cache()
|
||||
|
||||
def test_scheduler_store_uses_paths(self):
|
||||
"""scheduler/store.py should resolve default dir via paths.py."""
|
||||
from praisonaiagents.scheduler.store import _DEFAULT_DIR
|
||||
from praisonaiagents.paths import get_schedules_dir
|
||||
|
||||
assert _DEFAULT_DIR == str(get_schedules_dir())
|
||||
|
||||
def test_sqlite_backend_default_uses_paths(self):
|
||||
"""SQLiteBackend default db_path should use paths.py."""
|
||||
from praisonaiagents.storage.backends import SQLiteBackend
|
||||
from praisonaiagents.paths import get_storage_path
|
||||
|
||||
# We can't construct SQLiteBackend without it trying to create the DB,
|
||||
# so we verify the import is present
|
||||
import inspect
|
||||
source = inspect.getsource(SQLiteBackend.__init__)
|
||||
assert "get_storage_path" in source
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
"""
|
||||
Unit tests verifying that all modules use paths.py instead of hardcoded paths.
|
||||
|
||||
Tests every module fixed in Phases 2, 3, and 3.5 of the path standardization effort.
|
||||
Validates both source-level absence of hardcoded strings AND runtime behavior.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2 Modules (SDK core)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPolicyConfigWiring:
|
||||
"""Verify policy/config.py routes through paths.py."""
|
||||
|
||||
def test_default_rules_dir_matches_paths(self):
|
||||
from praisonaiagents.policy.config import DEFAULT_RULES_DIR
|
||||
from praisonaiagents.paths import get_rules_dir
|
||||
assert DEFAULT_RULES_DIR == str(get_rules_dir())
|
||||
|
||||
def test_no_hardcoded_expanduser(self):
|
||||
from praisonaiagents.policy import config
|
||||
source = inspect.getsource(config)
|
||||
assert 'expanduser("~"), ".praisonai", "rules"' not in source
|
||||
assert 'os.path.join(os.path.expanduser("~")' not in source
|
||||
|
||||
|
||||
class TestAutonomyWiring:
|
||||
"""Verify agent/autonomy.py routes through paths.py."""
|
||||
|
||||
def test_default_snapshot_dir_matches_paths(self):
|
||||
from praisonaiagents.agent.autonomy import AutonomyConfig
|
||||
from praisonaiagents.paths import get_snapshots_dir
|
||||
config = AutonomyConfig()
|
||||
assert config.snapshot_dir == str(get_snapshots_dir())
|
||||
|
||||
def test_snapshot_dir_respects_env(self):
|
||||
from praisonaiagents.paths import _clear_cache
|
||||
_clear_cache()
|
||||
with patch.dict(os.environ, {"PRAISONAI_HOME": "/custom"}):
|
||||
from praisonaiagents.agent.autonomy import AutonomyConfig
|
||||
config = AutonomyConfig()
|
||||
assert config.snapshot_dir == str(Path("/custom/snapshots"))
|
||||
_clear_cache()
|
||||
|
||||
def test_no_hardcoded_expanduser(self):
|
||||
from praisonaiagents.agent import autonomy
|
||||
source = inspect.getsource(autonomy.AutonomyConfig.__post_init__)
|
||||
assert 'expanduser' not in source
|
||||
|
||||
|
||||
class TestSchedulerStoreWiring:
|
||||
"""Verify scheduler/store.py routes through paths.py."""
|
||||
|
||||
def test_default_dir_matches_paths(self):
|
||||
from praisonaiagents.scheduler.store import _DEFAULT_DIR
|
||||
from praisonaiagents.paths import get_schedules_dir
|
||||
assert _DEFAULT_DIR == str(get_schedules_dir())
|
||||
|
||||
def test_no_hardcoded_expanduser(self):
|
||||
from praisonaiagents.scheduler import store
|
||||
source = inspect.getsource(store)
|
||||
assert 'expanduser("~"), ".praisonai"' not in source
|
||||
|
||||
|
||||
class TestStorageBackendsWiring:
|
||||
"""Verify storage/backends.py routes through paths.py."""
|
||||
|
||||
def test_sqlite_init_uses_get_storage_path(self):
|
||||
from praisonaiagents.storage.backends import SQLiteBackend
|
||||
source = inspect.getsource(SQLiteBackend.__init__)
|
||||
assert "get_storage_path" in source
|
||||
assert '"~/.praisonai/storage.db"' not in source
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 3 Modules (project-local)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSessionApiWiring:
|
||||
"""Verify session/api.py routes through paths.py."""
|
||||
|
||||
def test_no_hardcoded_praisonai_sessions(self):
|
||||
from praisonaiagents.session import api
|
||||
source = inspect.getsource(api)
|
||||
assert '".praisonai/sessions/' not in source
|
||||
assert "f'.praisonai/sessions" not in source
|
||||
|
||||
def test_has_get_session_dir_method(self):
|
||||
from praisonaiagents.session.api import Session
|
||||
assert hasattr(Session, '_get_session_dir')
|
||||
|
||||
def test_get_session_dir_uses_paths(self):
|
||||
from praisonaiagents.session.api import Session
|
||||
source = inspect.getsource(Session._get_session_dir)
|
||||
assert "get_project_sessions_dir" in source
|
||||
|
||||
|
||||
class TestKnowledgeWiring:
|
||||
"""Verify knowledge/knowledge.py routes through paths.py."""
|
||||
|
||||
def test_no_hardcoded_praisonai_literal(self):
|
||||
from praisonaiagents.knowledge import knowledge
|
||||
source = inspect.getsource(knowledge)
|
||||
# Should not have hardcoded path construction with ".praisonai"
|
||||
# But it's OK in docstrings/comments
|
||||
lines = source.split('\n')
|
||||
code_lines = [l for l in lines
|
||||
if l.strip() and not l.strip().startswith('#')
|
||||
and not l.strip().startswith('"""')
|
||||
and not l.strip().startswith("'''")
|
||||
and 'docstring' not in l.lower()]
|
||||
code_text = '\n'.join(code_lines)
|
||||
assert 'Path(".praisonai"' not in code_text
|
||||
assert '".praisonai"' not in code_text or 'DEFAULT_DIR_NAME' in source
|
||||
|
||||
|
||||
class TestContextAgentWiring:
|
||||
"""Verify agent/context_agent.py routes through paths.py."""
|
||||
|
||||
def test_prp_dir_uses_paths(self):
|
||||
from praisonaiagents.agent import context_agent
|
||||
source = inspect.getsource(context_agent)
|
||||
assert "get_project_prp_dir" in source
|
||||
|
||||
def test_no_hardcoded_prp_path(self):
|
||||
from praisonaiagents.agent import context_agent
|
||||
source = inspect.getsource(context_agent)
|
||||
assert 'Path(".praisonai/prp")' not in source
|
||||
|
||||
|
||||
class TestSummarizerWiring:
|
||||
"""Verify rag/summarizer.py routes through paths.py."""
|
||||
|
||||
def test_default_persist_path_matches_paths(self):
|
||||
from praisonaiagents.rag.summarizer import HierarchicalSummarizer
|
||||
from praisonaiagents.paths import get_project_summaries_dir
|
||||
hs = HierarchicalSummarizer()
|
||||
assert hs._persist_path == str(get_project_summaries_dir())
|
||||
|
||||
def test_no_hardcoded_summaries_path(self):
|
||||
from praisonaiagents.rag import summarizer
|
||||
source = inspect.getsource(summarizer.HierarchicalSummarizer.__init__)
|
||||
assert '".praisonai/summaries"' not in source
|
||||
|
||||
|
||||
class TestRetrievalConfigWiring:
|
||||
"""Verify rag/retrieval_config.py routes through paths.py."""
|
||||
|
||||
def test_default_persist_path_matches_paths(self):
|
||||
from praisonaiagents.rag.retrieval_config import RetrievalConfig
|
||||
from praisonaiagents.paths import get_project_data_dir
|
||||
rc = RetrievalConfig()
|
||||
assert rc.persist_path == str(get_project_data_dir())
|
||||
|
||||
def test_no_hardcoded_persist_path(self):
|
||||
from praisonaiagents.rag import retrieval_config
|
||||
source = inspect.getsource(retrieval_config)
|
||||
assert 'persist_path: str = ".praisonai"' not in source
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 3.5 Modules (batch fix)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMemoryMemoryWiring:
|
||||
"""Verify memory/memory.py routes through paths.py."""
|
||||
|
||||
def test_no_hardcoded_makedirs(self):
|
||||
from praisonaiagents.memory import memory
|
||||
source = inspect.getsource(memory.Memory.__init__)
|
||||
assert 'makedirs(".praisonai"' not in source
|
||||
|
||||
def test_no_hardcoded_short_db(self):
|
||||
from praisonaiagents.memory import memory
|
||||
source = inspect.getsource(memory.Memory.__init__)
|
||||
assert '".praisonai/short_term.db"' not in source
|
||||
|
||||
def test_no_hardcoded_long_db(self):
|
||||
from praisonaiagents.memory import memory
|
||||
source = inspect.getsource(memory.Memory.__init__)
|
||||
assert '".praisonai/long_term.db"' not in source
|
||||
|
||||
def test_uses_get_project_data_dir(self):
|
||||
from praisonaiagents.memory import memory
|
||||
source = inspect.getsource(memory.Memory.__init__)
|
||||
assert "get_project_data_dir" in source
|
||||
|
||||
|
||||
class TestMemoryHooksWiring:
|
||||
"""Verify memory/hooks.py routes through paths.py."""
|
||||
|
||||
def test_config_file_uses_default_dir_name(self):
|
||||
from praisonaiagents.memory import hooks
|
||||
source = inspect.getsource(hooks)
|
||||
# Should use DEFAULT_DIR_NAME for CONFIG_FILE
|
||||
assert "DEFAULT_DIR_NAME" in source or "_DIR_NAME" in source
|
||||
|
||||
def test_no_hardcoded_config_file(self):
|
||||
from praisonaiagents.memory import hooks
|
||||
# Check the HooksManager class body for hardcoded CONFIG_FILE
|
||||
source = inspect.getsource(hooks.HooksManager)
|
||||
assert 'CONFIG_FILE = ".praisonai/hooks.json"' not in source
|
||||
|
||||
def test_workspace_dir_uses_default_dir_name(self):
|
||||
from praisonaiagents.memory import hooks
|
||||
source = inspect.getsource(hooks.HooksManager.__init__)
|
||||
assert 'workspace_path / ".praisonai"' not in source
|
||||
|
||||
|
||||
class TestDocsManagerWiring:
|
||||
"""Verify memory/docs_manager.py routes through paths.py."""
|
||||
|
||||
def test_no_hardcoded_docs_dir_name(self):
|
||||
from praisonaiagents.memory import docs_manager
|
||||
source = inspect.getsource(docs_manager.DocsManager)
|
||||
assert 'DOCS_DIR_NAME = ".praisonai/docs"' not in source
|
||||
|
||||
def test_global_docs_path_uses_get_docs_dir(self):
|
||||
from praisonaiagents.memory import docs_manager
|
||||
source = inspect.getsource(docs_manager.DocsManager.__init__)
|
||||
assert "get_docs_dir" in source
|
||||
|
||||
def test_no_hardcoded_home_praisonai_docs(self):
|
||||
from praisonaiagents.memory import docs_manager
|
||||
source = inspect.getsource(docs_manager.DocsManager.__init__)
|
||||
assert 'Path.home() / ".praisonai" / "docs"' not in source
|
||||
|
||||
|
||||
class TestRulesManagerWiring:
|
||||
"""Verify memory/rules_manager.py routes through paths.py."""
|
||||
|
||||
def test_no_hardcoded_rules_dir_name(self):
|
||||
from praisonaiagents.memory import rules_manager
|
||||
source = inspect.getsource(rules_manager.RulesManager)
|
||||
assert 'RULES_DIR_NAME = ".praisonai/rules"' not in source
|
||||
|
||||
def test_global_rules_path_uses_get_rules_dir(self):
|
||||
from praisonaiagents.memory import rules_manager
|
||||
source = inspect.getsource(rules_manager.RulesManager.__init__)
|
||||
assert "get_rules_dir" in source
|
||||
|
||||
def test_no_hardcoded_home_praisonai_rules(self):
|
||||
from praisonaiagents.memory import rules_manager
|
||||
source = inspect.getsource(rules_manager.RulesManager.__init__)
|
||||
assert 'Path.home() / ".praisonai" / "rules"' not in source
|
||||
|
||||
|
||||
class TestMcpConfigWiring:
|
||||
"""Verify memory/mcp_config.py routes through paths.py."""
|
||||
|
||||
def test_no_hardcoded_mcp_dir_name(self):
|
||||
from praisonaiagents.memory import mcp_config
|
||||
source = inspect.getsource(mcp_config.MCPConfigManager)
|
||||
assert 'MCP_DIR_NAME = ".praisonai/mcp"' not in source
|
||||
|
||||
def test_global_mcp_path_uses_get_mcp_dir(self):
|
||||
from praisonaiagents.memory import mcp_config
|
||||
source = inspect.getsource(mcp_config.MCPConfigManager.__init__)
|
||||
assert "get_mcp_dir" in source
|
||||
|
||||
def test_no_hardcoded_home_praisonai_mcp(self):
|
||||
from praisonaiagents.memory import mcp_config
|
||||
source = inspect.getsource(mcp_config.MCPConfigManager.__init__)
|
||||
assert 'Path.home() / ".praisonai" / "mcp"' not in source
|
||||
|
||||
|
||||
class TestFileMemoryWiring:
|
||||
"""Verify memory/file_memory.py routes through paths.py."""
|
||||
|
||||
def test_no_hardcoded_base_path(self):
|
||||
from praisonaiagents.memory import file_memory
|
||||
source = inspect.getsource(file_memory.FileMemory.__init__)
|
||||
assert 'Path(".praisonai/memory")' not in source
|
||||
|
||||
def test_uses_get_project_data_dir(self):
|
||||
from praisonaiagents.memory import file_memory
|
||||
source = inspect.getsource(file_memory.FileMemory.__init__)
|
||||
assert "get_project_data_dir" in source
|
||||
|
||||
|
||||
class TestMemoryWorkflowsWiring:
|
||||
"""Verify memory/workflows.py routes through paths.py."""
|
||||
|
||||
def test_no_hardcoded_workflows_dir(self):
|
||||
from praisonaiagents.memory import workflows
|
||||
source = inspect.getsource(workflows.WorkflowManager)
|
||||
assert 'WORKFLOWS_DIR = ".praisonai/workflows"' not in source
|
||||
|
||||
def test_uses_default_dir_name(self):
|
||||
from praisonaiagents.memory import workflows
|
||||
source = inspect.getsource(workflows.WorkflowManager)
|
||||
assert "DEFAULT_DIR_NAME" in source or "_DIR_NAME" in source
|
||||
|
||||
def test_no_hardcoded_checkpoints_dir(self):
|
||||
from praisonaiagents.memory import workflows
|
||||
source = inspect.getsource(workflows.WorkflowManager._get_checkpoints_dir)
|
||||
assert '".praisonai"' not in source
|
||||
|
||||
|
||||
class TestWorkflowsWorkflowsWiring:
|
||||
"""Verify workflows/workflows.py routes through paths.py."""
|
||||
|
||||
def test_no_hardcoded_workflows_dir(self):
|
||||
from praisonaiagents.workflows import workflows
|
||||
source = inspect.getsource(workflows.WorkflowManager)
|
||||
assert 'WORKFLOWS_DIR = ".praisonai/workflows"' not in source
|
||||
|
||||
def test_uses_default_dir_name(self):
|
||||
from praisonaiagents.workflows import workflows
|
||||
source = inspect.getsource(workflows.WorkflowManager)
|
||||
assert "DEFAULT_DIR_NAME" in source or "_DIR_NAME" in source
|
||||
|
||||
def test_no_hardcoded_checkpoints_dir(self):
|
||||
from praisonaiagents.workflows import workflows
|
||||
source = inspect.getsource(workflows.WorkflowManager._get_checkpoints_dir)
|
||||
assert '".praisonai"' not in source
|
||||
|
||||
|
||||
class TestConfigLoaderWiring:
|
||||
"""Verify config/loader.py routes through paths.py."""
|
||||
|
||||
def test_no_hardcoded_plugin_dirs(self):
|
||||
from praisonaiagents.config import loader
|
||||
source = inspect.getsource(loader)
|
||||
assert '"./.praisonai/plugins/"' not in source
|
||||
assert '"~/.praisonai/plugins/"' not in source
|
||||
|
||||
def test_config_search_uses_paths(self):
|
||||
from praisonaiagents.config import loader
|
||||
source = inspect.getsource(loader._find_config_file)
|
||||
assert "get_project_data_dir" in source or "get_data_dir" in source
|
||||
|
||||
def test_global_config_path_uses_get_data_dir(self):
|
||||
from praisonaiagents.config import loader
|
||||
source = inspect.getsource(loader._find_config_file)
|
||||
assert "get_data_dir" in source
|
||||
|
||||
|
||||
class TestAgentsAgentsWiring:
|
||||
"""Verify agents/agents.py routes through paths.py."""
|
||||
|
||||
def test_no_hardcoded_memory_db(self):
|
||||
from praisonaiagents.agents import agents
|
||||
source = inspect.getsource(agents)
|
||||
assert '"./.praisonai/memory.db"' not in source
|
||||
|
||||
def test_no_hardcoded_chroma_db(self):
|
||||
from praisonaiagents.agents import agents
|
||||
source = inspect.getsource(agents)
|
||||
assert '"./.praisonai/chroma_db"' not in source
|
||||
|
||||
def test_uses_get_project_data_dir(self):
|
||||
from praisonaiagents.agents import agents
|
||||
source = inspect.getsource(agents)
|
||||
assert "get_project_data_dir" in source
|
||||
|
||||
|
||||
class TestLegacySessionWiring:
|
||||
"""Verify session.py (legacy) routes through paths.py."""
|
||||
|
||||
def test_has_get_session_dir_method(self):
|
||||
from praisonaiagents.session import Session
|
||||
assert hasattr(Session, '_get_session_dir')
|
||||
|
||||
def test_get_session_dir_uses_paths(self):
|
||||
from praisonaiagents.session import Session
|
||||
source = inspect.getsource(Session._get_session_dir)
|
||||
assert "get_project_sessions_dir" in source
|
||||
|
||||
def test_no_hardcoded_session_path(self):
|
||||
from praisonaiagents import session
|
||||
source = inspect.getsource(session.Session.__init__)
|
||||
assert 'f".praisonai/sessions/' not in source
|
||||
assert "makedirs(f\".praisonai" not in source
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-cutting: comprehensive hardcoded path scan
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNoHardcodedPathsComprehensive:
|
||||
"""
|
||||
Scan all fixed modules' source code for any remaining hardcoded
|
||||
.praisonai path construction patterns.
|
||||
"""
|
||||
|
||||
FIXED_MODULES = [
|
||||
"praisonaiagents.policy.config",
|
||||
"praisonaiagents.agent.autonomy",
|
||||
"praisonaiagents.scheduler.store",
|
||||
"praisonaiagents.storage.backends",
|
||||
"praisonaiagents.session.api",
|
||||
"praisonaiagents.knowledge.knowledge",
|
||||
"praisonaiagents.agent.context_agent",
|
||||
"praisonaiagents.rag.summarizer",
|
||||
"praisonaiagents.rag.retrieval_config",
|
||||
"praisonaiagents.memory.memory",
|
||||
"praisonaiagents.memory.hooks",
|
||||
"praisonaiagents.memory.docs_manager",
|
||||
"praisonaiagents.memory.rules_manager",
|
||||
"praisonaiagents.memory.mcp_config",
|
||||
"praisonaiagents.memory.file_memory",
|
||||
"praisonaiagents.memory.workflows",
|
||||
"praisonaiagents.workflows.workflows",
|
||||
"praisonaiagents.config.loader",
|
||||
"praisonaiagents.agents.agents",
|
||||
"praisonaiagents.session",
|
||||
]
|
||||
|
||||
# Patterns that indicate hardcoded path construction (not just docs/comments)
|
||||
FORBIDDEN_PATTERNS = [
|
||||
'Path(".praisonai"',
|
||||
'Path(".praisonai/',
|
||||
'os.makedirs(".praisonai"',
|
||||
"os.makedirs('.praisonai'",
|
||||
'"./.praisonai/',
|
||||
"'./.praisonai/",
|
||||
'= ".praisonai/',
|
||||
"= '.praisonai/",
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("module_name", FIXED_MODULES)
|
||||
def test_no_forbidden_patterns_in_code(self, module_name):
|
||||
"""Each fixed module should not contain forbidden path patterns in code."""
|
||||
import importlib
|
||||
mod = importlib.import_module(module_name)
|
||||
source = inspect.getsource(mod)
|
||||
|
||||
# Extract only code lines (skip docstrings and comments)
|
||||
in_docstring = False
|
||||
code_lines = []
|
||||
for line in source.split('\n'):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith('"""') or stripped.startswith("'''"):
|
||||
if stripped.count('"""') == 1 or stripped.count("'''") == 1:
|
||||
in_docstring = not in_docstring
|
||||
continue
|
||||
if in_docstring:
|
||||
continue
|
||||
if stripped.startswith('#'):
|
||||
continue
|
||||
code_lines.append(line)
|
||||
|
||||
code_text = '\n'.join(code_lines)
|
||||
|
||||
for pattern in self.FORBIDDEN_PATTERNS:
|
||||
assert pattern not in code_text, (
|
||||
f"Module {module_name} still contains forbidden pattern: {pattern!r}"
|
||||
)
|
||||
@@ -0,0 +1,188 @@
|
||||
"""
|
||||
Tests for streaming integration - verifying stream_emitter receives events through main path.
|
||||
|
||||
These tests verify that:
|
||||
1. stream_callback and emit_events are properly threaded through chat_completion_with_tools
|
||||
2. Agent.stream_emitter.emit receives StreamEvents during agent.start(stream=True)
|
||||
3. StreamMetrics are populated when enable_metrics() is called
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
from typing import List
|
||||
|
||||
|
||||
class TestStreamCallbackThreading:
|
||||
"""Test that stream_callback is properly threaded through the call chain."""
|
||||
|
||||
def test_chat_completion_with_tools_accepts_stream_callback(self):
|
||||
"""chat_completion_with_tools should accept stream_callback parameter."""
|
||||
from praisonaiagents.llm.openai_client import OpenAIClient
|
||||
import inspect
|
||||
|
||||
sig = inspect.signature(OpenAIClient.chat_completion_with_tools)
|
||||
params = list(sig.parameters.keys())
|
||||
|
||||
assert 'stream_callback' in params, "stream_callback parameter missing"
|
||||
assert 'emit_events' in params, "emit_events parameter missing"
|
||||
|
||||
def test_achat_completion_with_tools_accepts_stream_callback(self):
|
||||
"""achat_completion_with_tools should accept stream_callback parameter."""
|
||||
from praisonaiagents.llm.openai_client import OpenAIClient
|
||||
import inspect
|
||||
|
||||
sig = inspect.signature(OpenAIClient.achat_completion_with_tools)
|
||||
params = list(sig.parameters.keys())
|
||||
|
||||
assert 'stream_callback' in params, "stream_callback parameter missing"
|
||||
assert 'emit_events' in params, "emit_events parameter missing"
|
||||
|
||||
def test_process_stream_response_accepts_stream_callback(self):
|
||||
"""process_stream_response should accept stream_callback parameter."""
|
||||
from praisonaiagents.llm.openai_client import OpenAIClient
|
||||
import inspect
|
||||
|
||||
sig = inspect.signature(OpenAIClient.process_stream_response)
|
||||
params = list(sig.parameters.keys())
|
||||
|
||||
assert 'stream_callback' in params, "stream_callback parameter missing"
|
||||
assert 'emit_events' in params, "emit_events parameter missing"
|
||||
|
||||
|
||||
class TestAgentStreamEmitterWiring:
|
||||
"""Test that Agent properly wires stream_emitter through main path."""
|
||||
|
||||
def test_agent_has_stream_emitter_property(self):
|
||||
"""Agent should have stream_emitter property."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(name="test", instructions="Test agent")
|
||||
assert hasattr(agent, 'stream_emitter'), "Agent missing stream_emitter property"
|
||||
|
||||
def test_stream_emitter_has_emit_method(self):
|
||||
"""stream_emitter should have emit method."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(name="test", instructions="Test agent")
|
||||
assert hasattr(agent.stream_emitter, 'emit'), "stream_emitter missing emit method"
|
||||
assert callable(agent.stream_emitter.emit), "emit should be callable"
|
||||
|
||||
def test_stream_emitter_has_add_callback_method(self):
|
||||
"""stream_emitter should have add_callback method."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(name="test", instructions="Test agent")
|
||||
assert hasattr(agent.stream_emitter, 'add_callback'), "stream_emitter missing add_callback"
|
||||
assert callable(agent.stream_emitter.add_callback), "add_callback should be callable"
|
||||
|
||||
def test_stream_emitter_callback_receives_events(self):
|
||||
"""Callbacks added to stream_emitter should receive events when emit is called."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
|
||||
|
||||
agent = Agent(name="test", instructions="Test agent")
|
||||
|
||||
received_events: List[StreamEvent] = []
|
||||
def callback(event: StreamEvent):
|
||||
received_events.append(event)
|
||||
|
||||
agent.stream_emitter.add_callback(callback)
|
||||
|
||||
# Manually emit an event to test callback wiring
|
||||
test_event = StreamEvent(type=StreamEventType.DELTA_TEXT, content="Hello")
|
||||
agent.stream_emitter.emit(test_event)
|
||||
|
||||
assert len(received_events) == 1, "Callback should receive emitted event"
|
||||
assert received_events[0].type == StreamEventType.DELTA_TEXT
|
||||
assert received_events[0].content == "Hello"
|
||||
|
||||
|
||||
class TestStreamEventTypes:
|
||||
"""Test StreamEvent types are properly defined."""
|
||||
|
||||
def test_stream_event_types_exist(self):
|
||||
"""All required StreamEventType values should exist."""
|
||||
from praisonaiagents.streaming.events import StreamEventType
|
||||
|
||||
required_types = [
|
||||
'REQUEST_START',
|
||||
'HEADERS_RECEIVED',
|
||||
'FIRST_TOKEN',
|
||||
'DELTA_TEXT',
|
||||
'DELTA_TOOL_CALL',
|
||||
'TOOL_CALL_END',
|
||||
'LAST_TOKEN',
|
||||
'STREAM_END',
|
||||
'ERROR'
|
||||
]
|
||||
|
||||
for event_type in required_types:
|
||||
assert hasattr(StreamEventType, event_type), f"Missing StreamEventType.{event_type}"
|
||||
|
||||
def test_stream_event_has_required_fields(self):
|
||||
"""StreamEvent should have required fields."""
|
||||
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
|
||||
|
||||
event = StreamEvent(type=StreamEventType.DELTA_TEXT, content="test")
|
||||
|
||||
assert hasattr(event, 'type')
|
||||
assert hasattr(event, 'timestamp')
|
||||
assert hasattr(event, 'content')
|
||||
assert hasattr(event, 'tool_call')
|
||||
assert hasattr(event, 'is_reasoning')
|
||||
assert hasattr(event, 'agent_id')
|
||||
|
||||
|
||||
class TestStreamMetrics:
|
||||
"""Test StreamMetrics functionality."""
|
||||
|
||||
def test_stream_emitter_has_enable_metrics(self):
|
||||
"""stream_emitter should have enable_metrics method."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(name="test", instructions="Test agent")
|
||||
assert hasattr(agent.stream_emitter, 'enable_metrics')
|
||||
assert callable(agent.stream_emitter.enable_metrics)
|
||||
|
||||
def test_stream_emitter_has_get_metrics(self):
|
||||
"""stream_emitter should have get_metrics method."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(name="test", instructions="Test agent")
|
||||
assert hasattr(agent.stream_emitter, 'get_metrics')
|
||||
assert callable(agent.stream_emitter.get_metrics)
|
||||
|
||||
def test_metrics_updated_on_emit(self):
|
||||
"""Metrics should be updated when events are emitted."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
|
||||
|
||||
agent = Agent(name="test", instructions="Test agent")
|
||||
agent.stream_emitter.enable_metrics()
|
||||
|
||||
# Emit events
|
||||
agent.stream_emitter.emit(StreamEvent(type=StreamEventType.REQUEST_START))
|
||||
agent.stream_emitter.emit(StreamEvent(type=StreamEventType.FIRST_TOKEN, content="H"))
|
||||
agent.stream_emitter.emit(StreamEvent(type=StreamEventType.DELTA_TEXT, content="ello"))
|
||||
agent.stream_emitter.emit(StreamEvent(type=StreamEventType.LAST_TOKEN))
|
||||
agent.stream_emitter.emit(StreamEvent(type=StreamEventType.STREAM_END))
|
||||
|
||||
metrics = agent.stream_emitter.get_metrics()
|
||||
assert metrics is not None, "Metrics should be available after enable_metrics()"
|
||||
assert metrics.token_count >= 1, "Token count should be tracked"
|
||||
|
||||
|
||||
class TestStreamCallbackInChatKwargs:
|
||||
"""Test that _chat_completion passes stream_callback in chat_kwargs."""
|
||||
|
||||
def test_chat_kwargs_includes_stream_callback(self):
|
||||
"""_chat_completion should include stream_callback in chat_kwargs."""
|
||||
from praisonaiagents import Agent
|
||||
import inspect
|
||||
|
||||
# Read the source code of _chat_completion to verify stream_callback is passed
|
||||
source = inspect.getsource(Agent._chat_completion)
|
||||
|
||||
assert 'stream_callback' in source, "_chat_completion should reference stream_callback"
|
||||
assert 'stream_emitter.emit' in source, "_chat_completion should use stream_emitter.emit"
|
||||
assert 'emit_events' in source, "_chat_completion should reference emit_events"
|
||||
@@ -0,0 +1,437 @@
|
||||
"""
|
||||
TDD Tests for ast_grep tool.
|
||||
|
||||
Tests cover:
|
||||
1. Tool availability detection (installed vs not installed)
|
||||
2. Graceful fallback when not installed
|
||||
3. Tool assignment to Agent
|
||||
4. Autonomy mode integration
|
||||
5. Search, rewrite, and scan operations
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import subprocess
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
class TestAstGrepToolAvailability:
|
||||
"""Test ast-grep tool availability detection."""
|
||||
|
||||
def test_is_available_when_installed(self):
|
||||
"""Tool should report available when sg binary exists."""
|
||||
from praisonaiagents.tools.ast_grep_tool import is_ast_grep_available
|
||||
|
||||
with patch('shutil.which') as mock_which:
|
||||
mock_which.return_value = '/usr/local/bin/sg'
|
||||
assert is_ast_grep_available() is True
|
||||
|
||||
def test_is_not_available_when_not_installed(self):
|
||||
"""Tool should report not available when sg binary doesn't exist."""
|
||||
from praisonaiagents.tools import ast_grep_tool
|
||||
from praisonaiagents.tools.ast_grep_tool import is_ast_grep_available
|
||||
|
||||
# Clear cache to ensure fresh check
|
||||
ast_grep_tool._availability_cache = None
|
||||
|
||||
with patch('praisonaiagents.tools.ast_grep_tool.shutil.which') as mock_which:
|
||||
mock_which.return_value = None
|
||||
result = is_ast_grep_available()
|
||||
assert result is False
|
||||
|
||||
def test_availability_cached(self):
|
||||
"""Availability check should be cached for performance."""
|
||||
from praisonaiagents.tools import ast_grep_tool
|
||||
|
||||
# Clear cache
|
||||
ast_grep_tool._availability_cache = None
|
||||
|
||||
with patch('shutil.which') as mock_which:
|
||||
mock_which.return_value = '/usr/local/bin/sg'
|
||||
|
||||
# First call
|
||||
result1 = ast_grep_tool.is_ast_grep_available()
|
||||
# Second call should use cache
|
||||
result2 = ast_grep_tool.is_ast_grep_available()
|
||||
|
||||
# which should only be called once due to caching
|
||||
assert mock_which.call_count == 1
|
||||
assert result1 is True
|
||||
assert result2 is True
|
||||
|
||||
|
||||
class TestAstGrepToolGracefulFallback:
|
||||
"""Test graceful fallback when ast-grep is not installed."""
|
||||
|
||||
def test_search_returns_error_when_not_installed(self):
|
||||
"""Search should return helpful error when not installed."""
|
||||
from praisonaiagents.tools.ast_grep_tool import ast_grep_search
|
||||
|
||||
with patch('praisonaiagents.tools.ast_grep_tool.is_ast_grep_available') as mock_avail:
|
||||
mock_avail.return_value = False
|
||||
|
||||
result = ast_grep_search("def $FN($$$)", lang="python", path=".")
|
||||
|
||||
assert "not installed" in result.lower() or "not available" in result.lower()
|
||||
assert "pip install ast-grep-cli" in result
|
||||
|
||||
def test_rewrite_returns_error_when_not_installed(self):
|
||||
"""Rewrite should return helpful error when not installed."""
|
||||
from praisonaiagents.tools.ast_grep_tool import ast_grep_rewrite
|
||||
|
||||
with patch('praisonaiagents.tools.ast_grep_tool.is_ast_grep_available') as mock_avail:
|
||||
mock_avail.return_value = False
|
||||
|
||||
result = ast_grep_rewrite("def $FN($$$)", "def $FN(*args)", lang="python", path=".")
|
||||
|
||||
assert "not installed" in result.lower() or "not available" in result.lower()
|
||||
|
||||
def test_scan_returns_error_when_not_installed(self):
|
||||
"""Scan should return helpful error when not installed."""
|
||||
from praisonaiagents.tools.ast_grep_tool import ast_grep_scan
|
||||
|
||||
with patch('praisonaiagents.tools.ast_grep_tool.is_ast_grep_available') as mock_avail:
|
||||
mock_avail.return_value = False
|
||||
|
||||
result = ast_grep_scan(path=".")
|
||||
|
||||
assert "not installed" in result.lower() or "not available" in result.lower()
|
||||
|
||||
def test_tool_does_not_crash_when_not_installed(self):
|
||||
"""Tool should never crash, even when not installed."""
|
||||
from praisonaiagents.tools.ast_grep_tool import (
|
||||
ast_grep_search, ast_grep_rewrite, ast_grep_scan
|
||||
)
|
||||
|
||||
with patch('praisonaiagents.tools.ast_grep_tool.is_ast_grep_available') as mock_avail:
|
||||
mock_avail.return_value = False
|
||||
|
||||
# None of these should raise exceptions
|
||||
try:
|
||||
ast_grep_search("pattern", lang="python", path=".")
|
||||
ast_grep_rewrite("old", "new", lang="python", path=".")
|
||||
ast_grep_scan(path=".")
|
||||
except Exception as e:
|
||||
pytest.fail(f"Tool crashed when not installed: {e}")
|
||||
|
||||
|
||||
class TestAstGrepToolOperations:
|
||||
"""Test ast-grep tool operations when installed."""
|
||||
|
||||
def test_search_executes_subprocess(self):
|
||||
"""Search should execute sg CLI with correct arguments."""
|
||||
from praisonaiagents.tools.ast_grep_tool import ast_grep_search
|
||||
|
||||
with patch('praisonaiagents.tools.ast_grep_tool.is_ast_grep_available') as mock_avail:
|
||||
mock_avail.return_value = True
|
||||
|
||||
with patch('subprocess.run') as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout='{"matches": []}',
|
||||
stderr=''
|
||||
)
|
||||
|
||||
result = ast_grep_search("def $FN($$$)", lang="python", path="/test/path")
|
||||
|
||||
mock_run.assert_called_once()
|
||||
call_args = mock_run.call_args
|
||||
cmd_list = call_args[0][0]
|
||||
|
||||
assert 'sg' in cmd_list[0] or cmd_list[0] == 'sg'
|
||||
assert '--pattern' in cmd_list or 'def $FN($$$)' in cmd_list
|
||||
assert '--lang' in cmd_list or 'python' in cmd_list
|
||||
# Verify result is returned
|
||||
assert result is not None
|
||||
|
||||
def test_search_handles_subprocess_error(self):
|
||||
"""Search should handle subprocess errors gracefully."""
|
||||
from praisonaiagents.tools.ast_grep_tool import ast_grep_search
|
||||
|
||||
with patch('praisonaiagents.tools.ast_grep_tool.is_ast_grep_available') as mock_avail:
|
||||
mock_avail.return_value = True
|
||||
|
||||
with patch('subprocess.run') as mock_run:
|
||||
mock_run.side_effect = subprocess.SubprocessError("Command failed")
|
||||
|
||||
result = ast_grep_search("pattern", lang="python", path=".")
|
||||
|
||||
assert "error" in result.lower() or "failed" in result.lower()
|
||||
|
||||
def test_rewrite_dry_run_by_default(self):
|
||||
"""Rewrite should use dry-run by default for safety."""
|
||||
from praisonaiagents.tools.ast_grep_tool import ast_grep_rewrite
|
||||
|
||||
with patch('praisonaiagents.tools.ast_grep_tool.is_ast_grep_available') as mock_avail:
|
||||
mock_avail.return_value = True
|
||||
|
||||
with patch('subprocess.run') as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout='No changes',
|
||||
stderr=''
|
||||
)
|
||||
|
||||
result = ast_grep_rewrite("old", "new", lang="python", path=".")
|
||||
|
||||
# Should not actually modify files without explicit flag
|
||||
call_args = mock_run.call_args
|
||||
cmd_list = call_args[0][0]
|
||||
# Check that --update-all is NOT in the command (dry-run mode)
|
||||
assert '--update-all' not in cmd_list
|
||||
assert mock_run.called
|
||||
assert result is not None
|
||||
|
||||
|
||||
class TestAstGrepToolAgentIntegration:
|
||||
"""Test ast-grep tool integration with Agent."""
|
||||
|
||||
def test_tool_can_be_assigned_to_agent(self):
|
||||
"""Tool should be assignable to Agent via tools parameter."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools.ast_grep_tool import ast_grep_search
|
||||
|
||||
# Should not raise
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
tools=[ast_grep_search],
|
||||
)
|
||||
|
||||
assert ast_grep_search in agent.tools or any(
|
||||
getattr(t, '__name__', '') == 'ast_grep_search' for t in agent.tools
|
||||
)
|
||||
|
||||
def test_tool_works_when_not_installed_but_assigned(self):
|
||||
"""Agent should work even if ast-grep tool is assigned but not installed."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools.ast_grep_tool import ast_grep_search
|
||||
|
||||
with patch('praisonaiagents.tools.ast_grep_tool.is_ast_grep_available') as mock_avail:
|
||||
mock_avail.return_value = False
|
||||
|
||||
# Should not raise during agent creation
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
tools=[ast_grep_search],
|
||||
)
|
||||
|
||||
# Agent should be created successfully
|
||||
assert agent is not None
|
||||
|
||||
def test_tool_import_via_lazy_loading(self):
|
||||
"""Tool should be importable via lazy loading from tools package."""
|
||||
# This tests the TOOL_MAPPINGS integration
|
||||
from praisonaiagents.tools import ast_grep_search
|
||||
|
||||
assert callable(ast_grep_search)
|
||||
|
||||
def test_all_ast_grep_tools_importable(self):
|
||||
"""All ast-grep tools should be importable."""
|
||||
from praisonaiagents.tools import (
|
||||
ast_grep_search,
|
||||
ast_grep_rewrite,
|
||||
ast_grep_scan,
|
||||
)
|
||||
|
||||
assert callable(ast_grep_search)
|
||||
assert callable(ast_grep_rewrite)
|
||||
assert callable(ast_grep_scan)
|
||||
|
||||
|
||||
class TestAstGrepToolAutonomyIntegration:
|
||||
"""Test ast-grep tool integration with autonomy mode."""
|
||||
|
||||
def test_autonomy_config_has_default_tools_field(self):
|
||||
"""AutonomyConfig should have default_tools field."""
|
||||
from praisonaiagents.agent.autonomy import AutonomyConfig
|
||||
|
||||
config = AutonomyConfig()
|
||||
assert hasattr(config, 'default_tools')
|
||||
|
||||
def test_autonomy_default_tools_includes_ast_grep(self):
|
||||
"""Autonomy default_tools should include ast_grep when available."""
|
||||
from praisonaiagents.tools.ast_grep_tool import get_ast_grep_tools
|
||||
|
||||
with patch('praisonaiagents.tools.ast_grep_tool.is_ast_grep_available') as mock_avail:
|
||||
mock_avail.return_value = True
|
||||
|
||||
tools = get_ast_grep_tools()
|
||||
|
||||
# Should include ast_grep tools
|
||||
tool_names = [getattr(t, '__name__', str(t)) for t in tools]
|
||||
assert any('ast_grep' in name for name in tool_names)
|
||||
|
||||
def test_autonomy_default_tools_graceful_fallback(self):
|
||||
"""get_ast_grep_tools should return tools even when ast-grep not installed."""
|
||||
from praisonaiagents.tools.ast_grep_tool import get_ast_grep_tools
|
||||
|
||||
# get_ast_grep_tools always returns the tools - they handle unavailability internally
|
||||
tools = get_ast_grep_tools()
|
||||
|
||||
# Should return a list with the 3 ast-grep tools
|
||||
assert isinstance(tools, list)
|
||||
assert len(tools) == 3
|
||||
|
||||
def test_agent_with_autonomy_gets_default_tools(self):
|
||||
"""Agent with autonomy enabled should get default tools injected."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools.ast_grep_tool import ast_grep_search
|
||||
|
||||
with patch('praisonaiagents.tools.ast_grep_tool.is_ast_grep_available') as mock_avail:
|
||||
mock_avail.return_value = True
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
autonomy=True,
|
||||
)
|
||||
|
||||
# Agent should have ast-grep tools injected
|
||||
assert agent is not None
|
||||
assert len(agent.tools) > 0, "Agent with autonomy=True should have default tools"
|
||||
|
||||
# Verify ast_grep_search is in the tools
|
||||
tool_names = [getattr(t, '__name__', str(t)) for t in agent.tools]
|
||||
assert 'ast_grep_search' in tool_names, f"ast_grep_search should be in agent.tools, got: {tool_names}"
|
||||
|
||||
def test_agent_with_custom_default_tools(self):
|
||||
"""Agent with AutonomyConfig(default_tools=[X]) should use custom tools."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.agent.autonomy import AutonomyConfig
|
||||
|
||||
def custom_tool():
|
||||
"""A custom tool for testing."""
|
||||
return "custom"
|
||||
|
||||
config = AutonomyConfig(default_tools=[custom_tool])
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
autonomy=config,
|
||||
)
|
||||
|
||||
# Agent should have the custom tool
|
||||
assert custom_tool in agent.tools, "Custom default_tools should be injected"
|
||||
|
||||
def test_agent_autonomy_default_tools_in_config_dict(self):
|
||||
"""AutonomyConfig.default_tools should be serialized to agent.autonomy_config dict."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
with patch('praisonaiagents.tools.ast_grep_tool.is_ast_grep_available') as mock_avail:
|
||||
mock_avail.return_value = True
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
autonomy=True,
|
||||
)
|
||||
|
||||
# default_tools should be in the autonomy_config dict
|
||||
assert 'default_tools' in agent.autonomy_config, "default_tools should be in autonomy_config dict"
|
||||
|
||||
|
||||
class TestAstGrepToolSchema:
|
||||
"""Test ast-grep tool schema generation for LLM."""
|
||||
|
||||
def test_search_has_docstring(self):
|
||||
"""Search tool should have proper docstring for LLM."""
|
||||
from praisonaiagents.tools.ast_grep_tool import ast_grep_search
|
||||
|
||||
assert ast_grep_search.__doc__ is not None
|
||||
assert len(ast_grep_search.__doc__) > 50
|
||||
|
||||
def test_rewrite_has_docstring(self):
|
||||
"""Rewrite tool should have proper docstring for LLM."""
|
||||
from praisonaiagents.tools.ast_grep_tool import ast_grep_rewrite
|
||||
|
||||
assert ast_grep_rewrite.__doc__ is not None
|
||||
assert len(ast_grep_rewrite.__doc__) > 50
|
||||
|
||||
def test_scan_has_docstring(self):
|
||||
"""Scan tool should have proper docstring for LLM."""
|
||||
from praisonaiagents.tools.ast_grep_tool import ast_grep_scan
|
||||
|
||||
assert ast_grep_scan.__doc__ is not None
|
||||
assert len(ast_grep_scan.__doc__) > 50
|
||||
|
||||
|
||||
class TestAstGrepToolEdgeCases:
|
||||
"""Test edge cases and error handling."""
|
||||
|
||||
def test_empty_pattern_handled(self):
|
||||
"""Empty pattern should be handled gracefully."""
|
||||
from praisonaiagents.tools.ast_grep_tool import ast_grep_search
|
||||
|
||||
with patch('praisonaiagents.tools.ast_grep_tool.is_ast_grep_available') as mock_avail:
|
||||
mock_avail.return_value = True
|
||||
|
||||
with patch('subprocess.run') as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout='', stderr='Invalid pattern')
|
||||
|
||||
result = ast_grep_search("", lang="python", path=".")
|
||||
|
||||
# Should not crash, should return error message
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_invalid_language_handled(self):
|
||||
"""Invalid language should be handled gracefully."""
|
||||
from praisonaiagents.tools.ast_grep_tool import ast_grep_search
|
||||
|
||||
with patch('praisonaiagents.tools.ast_grep_tool.is_ast_grep_available') as mock_avail:
|
||||
mock_avail.return_value = True
|
||||
|
||||
with patch('subprocess.run') as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=1, stdout='', stderr='Unknown language'
|
||||
)
|
||||
|
||||
result = ast_grep_search("pattern", lang="invalid_lang", path=".")
|
||||
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_nonexistent_path_handled(self):
|
||||
"""Nonexistent path should be handled gracefully."""
|
||||
from praisonaiagents.tools.ast_grep_tool import ast_grep_search
|
||||
|
||||
with patch('praisonaiagents.tools.ast_grep_tool.is_ast_grep_available') as mock_avail:
|
||||
mock_avail.return_value = True
|
||||
|
||||
with patch('subprocess.run') as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=1, stdout='', stderr='Path not found'
|
||||
)
|
||||
|
||||
result = ast_grep_search("pattern", lang="python", path="/nonexistent/path")
|
||||
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
class TestAstGrepToolImportPerformance:
|
||||
"""Test that tool import doesn't impact performance."""
|
||||
|
||||
def test_lazy_import_no_subprocess_on_import(self):
|
||||
"""Importing the tool should not execute subprocess."""
|
||||
with patch('subprocess.run') as mock_run:
|
||||
# Re-import to test
|
||||
import importlib
|
||||
import praisonaiagents.tools.ast_grep_tool as module
|
||||
importlib.reload(module)
|
||||
|
||||
# subprocess.run should not be called on import
|
||||
mock_run.assert_not_called()
|
||||
|
||||
def test_availability_not_checked_on_import(self):
|
||||
"""Availability should not be checked on module import."""
|
||||
with patch('shutil.which') as mock_which:
|
||||
import importlib
|
||||
import praisonaiagents.tools.ast_grep_tool as module
|
||||
|
||||
# Clear cache to force fresh check
|
||||
module._availability_cache = None
|
||||
|
||||
importlib.reload(module)
|
||||
|
||||
# which should not be called on import
|
||||
mock_which.assert_not_called()
|
||||
@@ -0,0 +1,363 @@
|
||||
"""
|
||||
TDD Tests for ToolProfile system.
|
||||
|
||||
Tests the ToolProfile dataclass, built-in profiles, and profile resolution.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestToolProfileDataclass:
|
||||
"""Test ToolProfile dataclass structure."""
|
||||
|
||||
def test_tool_profile_has_required_fields(self):
|
||||
"""ToolProfile should have name, tools, and description fields."""
|
||||
from praisonaiagents.tools.profiles import ToolProfile
|
||||
|
||||
profile = ToolProfile(
|
||||
name="test",
|
||||
tools=["tool1", "tool2"],
|
||||
description="Test profile"
|
||||
)
|
||||
|
||||
assert profile.name == "test"
|
||||
assert profile.tools == ["tool1", "tool2"]
|
||||
assert profile.description == "Test profile"
|
||||
|
||||
def test_tool_profile_description_optional(self):
|
||||
"""ToolProfile description should default to empty string."""
|
||||
from praisonaiagents.tools.profiles import ToolProfile
|
||||
|
||||
profile = ToolProfile(name="test", tools=["tool1"])
|
||||
|
||||
assert profile.description == ""
|
||||
|
||||
def test_tool_profile_is_dataclass(self):
|
||||
"""ToolProfile should be a dataclass."""
|
||||
from dataclasses import is_dataclass
|
||||
from praisonaiagents.tools.profiles import ToolProfile
|
||||
|
||||
assert is_dataclass(ToolProfile)
|
||||
|
||||
|
||||
class TestBuiltinProfiles:
|
||||
"""Test built-in profiles are defined correctly."""
|
||||
|
||||
def test_builtin_profiles_dict_exists(self):
|
||||
"""BUILTIN_PROFILES dict should exist."""
|
||||
from praisonaiagents.tools.profiles import BUILTIN_PROFILES
|
||||
|
||||
assert isinstance(BUILTIN_PROFILES, dict)
|
||||
assert len(BUILTIN_PROFILES) > 0
|
||||
|
||||
def test_code_intelligence_profile_exists(self):
|
||||
"""code_intelligence profile should include ast-grep tools."""
|
||||
from praisonaiagents.tools.profiles import BUILTIN_PROFILES
|
||||
|
||||
assert "code_intelligence" in BUILTIN_PROFILES
|
||||
profile = BUILTIN_PROFILES["code_intelligence"]
|
||||
assert "ast_grep_search" in profile.tools
|
||||
assert "ast_grep_rewrite" in profile.tools
|
||||
assert "ast_grep_scan" in profile.tools
|
||||
|
||||
def test_file_ops_profile_exists(self):
|
||||
"""file_ops profile should include file operation tools."""
|
||||
from praisonaiagents.tools.profiles import BUILTIN_PROFILES
|
||||
|
||||
assert "file_ops" in BUILTIN_PROFILES
|
||||
profile = BUILTIN_PROFILES["file_ops"]
|
||||
assert "read_file" in profile.tools
|
||||
assert "write_file" in profile.tools
|
||||
assert "list_files" in profile.tools
|
||||
|
||||
def test_shell_profile_exists(self):
|
||||
"""shell profile should include shell tools."""
|
||||
from praisonaiagents.tools.profiles import BUILTIN_PROFILES
|
||||
|
||||
assert "shell" in BUILTIN_PROFILES
|
||||
profile = BUILTIN_PROFILES["shell"]
|
||||
assert "execute_command" in profile.tools
|
||||
|
||||
def test_web_profile_exists(self):
|
||||
"""web profile should include web search tools."""
|
||||
from praisonaiagents.tools.profiles import BUILTIN_PROFILES
|
||||
|
||||
assert "web" in BUILTIN_PROFILES
|
||||
profile = BUILTIN_PROFILES["web"]
|
||||
assert "internet_search" in profile.tools or "search_web" in profile.tools
|
||||
|
||||
def test_code_exec_profile_exists(self):
|
||||
"""code_exec profile should include code execution tools."""
|
||||
from praisonaiagents.tools.profiles import BUILTIN_PROFILES
|
||||
|
||||
assert "code_exec" in BUILTIN_PROFILES
|
||||
profile = BUILTIN_PROFILES["code_exec"]
|
||||
assert "execute_code" in profile.tools
|
||||
|
||||
def test_schedule_profile_exists(self):
|
||||
"""schedule profile should include schedule tools."""
|
||||
from praisonaiagents.tools.profiles import BUILTIN_PROFILES
|
||||
|
||||
assert "schedule" in BUILTIN_PROFILES
|
||||
profile = BUILTIN_PROFILES["schedule"]
|
||||
assert "schedule_add" in profile.tools
|
||||
assert "schedule_list" in profile.tools
|
||||
assert "schedule_remove" in profile.tools
|
||||
|
||||
|
||||
class TestAutonomyProfile:
|
||||
"""Test the composite AUTONOMY_PROFILE."""
|
||||
|
||||
def test_autonomy_profile_exists(self):
|
||||
"""AUTONOMY_PROFILE should exist."""
|
||||
from praisonaiagents.tools.profiles import AUTONOMY_PROFILE
|
||||
|
||||
assert AUTONOMY_PROFILE is not None
|
||||
assert AUTONOMY_PROFILE.name == "autonomy"
|
||||
|
||||
def test_autonomy_profile_includes_file_ops(self):
|
||||
"""AUTONOMY_PROFILE should include file_ops tools."""
|
||||
from praisonaiagents.tools.profiles import AUTONOMY_PROFILE
|
||||
|
||||
assert "read_file" in AUTONOMY_PROFILE.tools
|
||||
assert "write_file" in AUTONOMY_PROFILE.tools
|
||||
|
||||
def test_autonomy_profile_includes_shell(self):
|
||||
"""AUTONOMY_PROFILE should include shell tools."""
|
||||
from praisonaiagents.tools.profiles import AUTONOMY_PROFILE
|
||||
|
||||
assert "execute_command" in AUTONOMY_PROFILE.tools
|
||||
|
||||
def test_autonomy_profile_includes_web(self):
|
||||
"""AUTONOMY_PROFILE should include web tools."""
|
||||
from praisonaiagents.tools.profiles import AUTONOMY_PROFILE
|
||||
|
||||
assert "internet_search" in AUTONOMY_PROFILE.tools or "search_web" in AUTONOMY_PROFILE.tools
|
||||
|
||||
def test_autonomy_profile_includes_code_intelligence(self):
|
||||
"""AUTONOMY_PROFILE should include code_intelligence tools."""
|
||||
from praisonaiagents.tools.profiles import AUTONOMY_PROFILE
|
||||
|
||||
assert "ast_grep_search" in AUTONOMY_PROFILE.tools
|
||||
|
||||
def test_autonomy_profile_has_many_tools(self):
|
||||
"""AUTONOMY_PROFILE should have 15+ tools (not just 3)."""
|
||||
from praisonaiagents.tools.profiles import AUTONOMY_PROFILE
|
||||
|
||||
assert len(AUTONOMY_PROFILE.tools) >= 15, f"Expected 15+ tools, got {len(AUTONOMY_PROFILE.tools)}"
|
||||
|
||||
|
||||
class TestProfileRegistry:
|
||||
"""Test profile registration and retrieval."""
|
||||
|
||||
def test_get_profile_returns_builtin(self):
|
||||
"""get_profile should return built-in profiles."""
|
||||
from praisonaiagents.tools.profiles import get_profile
|
||||
|
||||
profile = get_profile("file_ops")
|
||||
assert profile.name == "file_ops"
|
||||
assert "read_file" in profile.tools
|
||||
|
||||
def test_get_profile_raises_for_unknown(self):
|
||||
"""get_profile should raise KeyError for unknown profiles."""
|
||||
from praisonaiagents.tools.profiles import get_profile
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
get_profile("nonexistent_profile")
|
||||
|
||||
def test_register_profile_adds_custom(self):
|
||||
"""register_profile should add custom profiles."""
|
||||
from praisonaiagents.tools.profiles import (
|
||||
ToolProfile, register_profile, get_profile, _custom_profiles
|
||||
)
|
||||
|
||||
# Clean up any previous test registrations
|
||||
if "test_custom" in _custom_profiles:
|
||||
del _custom_profiles["test_custom"]
|
||||
|
||||
custom = ToolProfile(
|
||||
name="test_custom",
|
||||
tools=["custom_tool1", "custom_tool2"],
|
||||
description="Test custom profile"
|
||||
)
|
||||
register_profile(custom)
|
||||
|
||||
retrieved = get_profile("test_custom")
|
||||
assert retrieved.name == "test_custom"
|
||||
assert "custom_tool1" in retrieved.tools
|
||||
|
||||
# Cleanup
|
||||
del _custom_profiles["test_custom"]
|
||||
|
||||
def test_custom_profile_overrides_builtin(self):
|
||||
"""Custom profiles should override built-in profiles with same name."""
|
||||
from praisonaiagents.tools.profiles import (
|
||||
ToolProfile, register_profile, get_profile, _custom_profiles
|
||||
)
|
||||
|
||||
# Register a custom profile with same name as builtin
|
||||
custom = ToolProfile(
|
||||
name="file_ops",
|
||||
tools=["custom_read", "custom_write"],
|
||||
description="Custom file ops"
|
||||
)
|
||||
register_profile(custom)
|
||||
|
||||
retrieved = get_profile("file_ops")
|
||||
assert "custom_read" in retrieved.tools
|
||||
|
||||
# Cleanup - restore original
|
||||
del _custom_profiles["file_ops"]
|
||||
|
||||
|
||||
class TestResolveProfiles:
|
||||
"""Test resolve_profiles function."""
|
||||
|
||||
def test_resolve_single_profile(self):
|
||||
"""resolve_profiles should return tools from a single profile."""
|
||||
from praisonaiagents.tools.profiles import resolve_profiles
|
||||
|
||||
tools = resolve_profiles("file_ops")
|
||||
assert "read_file" in tools
|
||||
assert "write_file" in tools
|
||||
|
||||
def test_resolve_multiple_profiles(self):
|
||||
"""resolve_profiles should combine tools from multiple profiles."""
|
||||
from praisonaiagents.tools.profiles import resolve_profiles
|
||||
|
||||
tools = resolve_profiles("file_ops", "shell")
|
||||
assert "read_file" in tools
|
||||
assert "execute_command" in tools
|
||||
|
||||
def test_resolve_profiles_deduplicates(self):
|
||||
"""resolve_profiles should deduplicate tools."""
|
||||
from praisonaiagents.tools.profiles import resolve_profiles
|
||||
|
||||
# autonomy includes file_ops, so calling both should not duplicate
|
||||
tools = resolve_profiles("autonomy")
|
||||
|
||||
# Count occurrences of read_file
|
||||
count = tools.count("read_file")
|
||||
assert count == 1, f"Expected 1 occurrence of read_file, got {count}"
|
||||
|
||||
def test_resolve_profiles_returns_list(self):
|
||||
"""resolve_profiles should return a list."""
|
||||
from praisonaiagents.tools.profiles import resolve_profiles
|
||||
|
||||
tools = resolve_profiles("shell")
|
||||
assert isinstance(tools, list)
|
||||
|
||||
def test_resolve_profiles_raises_for_unknown(self):
|
||||
"""resolve_profiles should raise KeyError for unknown profiles."""
|
||||
from praisonaiagents.tools.profiles import resolve_profiles
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
resolve_profiles("nonexistent")
|
||||
|
||||
|
||||
class TestAgentAutonomyWithProfiles:
|
||||
"""Test that Agent with autonomy=True uses AUTONOMY_PROFILE tools."""
|
||||
|
||||
def test_agent_autonomy_gets_profile_tools_when_no_tools_provided(self):
|
||||
"""Agent(autonomy=True) with no tools should get AUTONOMY_PROFILE tools."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# No tools provided - should inject AUTONOMY_PROFILE
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
autonomy=True,
|
||||
)
|
||||
|
||||
# Should have more than just 3 ast-grep tools
|
||||
assert len(agent.tools) >= 15, f"Expected 15+ tools, got {len(agent.tools)}"
|
||||
|
||||
# Check for file_ops tools
|
||||
tool_names = [getattr(t, '__name__', str(t)) for t in agent.tools]
|
||||
# At least one of these should be present
|
||||
has_file_tool = any(name in tool_names for name in ['read_file', 'write_file', 'list_files'])
|
||||
assert has_file_tool or len(agent.tools) >= 15, f"Expected file tools, got: {tool_names}"
|
||||
|
||||
def test_agent_autonomy_does_not_inject_when_tools_provided(self):
|
||||
"""Agent(autonomy=True) with tools should NOT inject AUTONOMY_PROFILE (avoid duplicates)."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
def my_tool():
|
||||
"""My custom tool."""
|
||||
return "custom"
|
||||
|
||||
# Tools provided - should NOT inject AUTONOMY_PROFILE
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
tools=[my_tool],
|
||||
autonomy=True,
|
||||
)
|
||||
|
||||
# Should only have the user-provided tool, not AUTONOMY_PROFILE
|
||||
assert len(agent.tools) == 1, f"Expected 1 tool (user-provided), got {len(agent.tools)}"
|
||||
assert my_tool in agent.tools
|
||||
|
||||
def test_agent_custom_default_tools_override(self):
|
||||
"""Agent with custom default_tools should use those instead."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.agent.autonomy import AutonomyConfig
|
||||
|
||||
def custom_tool():
|
||||
"""Custom tool."""
|
||||
return "custom"
|
||||
|
||||
config = AutonomyConfig(default_tools=[custom_tool])
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
autonomy=config,
|
||||
)
|
||||
|
||||
# Should have only the custom tool
|
||||
assert custom_tool in agent.tools
|
||||
# Should NOT have the full autonomy profile
|
||||
assert len(agent.tools) == 1
|
||||
|
||||
def test_agent_empty_default_tools_disables_injection(self):
|
||||
"""Agent with default_tools=[] should have no auto-injected tools."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.agent.autonomy import AutonomyConfig
|
||||
|
||||
config = AutonomyConfig(default_tools=[])
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="Test agent",
|
||||
autonomy=config,
|
||||
)
|
||||
|
||||
# Should have no tools
|
||||
assert len(agent.tools) == 0
|
||||
|
||||
|
||||
class TestProfileExports:
|
||||
"""Test that profiles are properly exported."""
|
||||
|
||||
def test_import_from_tools_package(self):
|
||||
"""ToolProfile should be importable from praisonaiagents.tools."""
|
||||
from praisonaiagents.tools import ToolProfile
|
||||
|
||||
assert ToolProfile is not None
|
||||
|
||||
def test_import_register_profile(self):
|
||||
"""register_profile should be importable from praisonaiagents.tools."""
|
||||
from praisonaiagents.tools import register_profile
|
||||
|
||||
assert callable(register_profile)
|
||||
|
||||
def test_import_resolve_profiles(self):
|
||||
"""resolve_profiles should be importable from praisonaiagents.tools."""
|
||||
from praisonaiagents.tools import resolve_profiles
|
||||
|
||||
assert callable(resolve_profiles)
|
||||
|
||||
def test_import_autonomy_profile(self):
|
||||
"""AUTONOMY_PROFILE should be importable from praisonaiagents.tools."""
|
||||
from praisonaiagents.tools import AUTONOMY_PROFILE
|
||||
|
||||
assert AUTONOMY_PROFILE is not None
|
||||
Generated
+21
-2
@@ -145,6 +145,21 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828, upload-time = "2024-03-22T14:39:34.521Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ast-grep-cli"
|
||||
version = "0.41.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/9c/4b5fbc28a606d1d3217891d41c8428518de8a52e8f83564daabbd4d8ba18/ast_grep_cli-0.41.0.tar.gz", hash = "sha256:9aee19fa53d582f4408ace5cffe3ff5635dff954c7ab78a15530c4acf5893a0e", size = 220762, upload-time = "2026-02-22T19:06:01.576Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/e2/e7604406b8487d43928a99fcfb66ac3aa9fba787527e901b96e65dbbd6d6/ast_grep_cli-0.41.0-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:93085ee153855fa9b1f486884f8aad788fb111f1e6a96bcfde240071d5033199", size = 14488134, upload-time = "2026-02-22T19:05:54.26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/b5/1f9ad69e28e2b98e0e99a4a7f981982937072c2a667f5ae808d8ffefa193/ast_grep_cli-0.41.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8db8e877b365a553f83a2a0e158d8cfa17fc33f8033774ee3248665449dcad1b", size = 7261908, upload-time = "2026-02-22T19:06:02.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/8b/ac2e7946e2b566483fb0005ed715767110663fb85a666030b38e43dee10f/ast_grep_cli-0.41.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:c10f70cd6e45ded98773022d9ae0bebfc497b8b2e85ae17e15abca174e7d0f93", size = 7106987, upload-time = "2026-02-22T19:05:59.003Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/00/90cc9f094fad9116f29076c1a5393df50fba7d7b25c402d2611d96f7e3f9/ast_grep_cli-0.41.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:92d384608e5fd98a301a03a380e0ce011d46adcd5f6bcf53b0b9ba125faa43ad", size = 7417585, upload-time = "2026-02-22T19:06:00.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/1b/390fd8ab3164e0f9377bd272196612f5047bfed35eb704df1c1e84776075/ast_grep_cli-0.41.0-py3-none-win32.whl", hash = "sha256:a7a4682cde7a1e1a8083dd8feed6f968d8f90a109e7c0917e1d5066807dfa46e", size = 6974015, upload-time = "2026-02-22T19:05:55.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/f5/71984cca3d3533c7e0a7dfe1784b7f6b12931463f6c5ce95ec36d5169219/ast_grep_cli-0.41.0-py3-none-win_amd64.whl", hash = "sha256:00570076cbf30e9c4c93f5577137c4d63245974420eff9e1fb3e3c6a9abefe97", size = 7413122, upload-time = "2026-02-22T19:05:57.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/ee/2add23700fa89661e84301c458a9a3218836edd5bbaeea49a7a283f4269a/ast_grep_cli-0.41.0-py3-none-win_arm64.whl", hash = "sha256:b8e0eeeec751fa213abc57ea20af4fdd8ae955a43e390914df85985f6933eb53", size = 7089061, upload-time = "2026-02-22T19:05:52.28Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-timeout"
|
||||
version = "4.0.3"
|
||||
@@ -2549,7 +2564,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.5.15"
|
||||
version = "1.5.41"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -2589,6 +2604,9 @@ auth = [
|
||||
{ name = "python-jose", extra = ["cryptography"] },
|
||||
{ name = "python-multipart" },
|
||||
]
|
||||
autonomy = [
|
||||
{ name = "ast-grep-cli" },
|
||||
]
|
||||
graph = [
|
||||
{ name = "chromadb" },
|
||||
{ name = "mem0ai", extra = ["graph"] },
|
||||
@@ -2629,6 +2647,7 @@ telemetry = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiohttp", specifier = ">=3.8.0" },
|
||||
{ name = "ast-grep-cli", marker = "extra == 'autonomy'", specifier = ">=0.30.0" },
|
||||
{ name = "chonkie", marker = "extra == 'knowledge'", specifier = ">=1.0.2" },
|
||||
{ name = "chromadb", marker = "extra == 'graph'", specifier = ">=1.0.0" },
|
||||
{ name = "chromadb", marker = "extra == 'knowledge'", specifier = ">=1.0.0" },
|
||||
@@ -2669,7 +2688,7 @@ requires-dist = [
|
||||
{ name = "uvicorn", marker = "extra == 'os'", specifier = ">=0.34.0" },
|
||||
{ name = "websockets", marker = "extra == 'mcp'", specifier = ">=12.0" },
|
||||
]
|
||||
provides-extras = ["mcp", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "auth", "all"]
|
||||
provides-extras = ["mcp", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "auth", "autonomy", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "propcache"
|
||||
|
||||
+190
-2810
File diff suppressed because it is too large.
Load diff
@@ -3,8 +3,8 @@ class Praisonai < Formula
|
||||
|
||||
desc "AI tools for various AI applications"
|
||||
homepage "https://github.com/MervinPraison/PraisonAI"
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.15.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.15.tar.gz | shasum -a 256`.split.first
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.41.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.41.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -11,7 +11,7 @@ This module uses FULL LAZY LOADING for all heavy dependencies:
|
||||
This ensures minimal import-time overhead.
|
||||
"""
|
||||
from pydantic import BaseModel
|
||||
from typing import Dict, List, Optional, Type, TypeVar
|
||||
from typing import Any, Dict, List, Optional, Type, TypeVar
|
||||
import os
|
||||
import json
|
||||
import yaml
|
||||
@@ -1377,4 +1377,168 @@ Example structure:
|
||||
with open(full_path, 'w') as f:
|
||||
yaml.dump(workflow_yaml, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
return full_path
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Job Workflow Auto Generator (Strategy 4)
|
||||
# =============================================================================
|
||||
|
||||
class JobWorkflowStep(BaseModel):
|
||||
"""A single step in a job workflow."""
|
||||
name: str
|
||||
step_type: str # "agent", "judge", "approve", "run", "action"
|
||||
config: Dict[str, Any]
|
||||
|
||||
|
||||
class JobWorkflowStructure(BaseModel):
|
||||
"""Structure for a job workflow with agent-centric steps."""
|
||||
name: str
|
||||
description: str
|
||||
steps: List[JobWorkflowStep]
|
||||
|
||||
|
||||
class JobWorkflowAutoGenerator(BaseAutoGenerator):
|
||||
"""
|
||||
Auto-generates job workflow YAML files with agent-centric steps.
|
||||
|
||||
Generates workflows with `type: job` that include:
|
||||
- agent: steps for AI agent execution
|
||||
- judge: steps for quality gates
|
||||
- approve: steps for approval gates
|
||||
- run: steps for shell commands
|
||||
- action: steps for built-in actions
|
||||
|
||||
Usage:
|
||||
generator = JobWorkflowAutoGenerator(topic="Generate changelog and publish")
|
||||
path = generator.generate()
|
||||
"""
|
||||
|
||||
def __init__(self, topic: str = "Automate a task",
|
||||
workflow_file: str = "job_workflow.yaml",
|
||||
config_list: Optional[List[Dict]] = None):
|
||||
"""
|
||||
Initialize the JobWorkflowAutoGenerator.
|
||||
|
||||
Args:
|
||||
topic: The task/topic for the workflow
|
||||
workflow_file: Output file name
|
||||
config_list: Optional LLM configuration
|
||||
"""
|
||||
super().__init__(config_list=config_list)
|
||||
self.topic = topic
|
||||
self.workflow_file = workflow_file
|
||||
|
||||
def generate(self, include_judge: bool = True, include_approve: bool = False) -> str:
|
||||
"""
|
||||
Generate a job workflow YAML file.
|
||||
|
||||
Args:
|
||||
include_judge: Include a judge step for quality gating
|
||||
include_approve: Include an approve step for human approval
|
||||
|
||||
Returns:
|
||||
Path to the generated workflow file
|
||||
"""
|
||||
prompt = self._get_prompt(include_judge, include_approve)
|
||||
|
||||
response = self._structured_completion(
|
||||
response_model=JobWorkflowStructure,
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a helpful assistant that designs job workflow structures with AI agent steps."},
|
||||
{"role": "user", "content": prompt}
|
||||
]
|
||||
)
|
||||
|
||||
return self._save_workflow(response)
|
||||
|
||||
def _get_prompt(self, include_judge: bool, include_approve: bool) -> str:
|
||||
"""Generate the prompt for job workflow generation."""
|
||||
tools_list = ", ".join(self.get_available_tools())
|
||||
|
||||
prompt = f"""Generate a job workflow structure for: "{self.topic}"
|
||||
|
||||
A job workflow uses `type: job` and supports these step types:
|
||||
1. **agent** - AI agent execution (role, instructions, prompt, model, tools, output_file)
|
||||
2. **judge** - Quality gate with threshold (input_file, criteria, threshold, on_fail)
|
||||
3. **approve** - Approval gate (description, risk_level, auto_approve)
|
||||
4. **run** - Shell command execution
|
||||
5. **action** - Built-in action (e.g., bump-version)
|
||||
|
||||
REQUIREMENTS:
|
||||
- Create 2-4 steps that accomplish the task
|
||||
- At least one step MUST be an "agent" step
|
||||
{"- Include a 'judge' step to validate quality" if include_judge else ""}
|
||||
{"- Include an 'approve' step for human approval before critical actions" if include_approve else ""}
|
||||
- Each step should have a clear name and purpose
|
||||
|
||||
Available tools for agent steps: {tools_list}
|
||||
|
||||
STEP CONFIG FORMATS:
|
||||
- agent: {{"role": "...", "instructions": "...", "prompt": "...", "model": "gpt-4o-mini", "tools": [], "output_file": "..."}}
|
||||
- judge: {{"input_file": "...", "criteria": "...", "threshold": 7.0, "on_fail": "stop"}}
|
||||
- approve: {{"description": "...", "risk_level": "medium", "auto_approve": false}}
|
||||
- run: {{"command": "..."}}
|
||||
- action: {{"name": "bump-version", "strategy": "patch"}}
|
||||
|
||||
Generate a workflow for: {self.topic}
|
||||
"""
|
||||
return prompt
|
||||
|
||||
def _save_workflow(self, data: JobWorkflowStructure) -> str:
|
||||
"""Save the job workflow to a YAML file."""
|
||||
# Build the workflow YAML structure
|
||||
workflow_yaml = {
|
||||
'type': 'job',
|
||||
'name': data.name,
|
||||
'description': data.description,
|
||||
'steps': []
|
||||
}
|
||||
|
||||
# Convert steps
|
||||
for step in data.steps:
|
||||
step_dict = {'name': step.name}
|
||||
|
||||
if step.step_type == 'agent':
|
||||
step_dict['agent'] = {
|
||||
'role': step.config.get('role', 'Assistant'),
|
||||
'instructions': step.config.get('instructions', ''),
|
||||
'prompt': step.config.get('prompt', step.config.get('instructions', '')),
|
||||
'model': step.config.get('model', 'gpt-4o-mini'),
|
||||
}
|
||||
if step.config.get('tools'):
|
||||
step_dict['agent']['tools'] = step.config['tools']
|
||||
if step.config.get('output_file'):
|
||||
step_dict['output_file'] = step.config['output_file']
|
||||
|
||||
elif step.step_type == 'judge':
|
||||
step_dict['judge'] = {
|
||||
'input_file': step.config.get('input_file', ''),
|
||||
'criteria': step.config.get('criteria', 'Output is high quality'),
|
||||
'threshold': step.config.get('threshold', 7.0),
|
||||
'on_fail': step.config.get('on_fail', 'stop'),
|
||||
}
|
||||
|
||||
elif step.step_type == 'approve':
|
||||
step_dict['approve'] = {
|
||||
'description': step.config.get('description', 'Approve this step'),
|
||||
'risk_level': step.config.get('risk_level', 'medium'),
|
||||
'auto_approve': step.config.get('auto_approve', False),
|
||||
}
|
||||
|
||||
elif step.step_type == 'run':
|
||||
step_dict['run'] = step.config.get('command', 'echo "Step executed"')
|
||||
|
||||
elif step.step_type == 'action':
|
||||
step_dict['action'] = step.config.get('name', 'bump-version')
|
||||
if step.config.get('strategy'):
|
||||
step_dict['strategy'] = step.config['strategy']
|
||||
|
||||
workflow_yaml['steps'].append(step_dict)
|
||||
|
||||
# Write to file
|
||||
full_path = os.path.abspath(self.workflow_file)
|
||||
with open(full_path, 'w') as f:
|
||||
yaml.dump(workflow_yaml, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
return full_path
|
||||
@@ -9,6 +9,15 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
# Module-level deprecation warning
|
||||
import warnings as _deprecation_warnings
|
||||
_deprecation_warnings.warn(
|
||||
"BotApprovalBackend is deprecated. Use platform-specific approval backends "
|
||||
"(SlackApproval, TelegramApproval, DiscordApproval, WebhookApproval, HTTPApproval) instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2
|
||||
)
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
|
||||
@@ -77,7 +86,7 @@ class BotApprovalBackend:
|
||||
msg = f"🔐 Approval required:\n\nTool: {tool_name}\nArgs: {args_preview}\n\nReply 'yes' to approve or 'no' to deny."
|
||||
|
||||
# Create a future to wait for the response
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
future: asyncio.Future = loop.create_future()
|
||||
self._pending[request_id] = future
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
Rate Limiting Utilities for Bot Adapters.
|
||||
|
||||
Provides platform-aware rate limiting to prevent 429 errors from messaging APIs.
|
||||
|
||||
Platform limits (as of 2024):
|
||||
- Telegram: ~30 messages/second to different users, stricter per-chat
|
||||
- Discord: 5 messages per 5 seconds per channel (1 msg/sec effective)
|
||||
- Slack: 1 message per second per channel
|
||||
- WhatsApp: ~80 messages/second (Cloud API), varies for Web
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, Optional
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RateLimitConfig:
|
||||
"""Configuration for rate limiting.
|
||||
|
||||
Attributes:
|
||||
messages_per_second: Maximum messages per second (global)
|
||||
per_channel_delay: Minimum delay between messages to same channel (seconds)
|
||||
burst_size: Number of messages allowed in a burst before throttling
|
||||
"""
|
||||
messages_per_second: float = 1.0
|
||||
per_channel_delay: float = 1.0
|
||||
burst_size: int = 5
|
||||
|
||||
|
||||
# Platform-specific defaults
|
||||
PLATFORM_LIMITS: Dict[str, RateLimitConfig] = {
|
||||
"telegram": RateLimitConfig(messages_per_second=25.0, per_channel_delay=0.05, burst_size=30),
|
||||
"discord": RateLimitConfig(messages_per_second=1.0, per_channel_delay=1.0, burst_size=5),
|
||||
"slack": RateLimitConfig(messages_per_second=1.0, per_channel_delay=1.0, burst_size=1),
|
||||
"whatsapp": RateLimitConfig(messages_per_second=50.0, per_channel_delay=0.1, burst_size=80),
|
||||
}
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Async rate limiter with per-channel tracking.
|
||||
|
||||
Uses token bucket algorithm for global rate and per-channel delays.
|
||||
|
||||
Example:
|
||||
limiter = RateLimiter.for_platform("telegram")
|
||||
|
||||
async def send(channel_id, msg):
|
||||
await limiter.acquire(channel_id)
|
||||
await api.send_message(channel_id, msg)
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[RateLimitConfig] = None):
|
||||
"""Initialize rate limiter.
|
||||
|
||||
Args:
|
||||
config: Rate limit configuration. Defaults to 1 msg/sec.
|
||||
"""
|
||||
self._config = config or RateLimitConfig()
|
||||
self._tokens = float(self._config.burst_size)
|
||||
self._last_refill = time.monotonic()
|
||||
self._channel_last_send: Dict[str, float] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@classmethod
|
||||
def for_platform(cls, platform: str) -> "RateLimiter":
|
||||
"""Create a rate limiter with platform-specific defaults.
|
||||
|
||||
Args:
|
||||
platform: Platform name (telegram, discord, slack, whatsapp)
|
||||
|
||||
Returns:
|
||||
Configured RateLimiter instance
|
||||
"""
|
||||
config = PLATFORM_LIMITS.get(platform.lower(), RateLimitConfig())
|
||||
return cls(config)
|
||||
|
||||
async def acquire(self, channel_id: Optional[str] = None) -> None:
|
||||
"""Wait until rate limit allows sending.
|
||||
|
||||
Args:
|
||||
channel_id: Optional channel ID for per-channel limiting
|
||||
"""
|
||||
async with self._lock:
|
||||
now = time.monotonic()
|
||||
|
||||
# Refill tokens based on elapsed time
|
||||
elapsed = now - self._last_refill
|
||||
self._tokens = min(
|
||||
self._config.burst_size,
|
||||
self._tokens + elapsed * self._config.messages_per_second
|
||||
)
|
||||
self._last_refill = now
|
||||
|
||||
# Wait for global token
|
||||
if self._tokens < 1.0:
|
||||
wait_time = (1.0 - self._tokens) / self._config.messages_per_second
|
||||
logger.debug(f"Rate limit: waiting {wait_time:.3f}s for global token")
|
||||
await asyncio.sleep(wait_time)
|
||||
self._tokens = 1.0
|
||||
|
||||
# Consume token
|
||||
self._tokens -= 1.0
|
||||
|
||||
# Per-channel delay
|
||||
if channel_id:
|
||||
last_send = self._channel_last_send.get(channel_id, 0.0)
|
||||
channel_elapsed = now - last_send
|
||||
if channel_elapsed < self._config.per_channel_delay:
|
||||
wait_time = self._config.per_channel_delay - channel_elapsed
|
||||
logger.debug(f"Rate limit: waiting {wait_time:.3f}s for channel {channel_id}")
|
||||
await asyncio.sleep(wait_time)
|
||||
self._channel_last_send[channel_id] = time.monotonic()
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset rate limiter state."""
|
||||
self._tokens = float(self._config.burst_size)
|
||||
self._last_refill = time.monotonic()
|
||||
self._channel_last_send.clear()
|
||||
|
||||
|
||||
def get_rate_limiter(platform: str) -> RateLimiter:
|
||||
"""Get a rate limiter for the specified platform.
|
||||
|
||||
This is a convenience function that creates a new limiter each time.
|
||||
For persistent limiting, create and store a RateLimiter instance.
|
||||
|
||||
Args:
|
||||
platform: Platform name
|
||||
|
||||
Returns:
|
||||
Configured RateLimiter
|
||||
"""
|
||||
return RateLimiter.for_platform(platform)
|
||||
@@ -145,7 +145,7 @@ class BotSessionManager:
|
||||
agent_lock = self._get_agent_lock(agent)
|
||||
async with user_lock:
|
||||
# Load history (may hit disk via run_in_executor for async safety)
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
user_history = await loop.run_in_executor(
|
||||
None, self._load_history, user_id
|
||||
)
|
||||
|
||||
@@ -27,6 +27,7 @@ from praisonaiagents.bots import (
|
||||
from ._commands import format_status, format_help
|
||||
from ._session import BotSessionManager
|
||||
from ._debounce import InboundDebouncer
|
||||
from ._ack import AckReactor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -58,6 +59,7 @@ class DiscordBot(ChatCommandMixin, MessageHookMixin):
|
||||
token: str,
|
||||
agent: Optional["Agent"] = None,
|
||||
config: Optional[BotConfig] = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the Discord bot.
|
||||
|
||||
@@ -65,7 +67,10 @@ class DiscordBot(ChatCommandMixin, MessageHookMixin):
|
||||
token: Discord bot token
|
||||
agent: Optional agent to handle messages
|
||||
config: Optional bot configuration
|
||||
**kwargs: Additional arguments for forward compatibility
|
||||
"""
|
||||
# B9: Store extra kwargs for forward compatibility
|
||||
self._extra_kwargs = kwargs
|
||||
self._token = token
|
||||
self._agent = agent
|
||||
self.config = config or BotConfig(token=token)
|
||||
@@ -81,6 +86,10 @@ class DiscordBot(ChatCommandMixin, MessageHookMixin):
|
||||
self._debouncer: InboundDebouncer = InboundDebouncer(
|
||||
debounce_ms=self.config.debounce_ms,
|
||||
)
|
||||
self._ack: AckReactor = AckReactor(
|
||||
ack_emoji=self.config.ack_emoji,
|
||||
done_emoji=self.config.done_emoji,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
@@ -186,6 +195,22 @@ class DiscordBot(ChatCommandMixin, MessageHookMixin):
|
||||
|
||||
if self._agent:
|
||||
user_id = str(message.author.id)
|
||||
|
||||
# Ack reaction - show processing indicator
|
||||
ack_ctx = None
|
||||
if self._ack.enabled:
|
||||
async def _discord_react(emoji, **kw):
|
||||
try:
|
||||
await message.add_reaction(emoji)
|
||||
except Exception:
|
||||
pass # Reactions may not be supported
|
||||
async def _discord_unreact(emoji, **kw):
|
||||
try:
|
||||
await message.remove_reaction(emoji, self._client.user)
|
||||
except Exception:
|
||||
pass
|
||||
ack_ctx = await self._ack.ack(react_fn=_discord_react)
|
||||
|
||||
async def _send_agent_response():
|
||||
text_to_send = await self._debouncer.debounce(user_id, bot_message.text)
|
||||
response = await self._session.chat(self._agent, user_id, text_to_send)
|
||||
@@ -196,6 +221,9 @@ class DiscordBot(ChatCommandMixin, MessageHookMixin):
|
||||
return
|
||||
await self._send_long_message(message.channel, send_result["content"], reference=message)
|
||||
self.fire_message_sent(str(message.channel.id), send_result["content"])
|
||||
# Done reaction - show completion
|
||||
if ack_ctx:
|
||||
await self._ack.done(ack_ctx, react_fn=_discord_react, unreact_fn=_discord_unreact)
|
||||
|
||||
if self.config.typing_indicator:
|
||||
async with message.channel.typing():
|
||||
|
||||
@@ -29,6 +29,7 @@ from .media import split_media_from_output, is_audio_file
|
||||
from ._commands import format_status, format_help
|
||||
from ._session import BotSessionManager
|
||||
from ._debounce import InboundDebouncer
|
||||
from ._ack import AckReactor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -66,6 +67,7 @@ class SlackBot(ChatCommandMixin, MessageHookMixin):
|
||||
signing_secret: Optional[str] = None,
|
||||
agent: Optional["Agent"] = None,
|
||||
config: Optional[BotConfig] = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the Slack bot.
|
||||
|
||||
@@ -75,7 +77,10 @@ class SlackBot(ChatCommandMixin, MessageHookMixin):
|
||||
signing_secret: Signing secret for webhook verification
|
||||
agent: Optional agent to handle messages
|
||||
config: Optional bot configuration
|
||||
**kwargs: Additional arguments for forward compatibility
|
||||
"""
|
||||
# B9: Store extra kwargs for forward compatibility
|
||||
self._extra_kwargs = kwargs
|
||||
self._token = token
|
||||
self._app_token = app_token
|
||||
self._signing_secret = signing_secret
|
||||
@@ -94,6 +99,10 @@ class SlackBot(ChatCommandMixin, MessageHookMixin):
|
||||
self._debouncer: InboundDebouncer = InboundDebouncer(
|
||||
debounce_ms=self.config.debounce_ms,
|
||||
)
|
||||
self._ack: AckReactor = AckReactor(
|
||||
ack_emoji=self.config.ack_emoji,
|
||||
done_emoji=self.config.done_emoji,
|
||||
)
|
||||
|
||||
# Audio capabilities
|
||||
self._stt_enabled: bool = False
|
||||
@@ -194,6 +203,35 @@ class SlackBot(ChatCommandMixin, MessageHookMixin):
|
||||
should_respond = True
|
||||
|
||||
if should_respond and self._agent:
|
||||
channel_id = event.get("channel", "")
|
||||
msg_ts = event.get("ts", "")
|
||||
|
||||
# Ack reaction - show processing indicator
|
||||
ack_ctx = None
|
||||
if self._ack.enabled and self._client:
|
||||
async def _slack_react(emoji, **kw):
|
||||
try:
|
||||
# Slack uses emoji names without colons
|
||||
emoji_name = emoji.strip(":")
|
||||
await self._client.reactions_add(
|
||||
channel=channel_id,
|
||||
timestamp=msg_ts,
|
||||
name=emoji_name,
|
||||
)
|
||||
except Exception:
|
||||
pass # Reactions may fail silently
|
||||
async def _slack_unreact(emoji, **kw):
|
||||
try:
|
||||
emoji_name = emoji.strip(":")
|
||||
await self._client.reactions_remove(
|
||||
channel=channel_id,
|
||||
timestamp=msg_ts,
|
||||
name=emoji_name,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
ack_ctx = await self._ack.ack(react_fn=_slack_react)
|
||||
|
||||
try:
|
||||
user_id = event.get("user", "unknown")
|
||||
logger.info(f"Message received: {text[:100]}...")
|
||||
@@ -201,7 +239,6 @@ class SlackBot(ChatCommandMixin, MessageHookMixin):
|
||||
response = await self._session.chat(self._agent, user_id, text)
|
||||
logger.info(f"Response sent: {response[:100]}...")
|
||||
|
||||
channel_id = event.get("channel", "")
|
||||
send_result = self.fire_message_sending(channel_id, str(response))
|
||||
if send_result["cancel"]:
|
||||
return
|
||||
@@ -219,6 +256,10 @@ class SlackBot(ChatCommandMixin, MessageHookMixin):
|
||||
channel_id, say, response, thread_ts=thread_ts
|
||||
)
|
||||
self.fire_message_sent(channel_id, response)
|
||||
|
||||
# Done reaction - show completion
|
||||
if ack_ctx and self._client:
|
||||
await self._ack.done(ack_ctx, react_fn=_slack_react, unreact_fn=_slack_unreact)
|
||||
except Exception as e:
|
||||
logger.error(f"Agent error: {e}")
|
||||
error_msg = str(e)
|
||||
|
||||
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user