mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-07 17:33:33 -10:00
Compare commits
83
Commits
No files matched your search
@@ -117,13 +117,25 @@ jobs:
|
||||
const labels = issue.labels.map((l) => l.name);
|
||||
|
||||
if (status !== 'DIRTY' && labels.includes(LABEL)) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
name: LABEL,
|
||||
});
|
||||
core.info(`Removed ${LABEL} from PR #${prNumber}`);
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
name: LABEL,
|
||||
});
|
||||
core.info(`Removed ${LABEL} from PR #${prNumber}`);
|
||||
} catch (err) {
|
||||
// Fork PRs may return 403/404 when the token cannot mutate labels on the head repo's issue.
|
||||
// For upstream PRs a 403/404 is a genuine failure that must surface, otherwise the
|
||||
// merge-blocking label stays in place while the check reports success.
|
||||
const isFork = headRepo && headRepo !== baseRepo;
|
||||
if (isFork && (err.status === 403 || err.status === 404)) {
|
||||
core.info(`Skip remove ${LABEL} on fork PR #${prNumber}: ${err.status}`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (status !== 'DIRTY') return { skipped: true, reason: 'not dirty' };
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
# ============================================
|
||||
smoke:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 12
|
||||
timeout-minutes: 16
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
|
||||
@@ -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.7.11" \
|
||||
"praisonai>=4.7.12" \
|
||||
"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.7.11" \
|
||||
"praisonai>=4.7.12" \
|
||||
"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.7.11" \
|
||||
"praisonai>=4.7.12" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -21,45 +21,45 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
| ✅ Agent | 22 | 59 | 19076 |
|
||||
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2996 |
|
||||
| ✅ Agent-to-User (A2U) | 1 | 3 | 701 |
|
||||
| ✅ Approval | 1 | 8 | 3969 |
|
||||
| ✅ Approval | 1 | 8 | 4028 |
|
||||
| ✅ Audio | 2 | 12 | 1000 |
|
||||
| ✅ Auto Generation | 5 | 12 | 3941 |
|
||||
| ✅ Autonomy | 3 | 7 | 2805 |
|
||||
| ✅ Bots | 7 | 38 | 15546 |
|
||||
| ✅ Bots | 7 | 38 | 15809 |
|
||||
| ✅ Budget | 1 | 1 | 287 |
|
||||
| ✅ CLI | 5 | 124 | 39107 |
|
||||
| ✅ CLI | 5 | 124 | 39199 |
|
||||
| ✅ Chunking | 2 | 2 | 435 |
|
||||
| ✅ Citations | 2 | 1 | 202 |
|
||||
| ✅ Code Execution | 2 | 14 | 5022 |
|
||||
| ✅ Conditions | 1 | 3 | 1386 |
|
||||
| ✅ Configuration | 3 | 8 | 3341 |
|
||||
| ✅ Context Management | 16 | 36 | 14331 |
|
||||
| ✅ Context Management | 16 | 36 | 14384 |
|
||||
| ✅ Deep Research | 8 | 2 | 591 |
|
||||
| ✅ Display | 6 | 3 | 844 |
|
||||
| ✅ Embeddings | 6 | 23 | 2051 |
|
||||
| ✅ Evaluation | 1 | 9 | 3537 |
|
||||
| ✅ Evaluation | 1 | 9 | 3637 |
|
||||
| ✅ Events | 1 | 2 | 782 |
|
||||
| ✅ Execution | 3 | 4 | 1693 |
|
||||
| ✅ Execution | 3 | 4 | 1694 |
|
||||
| ✅ Failover | 2 | 1 | 537 |
|
||||
| ✅ Files | 2 | 7 | 2376 |
|
||||
| ✅ Flow | 1 | 3 | 811 |
|
||||
| ✅ Gateway | 7 | 93 | 36047 |
|
||||
| ✅ Guardrails | 5 | 4 | 2638 |
|
||||
| ✅ Gateway | 7 | 97 | 37652 |
|
||||
| ✅ Guardrails | 5 | 4 | 2642 |
|
||||
| ✅ Handoffs | 11 | 6 | 2748 |
|
||||
| ✅ Hooks | 2 | 9 | 5230 |
|
||||
| ✅ Image | 1 | 11 | 1295 |
|
||||
| ✅ Knowledge | 4 | 20 | 6188 |
|
||||
| ✅ Knowledge | 4 | 20 | 6260 |
|
||||
| ✅ LLM | 3 | 15 | 5958 |
|
||||
| ✅ Loops | 4 | 5 | 1475 |
|
||||
| ✅ MCP | 1 | 64 | 15068 |
|
||||
| ✅ Memory | 6 | 21 | 8194 |
|
||||
| ✅ Loops | 4 | 5 | 1478 |
|
||||
| ✅ MCP | 1 | 64 | 15116 |
|
||||
| ✅ Memory | 6 | 21 | 8198 |
|
||||
| ✅ OCR | 2 | 1 | 237 |
|
||||
| ✅ Observability | 2 | 23 | 2859 |
|
||||
| ✅ Optimizer | 1 | 2 | 792 |
|
||||
| ✅ Output | 3 | 5 | 1289 |
|
||||
| ✅ Parallel Execution | 3 | 2 | 528 |
|
||||
| ✅ Planning | 6 | 7 | 1825 |
|
||||
| ✅ Plugins | 8 | 5 | 3785 |
|
||||
| ✅ Planning | 6 | 7 | 1827 |
|
||||
| ✅ Plugins | 8 | 5 | 4112 |
|
||||
| ✅ Prompts | 2 | 9 | 2043 |
|
||||
| ✅ Providers | 1 | 58 | 8751 |
|
||||
| ✅ Query | 1 | 2 | 736 |
|
||||
@@ -68,14 +68,14 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
| ✅ Reflection | 3 | 3 | 732 |
|
||||
| ✅ Retrieval | 2 | 5 | 1046 |
|
||||
| ✅ Routing | 1 | 2 | 430 |
|
||||
| ✅ Sandbox | 5 | 7 | 3712 |
|
||||
| ✅ Sandbox | 5 | 7 | 3720 |
|
||||
| ✅ Security | 1 | 3 | 3642 |
|
||||
| ✅ Sessions | 4 | 21 | 8386 |
|
||||
| ✅ Sessions | 4 | 21 | 8388 |
|
||||
| ✅ Skills | 6 | 16 | 6045 |
|
||||
| ✅ Tasks | 2 | 8 | 3266 |
|
||||
| ✅ Telemetry | 1 | 2 | 660 |
|
||||
| ✅ Templates | 1 | 9 | 1859 |
|
||||
| ✅ Tools | 12 | 145 | 44877 |
|
||||
| ✅ Tools | 12 | 147 | 45378 |
|
||||
| ✅ Tracing | 3 | 2 | 139 |
|
||||
| ✅ Vector Store | 1 | 12 | 1219 |
|
||||
| ✅ Video | 2 | 10 | 1303 |
|
||||
@@ -87,7 +87,7 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
|
||||
These docs exist but don't match any implemented feature category:
|
||||
|
||||
- ℹ️ Database (44 docs, 8056 lines)
|
||||
- ℹ️ Database (44 docs, 8292 lines)
|
||||
- ℹ️ Documents (1 docs, 810 lines)
|
||||
- ℹ️ Teams (1 docs, 214 lines)
|
||||
|
||||
|
||||
@@ -6,8 +6,13 @@ Compares agent instantiation times across popular AI agent frameworks.
|
||||
|
||||
Usage:
|
||||
python benchmarks/simple_benchmark.py
|
||||
python benchmarks/simple_benchmark.py --skip-crewai --json
|
||||
python benchmarks/simple_benchmark.py --json --fail-over-threshold
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from typing import Literal
|
||||
import argparse
|
||||
@@ -16,6 +21,14 @@ import argparse
|
||||
ITERATIONS = 100
|
||||
WARMUP = 10
|
||||
|
||||
THRESHOLDS_PATH = os.path.join(os.path.dirname(__file__), 'thresholds.json')
|
||||
|
||||
|
||||
def load_thresholds(path: str = THRESHOLDS_PATH) -> dict:
|
||||
"""Load benchmark thresholds from thresholds.json."""
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def sample_tool(city: Literal['nyc', 'sf']):
|
||||
"""Sample tool for benchmark testing."""
|
||||
@@ -36,35 +49,44 @@ def measure_instantiation(create_fn, iterations=ITERATIONS, warmup=WARMUP):
|
||||
return sum(times) / len(times)
|
||||
|
||||
|
||||
def run_benchmark():
|
||||
def run_benchmark(skip_crewai: bool = False, quiet: bool = False,
|
||||
iterations: int = ITERATIONS, warmup: int = WARMUP):
|
||||
"""Run the benchmark across all available frameworks (without tools)."""
|
||||
results = {}
|
||||
|
||||
print('=' * 60)
|
||||
print('PraisonAI Agents - Performance Benchmark')
|
||||
print('=' * 60)
|
||||
print(f'\nIterations: {ITERATIONS}')
|
||||
print('Metric: Agent instantiation time (microseconds)\n')
|
||||
|
||||
def log(msg: str = ''):
|
||||
if not quiet:
|
||||
print(msg)
|
||||
|
||||
def measure(create_fn):
|
||||
return measure_instantiation(create_fn, iterations=iterations, warmup=warmup)
|
||||
|
||||
log('=' * 60)
|
||||
log('PraisonAI Agents - Performance Benchmark')
|
||||
log('=' * 60)
|
||||
log(f'\nIterations: {iterations}')
|
||||
log(f'Warmup: {warmup}')
|
||||
log('Metric: Agent instantiation time (microseconds)\n')
|
||||
|
||||
# PraisonAI (without tools)
|
||||
print("Testing PraisonAI...")
|
||||
log("Testing PraisonAI...")
|
||||
from praisonaiagents import Agent as PraisonAgent
|
||||
|
||||
results['PraisonAI'] = measure_instantiation(
|
||||
results['PraisonAI'] = measure(
|
||||
lambda: PraisonAgent(name='Test', model='gpt-4o-mini', output="silent")
|
||||
)
|
||||
|
||||
results['PraisonAI (LiteLLM)'] = measure_instantiation(
|
||||
results['PraisonAI (LiteLLM)'] = measure(
|
||||
lambda: PraisonAgent(name='Test', model='openai/gpt-4o-mini', output="silent")
|
||||
)
|
||||
|
||||
# Other frameworks for comparison
|
||||
print("Testing other frameworks...")
|
||||
log("Testing other frameworks...")
|
||||
|
||||
try:
|
||||
from agno.agent import Agent as AgnoAgent
|
||||
from agno.models.openai import OpenAIChat
|
||||
results['Agno'] = measure_instantiation(
|
||||
results['Agno'] = measure(
|
||||
lambda: AgnoAgent(model=OpenAIChat(id='gpt-4o-mini'))
|
||||
)
|
||||
except ImportError:
|
||||
@@ -72,7 +94,7 @@ def run_benchmark():
|
||||
|
||||
try:
|
||||
from pydantic_ai import Agent as PydanticAgent
|
||||
results['PydanticAI'] = measure_instantiation(
|
||||
results['PydanticAI'] = measure(
|
||||
lambda: PydanticAgent('openai:gpt-4o-mini')
|
||||
)
|
||||
except ImportError:
|
||||
@@ -80,7 +102,7 @@ def run_benchmark():
|
||||
|
||||
try:
|
||||
from agents import Agent as OpenAIAgent
|
||||
results['OpenAI Agents SDK'] = measure_instantiation(
|
||||
results['OpenAI Agents SDK'] = measure(
|
||||
lambda: OpenAIAgent(name='Test', model='gpt-4o-mini')
|
||||
)
|
||||
except ImportError:
|
||||
@@ -96,47 +118,50 @@ def run_benchmark():
|
||||
"""Get weather info."""
|
||||
return 'sunny' if city == 'sf' else 'cloudy'
|
||||
|
||||
results['LangGraph'] = measure_instantiation(
|
||||
results['LangGraph'] = measure(
|
||||
lambda: create_react_agent(model=ChatOpenAI(model='gpt-4o-mini'), tools=[get_weather_lg])
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from crewai.agent import Agent as CrewAgent
|
||||
from crewai.tools import tool as crewai_tool
|
||||
|
||||
@crewai_tool("Weather Tool")
|
||||
def get_weather_crew(city: Literal['nyc', 'sf']):
|
||||
"""Get weather info."""
|
||||
return 'sunny' if city == 'sf' else 'cloudy'
|
||||
|
||||
results['CrewAI'] = measure_instantiation(
|
||||
lambda: CrewAgent(
|
||||
role='Weather Agent',
|
||||
goal='Provide weather info',
|
||||
backstory='A weather expert',
|
||||
tools=[get_weather_crew],
|
||||
verbose=False
|
||||
if skip_crewai:
|
||||
log("Skipping CrewAI (--skip-crewai): optional framework import can hang on some platforms.")
|
||||
else:
|
||||
try:
|
||||
from crewai.agent import Agent as CrewAgent
|
||||
from crewai.tools import tool as crewai_tool
|
||||
|
||||
@crewai_tool("Weather Tool")
|
||||
def get_weather_crew(city: Literal['nyc', 'sf']):
|
||||
"""Get weather info."""
|
||||
return 'sunny' if city == 'sf' else 'cloudy'
|
||||
|
||||
results['CrewAI'] = measure(
|
||||
lambda: CrewAgent(
|
||||
role='Weather Agent',
|
||||
goal='Provide weather info',
|
||||
backstory='A weather expert',
|
||||
tools=[get_weather_crew],
|
||||
verbose=False
|
||||
)
|
||||
)
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Print results
|
||||
print('\n' + '=' * 60)
|
||||
print('RESULTS')
|
||||
print('=' * 60)
|
||||
log('\n' + '=' * 60)
|
||||
log('RESULTS')
|
||||
log('=' * 60)
|
||||
|
||||
baseline = results.get('PraisonAI', 1)
|
||||
print(f"\n{'Framework':<25} {'Avg Time (μs)':<15} {'Relative':<10}")
|
||||
print('-' * 50)
|
||||
log(f"\n{'Framework':<25} {'Avg Time (μs)':<15} {'Relative':<10}")
|
||||
log('-' * 50)
|
||||
|
||||
for name, avg in sorted(results.items(), key=lambda x: x[1]):
|
||||
ratio = avg / baseline
|
||||
print(f'{name:<25} {avg:<15.2f} {ratio:.2f}x')
|
||||
log(f'{name:<25} {avg:<15.2f} {ratio:.2f}x')
|
||||
|
||||
print('\n' + '=' * 60)
|
||||
log('\n' + '=' * 60)
|
||||
return results
|
||||
|
||||
|
||||
@@ -163,8 +188,13 @@ def get_package_versions():
|
||||
return versions
|
||||
|
||||
|
||||
def save_results(results: dict, filename: str = 'BENCHMARK_RESULTS.md'):
|
||||
"""Save benchmark results to a markdown file and update README."""
|
||||
def save_results(results: dict, filename: str = 'BENCHMARK_RESULTS.md', *,
|
||||
iterations: int = ITERATIONS, log=print):
|
||||
"""Save benchmark results to a markdown file and update README.
|
||||
|
||||
Status messages are emitted via ``log`` (defaults to ``print``). Pass a
|
||||
stderr logger when stdout must stay machine-readable (e.g. ``--json``).
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
@@ -191,7 +221,7 @@ def save_results(results: dict, filename: str = 'BENCHMARK_RESULTS.md'):
|
||||
with open(filepath, 'w') as f:
|
||||
f.write('# PraisonAI Agents - Benchmark Results\n\n')
|
||||
f.write(f'**Generated:** {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}\n')
|
||||
f.write(f'**Iterations:** {ITERATIONS}\n')
|
||||
f.write(f'**Iterations:** {iterations}\n')
|
||||
f.write('**Test:** Agent instantiation (without tools)\n\n')
|
||||
f.write('## Results\n\n')
|
||||
f.write('| Framework | Avg Time (μs) | Relative |\n')
|
||||
@@ -208,17 +238,17 @@ def save_results(results: dict, filename: str = 'BENCHMARK_RESULTS.md'):
|
||||
f.write('python benchmarks/simple_benchmark.py\n')
|
||||
f.write('```\n')
|
||||
|
||||
print(f'\nResults saved to: {filepath}')
|
||||
log(f'\nResults saved to: {filepath}')
|
||||
|
||||
# Also update the main README.md
|
||||
readme_path = os.path.join(os.path.dirname(__file__), '..', '..', '..', 'README.md')
|
||||
if os.path.exists(readme_path):
|
||||
update_readme(readme_path, table_rows)
|
||||
update_readme(readme_path, table_rows, log=log)
|
||||
|
||||
return filepath
|
||||
|
||||
|
||||
def update_readme(readme_path: str, table_rows: list):
|
||||
def update_readme(readme_path: str, table_rows: list, *, log=print):
|
||||
"""Update the performance section in README.md with latest results."""
|
||||
import re
|
||||
|
||||
@@ -239,17 +269,54 @@ def update_readme(readme_path: str, table_rows: list):
|
||||
with open(readme_path, 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
print(f'README.md updated: {readme_path}')
|
||||
log(f'README.md updated: {readme_path}')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='PraisonAI Agents - Performance Benchmark')
|
||||
parser.add_argument('--save', action='store_true', help='Save results to file')
|
||||
parser.add_argument('--skip-crewai', action='store_true',
|
||||
help='Skip the optional CrewAI benchmark (its import can hang on some platforms)')
|
||||
parser.add_argument('--json', action='store_true',
|
||||
help='Emit results as JSON to stdout (for CI baselines/artifacts)')
|
||||
parser.add_argument('--fail-over-threshold', action='store_true',
|
||||
help='Exit non-zero if PraisonAI instantiation exceeds the budget in thresholds.json')
|
||||
args = parser.parse_args()
|
||||
|
||||
thresholds = load_thresholds()
|
||||
iterations = int(thresholds.get('iterations', ITERATIONS))
|
||||
warmup = int(thresholds.get('warmup', WARMUP))
|
||||
|
||||
results = run_benchmark(
|
||||
skip_crewai=args.skip_crewai,
|
||||
quiet=args.json,
|
||||
iterations=iterations,
|
||||
warmup=warmup,
|
||||
)
|
||||
|
||||
if args.save:
|
||||
# Keep stdout machine-readable under --json by sending save status to stderr.
|
||||
save_log = (lambda msg='': print(msg, file=sys.stderr)) if args.json else print
|
||||
save_results(results, iterations=iterations, log=save_log)
|
||||
elif not args.json:
|
||||
print('\nResults not saved (use --save flag to save results to file)')
|
||||
|
||||
exit_code = 0
|
||||
if args.fail_over_threshold:
|
||||
max_us = thresholds['praisonai_instantiation_us_max']
|
||||
actual_us = results.get('PraisonAI')
|
||||
if actual_us is not None and actual_us > max_us:
|
||||
print(
|
||||
f"ERROR: PraisonAI instantiation {actual_us:.2f} us > threshold {max_us} us",
|
||||
file=sys.stderr,
|
||||
)
|
||||
exit_code = 1
|
||||
|
||||
if args.json:
|
||||
print(json.dumps({'results': results, 'iterations': iterations, 'warmup': warmup}))
|
||||
|
||||
return exit_code
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description='PraisonAI Agents - Performance Benchmark')
|
||||
parser.add_argument('--save', action='store_true', help='Save results to file')
|
||||
args = parser.parse_args()
|
||||
|
||||
results = run_benchmark()
|
||||
|
||||
if args.save:
|
||||
save_results(results)
|
||||
else:
|
||||
print('\nResults not saved (use --save flag to save results to file)')
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"praisonai_instantiation_us_max": 450,
|
||||
"iterations": 100,
|
||||
"warmup": 10
|
||||
}
|
||||
@@ -7,10 +7,7 @@ from praisonaiagents._logging import get_logger
|
||||
import asyncio
|
||||
import contextlib
|
||||
import threading
|
||||
import concurrent.futures
|
||||
import random
|
||||
import re
|
||||
from typing import List, Optional, Any, Dict, Union, Literal, TYPE_CHECKING, Callable, Generator
|
||||
from typing import List, Optional, Any, Dict, Union, Literal, TYPE_CHECKING, Callable
|
||||
from collections import OrderedDict
|
||||
import inspect
|
||||
|
||||
@@ -7126,6 +7123,25 @@ Answer:"""
|
||||
"""The most recent rejection message a guardrail sent back to the model."""
|
||||
return getattr(self, "_last_guardrail_error", None)
|
||||
|
||||
def _guardrail_blocked_message(self, error=None):
|
||||
"""Fixed, user-facing string returned when an output guardrail blocks a run.
|
||||
|
||||
A blocked response must never surface the rejected (potentially unsafe)
|
||||
model text, but it must also never be ``None`` - callers expect a
|
||||
``str`` and would otherwise crash on ``.strip()`` (issue #5596, FR-001).
|
||||
|
||||
The returned string is intentionally **fixed** and carries no diagnostic
|
||||
detail: a guardrail's own rejection reason (especially an LLM validator's
|
||||
free-text) can echo the blocked output, so appending it would leak the
|
||||
very content the guardrail rejected. The reason is logged instead, where
|
||||
it stays auditable without reaching the caller. The rejection is also
|
||||
exposed programmatically via :attr:`last_guardrail_error`.
|
||||
"""
|
||||
reason = str(error) if error else getattr(self, "_last_guardrail_error", None)
|
||||
if reason:
|
||||
logging.info(f"Agent {self.name}: guardrail block reason: {reason}")
|
||||
return "I'm sorry, but I can't share that response because it did not pass the safety guardrail."
|
||||
|
||||
def _process_guardrail(self, task_output):
|
||||
"""Process the guardrail validation for a task output.
|
||||
|
||||
|
||||
@@ -751,6 +751,43 @@ Your Goal: {self.goal}"""
|
||||
|
||||
return None
|
||||
|
||||
def _coerce_structured_output(self, response_text, schema_model):
|
||||
"""Parse a string response into the requested Pydantic model.
|
||||
|
||||
When ``output_pydantic``/``output_json`` is a Pydantic model, callers
|
||||
expect a validated instance rather than a raw string. This tolerates
|
||||
markdown code fences around the JSON payload.
|
||||
|
||||
Returns the parsed model instance on success, or the original
|
||||
``response_text`` unchanged if parsing is not possible (preserves
|
||||
backward compatibility for non-Pydantic schemas or malformed output).
|
||||
"""
|
||||
if not schema_model or not isinstance(response_text, str):
|
||||
return response_text
|
||||
if not hasattr(schema_model, 'model_validate_json'):
|
||||
return response_text
|
||||
|
||||
candidate = response_text.strip()
|
||||
try:
|
||||
return schema_model.model_validate_json(candidate)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Strip a surrounding markdown code fence (```json ... ```) if present
|
||||
fenced = re.search(r"```(?:json)?\s*(.*?)\s*```", candidate, re.DOTALL)
|
||||
if fenced:
|
||||
try:
|
||||
return schema_model.model_validate_json(fenced.group(1).strip())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logging.warning(
|
||||
f"Agent {self.name}: output_pydantic={getattr(schema_model, '__name__', schema_model)} "
|
||||
f"was requested but the response could not be parsed into the model; "
|
||||
f"returning raw string."
|
||||
)
|
||||
return response_text
|
||||
|
||||
def _supports_native_structured_output(self):
|
||||
"""Check if current model supports native structured output via response_format.
|
||||
|
||||
@@ -3562,6 +3599,13 @@ Your Goal: {self.goal}"""
|
||||
# Apply guardrail validation for custom LLM response
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token, messages=processed_history)
|
||||
# Coerce into the requested Pydantic model so callers
|
||||
# get a validated instance, not a raw JSON string.
|
||||
# Only output_pydantic changes the return type; output_json
|
||||
# must stay a string for backward-compatible downstream
|
||||
# JSON parsing (task parsing, workflow results).
|
||||
if output_pydantic:
|
||||
validated_response = self._coerce_structured_output(validated_response, output_pydantic)
|
||||
# Execute callback and display after validation
|
||||
self._execute_callback_and_display(prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return self._trigger_after_agent_hook(prompt, validated_response, start_time)
|
||||
@@ -3569,7 +3613,7 @@ Your Goal: {self.goal}"""
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for custom LLM: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self._rollback_chat_history_to(chat_history_length)
|
||||
return None
|
||||
return self._guardrail_blocked_message(e)
|
||||
except ToolExecutionError:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -3693,6 +3737,11 @@ Your Goal: {self.goal}"""
|
||||
# Apply guardrail validation even for JSON output
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token, messages=messages)
|
||||
# Coerce into the requested Pydantic model so callers
|
||||
# get a validated instance, not a raw JSON string.
|
||||
# output_json intentionally stays a string (backward compat).
|
||||
if output_pydantic:
|
||||
validated_response = self._coerce_structured_output(validated_response, output_pydantic)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
@@ -3700,7 +3749,7 @@ Your Goal: {self.goal}"""
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for JSON output: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self._rollback_chat_history_to(chat_history_length)
|
||||
return None
|
||||
return self._guardrail_blocked_message(e)
|
||||
|
||||
if not self.self_reflect:
|
||||
# User message already added before LLM call via _build_messages
|
||||
@@ -3721,7 +3770,7 @@ Your Goal: {self.goal}"""
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for reasoning content: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self._rollback_chat_history_to(chat_history_length)
|
||||
return None
|
||||
return self._guardrail_blocked_message(e)
|
||||
# Apply guardrail to regular response
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token, messages=messages)
|
||||
@@ -3732,7 +3781,7 @@ Your Goal: {self.goal}"""
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self._rollback_chat_history_to(chat_history_length)
|
||||
return None
|
||||
return self._guardrail_blocked_message(e)
|
||||
|
||||
reflection_prompt = f"""
|
||||
Reflect on your previous response: '{response_text}'.
|
||||
@@ -3800,7 +3849,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# Rollback chat history on guardrail failure
|
||||
self._rollback_chat_history_to(chat_history_length)
|
||||
self._end_run(None, "error", {"error": str(e)})
|
||||
return None
|
||||
return self._guardrail_blocked_message(e)
|
||||
|
||||
# Check if we've hit max reflections
|
||||
if reflection_count >= self.max_reflect - 1:
|
||||
@@ -3818,7 +3867,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed after max reflections: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self._rollback_chat_history_to(chat_history_length)
|
||||
return None
|
||||
return self._guardrail_blocked_message(e)
|
||||
|
||||
# If not satisfactory and not at max reflections, continue with regeneration
|
||||
logging.debug(f"{self.name} reflection count {reflection_count + 1}, continuing reflection process")
|
||||
@@ -4228,6 +4277,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# Apply guardrail validation for custom LLM response
|
||||
try:
|
||||
validated_response = await self._aapply_guardrail_with_retry(response_text, prompt, temperature, tools, task_name, task_description, task_id, messages=effective_history)
|
||||
# Coerce into the requested Pydantic model so callers
|
||||
# get a validated instance, not a raw JSON string.
|
||||
# output_json intentionally stays a string (backward compat).
|
||||
if output_pydantic:
|
||||
validated_response = self._coerce_structured_output(validated_response, output_pydantic)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(normalized_content, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return await self._atrigger_after_agent_hook(prompt, validated_response, start_time)
|
||||
@@ -4235,7 +4289,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for custom LLM: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self._rollback_chat_history_to(chat_history_length)
|
||||
return None
|
||||
return self._guardrail_blocked_message(e)
|
||||
except ToolExecutionError:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -4373,6 +4427,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# Apply guardrail validation even for JSON output
|
||||
try:
|
||||
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, messages=messages)
|
||||
# Coerce into the requested Pydantic model so callers
|
||||
# get a validated instance, not a raw JSON string.
|
||||
# output_json intentionally stays a string (backward compat).
|
||||
if output_pydantic:
|
||||
validated_response = self._coerce_structured_output(validated_response, output_pydantic)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return await self._atrigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
@@ -4380,7 +4439,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for JSON output: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self._rollback_chat_history_to(chat_history_length)
|
||||
return None
|
||||
return self._guardrail_blocked_message(e)
|
||||
|
||||
# For regular responses (no self-reflection)
|
||||
if not self.self_reflect:
|
||||
@@ -4403,7 +4462,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for reasoning content: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self._rollback_chat_history_to(chat_history_length)
|
||||
return None
|
||||
return self._guardrail_blocked_message(e)
|
||||
else:
|
||||
# Apply guardrail to regular response content
|
||||
try:
|
||||
@@ -4415,7 +4474,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self._rollback_chat_history_to(chat_history_length)
|
||||
return None
|
||||
return self._guardrail_blocked_message(e)
|
||||
|
||||
# If self-reflection is enabled, implement reflection logic
|
||||
if self.self_reflect:
|
||||
@@ -4455,7 +4514,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self._rollback_chat_history_to(chat_history_length)
|
||||
return None
|
||||
return self._guardrail_blocked_message(e)
|
||||
|
||||
_areflection_kwargs = {}
|
||||
if temperature is not None:
|
||||
@@ -4489,7 +4548,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed after reflection: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self._rollback_chat_history_to(chat_history_length)
|
||||
return None
|
||||
return self._guardrail_blocked_message(e)
|
||||
|
||||
# Check if we've hit max reflections
|
||||
if reflection_count >= self.max_reflect - 1:
|
||||
@@ -4508,7 +4567,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed after max reflections: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self._rollback_chat_history_to(chat_history_length)
|
||||
return None
|
||||
return self._guardrail_blocked_message(e)
|
||||
|
||||
# Regenerate response based on reflection
|
||||
regenerate_messages = reflection_messages + [
|
||||
@@ -4558,7 +4617,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed after reflection error: {guard_e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self._rollback_chat_history_to(chat_history_length)
|
||||
return None
|
||||
return self._guardrail_blocked_message(guard_e)
|
||||
continue
|
||||
|
||||
# This should never be reached due to the returns above
|
||||
@@ -4608,6 +4667,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if get_logger().getEffectiveLevel() == logging.DEBUG:
|
||||
total_time = time.time() - start_time
|
||||
logging.debug(f"Agent.achat completed in {total_time:.2f} seconds")
|
||||
# Coerce into the requested Pydantic model so callers
|
||||
# get a validated instance, not a raw JSON string.
|
||||
# output_json intentionally stays a string (backward compat).
|
||||
if output_pydantic:
|
||||
response_text = self._coerce_structured_output(response_text, output_pydantic)
|
||||
# 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 await self._atrigger_after_agent_hook(original_prompt, response_text, start_time)
|
||||
@@ -4722,7 +4786,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for OpenAI client: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self._rollback_chat_history_to(chat_history_length)
|
||||
return None
|
||||
return self._guardrail_blocked_message(e)
|
||||
except ToolExecutionError:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
||||
@@ -764,6 +764,50 @@ class MemoryMixin:
|
||||
except Exception as e:
|
||||
logging.debug(f"Auto-memory extraction failed: {e}")
|
||||
|
||||
def _persist_memory_turn(self, user_message: str, assistant_response: str):
|
||||
"""Persist a raw conversation turn to the memory store.
|
||||
|
||||
Called after each response when ``_memory_instance`` is set but
|
||||
``auto_memory`` extraction is off. Stores the turn to short-term memory
|
||||
so a later Agent sharing the same store (e.g. ``memory={"user_id": uid}``)
|
||||
recalls it via ``get_memory_context()``. Best-effort: never raises.
|
||||
|
||||
Async-safe: a memory store write is blocking file/DB I/O. When this runs
|
||||
inside a live event loop (the async ``achat``/``astart`` after-agent path),
|
||||
the write is offloaded to a worker thread so it never stalls the loop
|
||||
(AGENTS.md §4.5). On the sync path (no running loop) it writes inline.
|
||||
"""
|
||||
memory = getattr(self, "_memory_instance", None)
|
||||
if memory is None or not assistant_response:
|
||||
return
|
||||
store = getattr(memory, "store_short_term", None) or getattr(memory, "add_short_term", None)
|
||||
if store is None:
|
||||
return
|
||||
text = f"User: {user_message}\nAssistant: {assistant_response}"
|
||||
metadata = {"agent_id": getattr(self, "agent_id", getattr(self, "name", None))}
|
||||
|
||||
def _do_store():
|
||||
try:
|
||||
store(text, metadata=metadata)
|
||||
except Exception as e:
|
||||
logging.debug(f"Memory turn persistence failed: {e}")
|
||||
|
||||
try:
|
||||
import asyncio
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
# No running loop (sync chat/run/start path): write inline.
|
||||
_do_store()
|
||||
return
|
||||
# Inside a live loop: offload the blocking write so it cannot stall the
|
||||
# event loop. Fire-and-forget — persistence is best-effort.
|
||||
try:
|
||||
import asyncio
|
||||
asyncio.get_running_loop().run_in_executor(None, _do_store)
|
||||
except Exception as e:
|
||||
logging.debug(f"Memory turn persistence scheduling failed: {e}")
|
||||
_do_store()
|
||||
|
||||
def _process_auto_learning(self):
|
||||
"""Process auto-learning extraction after agent response.
|
||||
|
||||
|
||||
@@ -547,6 +547,15 @@ class ToolExecutionMixin:
|
||||
so disabling or unregistering a plugin revokes its tools from this
|
||||
agent's discovery and execution paths.
|
||||
"""
|
||||
# Hot-path fast exit: when no plugins are registered (the common case)
|
||||
# skip constructing/scanning the manager entirely on every Agent init.
|
||||
try:
|
||||
from ..plugins.manager import has_registered_plugins
|
||||
|
||||
if not has_registered_plugins():
|
||||
return
|
||||
except Exception: # pragma: no cover - defensive plugin boundary
|
||||
pass
|
||||
try:
|
||||
from ..plugins import get_plugin_manager
|
||||
|
||||
@@ -673,6 +682,15 @@ class ToolExecutionMixin:
|
||||
prevent the Agent from constructing. Zero overhead when no such plugin
|
||||
is enabled (each collector returns an empty list).
|
||||
"""
|
||||
# Hot-path fast exit: when no plugins are registered (the common case)
|
||||
# skip constructing/scanning the manager entirely on every Agent init.
|
||||
try:
|
||||
from ..plugins.manager import has_registered_plugins
|
||||
|
||||
if not has_registered_plugins():
|
||||
return
|
||||
except Exception: # pragma: no cover - defensive plugin boundary
|
||||
pass
|
||||
try:
|
||||
from ..plugins import get_plugin_manager
|
||||
|
||||
@@ -1904,6 +1922,11 @@ class ToolExecutionMixin:
|
||||
``_atrigger_after_agent_hook`` without any async-safety change.
|
||||
"""
|
||||
from ..hooks import HookEvent, AfterAgentInput
|
||||
# AfterAgentInput.response is typed/serialized as a string (its
|
||||
# to_dict truncates via ``response[:500]``). Structured-output turns
|
||||
# (output_pydantic) return a Pydantic instance, so coerce to str here
|
||||
# to keep string-only hooks (e.g. command hooks) from crashing while
|
||||
# the public chat() return value stays the model.
|
||||
return AfterAgentInput(
|
||||
session_id=getattr(self, '_session_id', 'default'),
|
||||
cwd=os.getcwd(),
|
||||
@@ -1911,7 +1934,7 @@ class ToolExecutionMixin:
|
||||
timestamp=str(time.time()),
|
||||
agent_name=self.name,
|
||||
prompt=prompt if isinstance(prompt, str) else str(prompt),
|
||||
response=response or "",
|
||||
response=response if isinstance(response, str) else (str(response) if response else ""),
|
||||
tools_used=tools_used or [],
|
||||
total_tokens=0,
|
||||
execution_time_ms=(time.time() - start_time) * 1000
|
||||
@@ -1928,7 +1951,16 @@ class ToolExecutionMixin:
|
||||
# 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))
|
||||
if getattr(self, "_auto_memory", False):
|
||||
self._process_auto_memory(prompt_str, str(response))
|
||||
else:
|
||||
# When memory is enabled but auto_memory extraction is off, still
|
||||
# persist the raw turn so a later Agent sharing the same memory
|
||||
# (e.g. memory={"user_id": uid}) can recall it. Without this the
|
||||
# sync chat/run/start path wrote nothing and cross-session recall
|
||||
# silently returned empty (issue #5595). Mirrors the write already
|
||||
# done on the async unified path.
|
||||
self._persist_memory_turn(prompt_str, str(response))
|
||||
|
||||
# Auto-learning extraction (opt-in via LearnConfig(mode=LearnMode.AGENTIC))
|
||||
self._process_auto_learning()
|
||||
|
||||
@@ -125,6 +125,13 @@ class BotConfig:
|
||||
|
||||
# Edit interval for streaming responses in milliseconds (default: 700ms)
|
||||
stream_edit_interval_ms: int = 700
|
||||
|
||||
# Opt-in privacy-safe runtime footer on the final reply (default: False).
|
||||
# When True, a short trailing line (e.g. ``— gpt-4o · 4.2s``) reporting the
|
||||
# model and turn latency is appended to the final reply only. Privacy-safe:
|
||||
# never surfaces tool args, URLs, paths or message content. Unavailable
|
||||
# fields are omitted rather than shown as placeholders.
|
||||
footer: bool = False
|
||||
|
||||
# Intentional silence support
|
||||
allow_silence: bool = False
|
||||
@@ -185,6 +192,7 @@ class BotConfig:
|
||||
"owner_user_id": "***" if self.owner_user_id else None,
|
||||
"streaming": self.streaming,
|
||||
"stream_edit_interval_ms": self.stream_edit_interval_ms,
|
||||
"footer": self.footer,
|
||||
"allow_silence": self.allow_silence,
|
||||
"silence_token": self.silence_token,
|
||||
"allow_bots": self.allow_bots,
|
||||
|
||||
@@ -1002,14 +1002,6 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
]):
|
||||
return "idle_timeout"
|
||||
|
||||
# Format errors
|
||||
if any(indicator in error_str for indicator in [
|
||||
"validation error", "invalid format", "parse error",
|
||||
"parsing error", "decode error", "malformed",
|
||||
"invalid json", "schema error"
|
||||
]):
|
||||
return "format_error"
|
||||
|
||||
# Default fallback
|
||||
return "unknown"
|
||||
|
||||
@@ -2039,98 +2031,6 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
schemas.append(f"- {name}({', '.join(param_strs)})")
|
||||
return '\n'.join(schemas) if schemas else "None"
|
||||
|
||||
def _param_accepts_string(self, function_name, param_name, tools) -> bool:
|
||||
"""True when a tool declares this parameter as accepting a string.
|
||||
|
||||
Used to decide whether an argument whose value matches a previous tool's
|
||||
name is a legitimate string or a weak model's way of referring to that
|
||||
tool's result. A declared string parameter is left alone.
|
||||
"""
|
||||
try:
|
||||
for tool in tools or []:
|
||||
fn = tool.get("function") if isinstance(tool, dict) else None
|
||||
if not fn or fn.get("name") != function_name:
|
||||
continue
|
||||
spec = (fn.get("parameters") or {}).get("properties", {}).get(param_name)
|
||||
if not isinstance(spec, dict):
|
||||
return False
|
||||
return self._schema_accepts_string(spec)
|
||||
except Exception: # noqa: BLE001 -- never break a tool call on a schema read
|
||||
return False
|
||||
return False
|
||||
|
||||
def _schema_accepts_string(self, spec) -> bool:
|
||||
"""True when a JSON-Schema fragment can accept a string value.
|
||||
|
||||
Handles the shapes this repo's own generator emits: a bare
|
||||
``{"type": "string"}``, a list type ``{"type": ["string", "null"]}``,
|
||||
and the ``anyOf``/``oneOf``/``allOf`` unions produced for ``Optional[str]``
|
||||
and ``Union`` parameters (``tools/schema.py``). Without the union case an
|
||||
``Optional[str]`` argument was still treated as a result reference and
|
||||
silently overwritten.
|
||||
"""
|
||||
if not isinstance(spec, dict):
|
||||
return False
|
||||
declared = spec.get("type")
|
||||
if isinstance(declared, list):
|
||||
if "string" in declared:
|
||||
return True
|
||||
elif declared == "string":
|
||||
return True
|
||||
# enum without an explicit type is string-typed in JSON Schema practice
|
||||
if declared is None and isinstance(spec.get("enum"), list):
|
||||
if any(isinstance(v, str) for v in spec["enum"]):
|
||||
return True
|
||||
for key in ("anyOf", "oneOf", "allOf"):
|
||||
members = spec.get(key)
|
||||
if isinstance(members, list) and any(
|
||||
self._schema_accepts_string(m) for m in members):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _force_tool_usage_message(self, response_text, tool_calls, formatted_tools,
|
||||
iteration_count):
|
||||
"""The nudge to send when a model ignored tools it should have used.
|
||||
|
||||
Returns the message content, or None when no nudge is warranted. Shared
|
||||
so every response path applies the same policy -- this lived inline in
|
||||
get_response only, so an agent that was awaited instead of called
|
||||
silently lost a setting it had accepted.
|
||||
"""
|
||||
if not self._should_force_tool_usage(
|
||||
response_text, tool_calls, formatted_tools, iteration_count):
|
||||
return None
|
||||
tool_names = self._get_tool_names_for_prompt(formatted_tools)
|
||||
logging.debug(
|
||||
f"[OLLAMA_RELIABILITY] Force tool usage triggered. Adding prompt for tools: {tool_names}")
|
||||
return self.FORCE_TOOL_USAGE_PROMPT.format(tool_names=tool_names)
|
||||
|
||||
def _tool_repair_message(self, tool_calls, formatted_tools):
|
||||
"""The correction to send when a model produced an invalid tool call.
|
||||
|
||||
Returns the message content and charges the repair budget, or None when
|
||||
the calls are valid or the budget is spent. Shared for the same reason
|
||||
as _force_tool_usage_message.
|
||||
"""
|
||||
repair_attempt_count = getattr(self, '_current_repair_count', 0)
|
||||
max_tool_repairs = getattr(self, 'max_tool_repairs', 0)
|
||||
if not (tool_calls and max_tool_repairs > 0
|
||||
and repair_attempt_count < max_tool_repairs):
|
||||
return None
|
||||
validation_errors = [e for e in
|
||||
(self._validate_tool_call(tc, formatted_tools) for tc in tool_calls)
|
||||
if e]
|
||||
if not validation_errors:
|
||||
return None
|
||||
error_msg = "; ".join(validation_errors)
|
||||
tool_schemas = self._get_tool_schemas_for_prompt(formatted_tools)
|
||||
logging.debug(
|
||||
f"[OLLAMA_RELIABILITY] Tool call repair attempt "
|
||||
f"{repair_attempt_count + 1}/{max_tool_repairs}: {error_msg}")
|
||||
self._current_repair_count = repair_attempt_count + 1
|
||||
return self.TOOL_CALL_REPAIR_PROMPT.format(
|
||||
error=error_msg, tool_schemas=tool_schemas)
|
||||
|
||||
def _should_force_tool_usage(self, response_text: str, tool_calls: Optional[List], formatted_tools: Optional[List], iteration_count: int) -> bool:
|
||||
"""
|
||||
Determine if we should force tool usage based on settings and context.
|
||||
@@ -6565,6 +6465,9 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
finish_reason = getattr(choice, "finish_reason", None)
|
||||
msg = getattr(choice, "message", None)
|
||||
refusal = getattr(msg, "refusal", None) if msg is not None else None
|
||||
if not choices:
|
||||
from .openai_client import OpenAIClient
|
||||
finish_reason = OpenAIClient._responses_incomplete_finish_reason(response)
|
||||
if finish_reason is None and not refusal:
|
||||
return
|
||||
from ..agent.run_outcome import classify_finish_reason
|
||||
@@ -7128,7 +7031,9 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# (input_text / input_image); local image paths become
|
||||
# data URLs. Plain-string content is passed through.
|
||||
from .openai_client import OpenAIClient
|
||||
item = dict(msg)
|
||||
# Do not forward persisted metadata/provider extensions as
|
||||
# Responses message properties.
|
||||
item = {"role": role}
|
||||
item["content"] = OpenAIClient._build_responses_content(
|
||||
msg.get("content", "")
|
||||
)
|
||||
@@ -7256,6 +7161,15 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
self._track_token_usage(response, self._response_model_for_tracking(response))
|
||||
return response
|
||||
|
||||
def _raise_responses_failure(self, error) -> None:
|
||||
"""Surface a failed Responses request without accepting partial output."""
|
||||
code = error.get("code") if isinstance(error, dict) else getattr(error, "code", None)
|
||||
message = error.get("message") if isinstance(error, dict) else getattr(error, "message", None)
|
||||
if self._last_stop_reason == "completed":
|
||||
self._last_stop_reason = "error"
|
||||
label = f"Responses API failed ({code})" if code else "Responses API failed"
|
||||
raise LLMResponseError(f"{label}: {message or 'Provider returned a failed response.'}")
|
||||
|
||||
def _extract_from_responses_output(self, response) -> tuple:
|
||||
"""
|
||||
Parse a ``ResponsesAPIResponse`` into the same (text, tool_calls,
|
||||
@@ -7267,11 +7181,16 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
Unlike Chat Completions, text and tool calls are *always* separate
|
||||
items, so ``content`` is never null when text is present.
|
||||
"""
|
||||
status = response.get("status") if isinstance(response, dict) else getattr(response, "status", None)
|
||||
if status == "failed":
|
||||
error = response.get("error") if isinstance(response, dict) else getattr(response, "error", None)
|
||||
self._raise_responses_failure(error)
|
||||
self._record_finish_reason(response)
|
||||
response_text = ""
|
||||
tool_calls: List[Dict[str, Any]] = []
|
||||
reasoning_content = None
|
||||
|
||||
output_items = getattr(response, 'output', None) or []
|
||||
output_items = (response.get('output') if isinstance(response, dict) else getattr(response, 'output', None)) or []
|
||||
for item in output_items:
|
||||
# Handle both dict and object access
|
||||
if isinstance(item, dict):
|
||||
@@ -7329,6 +7248,34 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
return response_text, tool_calls if tool_calls else None, reasoning_content
|
||||
|
||||
@staticmethod
|
||||
def _close_responses_stream(stream) -> None:
|
||||
"""Close a stream or its LiteLLM transport without masking its error."""
|
||||
try:
|
||||
close = getattr(stream, "close", None)
|
||||
if not callable(close):
|
||||
close = getattr(getattr(stream, "response", None), "close", None)
|
||||
if callable(close):
|
||||
close()
|
||||
except Exception:
|
||||
logging.debug("Responses stream cleanup failed", exc_info=True)
|
||||
|
||||
@staticmethod
|
||||
async def _aclose_responses_stream(stream) -> None:
|
||||
"""Await transport cleanup on completion, failure or cancellation."""
|
||||
try:
|
||||
target = stream
|
||||
close = getattr(target, "aclose", None) or getattr(target, "close", None)
|
||||
if not callable(close):
|
||||
target = getattr(stream, "response", None)
|
||||
close = getattr(target, "aclose", None) or getattr(target, "close", None)
|
||||
if callable(close):
|
||||
result = close()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
except Exception:
|
||||
logging.debug("Responses stream cleanup failed", exc_info=True)
|
||||
|
||||
def _stream_responses_api(
|
||||
self,
|
||||
params: Dict[str, Any],
|
||||
@@ -7360,95 +7307,108 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if _emit:
|
||||
from ..streaming.events import StreamEvent, StreamEventType
|
||||
|
||||
for event in litellm.responses(**params):
|
||||
# Access event type — handle both object and dict
|
||||
if isinstance(event, dict):
|
||||
evt_type = event.get("type", "")
|
||||
else:
|
||||
evt_type = getattr(event, "type", "")
|
||||
|
||||
# ── Completed — capture the final response for usage accounting ──
|
||||
if evt_type == "response.completed":
|
||||
_final_response = (event.get("response") if isinstance(event, dict)
|
||||
else getattr(event, "response", None))
|
||||
|
||||
# ── Text delta ──────────────────────────────────────────
|
||||
if evt_type == "response.output_text.delta":
|
||||
delta_text = (event.get("delta", "") if isinstance(event, dict)
|
||||
else getattr(event, "delta", ""))
|
||||
response_text += delta_text
|
||||
if _emit and delta_text:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
timestamp=time.perf_counter(),
|
||||
content=delta_text,
|
||||
is_reasoning=False,
|
||||
))
|
||||
|
||||
# ── Reasoning delta ─────────────────────────────────────
|
||||
elif evt_type == "response.reasoning_summary_text.delta":
|
||||
delta_text = (event.get("delta", "") if isinstance(event, dict)
|
||||
else getattr(event, "delta", ""))
|
||||
if _emit and delta_text:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
timestamp=time.perf_counter(),
|
||||
content=delta_text,
|
||||
is_reasoning=True,
|
||||
))
|
||||
|
||||
# ── Function call arguments delta ───────────────────────
|
||||
elif evt_type == "response.function_call_arguments.delta":
|
||||
idx = (event.get("output_index", 0) if isinstance(event, dict)
|
||||
else getattr(event, "output_index", 0))
|
||||
delta_args = (event.get("delta", "") if isinstance(event, dict)
|
||||
else getattr(event, "delta", ""))
|
||||
if idx not in _pending_tools:
|
||||
item_id = (event.get("item_id", f"tool_{idx}") if isinstance(event, dict)
|
||||
else getattr(event, "item_id", f"tool_{idx}"))
|
||||
_pending_tools[idx] = {
|
||||
"id": item_id,
|
||||
"name": "",
|
||||
"arguments": "",
|
||||
}
|
||||
_pending_tools[idx]["arguments"] += delta_args
|
||||
|
||||
# ── Output item done — finalise tool call metadata ──────
|
||||
elif evt_type == "response.output_item.done":
|
||||
response_stream = litellm.responses(**params)
|
||||
try:
|
||||
for event in response_stream:
|
||||
# Access event type — handle both object and dict
|
||||
if isinstance(event, dict):
|
||||
item = event.get("item", {})
|
||||
idx = event.get("output_index", 0)
|
||||
evt_type = event.get("type", "")
|
||||
else:
|
||||
item = getattr(event, "item", None) or {}
|
||||
idx = getattr(event, "output_index", 0)
|
||||
evt_type = getattr(event, "type", "")
|
||||
|
||||
if isinstance(item, dict):
|
||||
i_type = item.get("type", "")
|
||||
else:
|
||||
i_type = getattr(item, "type", "")
|
||||
if evt_type == "response.failed":
|
||||
failed_response = event.get("response") if isinstance(event, dict) else getattr(event, "response", None)
|
||||
error = failed_response.get("error") if isinstance(failed_response, dict) else getattr(failed_response, "error", None)
|
||||
self._raise_responses_failure(error)
|
||||
if evt_type == "error":
|
||||
self._raise_responses_failure(event)
|
||||
|
||||
if i_type == "function_call":
|
||||
if isinstance(item, dict):
|
||||
call_id = item.get("call_id", item.get("id", f"tool_{idx}"))
|
||||
fn_name = item.get("name", "")
|
||||
fn_args = item.get("arguments", "")
|
||||
else:
|
||||
call_id = getattr(item, "call_id", None) or getattr(item, "id", f"tool_{idx}")
|
||||
fn_name = getattr(item, "name", "")
|
||||
fn_args = getattr(item, "arguments", "")
|
||||
# ── Completed — capture the final response for usage accounting ──
|
||||
if evt_type in ("response.completed", "response.incomplete"):
|
||||
_final_response = (event.get("response") if isinstance(event, dict)
|
||||
else getattr(event, "response", None))
|
||||
self._record_finish_reason(_final_response)
|
||||
|
||||
if idx in _pending_tools:
|
||||
_pending_tools[idx]["id"] = call_id
|
||||
_pending_tools[idx]["name"] = fn_name
|
||||
# Prefer the accumulated delta args if available, else use final
|
||||
if not _pending_tools[idx]["arguments"]:
|
||||
_pending_tools[idx]["arguments"] = fn_args
|
||||
else:
|
||||
# ── Text delta ──────────────────────────────────────────
|
||||
if evt_type == "response.output_text.delta":
|
||||
delta_text = (event.get("delta", "") if isinstance(event, dict)
|
||||
else getattr(event, "delta", ""))
|
||||
response_text += delta_text
|
||||
if _emit and delta_text:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
timestamp=time.perf_counter(),
|
||||
content=delta_text,
|
||||
is_reasoning=False,
|
||||
))
|
||||
|
||||
# ── Reasoning delta ─────────────────────────────────────
|
||||
elif evt_type == "response.reasoning_summary_text.delta":
|
||||
delta_text = (event.get("delta", "") if isinstance(event, dict)
|
||||
else getattr(event, "delta", ""))
|
||||
if _emit and delta_text:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
timestamp=time.perf_counter(),
|
||||
content=delta_text,
|
||||
is_reasoning=True,
|
||||
))
|
||||
|
||||
# ── Function call arguments delta ───────────────────────
|
||||
elif evt_type == "response.function_call_arguments.delta":
|
||||
idx = (event.get("output_index", 0) if isinstance(event, dict)
|
||||
else getattr(event, "output_index", 0))
|
||||
delta_args = (event.get("delta", "") if isinstance(event, dict)
|
||||
else getattr(event, "delta", ""))
|
||||
if idx not in _pending_tools:
|
||||
item_id = (event.get("item_id", f"tool_{idx}") if isinstance(event, dict)
|
||||
else getattr(event, "item_id", f"tool_{idx}"))
|
||||
_pending_tools[idx] = {
|
||||
"id": call_id,
|
||||
"name": fn_name,
|
||||
"arguments": fn_args,
|
||||
"id": item_id,
|
||||
"name": "",
|
||||
"arguments": "",
|
||||
}
|
||||
_pending_tools[idx]["arguments"] += delta_args
|
||||
|
||||
# ── Output item done — finalise tool call metadata ──────
|
||||
elif evt_type == "response.output_item.done":
|
||||
if isinstance(event, dict):
|
||||
item = event.get("item", {})
|
||||
idx = event.get("output_index", 0)
|
||||
else:
|
||||
item = getattr(event, "item", None) or {}
|
||||
idx = getattr(event, "output_index", 0)
|
||||
|
||||
if isinstance(item, dict):
|
||||
i_type = item.get("type", "")
|
||||
else:
|
||||
i_type = getattr(item, "type", "")
|
||||
|
||||
if i_type == "function_call":
|
||||
if isinstance(item, dict):
|
||||
call_id = item.get("call_id", item.get("id", f"tool_{idx}"))
|
||||
fn_name = item.get("name", "")
|
||||
fn_args = item.get("arguments", "")
|
||||
else:
|
||||
call_id = getattr(item, "call_id", None) or getattr(item, "id", f"tool_{idx}")
|
||||
fn_name = getattr(item, "name", "")
|
||||
fn_args = getattr(item, "arguments", "")
|
||||
|
||||
if idx in _pending_tools:
|
||||
_pending_tools[idx]["id"] = call_id
|
||||
_pending_tools[idx]["name"] = fn_name
|
||||
# Prefer the accumulated delta args if available, else use final
|
||||
if not _pending_tools[idx]["arguments"]:
|
||||
_pending_tools[idx]["arguments"] = fn_args
|
||||
else:
|
||||
_pending_tools[idx] = {
|
||||
"id": call_id,
|
||||
"name": fn_name,
|
||||
"arguments": fn_args,
|
||||
}
|
||||
|
||||
finally:
|
||||
self._close_responses_stream(response_stream)
|
||||
|
||||
# ── Build tool_calls list in output_index order ─────────────
|
||||
tool_calls: List[Dict[str, Any]] = []
|
||||
@@ -7491,80 +7451,93 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if _emit:
|
||||
from ..streaming.events import StreamEvent, StreamEventType
|
||||
|
||||
async for event in await litellm.aresponses(**params):
|
||||
if isinstance(event, dict):
|
||||
evt_type = event.get("type", "")
|
||||
else:
|
||||
evt_type = getattr(event, "type", "")
|
||||
|
||||
if evt_type == "response.completed":
|
||||
_final_response = (event.get("response") if isinstance(event, dict)
|
||||
else getattr(event, "response", None))
|
||||
|
||||
if evt_type == "response.output_text.delta":
|
||||
delta_text = (event.get("delta", "") if isinstance(event, dict)
|
||||
else getattr(event, "delta", ""))
|
||||
response_text += delta_text
|
||||
if _emit and delta_text:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
timestamp=time.perf_counter(),
|
||||
content=delta_text,
|
||||
is_reasoning=False,
|
||||
))
|
||||
|
||||
elif evt_type == "response.reasoning_summary_text.delta":
|
||||
delta_text = (event.get("delta", "") if isinstance(event, dict)
|
||||
else getattr(event, "delta", ""))
|
||||
if _emit and delta_text:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
timestamp=time.perf_counter(),
|
||||
content=delta_text,
|
||||
is_reasoning=True,
|
||||
))
|
||||
|
||||
elif evt_type == "response.function_call_arguments.delta":
|
||||
idx = (event.get("output_index", 0) if isinstance(event, dict)
|
||||
else getattr(event, "output_index", 0))
|
||||
delta_args = (event.get("delta", "") if isinstance(event, dict)
|
||||
else getattr(event, "delta", ""))
|
||||
if idx not in _pending_tools:
|
||||
item_id = (event.get("item_id", f"tool_{idx}") if isinstance(event, dict)
|
||||
else getattr(event, "item_id", f"tool_{idx}"))
|
||||
_pending_tools[idx] = {"id": item_id, "name": "", "arguments": ""}
|
||||
_pending_tools[idx]["arguments"] += delta_args
|
||||
|
||||
elif evt_type == "response.output_item.done":
|
||||
response_stream = await litellm.aresponses(**params)
|
||||
try:
|
||||
async for event in response_stream:
|
||||
if isinstance(event, dict):
|
||||
item = event.get("item", {})
|
||||
idx = event.get("output_index", 0)
|
||||
evt_type = event.get("type", "")
|
||||
else:
|
||||
item = getattr(event, "item", None) or {}
|
||||
idx = getattr(event, "output_index", 0)
|
||||
evt_type = getattr(event, "type", "")
|
||||
|
||||
if isinstance(item, dict):
|
||||
i_type = item.get("type", "")
|
||||
else:
|
||||
i_type = getattr(item, "type", "")
|
||||
if evt_type == "response.failed":
|
||||
failed_response = event.get("response") if isinstance(event, dict) else getattr(event, "response", None)
|
||||
error = failed_response.get("error") if isinstance(failed_response, dict) else getattr(failed_response, "error", None)
|
||||
self._raise_responses_failure(error)
|
||||
if evt_type == "error":
|
||||
self._raise_responses_failure(event)
|
||||
|
||||
if evt_type in ("response.completed", "response.incomplete"):
|
||||
_final_response = (event.get("response") if isinstance(event, dict)
|
||||
else getattr(event, "response", None))
|
||||
self._record_finish_reason(_final_response)
|
||||
|
||||
if evt_type == "response.output_text.delta":
|
||||
delta_text = (event.get("delta", "") if isinstance(event, dict)
|
||||
else getattr(event, "delta", ""))
|
||||
response_text += delta_text
|
||||
if _emit and delta_text:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
timestamp=time.perf_counter(),
|
||||
content=delta_text,
|
||||
is_reasoning=False,
|
||||
))
|
||||
|
||||
elif evt_type == "response.reasoning_summary_text.delta":
|
||||
delta_text = (event.get("delta", "") if isinstance(event, dict)
|
||||
else getattr(event, "delta", ""))
|
||||
if _emit and delta_text:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
timestamp=time.perf_counter(),
|
||||
content=delta_text,
|
||||
is_reasoning=True,
|
||||
))
|
||||
|
||||
elif evt_type == "response.function_call_arguments.delta":
|
||||
idx = (event.get("output_index", 0) if isinstance(event, dict)
|
||||
else getattr(event, "output_index", 0))
|
||||
delta_args = (event.get("delta", "") if isinstance(event, dict)
|
||||
else getattr(event, "delta", ""))
|
||||
if idx not in _pending_tools:
|
||||
item_id = (event.get("item_id", f"tool_{idx}") if isinstance(event, dict)
|
||||
else getattr(event, "item_id", f"tool_{idx}"))
|
||||
_pending_tools[idx] = {"id": item_id, "name": "", "arguments": ""}
|
||||
_pending_tools[idx]["arguments"] += delta_args
|
||||
|
||||
elif evt_type == "response.output_item.done":
|
||||
if isinstance(event, dict):
|
||||
item = event.get("item", {})
|
||||
idx = event.get("output_index", 0)
|
||||
else:
|
||||
item = getattr(event, "item", None) or {}
|
||||
idx = getattr(event, "output_index", 0)
|
||||
|
||||
if i_type == "function_call":
|
||||
if isinstance(item, dict):
|
||||
call_id = item.get("call_id", item.get("id", f"tool_{idx}"))
|
||||
fn_name = item.get("name", "")
|
||||
fn_args = item.get("arguments", "")
|
||||
i_type = item.get("type", "")
|
||||
else:
|
||||
call_id = getattr(item, "call_id", None) or getattr(item, "id", f"tool_{idx}")
|
||||
fn_name = getattr(item, "name", "")
|
||||
fn_args = getattr(item, "arguments", "")
|
||||
i_type = getattr(item, "type", "")
|
||||
|
||||
if idx in _pending_tools:
|
||||
_pending_tools[idx]["id"] = call_id
|
||||
_pending_tools[idx]["name"] = fn_name
|
||||
if not _pending_tools[idx]["arguments"]:
|
||||
_pending_tools[idx]["arguments"] = fn_args
|
||||
else:
|
||||
_pending_tools[idx] = {"id": call_id, "name": fn_name, "arguments": fn_args}
|
||||
if i_type == "function_call":
|
||||
if isinstance(item, dict):
|
||||
call_id = item.get("call_id", item.get("id", f"tool_{idx}"))
|
||||
fn_name = item.get("name", "")
|
||||
fn_args = item.get("arguments", "")
|
||||
else:
|
||||
call_id = getattr(item, "call_id", None) or getattr(item, "id", f"tool_{idx}")
|
||||
fn_name = getattr(item, "name", "")
|
||||
fn_args = getattr(item, "arguments", "")
|
||||
|
||||
if idx in _pending_tools:
|
||||
_pending_tools[idx]["id"] = call_id
|
||||
_pending_tools[idx]["name"] = fn_name
|
||||
if not _pending_tools[idx]["arguments"]:
|
||||
_pending_tools[idx]["arguments"] = fn_args
|
||||
else:
|
||||
_pending_tools[idx] = {"id": call_id, "name": fn_name, "arguments": fn_args}
|
||||
|
||||
finally:
|
||||
await self._aclose_responses_stream(response_stream)
|
||||
|
||||
tool_calls: List[Dict[str, Any]] = []
|
||||
for idx in sorted(_pending_tools.keys()):
|
||||
|
||||
@@ -781,7 +781,9 @@ class OpenAIClient:
|
||||
"output": msg.get("content", ""),
|
||||
})
|
||||
else:
|
||||
item = dict(msg)
|
||||
# Persisted history may carry local metadata/provider fields.
|
||||
# Responses message items accept role/content, not that record.
|
||||
item = {"role": role}
|
||||
item["content"] = self._build_responses_content(
|
||||
msg.get("content", "")
|
||||
)
|
||||
@@ -896,16 +898,34 @@ class OpenAIClient:
|
||||
responses_content.append(part)
|
||||
return responses_content
|
||||
|
||||
@staticmethod
|
||||
def _responses_incomplete_finish_reason(response) -> Optional[str]:
|
||||
"""Map known incomplete reasons to existing Chat Completions outcomes."""
|
||||
status = response.get("status") if isinstance(response, dict) else getattr(response, "status", None)
|
||||
if status != "incomplete":
|
||||
return None
|
||||
details = response.get("incomplete_details") if isinstance(response, dict) else getattr(response, "incomplete_details", None)
|
||||
reason = details.get("reason") if isinstance(details, dict) else getattr(details, "reason", None)
|
||||
return {"max_output_tokens": "length", "content_filter": "content_filter"}.get(reason)
|
||||
|
||||
def _responses_to_chat_completion(self, response) -> ChatCompletion:
|
||||
"""
|
||||
Wrap a Responses API response into a ChatCompletion dataclass
|
||||
so that all downstream code (tool loop, display, callbacks)
|
||||
works without modification.
|
||||
"""
|
||||
status = response.get("status") if isinstance(response, dict) else getattr(response, "status", None)
|
||||
if status == "failed":
|
||||
from .llm import LLMResponseError
|
||||
error = response.get("error") if isinstance(response, dict) else getattr(response, "error", None)
|
||||
code = error.get("code") if isinstance(error, dict) else getattr(error, "code", None)
|
||||
message = error.get("message") if isinstance(error, dict) else getattr(error, "message", None)
|
||||
label = f"Responses API failed ({code})" if code else "Responses API failed"
|
||||
raise LLMResponseError(f"{label}: {message or 'Provider returned a failed response.'}")
|
||||
response_text = ""
|
||||
tool_calls_list: List[ToolCall] = []
|
||||
|
||||
output_items = getattr(response, 'output', None) or []
|
||||
output_items = (response.get('output') if isinstance(response, dict) else getattr(response, 'output', None)) or []
|
||||
for item in output_items:
|
||||
item_type = getattr(item, "type", "") if not isinstance(item, dict) else item.get("type", "")
|
||||
|
||||
@@ -940,13 +960,16 @@ class OpenAIClient:
|
||||
))
|
||||
|
||||
# Build usage
|
||||
raw_usage = getattr(response, 'usage', None)
|
||||
raw_usage = response.get('usage') if isinstance(response, dict) else getattr(response, 'usage', None)
|
||||
usage = None
|
||||
if raw_usage:
|
||||
def usage_value(name):
|
||||
return raw_usage.get(name, 0) if isinstance(raw_usage, dict) else getattr(raw_usage, name, 0)
|
||||
|
||||
usage = CompletionUsage(
|
||||
prompt_tokens=getattr(raw_usage, 'input_tokens', 0),
|
||||
completion_tokens=getattr(raw_usage, 'output_tokens', 0),
|
||||
total_tokens=getattr(raw_usage, 'total_tokens', 0),
|
||||
prompt_tokens=usage_value('input_tokens'),
|
||||
completion_tokens=usage_value('output_tokens'),
|
||||
total_tokens=usage_value('total_tokens'),
|
||||
)
|
||||
|
||||
message = ChatCompletionMessage(
|
||||
@@ -955,7 +978,7 @@ class OpenAIClient:
|
||||
tool_calls=tool_calls_list if tool_calls_list else None,
|
||||
)
|
||||
choice = Choice(
|
||||
finish_reason="tool_calls" if tool_calls_list else "stop",
|
||||
finish_reason=self._responses_incomplete_finish_reason(response) or ("tool_calls" if tool_calls_list else "stop"),
|
||||
index=0,
|
||||
message=message,
|
||||
)
|
||||
@@ -1053,6 +1076,7 @@ class OpenAIClient:
|
||||
Returns:
|
||||
ChatCompletion object or None if error
|
||||
"""
|
||||
raw = None
|
||||
# Lazy import StreamEvent types only when needed
|
||||
_emit = emit_events and stream_callback is not None
|
||||
if _emit:
|
||||
@@ -1112,6 +1136,9 @@ class OpenAIClient:
|
||||
except (FileNotFoundError, ValueError):
|
||||
raise
|
||||
except Exception as e:
|
||||
status = raw.get("status") if isinstance(raw, dict) else getattr(raw, "status", None)
|
||||
if status == "failed":
|
||||
raise
|
||||
self.logger.warning(f"Responses API streaming failed, falling back: {e}")
|
||||
# Fall through to Chat Completions streaming
|
||||
|
||||
@@ -1282,6 +1309,9 @@ class OpenAIClient:
|
||||
return final_response
|
||||
|
||||
except Exception as e:
|
||||
status = raw.get("status") if isinstance(raw, dict) else getattr(raw, "status", None)
|
||||
if status == "failed":
|
||||
raise
|
||||
self.logger.error(f"Error in stream processing: {e}")
|
||||
return None
|
||||
|
||||
@@ -1318,6 +1348,7 @@ class OpenAIClient:
|
||||
Returns:
|
||||
ChatCompletion object or None if error
|
||||
"""
|
||||
raw = None
|
||||
# Lazy import StreamEvent types only when needed
|
||||
_emit = emit_events and stream_callback is not None
|
||||
if _emit:
|
||||
@@ -1386,6 +1417,9 @@ class OpenAIClient:
|
||||
except (FileNotFoundError, ValueError):
|
||||
raise
|
||||
except Exception as e:
|
||||
status = raw.get("status") if isinstance(raw, dict) else getattr(raw, "status", None)
|
||||
if status == "failed":
|
||||
raise
|
||||
self.logger.warning(f"Responses API async streaming failed, falling back: {e}")
|
||||
# Fall through to Chat Completions streaming
|
||||
|
||||
@@ -1548,6 +1582,9 @@ class OpenAIClient:
|
||||
return final_response
|
||||
|
||||
except Exception as e:
|
||||
status = raw.get("status") if isinstance(raw, dict) else getattr(raw, "status", None)
|
||||
if status == "failed":
|
||||
raise
|
||||
self.logger.error(f"Error in async stream processing: {e}")
|
||||
return None
|
||||
|
||||
@@ -1652,6 +1689,7 @@ class OpenAIClient:
|
||||
"""
|
||||
# ── Responses API path (non-streaming only) ────────────────────
|
||||
if not stream and self._use_responses_api(model):
|
||||
raw = None
|
||||
try:
|
||||
resp_params = self._build_responses_input(
|
||||
messages, model, temperature, tools,
|
||||
@@ -1662,6 +1700,9 @@ class OpenAIClient:
|
||||
except (FileNotFoundError, ValueError):
|
||||
raise
|
||||
except Exception as e:
|
||||
status = raw.get("status") if isinstance(raw, dict) else getattr(raw, "status", None)
|
||||
if status == "failed":
|
||||
raise
|
||||
self.logger.warning(f"Responses API failed, falling back to Chat Completions: {e}")
|
||||
# Fall through to Chat Completions
|
||||
|
||||
@@ -1716,6 +1757,7 @@ class OpenAIClient:
|
||||
"""
|
||||
# ── Responses API path (non-streaming only) ────────────────────
|
||||
if not stream and self._use_responses_api(model):
|
||||
raw = None
|
||||
try:
|
||||
resp_params = self._build_responses_input(
|
||||
messages, model, temperature, tools,
|
||||
@@ -1726,6 +1768,9 @@ class OpenAIClient:
|
||||
except (FileNotFoundError, ValueError):
|
||||
raise
|
||||
except Exception as e:
|
||||
status = raw.get("status") if isinstance(raw, dict) else getattr(raw, "status", None)
|
||||
if status == "failed":
|
||||
raise
|
||||
self.logger.warning(f"Responses API failed, falling back to Chat Completions: {e}")
|
||||
# Fall through to Chat Completions
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ No dependencies required - stores data as plain Python lists.
|
||||
"""
|
||||
|
||||
import threading
|
||||
from copy import deepcopy
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
@@ -15,6 +16,8 @@ class InMemoryAdapter:
|
||||
|
||||
Stores short-term and long-term memories as plain Python lists.
|
||||
Data is not persisted across process restarts.
|
||||
Metadata must support deepcopy; writes and reads own independent copies so
|
||||
caller edits cannot change stored records outside the adapter's lock.
|
||||
|
||||
Usage:
|
||||
adapter = InMemoryAdapter()
|
||||
@@ -48,7 +51,7 @@ class InMemoryAdapter:
|
||||
"id": str(self._next_id),
|
||||
"text": text,
|
||||
"type": "short",
|
||||
"metadata": metadata,
|
||||
"metadata": deepcopy(metadata),
|
||||
}
|
||||
self._next_id += 1
|
||||
self._data.append(entry)
|
||||
@@ -64,7 +67,7 @@ class InMemoryAdapter:
|
||||
for e in self._data
|
||||
if e["type"] == "short" and query.lower() in e["text"].lower()
|
||||
]
|
||||
return results[:limit]
|
||||
return deepcopy(results[:limit])
|
||||
|
||||
def store_long_term(
|
||||
self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs
|
||||
@@ -74,7 +77,7 @@ class InMemoryAdapter:
|
||||
"id": str(self._next_id),
|
||||
"text": text,
|
||||
"type": "long",
|
||||
"metadata": metadata,
|
||||
"metadata": deepcopy(metadata),
|
||||
}
|
||||
self._next_id += 1
|
||||
self._data.append(entry)
|
||||
@@ -90,7 +93,7 @@ class InMemoryAdapter:
|
||||
for e in self._data
|
||||
if e["type"] == "long" and query.lower() in e["text"].lower()
|
||||
]
|
||||
return results[:limit]
|
||||
return deepcopy(results[:limit])
|
||||
|
||||
def delete_memory(self, memory_id: str, tier: Optional[str] = None, **kwargs) -> bool:
|
||||
"""Delete a memory by ID. Returns True if an entry was removed.
|
||||
@@ -123,5 +126,8 @@ class InMemoryAdapter:
|
||||
|
||||
def get_all_memories(self, **kwargs) -> List[Dict[str, Any]]:
|
||||
with self._lock:
|
||||
# Return defensive copy to prevent external mutation of internal state
|
||||
return [dict(entry) for entry in self._data]
|
||||
# Entries are owned on insertion and never mutated afterward.
|
||||
# Keep their membership snapshot under lock, then copy nested data
|
||||
# without blocking stores, deletes, or eviction.
|
||||
snapshot = list(self._data)
|
||||
return deepcopy(snapshot)
|
||||
@@ -19,6 +19,16 @@ from ..protocols import MemoryProtocol
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _metadata_user_id(raw):
|
||||
"""Match the existing Python JSON reader, including non-finite values."""
|
||||
try:
|
||||
metadata = json.loads(raw) if raw else {}
|
||||
return str(metadata["user_id"]) if isinstance(metadata, dict) and metadata.get("user_id") is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
class SqliteMemoryAdapter:
|
||||
"""
|
||||
SQLite-based memory adapter implementing MemoryProtocol.
|
||||
@@ -73,12 +83,13 @@ class SqliteMemoryAdapter:
|
||||
|
||||
def _get_stm_conn(self):
|
||||
"""Get thread-local short-term memory connection."""
|
||||
if not hasattr(self._local, 'stm_conn'):
|
||||
if getattr(self._local, 'stm_conn', None) is None:
|
||||
self._local.stm_conn = sqlite3.connect(
|
||||
self.short_db,
|
||||
check_same_thread=False,
|
||||
timeout=10.0
|
||||
)
|
||||
self._local.stm_conn.create_function("memory_user_id", 1, _metadata_user_id, deterministic=True)
|
||||
self._local.stm_conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS short_term_memory (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -97,12 +108,13 @@ class SqliteMemoryAdapter:
|
||||
|
||||
def _get_ltm_conn(self):
|
||||
"""Get thread-local long-term memory connection."""
|
||||
if not hasattr(self._local, 'ltm_conn'):
|
||||
if getattr(self._local, 'ltm_conn', None) is None:
|
||||
self._local.ltm_conn = sqlite3.connect(
|
||||
self.long_db,
|
||||
check_same_thread=False,
|
||||
timeout=10.0
|
||||
)
|
||||
self._local.ltm_conn.create_function("memory_user_id", 1, _metadata_user_id, deterministic=True)
|
||||
self._local.ltm_conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS long_term_memory (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -143,10 +155,13 @@ class SqliteMemoryAdapter:
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search short-term memory."""
|
||||
conn = self._get_stm_conn()
|
||||
user_id = kwargs.get("user_id")
|
||||
user_filter = " AND memory_user_id(metadata) = ?" if user_id is not None else ""
|
||||
params = (f"%{query}%", str(user_id), limit) if user_id is not None else (f"%{query}%", limit)
|
||||
cursor = conn.execute(
|
||||
"SELECT id, content, metadata, timestamp FROM short_term_memory "
|
||||
"WHERE content LIKE ? ORDER BY timestamp DESC LIMIT ?",
|
||||
(f"%{query}%", limit)
|
||||
"WHERE content LIKE ?" + user_filter + " ORDER BY timestamp DESC LIMIT ?",
|
||||
params
|
||||
)
|
||||
|
||||
results = []
|
||||
@@ -184,10 +199,13 @@ class SqliteMemoryAdapter:
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search long-term memory."""
|
||||
conn = self._get_ltm_conn()
|
||||
user_id = kwargs.get("user_id")
|
||||
user_filter = " AND memory_user_id(metadata) = ?" if user_id is not None else ""
|
||||
params = (f"%{query}%", str(user_id), limit) if user_id is not None else (f"%{query}%", limit)
|
||||
cursor = conn.execute(
|
||||
"SELECT id, content, metadata, timestamp FROM long_term_memory "
|
||||
"WHERE content LIKE ? ORDER BY timestamp DESC LIMIT ?",
|
||||
(f"%{query}%", limit)
|
||||
"WHERE content LIKE ?" + user_filter + " ORDER BY timestamp DESC LIMIT ?",
|
||||
params
|
||||
)
|
||||
|
||||
results = []
|
||||
@@ -252,8 +270,10 @@ class SqliteMemoryAdapter:
|
||||
|
||||
def get_all_memories(self, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""Get all memories from both short-term and long-term."""
|
||||
short_memories = self.search_short_term("", limit=1000)
|
||||
long_memories = self.search_long_term("", limit=1000)
|
||||
# SQLite's negative LIMIT means no upper bound. Enumeration must return
|
||||
# every row; ordinary searches retain their default/explicit limits.
|
||||
short_memories = self.search_short_term("", limit=-1)
|
||||
long_memories = self.search_long_term("", limit=-1)
|
||||
|
||||
# Mark memory types
|
||||
for memory in short_memories:
|
||||
@@ -272,4 +292,30 @@ class SqliteMemoryAdapter:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._all_connections.clear()
|
||||
self._all_connections.clear()
|
||||
|
||||
def close_thread_connections(self):
|
||||
"""Close only the calling thread's connections.
|
||||
|
||||
Leaves the shared adapter and other threads' connections intact so the
|
||||
adapter can be reused; the calling thread lazily reopens its own
|
||||
connections on next access.
|
||||
|
||||
A connection is only removed from the registry and cleared from
|
||||
thread-local storage after it closes successfully. If ``close()`` raises,
|
||||
the connection stays tracked so a later ``close_connections()`` /
|
||||
``close_thread_connections()`` can retry it, and the failure is logged
|
||||
rather than silently swallowed.
|
||||
"""
|
||||
for attr in ("stm_conn", "ltm_conn"):
|
||||
conn = getattr(self._local, attr, None)
|
||||
if conn is None:
|
||||
continue
|
||||
try:
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to close {attr}; keeping it tracked for retry: {e}")
|
||||
continue
|
||||
with self._connection_lock:
|
||||
self._all_connections.discard(conn)
|
||||
setattr(self._local, attr, None)
|
||||
@@ -179,6 +179,11 @@ class DocsManager:
|
||||
self._docs[key] = doc
|
||||
self._log(f"Loaded doc: {key}")
|
||||
|
||||
@staticmethod
|
||||
def _effective_priority(priority: int, scope: str) -> int:
|
||||
"""Apply the same global-document offset on creation and reload."""
|
||||
return priority - 1000 if scope == "global" else priority
|
||||
|
||||
def _load_all_docs(self):
|
||||
"""Load all docs from global and workspace directories."""
|
||||
self._docs.clear()
|
||||
@@ -188,7 +193,7 @@ class DocsManager:
|
||||
self._load_docs_from_dir(self.global_docs_path, "global")
|
||||
for key, doc in self._docs.items():
|
||||
if key.startswith("global:"):
|
||||
doc.priority = doc.priority - 1000 # Lower priority for global
|
||||
doc.priority = self._effective_priority(doc.priority, "global")
|
||||
|
||||
# 2. Load workspace docs
|
||||
workspace_docs_dir = self.workspace_path / self.DOCS_DIR_NAME.replace("/", os.sep)
|
||||
@@ -355,8 +360,8 @@ class DocsManager:
|
||||
frontmatter_lines = ["---"]
|
||||
if description:
|
||||
frontmatter_lines.append(f'description: "{description}"')
|
||||
if priority != 0:
|
||||
frontmatter_lines.append(f"priority: {priority}")
|
||||
# Persist explicit zero instead of reloading the workspace default (100).
|
||||
frontmatter_lines.append(f"priority: {priority}")
|
||||
if tags:
|
||||
tags_str = ", ".join(f'"{t}"' for t in tags)
|
||||
frontmatter_lines.append(f"tags: [{tags_str}]")
|
||||
@@ -372,7 +377,7 @@ class DocsManager:
|
||||
name=name,
|
||||
content=content,
|
||||
description=description,
|
||||
priority=priority,
|
||||
priority=self._effective_priority(priority, scope),
|
||||
tags=tags or [],
|
||||
file_path=str(file_path)
|
||||
)
|
||||
|
||||
@@ -266,11 +266,12 @@ class FileMemory:
|
||||
return True
|
||||
except (IOError, OSError) as e:
|
||||
self._log(f"Error writing {filepath}: {e}", logging.ERROR)
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
tmp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
def commit_memory_batch(
|
||||
self,
|
||||
|
||||
@@ -2532,16 +2532,31 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
# Other active threads should manage their own connections
|
||||
# Only clear full registry in __del__ or explicit shutdown
|
||||
|
||||
# Close memory adapter if it exists (protocol-driven path)
|
||||
# Close memory adapter if it exists (protocol-driven path).
|
||||
#
|
||||
# SQLite is thread-scoped by contract (#1562/#1563): close only the
|
||||
# calling thread's adapter connections and KEEP the shared adapter so
|
||||
# other threads and subsequent same-thread searches keep using the
|
||||
# adapter's tables. Tearing it down here would make searches fall back
|
||||
# to legacy SQL against short_mem/long_mem (#5486).
|
||||
if hasattr(self, 'memory_adapter') and self.memory_adapter:
|
||||
try:
|
||||
if hasattr(self.memory_adapter, 'close') and callable(self.memory_adapter.close):
|
||||
self.memory_adapter.close()
|
||||
logger.debug("Memory adapter closed successfully")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing memory adapter: {e}")
|
||||
finally:
|
||||
self.memory_adapter = None
|
||||
if isinstance(self.memory_adapter, SqliteMemoryAdapter):
|
||||
try:
|
||||
if hasattr(self.memory_adapter, 'close_thread_connections'):
|
||||
self.memory_adapter.close_thread_connections()
|
||||
logger.debug("SQLite adapter thread connections closed")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing SQLite adapter thread connections: {e}")
|
||||
# Intentionally preserve self.memory_adapter for reuse.
|
||||
else:
|
||||
try:
|
||||
if hasattr(self.memory_adapter, 'close') and callable(self.memory_adapter.close):
|
||||
self.memory_adapter.close()
|
||||
logger.debug("Memory adapter closed successfully")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing memory adapter: {e}")
|
||||
finally:
|
||||
self.memory_adapter = None
|
||||
|
||||
# Close MongoDB client if it exists (legacy path)
|
||||
if hasattr(self, 'mongo_client') and self.mongo_client:
|
||||
|
||||
@@ -1361,3 +1361,15 @@ def get_plugin_manager() -> PluginManager:
|
||||
if _default_manager is None:
|
||||
_default_manager = PluginManager()
|
||||
return _default_manager
|
||||
|
||||
|
||||
def has_registered_plugins() -> bool:
|
||||
"""Return whether the global manager has any plugins registered.
|
||||
|
||||
Cheap, allocation-free fast path for the hot Agent-init merge calls: when
|
||||
the singleton has not been created or holds no plugins (the common case),
|
||||
callers can skip constructing/scanning the manager entirely. Does NOT
|
||||
create the singleton as a side effect.
|
||||
"""
|
||||
manager = _default_manager
|
||||
return manager is not None and bool(manager._plugins)
|
||||
@@ -13,6 +13,7 @@ import uuid
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -502,6 +503,10 @@ class Session:
|
||||
) -> bool:
|
||||
"""Persist one sub-agent transcript onto this session's own record.
|
||||
|
||||
Built-in stores merge the entry under their session file lock. Older
|
||||
custom stores retain the bounded read/write verification fallback below;
|
||||
that fallback cannot guarantee atomic preservation of concurrent entries.
|
||||
|
||||
The store replaces the whole ``AGENT_HISTORY_KEY`` map per write, so a
|
||||
naive read-then-replace would let a concurrent writer (another agent
|
||||
under the same parent) clobber this agent's entry, or vice versa. We
|
||||
@@ -509,6 +514,14 @@ class Session:
|
||||
*other* key changed underneath us, retrying a bounded number of times.
|
||||
Legacy per-agent records are always merged in (they stay on disk).
|
||||
"""
|
||||
merge_map = getattr(session_store, "merge_session_metadata_map", None)
|
||||
if callable(merge_map):
|
||||
# Built-in stores merge inside their locked read/modify/write path.
|
||||
# A post-write comparison cannot detect entries lost to a stale map.
|
||||
return merge_map(
|
||||
self.session_id, AGENT_HISTORY_KEY, {agent_key: messages},
|
||||
defaults=self._legacy_agent_histories(session_store),
|
||||
)
|
||||
update_meta = getattr(session_store, "update_session_metadata", None)
|
||||
if not callable(update_meta):
|
||||
return False
|
||||
@@ -564,6 +577,7 @@ class Session:
|
||||
{
|
||||
"role": msg.get("role", "user"),
|
||||
"content": msg.get("content", ""),
|
||||
**deepcopy(msg),
|
||||
}
|
||||
for msg in chat_history
|
||||
if isinstance(msg, dict)
|
||||
|
||||
@@ -59,17 +59,22 @@ def _progress_section(checkpoint: Optional[Dict[str, Any]]) -> List[str]:
|
||||
if not isinstance(checkpoint, dict):
|
||||
return []
|
||||
completed = checkpoint.get("completed_steps")
|
||||
total = checkpoint.get("total_steps") or checkpoint.get("step_count")
|
||||
last = checkpoint.get("last_step") or checkpoint.get("current_step")
|
||||
if completed is None and last is None:
|
||||
return []
|
||||
if total:
|
||||
head = f"PROGRESS: {completed or 0} of {total} workflow steps done"
|
||||
else:
|
||||
head = f"PROGRESS: {completed or 0} workflow steps done"
|
||||
if last:
|
||||
head += f"; last completed: {last}"
|
||||
return [head]
|
||||
total = checkpoint.get("total_steps")
|
||||
if total is None:
|
||||
total = checkpoint.get("step_count")
|
||||
last = checkpoint.get("last_step")
|
||||
current = checkpoint.get("current_step")
|
||||
parts: List[str] = []
|
||||
if completed is not None:
|
||||
if total is not None:
|
||||
parts.append(f"{completed} of {total} workflow steps done")
|
||||
else:
|
||||
parts.append(f"{completed} workflow steps done")
|
||||
if last is not None and last != "":
|
||||
parts.append(f"last completed: {last}")
|
||||
if current is not None and current != "":
|
||||
parts.append(f"current step: {current}")
|
||||
return ["PROGRESS: " + "; ".join(parts)] if parts else []
|
||||
|
||||
|
||||
def _recent_actions(recap_text: str) -> List[str]:
|
||||
|
||||
@@ -34,7 +34,7 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from praisonaiagents._logging import get_logger
|
||||
|
||||
from .store import DefaultSessionStore, SessionData
|
||||
from .store import DefaultSessionStore, FileLock, SessionData
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -370,6 +370,27 @@ class SqliteSessionStore(DefaultSessionStore):
|
||||
self._index_session(session)
|
||||
return ok
|
||||
|
||||
def _save_imported_session(self, session: SessionData, *, overwrite: bool = True) -> bool:
|
||||
"""Refresh recall and routing after a verbatim portable restore."""
|
||||
ok = super()._save_imported_session(session, overwrite=overwrite)
|
||||
if ok:
|
||||
try:
|
||||
filepath = self._get_session_path(session.session_id)
|
||||
with FileLock(filepath, self.lock_timeout):
|
||||
# Read-only cache fallback is unsafe after an overwrite:
|
||||
# it can restore routing fields removed by the import.
|
||||
fresh = self._load_session_from_disk(session.session_id, filepath)
|
||||
self._reingest_spill(session.session_id, fresh)
|
||||
with self._lock:
|
||||
self._cache[session.session_id] = fresh
|
||||
self._index_session(fresh)
|
||||
except Exception as exc:
|
||||
# The JSON write succeeded, but the old index is no longer a
|
||||
# trustworthy view. Fail closed until a later refresh/rebuild.
|
||||
self._deindex_session(session.session_id)
|
||||
logger.debug("Post-import index refresh failed for %s: %s", session.session_id, exc)
|
||||
return ok
|
||||
|
||||
def _modify_session_locked(self, session_id, mutator, **kwargs) -> bool:
|
||||
"""Refresh the index after any locked read-modify-write.
|
||||
|
||||
|
||||
@@ -158,7 +158,9 @@ class SqliteTranscriptStore(DefaultSessionStore):
|
||||
if not session_dir or not os.path.isdir(session_dir):
|
||||
return
|
||||
try:
|
||||
filenames = [f for f in os.listdir(session_dir) if f.endswith(".json")]
|
||||
filenames = sorted(
|
||||
f for f in os.listdir(session_dir) if f.endswith(".json")
|
||||
)
|
||||
except OSError:
|
||||
return
|
||||
if not filenames:
|
||||
@@ -175,7 +177,7 @@ class SqliteTranscriptStore(DefaultSessionStore):
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, json.JSONDecodeError, TypeError):
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
|
||||
@@ -1622,6 +1622,27 @@ class DefaultSessionStore:
|
||||
session_id, _apply, error_label="update session metadata"
|
||||
)
|
||||
|
||||
def merge_session_metadata_map(
|
||||
self, session_id: str, key: str, updates: Dict[str, Any],
|
||||
*, defaults: Optional[Dict[str, Any]] = None,
|
||||
) -> bool:
|
||||
"""Merge nested metadata entries using the freshest map under FileLock.
|
||||
|
||||
Defaults fill missing entries; persisted entries win over defaults,
|
||||
and the supplied updates win over both.
|
||||
"""
|
||||
def _apply(session: SessionData) -> None:
|
||||
merged = dict(defaults or {})
|
||||
current = session.metadata.get(key)
|
||||
if isinstance(current, dict):
|
||||
merged.update(current)
|
||||
merged.update(updates)
|
||||
session.metadata[key] = merged
|
||||
|
||||
return self._modify_session_locked(
|
||||
session_id, _apply, error_label="merge session metadata map"
|
||||
)
|
||||
|
||||
def rename_session(self, session_id: str, title: str) -> bool:
|
||||
"""Give a session a human-readable title (Issue #3737).
|
||||
|
||||
@@ -2306,7 +2327,7 @@ class DefaultSessionStore:
|
||||
out.append(data)
|
||||
return out
|
||||
|
||||
def _save_imported_session(self, session: SessionData) -> bool:
|
||||
def _save_imported_session(self, session: SessionData, *, overwrite: bool = True) -> bool:
|
||||
"""Persist a restored session verbatim (no retention/window applied).
|
||||
|
||||
Mirrors ``_save_session`` (timestamp + atomic, file-locked write) but
|
||||
@@ -2317,6 +2338,10 @@ class DefaultSessionStore:
|
||||
filepath = self._get_session_path(session.session_id)
|
||||
session.updated_at = datetime.now(timezone.utc).isoformat()
|
||||
with FileLock(filepath, self.lock_timeout):
|
||||
# The initial import check can race with a peer creating this file.
|
||||
# Check again inside the same lock that protects the replacement.
|
||||
if not overwrite and os.path.exists(filepath):
|
||||
raise FileExistsError(filepath)
|
||||
if not self._atomic_write_json(filepath, session.to_dict()):
|
||||
logger.error(f"Failed to save imported session {session.session_id}")
|
||||
return False
|
||||
@@ -2416,7 +2441,7 @@ class DefaultSessionStore:
|
||||
# Persist the imported record verbatim: an import is a restore,
|
||||
# so the destination's retention/active_window must not truncate
|
||||
# or compact a valid larger export before it lands on disk.
|
||||
if not self._save_imported_session(session):
|
||||
if not self._save_imported_session(session, overwrite=overwrite):
|
||||
report.skipped.append(
|
||||
{"session_id": session_id, "reason": "write failed"}
|
||||
)
|
||||
@@ -2424,6 +2449,10 @@ class DefaultSessionStore:
|
||||
with self._lock:
|
||||
self._cache[session_id] = session
|
||||
report.imported += 1
|
||||
except FileExistsError:
|
||||
report.skipped.append(
|
||||
{"session_id": session_id, "reason": "already exists (use overwrite)"}
|
||||
)
|
||||
except Exception as e: # pragma: no cover - defensive; one bad record
|
||||
report.skipped.append(
|
||||
{"session_id": session_id, "reason": f"import error: {e}"}
|
||||
|
||||
@@ -66,6 +66,9 @@ def generate_title(
|
||||
... )
|
||||
>>> # Returns something like: "Python Code Debugging Help"
|
||||
"""
|
||||
if max_length <= 0:
|
||||
return ""
|
||||
|
||||
# Fallback title from user message if generation fails
|
||||
fallback_title = _create_fallback_title(user_msg, max_length)
|
||||
|
||||
@@ -119,6 +122,9 @@ async def generate_title_async(
|
||||
Returns:
|
||||
Generated title string, or fallback based on user message
|
||||
"""
|
||||
if max_length <= 0:
|
||||
return ""
|
||||
|
||||
fallback_title = _create_fallback_title(user_msg, max_length)
|
||||
|
||||
try:
|
||||
@@ -152,8 +158,7 @@ Return ONLY the title text, no quotes, no explanation."""
|
||||
title = title.replace('\n', ' ').replace('\r', '')
|
||||
|
||||
# Truncate if too long
|
||||
if len(title) > max_length:
|
||||
title = title[:max_length-3] + "..."
|
||||
title = _truncate_title(title, max_length)
|
||||
|
||||
# Return if non-empty
|
||||
if title and len(title.strip()) > 0:
|
||||
@@ -176,6 +181,17 @@ Return ONLY the title text, no quotes, no explanation."""
|
||||
return fallback_title
|
||||
|
||||
|
||||
def _truncate_title(title: str, max_length: int) -> str:
|
||||
"""Apply the character cap without using negative ellipsis slices."""
|
||||
if max_length <= 0:
|
||||
return ""
|
||||
if len(title) <= max_length:
|
||||
return title
|
||||
if max_length <= 3:
|
||||
return title[:max_length]
|
||||
return title[:max_length - 3] + "..."
|
||||
|
||||
|
||||
def _create_fallback_title(user_msg: str, max_length: int) -> str:
|
||||
"""Create a fallback title from the user message.
|
||||
|
||||
@@ -187,7 +203,7 @@ def _create_fallback_title(user_msg: str, max_length: int) -> str:
|
||||
Simple title based on user message
|
||||
"""
|
||||
if not user_msg or not user_msg.strip():
|
||||
return "Chat Session"
|
||||
return _truncate_title("Chat Session", max_length)
|
||||
|
||||
# Clean up the message
|
||||
clean_msg = user_msg.strip()
|
||||
@@ -200,8 +216,4 @@ def _create_fallback_title(user_msg: str, max_length: int) -> str:
|
||||
if '.' in clean_msg:
|
||||
clean_msg = clean_msg.split('.')[0]
|
||||
|
||||
# Truncate if too long
|
||||
if len(clean_msg) > max_length:
|
||||
clean_msg = clean_msg[:max_length-3] + "..."
|
||||
|
||||
return clean_msg if clean_msg else "Chat Session"
|
||||
return _truncate_title(clean_msg or "Chat Session", max_length)
|
||||
@@ -69,6 +69,17 @@ class FileBackend:
|
||||
# Sanitize key for filesystem
|
||||
safe_key = "".join(c if c.isalnum() or c in "-_" else "_" for c in key)
|
||||
return self.storage_dir / f"{safe_key}{self.suffix}"
|
||||
|
||||
def _path_to_key(self, file_path: Path) -> Optional[str]:
|
||||
"""Recognize records using the full configured suffix."""
|
||||
if not file_path.is_file():
|
||||
return None
|
||||
if self.suffix:
|
||||
if file_path.name.endswith(self.suffix):
|
||||
return file_path.name[:-len(self.suffix)]
|
||||
return None
|
||||
# Preserve the existing no-extension selection for an empty suffix.
|
||||
return file_path.name if not file_path.suffix else None
|
||||
|
||||
def save(self, key: str, data: Dict[str, Any]) -> None:
|
||||
"""Save data with the given key."""
|
||||
@@ -84,11 +95,11 @@ class FileBackend:
|
||||
delete=False,
|
||||
suffix=".tmp"
|
||||
) as f:
|
||||
temp_path = f.name
|
||||
if self.pretty:
|
||||
json.dump(data, f, indent=2, default=str, ensure_ascii=False)
|
||||
else:
|
||||
json.dump(data, f, default=str, ensure_ascii=False)
|
||||
temp_path = f.name
|
||||
|
||||
os.replace(temp_path, file_path)
|
||||
except Exception as e:
|
||||
@@ -110,7 +121,7 @@ class FileBackend:
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
except (json.JSONDecodeError, UnicodeDecodeError, IOError) as e:
|
||||
logger.warning(f"Failed to load {key}: {e}")
|
||||
return None
|
||||
|
||||
@@ -133,8 +144,8 @@ class FileBackend:
|
||||
keys = []
|
||||
|
||||
for file_path in self.storage_dir.iterdir():
|
||||
if file_path.is_file() and file_path.suffix == self.suffix:
|
||||
key = file_path.stem
|
||||
key = self._path_to_key(file_path)
|
||||
if key is not None:
|
||||
if not prefix or key.startswith(prefix):
|
||||
keys.append(key)
|
||||
|
||||
@@ -149,7 +160,7 @@ class FileBackend:
|
||||
count = 0
|
||||
with self._lock:
|
||||
for file_path in self.storage_dir.iterdir():
|
||||
if file_path.is_file() and file_path.suffix == self.suffix:
|
||||
if self._path_to_key(file_path) is not None:
|
||||
try:
|
||||
file_path.unlink()
|
||||
count += 1
|
||||
@@ -191,13 +202,21 @@ class SQLiteBackend:
|
||||
if not isinstance(table_name, str) or not _re.match(r'^[a-zA-Z0-9_]+$', table_name):
|
||||
raise ValueError("table_name must contain only alphanumeric characters and underscores")
|
||||
self.table_name = table_name
|
||||
# Quote identifiers so validated names that are SQL keywords or start
|
||||
# with a digit (e.g. "select", "9table") are valid in all statements.
|
||||
self._quoted_table = f'"{table_name}"'
|
||||
self._quoted_index = f'"idx_{table_name}_key"'
|
||||
self._local = threading.local()
|
||||
|
||||
# Ensure directory exists
|
||||
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if auto_create:
|
||||
self._create_table()
|
||||
try:
|
||||
self._create_table()
|
||||
except Exception:
|
||||
self.close()
|
||||
raise
|
||||
|
||||
def _get_conn(self):
|
||||
"""Get thread-local connection."""
|
||||
@@ -220,7 +239,7 @@ class SQLiteBackend:
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS {self.table_name} (
|
||||
CREATE TABLE IF NOT EXISTS {self._quoted_table} (
|
||||
key TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
created_at REAL DEFAULT (strftime('%s', 'now')),
|
||||
@@ -230,8 +249,8 @@ class SQLiteBackend:
|
||||
|
||||
# Index for prefix queries
|
||||
cur.execute(f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_{self.table_name}_key
|
||||
ON {self.table_name}(key)
|
||||
CREATE INDEX IF NOT EXISTS {self._quoted_index}
|
||||
ON {self._quoted_table}(key)
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
@@ -244,7 +263,7 @@ class SQLiteBackend:
|
||||
json_data = json.dumps(data, default=str, ensure_ascii=False)
|
||||
|
||||
cur.execute(f"""
|
||||
INSERT INTO {self.table_name} (key, data, updated_at)
|
||||
INSERT INTO {self._quoted_table} (key, data, updated_at)
|
||||
VALUES (?, ?, strftime('%s', 'now'))
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
data = excluded.data,
|
||||
@@ -259,7 +278,7 @@ class SQLiteBackend:
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT data FROM {self.table_name} WHERE key = ?
|
||||
SELECT data FROM {self._quoted_table} WHERE key = ?
|
||||
""", (key,))
|
||||
|
||||
row = cur.fetchone()
|
||||
@@ -276,7 +295,7 @@ class SQLiteBackend:
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute(f"""
|
||||
DELETE FROM {self.table_name} WHERE key = ?
|
||||
DELETE FROM {self._quoted_table} WHERE key = ?
|
||||
""", (key,))
|
||||
|
||||
deleted = cur.rowcount > 0
|
||||
@@ -290,13 +309,13 @@ class SQLiteBackend:
|
||||
|
||||
if prefix:
|
||||
cur.execute(f"""
|
||||
SELECT key FROM {self.table_name}
|
||||
SELECT key FROM {self._quoted_table}
|
||||
WHERE key LIKE ?
|
||||
ORDER BY key
|
||||
""", (f"{prefix}%",))
|
||||
else:
|
||||
cur.execute(f"""
|
||||
SELECT key FROM {self.table_name}
|
||||
SELECT key FROM {self._quoted_table}
|
||||
ORDER BY key
|
||||
""")
|
||||
|
||||
@@ -308,7 +327,7 @@ class SQLiteBackend:
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT 1 FROM {self.table_name} WHERE key = ? LIMIT 1
|
||||
SELECT 1 FROM {self._quoted_table} WHERE key = ? LIMIT 1
|
||||
""", (key,))
|
||||
|
||||
return cur.fetchone() is not None
|
||||
@@ -318,10 +337,10 @@ class SQLiteBackend:
|
||||
conn = self._get_conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute(f"SELECT COUNT(*) as count FROM {self.table_name}")
|
||||
cur.execute(f"SELECT COUNT(*) as count FROM {self._quoted_table}")
|
||||
count = cur.fetchone()["count"]
|
||||
|
||||
cur.execute(f"DELETE FROM {self.table_name}")
|
||||
cur.execute(f"DELETE FROM {self._quoted_table}")
|
||||
conn.commit()
|
||||
|
||||
return count
|
||||
|
||||
@@ -262,8 +262,8 @@ class BaseJSONStore:
|
||||
delete=False,
|
||||
suffix=".tmp"
|
||||
) as f:
|
||||
json.dump(self._data, f, indent=2, default=str, ensure_ascii=False)
|
||||
temp_path = f.name
|
||||
json.dump(self._data, f, indent=2, default=str, ensure_ascii=False)
|
||||
|
||||
# Atomic rename
|
||||
if self.use_file_lock:
|
||||
@@ -343,7 +343,7 @@ class BaseJSONStore:
|
||||
def list_json_sessions(
|
||||
storage_dir: Path,
|
||||
suffix: str = ".json",
|
||||
limit: int = 50,
|
||||
limit: Optional[int] = 50,
|
||||
) -> List[BaseSessionInfo]:
|
||||
"""
|
||||
List all sessions in a directory.
|
||||
@@ -353,7 +353,7 @@ def list_json_sessions(
|
||||
Args:
|
||||
storage_dir: Directory to search
|
||||
suffix: File suffix to filter by
|
||||
limit: Maximum number of sessions to return
|
||||
limit: Maximum number of sessions to return, or None for all sessions
|
||||
|
||||
Returns:
|
||||
List of BaseSessionInfo, sorted by modification time (newest first)
|
||||
@@ -370,7 +370,7 @@ def list_json_sessions(
|
||||
# Try to get item count from file
|
||||
if suffix == ".json":
|
||||
try:
|
||||
with open(file_path, "r") as f:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
# Common patterns for item count
|
||||
if "iterations" in data:
|
||||
@@ -383,7 +383,7 @@ def list_json_sessions(
|
||||
pass
|
||||
elif suffix == ".jsonl":
|
||||
try:
|
||||
with open(file_path, "r") as f:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
info.item_count = sum(1 for _ in f)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -424,7 +424,7 @@ def cleanup_old_sessions(
|
||||
now = datetime.now()
|
||||
|
||||
# Get all sessions sorted by age (oldest first)
|
||||
sessions = list_json_sessions(storage_dir, suffix, limit=10000)
|
||||
sessions = list_json_sessions(storage_dir, suffix, limit=None)
|
||||
sessions.sort(key=lambda s: s.modified_at)
|
||||
|
||||
# Delete old sessions
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "1.7.9"
|
||||
version = "1.7.10"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Opt-in real LLM regression for documentation priority across reload.
|
||||
|
||||
Run with RUN_REAL_KEY_TESTS=1 and a configured provider key.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("RUN_REAL_KEY_TESTS") != "1",
|
||||
reason="requires an explicitly enabled real provider",
|
||||
)
|
||||
def test_real_agent_uses_same_document_context_after_reload(tmp_path):
|
||||
"""Effective priority must select the same source for real agent answers."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.memory.docs_manager import DocsManager
|
||||
|
||||
options = dict(
|
||||
workspace_path=str(tmp_path),
|
||||
global_docs_path=str(tmp_path / "global"),
|
||||
)
|
||||
manager = DocsManager(**options)
|
||||
manager.create_doc("local", "The project codename is NEBULA-7.", priority=200)
|
||||
manager.create_doc(
|
||||
"shared", "The project codename is ORBIT-9.", priority=300, scope="global"
|
||||
)
|
||||
manager.create_doc("zero", "Supplementary reference.", priority=0)
|
||||
original_context = manager.format_docs_for_prompt()
|
||||
|
||||
def verify(current):
|
||||
"""Check effective priorities and the real answer for one manager lifecycle."""
|
||||
assert current.get_doc("zero").priority == 0
|
||||
assert current.get_doc("shared").priority == -700
|
||||
context = current.format_docs_for_prompt()
|
||||
assert context == original_context
|
||||
assert "NEBULA-7" in context
|
||||
assert "ORBIT-9" not in context
|
||||
reader = Agent(
|
||||
name="documentation-reader",
|
||||
instructions="Answer only from this documentation:\n" + context,
|
||||
)
|
||||
result = reader.start("What is the project codename? Return only the codename.")
|
||||
print(result)
|
||||
assert "NEBULA-7" in str(result)
|
||||
assert "ORBIT-9" not in str(result)
|
||||
|
||||
verify(manager)
|
||||
manager.reload()
|
||||
verify(manager)
|
||||
verify(DocsManager(**options))
|
||||
@@ -119,6 +119,54 @@ class TestShortTermMemory:
|
||||
assert items[0].content == "Persistent memory"
|
||||
|
||||
|
||||
class TestAtomicWriteCleanup:
|
||||
"""Tests for atomic write staging-file cleanup (regression for #5491)."""
|
||||
|
||||
def test_serialization_error_leaves_no_temp_file(self):
|
||||
"""A non-serializable value must not leave a .*.tmp staging file behind."""
|
||||
from pathlib import Path
|
||||
from praisonaiagents.memory.file_memory import FileMemory
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
memory = FileMemory(base_path=tmpdir)
|
||||
target = memory.short_term_file
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
memory._write_json(target, {"bad": object()})
|
||||
|
||||
leftovers = list(Path(target.parent).glob(f".{target.name}.*.tmp"))
|
||||
assert leftovers == [], f"leftover temp files: {leftovers}"
|
||||
|
||||
def test_serialization_error_preserves_existing_file(self):
|
||||
"""A failed write must not clobber the previously persisted file."""
|
||||
from praisonaiagents.memory.file_memory import FileMemory
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
memory = FileMemory(base_path=tmpdir)
|
||||
target = memory.short_term_file
|
||||
|
||||
assert memory._write_json(target, [{"content": "keep me"}]) is True
|
||||
original = target.read_text(encoding="utf-8")
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
memory._write_json(target, {"bad": object()})
|
||||
|
||||
assert target.read_text(encoding="utf-8") == original
|
||||
|
||||
def test_successful_write_removes_temp_file(self):
|
||||
"""A successful atomic write must leave no staging file behind."""
|
||||
from pathlib import Path
|
||||
from praisonaiagents.memory.file_memory import FileMemory
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
memory = FileMemory(base_path=tmpdir)
|
||||
target = memory.short_term_file
|
||||
|
||||
assert memory._write_json(target, [{"content": "ok"}]) is True
|
||||
leftovers = list(Path(target.parent).glob(f".{target.name}.*.tmp"))
|
||||
assert leftovers == [], f"leftover temp files: {leftovers}"
|
||||
|
||||
|
||||
class TestLongTermMemory:
|
||||
"""Tests for long-term memory functionality."""
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Regression tests for issue #5596.
|
||||
|
||||
A guardrail that blocks model output must never make Agent.chat/achat return
|
||||
``None`` (callers expect a ``str`` and crash on ``.strip()``). On a block the
|
||||
agent returns a safe, non-empty fallback string instead of leaking the rejected
|
||||
text.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents import Agent, TaskOutput
|
||||
|
||||
|
||||
def _blocking_guardrail(task_output: TaskOutput):
|
||||
"""Reject every output, mimicking a forbidden-token guardrail."""
|
||||
return False, "contains forbidden token"
|
||||
|
||||
|
||||
def test_guardrail_blocked_message_is_safe_non_empty_string():
|
||||
agent = Agent(
|
||||
name="GuardAgent",
|
||||
instructions="You are a test agent",
|
||||
llm="gpt-4o-mini",
|
||||
guardrails=_blocking_guardrail,
|
||||
)
|
||||
message = agent._guardrail_blocked_message("contains forbidden token")
|
||||
assert isinstance(message, str)
|
||||
assert message.strip()
|
||||
# Fixed safe string (FR-001): the guardrail reason must NOT leak to the
|
||||
# caller, since an LLM validator's reason can echo the rejected output.
|
||||
assert "contains forbidden token" not in message
|
||||
|
||||
|
||||
def test_guardrail_blocked_message_without_reason():
|
||||
agent = Agent(
|
||||
name="GuardAgent",
|
||||
instructions="You are a test agent",
|
||||
llm="gpt-4o-mini",
|
||||
guardrails=_blocking_guardrail,
|
||||
)
|
||||
message = agent._guardrail_blocked_message()
|
||||
assert isinstance(message, str)
|
||||
assert message.strip()
|
||||
|
||||
|
||||
def test_chat_returns_safe_message_not_none_on_block():
|
||||
agent = Agent(
|
||||
name="GuardAgent",
|
||||
instructions="You are a test agent",
|
||||
llm="gpt-4o-mini",
|
||||
guardrails=_blocking_guardrail,
|
||||
)
|
||||
# retries=0 so the retry loop raises immediately without a network call.
|
||||
agent.max_guardrail_retries = 0
|
||||
# Force the custom-LLM branch and feed it forbidden output offline.
|
||||
agent._using_custom_llm = True
|
||||
agent.llm_instance = MagicMock()
|
||||
agent.llm_instance.get_response.return_value = "LEAKME secret"
|
||||
|
||||
result = agent.chat("say something")
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, str)
|
||||
assert result.strip()
|
||||
assert "LEAKME" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_achat_returns_safe_message_not_none_on_block():
|
||||
agent = Agent(
|
||||
name="GuardAgent",
|
||||
instructions="You are a test agent",
|
||||
llm="gpt-4o-mini",
|
||||
guardrails=_blocking_guardrail,
|
||||
)
|
||||
agent.max_guardrail_retries = 0
|
||||
|
||||
async def _fake_get_response_async(**kwargs):
|
||||
return "LEAKME secret"
|
||||
|
||||
agent._using_custom_llm = True
|
||||
agent.llm_instance = MagicMock()
|
||||
agent.llm_instance.get_response_async = _fake_get_response_async
|
||||
|
||||
result = await agent.achat("say something")
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, str)
|
||||
assert result.strip()
|
||||
assert "LEAKME" not in result
|
||||
|
||||
|
||||
def test_chat_does_not_leak_guardrail_reason():
|
||||
"""The fixed fallback must not echo the guardrail's rejection reason.
|
||||
|
||||
An LLM validator's reason can quote the blocked output, so the reason is
|
||||
logged (and exposed via ``last_guardrail_error``) but never returned.
|
||||
"""
|
||||
agent = Agent(
|
||||
name="GuardAgent",
|
||||
instructions="You are a test agent",
|
||||
llm="gpt-4o-mini",
|
||||
guardrails=_blocking_guardrail,
|
||||
)
|
||||
agent.max_guardrail_retries = 0
|
||||
agent._using_custom_llm = True
|
||||
agent.llm_instance = MagicMock()
|
||||
agent.llm_instance.get_response.return_value = "LEAKME secret"
|
||||
|
||||
result = agent.chat("say something")
|
||||
|
||||
assert isinstance(result, str) and result.strip()
|
||||
assert "LEAKME" not in result
|
||||
assert "contains forbidden token" not in result
|
||||
|
||||
|
||||
def test_chat_returns_safe_message_with_reflection_enabled():
|
||||
"""A guardrail block with self-reflection on still yields a safe string."""
|
||||
from praisonaiagents import ReflectionConfig
|
||||
|
||||
agent = Agent(
|
||||
name="GuardAgent",
|
||||
instructions="You are a test agent",
|
||||
llm="gpt-4o-mini",
|
||||
guardrails=_blocking_guardrail,
|
||||
reflection=ReflectionConfig(min_iterations=1, max_iterations=1),
|
||||
)
|
||||
agent.max_guardrail_retries = 0
|
||||
agent._using_custom_llm = True
|
||||
agent.llm_instance = MagicMock()
|
||||
agent.llm_instance.get_response.return_value = "LEAKME secret"
|
||||
|
||||
result = agent.chat("say something")
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, str)
|
||||
assert result.strip()
|
||||
assert "LEAKME" not in result
|
||||
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
Regression tests for cross-session memory recall (issue #5595).
|
||||
|
||||
Two Agent instances sharing the same memory store (e.g. the same user_id)
|
||||
must be able to recall facts written during an earlier turn. Previously the
|
||||
synchronous chat/run/start path never wrote the interaction to the memory
|
||||
store, so the second agent recalled nothing.
|
||||
|
||||
These tests drive the after-agent side-effect pipeline directly so they do
|
||||
not require an LLM or OPENAI_API_KEY.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.memory import FileMemory
|
||||
|
||||
|
||||
class TestCrossSessionMemoryRecall(unittest.TestCase):
|
||||
"""MEM-001/002/003 from issue #5595."""
|
||||
|
||||
def _agent(self, user_id, base_path):
|
||||
memory = FileMemory(user_id=user_id, base_path=base_path)
|
||||
return Agent(
|
||||
name="Assistant",
|
||||
instructions="You are a helpful assistant.",
|
||||
memory=memory,
|
||||
)
|
||||
|
||||
def test_turn_persisted_and_recalled_across_instances(self):
|
||||
"""MEM-001: a fact stored in session A is recalled by session B."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
base = f"{tmpdir}/memory"
|
||||
|
||||
# Session A: simulate a completed turn storing a codename.
|
||||
agent_a = self._agent("e2e-memory-test", base)
|
||||
agent_a._after_agent_side_effects(
|
||||
"My codename is ORANGE-PANDA. ACK.",
|
||||
"Acknowledged. Your codename is ORANGE-PANDA.",
|
||||
)
|
||||
|
||||
# Session B: a fresh agent sharing the same user_id must recall it.
|
||||
agent_b = self._agent("e2e-memory-test", base)
|
||||
context = agent_b.get_memory_context(query="What is my codename?")
|
||||
self.assertIn("ORANGE-PANDA", context)
|
||||
|
||||
def test_different_user_id_is_isolated(self):
|
||||
"""MEM-002: a different user_id must not see another user's memory."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
base = f"{tmpdir}/memory"
|
||||
|
||||
agent_a = self._agent("user-a", base)
|
||||
agent_a._after_agent_side_effects(
|
||||
"My codename is ORANGE-PANDA.",
|
||||
"Acknowledged. Your codename is ORANGE-PANDA.",
|
||||
)
|
||||
|
||||
agent_b = self._agent("user-b", base)
|
||||
context = agent_b.get_memory_context(query="What is my codename?")
|
||||
self.assertNotIn("ORANGE-PANDA", context)
|
||||
|
||||
def test_empty_response_is_not_persisted(self):
|
||||
"""A turn with no response must not write an empty entry."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
base = f"{tmpdir}/memory"
|
||||
agent = self._agent("user-empty", base)
|
||||
agent._after_agent_side_effects("hello", "")
|
||||
self.assertEqual(
|
||||
agent._memory_instance.get_stats()["short_term_count"], 0
|
||||
)
|
||||
|
||||
def test_dict_memory_config_persists_and_recalls(self):
|
||||
"""MEM-004: the advertised ``memory={'user_id': uid}`` dict config —
|
||||
resolved by the Agent itself, not a preconstructed FileMemory — persists
|
||||
a turn that a second Agent with the same user_id recalls.
|
||||
|
||||
Guards the configuration-resolution + hook wiring that the direct-call
|
||||
tests above cannot (Greptile #3). Runs in a temp cwd so the default
|
||||
FileMemory base path is hermetic; no LLM/API key needed.
|
||||
"""
|
||||
cwd = os.getcwd()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
os.chdir(tmpdir)
|
||||
try:
|
||||
uid = "dict-config-user"
|
||||
agent_a = Agent(name="A", instructions="x", memory={"user_id": uid})
|
||||
self.assertIsInstance(agent_a._memory_instance, FileMemory)
|
||||
agent_a._after_agent_side_effects(
|
||||
"My codename is BLUE-FOX.",
|
||||
"Acknowledged. Your codename is BLUE-FOX.",
|
||||
)
|
||||
|
||||
agent_b = Agent(name="B", instructions="x", memory={"user_id": uid})
|
||||
context = agent_b.get_memory_context(query="What is my codename?")
|
||||
self.assertIn("BLUE-FOX", context)
|
||||
finally:
|
||||
os.chdir(cwd)
|
||||
|
||||
def test_async_path_persists_without_blocking_loop(self):
|
||||
"""MEM-005: on the async after-agent path the blocking store write is
|
||||
offloaded to a thread (AGENTS.md §4.5), yet the turn is still persisted
|
||||
and recalled by a later Agent. Exercises the running-loop branch of
|
||||
``_persist_memory_turn``.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
base = f"{tmpdir}/memory"
|
||||
|
||||
async def write_turn():
|
||||
agent_a = self._agent("async-user", base)
|
||||
agent_a._after_agent_side_effects(
|
||||
"My codename is GREEN-HAWK.",
|
||||
"Acknowledged. Your codename is GREEN-HAWK.",
|
||||
)
|
||||
# The write is scheduled via run_in_executor; yield until the
|
||||
# short-term store reflects it (bounded) rather than racing it.
|
||||
for _ in range(500):
|
||||
if agent_a._memory_instance.get_stats()["short_term_count"] > 0:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
asyncio.run(write_turn())
|
||||
|
||||
agent_b = self._agent("async-user", base)
|
||||
context = agent_b.get_memory_context(query="What is my codename?")
|
||||
self.assertIn("GREEN-HAWK", context)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1191,6 +1191,74 @@ class TestPluginTypedSubsystems:
|
||||
mgr.disable(name)
|
||||
mgr.unregister(name)
|
||||
|
||||
def test_has_registered_plugins_reflects_registration(self):
|
||||
"""The hot-path gate is False with no plugins and True once registered."""
|
||||
from praisonaiagents.plugins.plugin import PluginType
|
||||
from praisonaiagents.plugins import get_plugin_manager
|
||||
from praisonaiagents.plugins.manager import has_registered_plugins
|
||||
|
||||
mgr = get_plugin_manager()
|
||||
# Clean baseline: no plugins registered on the singleton.
|
||||
if not mgr._plugins:
|
||||
assert has_registered_plugins() is False
|
||||
|
||||
plugin, name = self._make_plugin(
|
||||
PluginType.GUARDRAIL, name="gate_probe_test",
|
||||
as_guardrail=lambda self: object(),
|
||||
)
|
||||
mgr.register(plugin)
|
||||
try:
|
||||
assert has_registered_plugins() is True
|
||||
finally:
|
||||
mgr.disable(name)
|
||||
mgr.unregister(name)
|
||||
|
||||
def test_has_registered_plugins_does_not_create_singleton(self):
|
||||
"""The gate must stay False and NOT build the singleton when uncreated.
|
||||
|
||||
The hot-path optimisation relies on the common case (no plugins ever
|
||||
registered) being allocation-free. This isolates the
|
||||
``_default_manager is None`` branch, which the other tests never reach
|
||||
because they call ``get_plugin_manager()`` first.
|
||||
"""
|
||||
from praisonaiagents.plugins import manager as manager_mod
|
||||
|
||||
saved = manager_mod._default_manager
|
||||
manager_mod._default_manager = None
|
||||
try:
|
||||
assert manager_mod.has_registered_plugins() is False
|
||||
# The cheap check must not have constructed the singleton.
|
||||
assert manager_mod._default_manager is None
|
||||
finally:
|
||||
manager_mod._default_manager = saved
|
||||
|
||||
def test_registered_plugin_still_merges_despite_fast_path(self):
|
||||
"""Agent init must still route a registered plugin even with the gate."""
|
||||
from praisonaiagents.plugins.plugin import PluginType
|
||||
from praisonaiagents.plugins import get_plugin_manager
|
||||
from praisonaiagents.plugins.manager import has_registered_plugins
|
||||
from praisonaiagents import Agent
|
||||
|
||||
class _G:
|
||||
def validate_output(self, content, **kw):
|
||||
return True, content
|
||||
|
||||
plugin, name = self._make_plugin(
|
||||
PluginType.GUARDRAIL, name="fastpath_merge_test",
|
||||
as_guardrail=lambda self: _G(),
|
||||
)
|
||||
mgr = get_plugin_manager()
|
||||
mgr.register(plugin)
|
||||
mgr.enable(name)
|
||||
try:
|
||||
# Gate is True, so the merge methods do NOT early-return.
|
||||
assert has_registered_plugins() is True
|
||||
agent = Agent(name="t", instructions="x", llm="gpt-4o-mini")
|
||||
assert agent._tool_result_guardrails
|
||||
finally:
|
||||
mgr.disable(name)
|
||||
mgr.unregister(name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -270,9 +270,11 @@ class TestAlwaysFailingValidatorIsBounded:
|
||||
except _CallCap as exc:
|
||||
pytest.fail(str(exc))
|
||||
|
||||
# chat() reports guardrail exhaustion by returning None after rolling
|
||||
# back history; the reason is on the agent for the caller to read.
|
||||
assert result is None
|
||||
# chat() reports guardrail exhaustion with a safe, non-empty fallback
|
||||
# string after rolling back history - never None (issue #5596) and
|
||||
# never the rejected answer. The reason is also on the agent to read.
|
||||
assert isinstance(result, str) and result.strip()
|
||||
assert "10001" not in result
|
||||
assert agent.guardrail_retry_count == 2, \
|
||||
f"must retry exactly max_guardrail_retries times, got {agent.guardrail_retry_count}"
|
||||
assert t.calls == 1 + 2, f"1 initial call + 2 retries, got {t.calls}"
|
||||
@@ -300,7 +302,8 @@ class TestAlwaysFailingValidatorIsBounded:
|
||||
def test_max_retries_zero_never_retries(self):
|
||||
agent, t = _agent(["10001", "SW1A 1AA"], rejects_first_answer, max_retries=0)
|
||||
result = agent.chat("what is the postcode?", stream=False)
|
||||
assert result is None
|
||||
assert isinstance(result, str) and result.strip()
|
||||
assert "10001" not in result
|
||||
assert t.calls == 1, f"max_guardrail_retries=0 must not call the model again (got {t.calls})"
|
||||
assert agent.guardrail_retry_count == 0
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Unit tests for Agent.chat structured-output coercion.
|
||||
|
||||
Covers the fix for issue #5594: ``Agent.chat(output_pydantic=Model)`` must
|
||||
return a validated Pydantic instance (not a raw JSON string) on the live
|
||||
OpenAI/LiteLLM path. These tests exercise the ``_coerce_structured_output``
|
||||
helper directly so no live LLM is required.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from praisonaiagents.agent.chat_mixin import ChatMixin
|
||||
|
||||
|
||||
class Pair(BaseModel):
|
||||
city: str
|
||||
country: str
|
||||
|
||||
|
||||
def _fake_completion(content):
|
||||
"""Build a minimal OpenAI-style response object for mocking."""
|
||||
message = SimpleNamespace(content=content, reasoning_content=None, tool_calls=None)
|
||||
return SimpleNamespace(choices=[SimpleNamespace(message=message)])
|
||||
|
||||
|
||||
def _mixin():
|
||||
m = ChatMixin()
|
||||
m.name = "TestAgent"
|
||||
return m
|
||||
|
||||
|
||||
def test_valid_json_string_returns_model():
|
||||
out = _mixin()._coerce_structured_output(
|
||||
'{"city": "Paris", "country": "France"}', Pair
|
||||
)
|
||||
assert isinstance(out, Pair)
|
||||
assert out.city == "Paris"
|
||||
assert out.country == "France"
|
||||
|
||||
|
||||
def test_markdown_fenced_json_returns_model():
|
||||
fenced = '```json\n{"city": "Paris", "country": "France"}\n```'
|
||||
out = _mixin()._coerce_structured_output(fenced, Pair)
|
||||
assert isinstance(out, Pair)
|
||||
assert out.city == "Paris"
|
||||
|
||||
|
||||
def test_prose_falls_back_to_raw_string():
|
||||
out = _mixin()._coerce_structured_output(
|
||||
"The capital of France is Paris.", Pair
|
||||
)
|
||||
assert isinstance(out, str)
|
||||
assert out == "The capital of France is Paris."
|
||||
|
||||
|
||||
def test_no_schema_returns_input_unchanged():
|
||||
out = _mixin()._coerce_structured_output("hello", None)
|
||||
assert out == "hello"
|
||||
|
||||
|
||||
def test_non_string_input_returns_unchanged():
|
||||
instance = Pair(city="Paris", country="France")
|
||||
out = _mixin()._coerce_structured_output(instance, Pair)
|
||||
assert out is instance
|
||||
|
||||
|
||||
def test_dict_schema_without_model_validate_json_returns_raw():
|
||||
# dict schemas (inline JSON schema) have no model_validate_json; must not crash
|
||||
out = _mixin()._coerce_structured_output('{"a": 1}', {"type": "object"})
|
||||
assert out == '{"a": 1}'
|
||||
|
||||
|
||||
# --- Public-path tests: chat()/achat() wiring and backward-compat guards ---
|
||||
|
||||
|
||||
def _agent(monkeypatch, content):
|
||||
"""Build a real Agent whose LLM call is mocked to return ``content``.
|
||||
|
||||
A placeholder API key lets the lazy OpenAI client construct; the network is
|
||||
never touched because ``_chat_completion`` is mocked.
|
||||
"""
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "not-needed")
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(name="TestAgent", instructions="test")
|
||||
monkeypatch.setattr(agent, "_chat_completion", lambda *a, **k: _fake_completion(content))
|
||||
return agent
|
||||
|
||||
|
||||
def test_chat_output_pydantic_returns_model(monkeypatch):
|
||||
# Public chat() path must return a validated Pydantic instance (issue #5594).
|
||||
agent = _agent(monkeypatch, '{"city": "Paris", "country": "France"}')
|
||||
out = agent.chat("capital of France?", output_pydantic=Pair)
|
||||
assert isinstance(out, Pair)
|
||||
assert out.city == "Paris"
|
||||
|
||||
|
||||
def test_chat_output_json_stays_string(monkeypatch):
|
||||
# Backward compat: output_json must keep returning a raw JSON string so that
|
||||
# downstream task/workflow JSON parsing (json.loads) keeps working.
|
||||
agent = _agent(monkeypatch, '{"city": "Paris", "country": "France"}')
|
||||
out = agent.chat("capital of France?", output_json=Pair)
|
||||
assert isinstance(out, str)
|
||||
assert '"Paris"' in out
|
||||
|
||||
|
||||
def test_after_agent_hook_receives_string_for_pydantic(monkeypatch):
|
||||
# A Pydantic response must not crash command/string hooks: the after-agent
|
||||
# hook payload truncates via response[:500], which requires a string.
|
||||
agent = _agent(monkeypatch, '{"city": "Paris", "country": "France"}')
|
||||
payload = agent._build_after_agent_input(
|
||||
prompt="p", response=Pair(city="Paris", country="France"), start_time=0.0
|
||||
)
|
||||
assert isinstance(payload.response, str)
|
||||
# to_dict() performs the [:500] slice that would otherwise raise on a model
|
||||
assert isinstance(payload.to_dict()["response"], str)
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Native failed Responses must surface without a second provider request."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from openai.types.responses import ResponseError
|
||||
from openai.types.chat import ChatCompletionChunk
|
||||
|
||||
from praisonaiagents.llm.llm import LLMResponseError
|
||||
from praisonaiagents.llm.openai_client import OpenAIClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('mode', ['sync', 'async', 'sync_stream', 'async_stream'])
|
||||
@pytest.mark.parametrize('shape', ['sdk', 'dict'])
|
||||
@pytest.mark.parametrize('profile', ['failed', 'missing_error', 'completed', 'unsupported'])
|
||||
async def test_native_responses_failure_and_compatibility_fallback(mode, shape, profile, monkeypatch):
|
||||
monkeypatch.delenv('OPENAI_API_BASE', raising=False)
|
||||
monkeypatch.delenv('OPENAI_BASE_URL', raising=False)
|
||||
calls = []
|
||||
error = ResponseError(code='server_error', message='Provider failed.') if profile == 'failed' else None
|
||||
fields = {
|
||||
'status': 'failed' if profile in ('failed', 'missing_error') else 'completed',
|
||||
'error': error if shape == 'sdk' or error is None else error.model_dump(),
|
||||
'output': [{'type': 'message', 'content': [{'type': 'output_text', 'text': 'answer'}]}],
|
||||
'usage': {'input_tokens': 3, 'output_tokens': 2, 'total_tokens': 5},
|
||||
}
|
||||
if shape == 'sdk':
|
||||
fields['usage'] = SimpleNamespace(**fields['usage'])
|
||||
raw = SimpleNamespace(**fields) if shape == 'sdk' else fields
|
||||
|
||||
def respond(**kwargs):
|
||||
calls.append('responses')
|
||||
if profile == 'unsupported':
|
||||
raise NotImplementedError('Responses unsupported')
|
||||
return raw
|
||||
|
||||
async def arespond(**kwargs):
|
||||
return respond(**kwargs)
|
||||
|
||||
fallback = SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content='fallback'))])
|
||||
|
||||
def chat(**kwargs):
|
||||
calls.append('chat')
|
||||
if kwargs.get('stream'):
|
||||
return iter([ChatCompletionChunk(
|
||||
id='chat-test', object='chat.completion.chunk', created=0, model='gpt-4o-mini',
|
||||
choices=[{'index': 0, 'delta': {'content': 'fallback'}, 'finish_reason': 'stop'}],
|
||||
)])
|
||||
return fallback
|
||||
|
||||
async def achat(**kwargs):
|
||||
result = chat(**kwargs)
|
||||
if kwargs.get('stream'):
|
||||
async def chunks():
|
||||
for chunk in result:
|
||||
yield chunk
|
||||
return chunks()
|
||||
return result
|
||||
|
||||
client = OpenAIClient(api_key='sk-test-not-real')
|
||||
client._sync_client = SimpleNamespace(
|
||||
responses=SimpleNamespace(create=respond), chat=SimpleNamespace(completions=SimpleNamespace(create=chat)),
|
||||
)
|
||||
client._async_client = SimpleNamespace(
|
||||
responses=SimpleNamespace(create=arespond), chat=SimpleNamespace(completions=SimpleNamespace(create=achat)),
|
||||
)
|
||||
|
||||
async def invoke():
|
||||
messages = [{'role': 'user', 'content': 'question'}]
|
||||
if mode == 'sync_stream':
|
||||
return client.process_stream_response(messages, model='gpt-4o-mini')
|
||||
if mode == 'async_stream':
|
||||
return await client.process_stream_response_async(messages, model='gpt-4o-mini')
|
||||
return client.create_completion(messages) if mode == 'sync' else await client.acreate_completion(messages)
|
||||
|
||||
if profile in ('failed', 'missing_error'):
|
||||
with pytest.raises(LLMResponseError, match='Responses API failed') as caught:
|
||||
await invoke()
|
||||
if error:
|
||||
assert 'server_error' in str(caught.value)
|
||||
assert 'Provider failed.' in str(caught.value)
|
||||
assert calls == ['responses']
|
||||
else:
|
||||
result = await invoke()
|
||||
assert result.choices[0].message.content == ('fallback' if profile == 'unsupported' else 'answer')
|
||||
assert calls == (['responses', 'chat'] if profile == 'unsupported' else ['responses'])
|
||||
if profile == 'completed':
|
||||
assert result.usage is not None
|
||||
assert result.usage.prompt_tokens == 3
|
||||
assert result.usage.completion_tokens == 2
|
||||
assert result.usage.total_tokens == 5
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Provider failures cannot become successful Responses answers."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from openai.types.responses import ResponseError
|
||||
|
||||
from praisonaiagents.llm.llm import LLM, LLMResponseError
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('entry', ['sync', 'async', 'stream', 'astream'])
|
||||
@pytest.mark.parametrize('shape', ['sdk', 'dict'])
|
||||
@pytest.mark.parametrize('profile', ['failed', 'missing_error', 'completed'])
|
||||
async def test_public_responses_failure(entry, shape, profile, monkeypatch):
|
||||
error = ResponseError(code='server_error', message='Provider failed.') if profile == 'failed' else None
|
||||
status = 'completed' if profile == 'completed' else 'failed'
|
||||
fields = {
|
||||
'status': status,
|
||||
'error': error if shape == 'sdk' or error is None else error.model_dump(),
|
||||
'output': [{'type': 'message', 'content': [{'type': 'output_text', 'text': 'partial'}]}],
|
||||
}
|
||||
response = SimpleNamespace(**fields) if shape == 'sdk' else fields
|
||||
llm = LLM(model='gpt-4o-mini')
|
||||
requests = []
|
||||
|
||||
def respond(**kwargs):
|
||||
requests.append(kwargs)
|
||||
return response
|
||||
|
||||
async def arespond(**kwargs):
|
||||
return respond(**kwargs)
|
||||
|
||||
llm._call_responses_api = respond
|
||||
llm._call_responses_api_async = arespond
|
||||
if entry in ('stream', 'astream'):
|
||||
import litellm
|
||||
monkeypatch.setattr('praisonaiagents.llm.llm.check_model_request', lambda *args: None)
|
||||
events = [
|
||||
{'type': 'response.output_text.delta', 'delta': 'partial'},
|
||||
{'type': 'response.' + status, 'response': response},
|
||||
]
|
||||
if shape == 'sdk':
|
||||
events = [SimpleNamespace(**event) for event in events]
|
||||
|
||||
def stream(**kwargs):
|
||||
requests.append(kwargs)
|
||||
return iter(events)
|
||||
|
||||
async def astream(**kwargs):
|
||||
requests.append(kwargs)
|
||||
|
||||
async def iterate():
|
||||
for event in events:
|
||||
yield event
|
||||
|
||||
return iterate()
|
||||
|
||||
monkeypatch.setattr(litellm, 'responses', stream)
|
||||
monkeypatch.setattr(litellm, 'aresponses', astream)
|
||||
|
||||
async def invoke():
|
||||
kwargs = dict(stream=entry in ('stream', 'astream'), verbose=False)
|
||||
return await llm.get_response_async('question', **kwargs) if entry in ('async', 'astream') else llm.get_response('question', **kwargs)
|
||||
|
||||
if profile == 'completed':
|
||||
assert await invoke() == 'partial'
|
||||
assert llm._last_stop_reason == 'completed'
|
||||
else:
|
||||
with pytest.raises(LLMResponseError, match='Responses API failed') as caught:
|
||||
await invoke()
|
||||
assert llm._last_stop_reason == 'error'
|
||||
if error:
|
||||
assert 'server_error' in str(caught.value)
|
||||
assert 'Provider failed.' in str(caught.value)
|
||||
assert len(requests) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('mode', ['sync', 'async'])
|
||||
@pytest.mark.parametrize('shape', ['sdk', 'dict'])
|
||||
async def test_stream_error_event_prevents_pending_tool_execution(mode, shape, monkeypatch):
|
||||
import litellm
|
||||
llm = LLM(model='gpt-4o-mini')
|
||||
effects = []
|
||||
requests = []
|
||||
|
||||
def lookup() -> str:
|
||||
"""Return a lookup result."""
|
||||
effects.append('executed')
|
||||
return 'found'
|
||||
|
||||
events = [
|
||||
{'type': 'response.output_item.done', 'output_index': 0, 'item': {
|
||||
'type': 'function_call', 'call_id': 'call_1', 'name': 'lookup', 'arguments': '{}',
|
||||
}},
|
||||
{'type': 'error', 'code': 'server_error', 'message': 'Stream failed.'},
|
||||
]
|
||||
if shape == 'sdk':
|
||||
events = [SimpleNamespace(**event) for event in events]
|
||||
monkeypatch.setattr('praisonaiagents.llm.llm.check_model_request', lambda *args: None)
|
||||
|
||||
def stream(**kwargs):
|
||||
requests.append(kwargs)
|
||||
return iter(events)
|
||||
|
||||
async def astream(**kwargs):
|
||||
requests.append(kwargs)
|
||||
|
||||
async def iterate():
|
||||
for event in events:
|
||||
yield event
|
||||
|
||||
return iterate()
|
||||
|
||||
monkeypatch.setattr(litellm, 'responses', stream)
|
||||
monkeypatch.setattr(litellm, 'aresponses', astream)
|
||||
with pytest.raises(LLMResponseError, match='Stream failed.'):
|
||||
kwargs = dict(stream=True, verbose=False, tools=[lookup], max_iterations=1)
|
||||
if mode == 'sync':
|
||||
llm.get_response('question', **kwargs)
|
||||
else:
|
||||
await llm.get_response_async('question', **kwargs)
|
||||
assert effects == []
|
||||
assert len(requests) == 1
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Incomplete Responses retain their terminal reason and streamed usage."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from openai.types.responses.response import IncompleteDetails
|
||||
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
from praisonaiagents.llm.openai_client import OpenAIClient
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_responses_endpoint(monkeypatch):
|
||||
monkeypatch.delenv('OPENAI_API_BASE', raising=False)
|
||||
monkeypatch.delenv('OPENAI_BASE_URL', raising=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('entry', ['llm_sync', 'llm_async', 'llm_stream', 'llm_astream', 'client_sync', 'client_async'])
|
||||
@pytest.mark.parametrize('shape', ['sdk', 'dict'])
|
||||
@pytest.mark.parametrize('status,reason,expected_finish,expected_outcome', [
|
||||
('incomplete', 'max_output_tokens', 'length', 'length_truncated'),
|
||||
('incomplete', 'content_filter', 'content_filter', 'content_filtered'),
|
||||
('completed', 'max_output_tokens', 'stop', 'completed'),
|
||||
])
|
||||
async def test_public_incomplete_responses(entry, shape, status, reason, expected_finish, expected_outcome, monkeypatch):
|
||||
details = IncompleteDetails(reason=reason)
|
||||
response = SimpleNamespace(
|
||||
status=status, incomplete_details=details if shape == 'sdk' else details.model_dump(),
|
||||
output=[{'type': 'message', 'content': [{'type': 'output_text', 'text': 'partial'}]}],
|
||||
usage=(
|
||||
SimpleNamespace(input_tokens=3, output_tokens=2, total_tokens=5)
|
||||
if shape == 'sdk' else {'input_tokens': 3, 'output_tokens': 2, 'total_tokens': 5}
|
||||
),
|
||||
)
|
||||
requests = []
|
||||
|
||||
def respond(**kwargs):
|
||||
requests.append(kwargs)
|
||||
return response
|
||||
|
||||
async def arespond(**kwargs):
|
||||
return respond(**kwargs)
|
||||
|
||||
if entry.startswith('llm'):
|
||||
import litellm
|
||||
|
||||
llm = LLM(model='gpt-4o-mini')
|
||||
monkeypatch.setattr('praisonaiagents.llm.llm.check_model_request', lambda *args: None)
|
||||
monkeypatch.setattr(litellm, 'responses', respond)
|
||||
monkeypatch.setattr(litellm, 'aresponses', arespond)
|
||||
if entry in ('llm_stream', 'llm_astream'):
|
||||
events = [
|
||||
{'type': 'response.output_text.delta', 'delta': 'partial'},
|
||||
{'type': 'response.' + status, 'response': response},
|
||||
]
|
||||
if shape == 'sdk':
|
||||
events = [SimpleNamespace(**event) for event in events]
|
||||
|
||||
def stream(**kwargs):
|
||||
requests.append(kwargs)
|
||||
return iter(events)
|
||||
|
||||
async def astream(**kwargs):
|
||||
requests.append(kwargs)
|
||||
|
||||
async def iterate():
|
||||
for event in events:
|
||||
yield event
|
||||
|
||||
return iterate()
|
||||
|
||||
monkeypatch.setattr(litellm, 'responses', stream)
|
||||
monkeypatch.setattr(litellm, 'aresponses', astream)
|
||||
kwargs = dict(stream=entry in ('llm_stream', 'llm_astream'), verbose=False)
|
||||
answer = await llm.get_response_async('question', **kwargs) if entry in ('llm_async', 'llm_astream') else llm.get_response('question', **kwargs)
|
||||
assert answer == 'partial'
|
||||
assert llm._last_stop_reason == expected_outcome
|
||||
for metrics in (llm.last_token_metrics, llm.session_token_metrics):
|
||||
assert metrics is not None
|
||||
assert metrics.input_tokens == 3
|
||||
assert metrics.output_tokens == 2
|
||||
assert metrics.total_tokens == 5
|
||||
else:
|
||||
client = OpenAIClient(api_key='sk-test-not-real')
|
||||
client._sync_client = SimpleNamespace(responses=SimpleNamespace(create=respond))
|
||||
client._async_client = SimpleNamespace(responses=SimpleNamespace(create=arespond))
|
||||
messages = [{'role': 'user', 'content': 'question'}]
|
||||
answer = client.create_completion(messages) if entry == 'client_sync' else await client.acreate_completion(messages)
|
||||
assert answer.choices[0].message.content == 'partial'
|
||||
assert answer.choices[0].finish_reason == expected_finish
|
||||
assert answer.usage.prompt_tokens == 3
|
||||
assert answer.usage.completion_tokens == 2
|
||||
assert answer.usage.total_tokens == 5
|
||||
assert len(requests) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize('reason', ['max_output_tokens', 'content_filter'])
|
||||
@pytest.mark.parametrize('existing', ['max_steps', 'cancelled'])
|
||||
def test_incomplete_response_preserves_stronger_terminal_reason(reason, existing):
|
||||
llm = LLM(model='gpt-4o-mini')
|
||||
llm._last_stop_reason = existing
|
||||
llm._extract_from_responses_output(SimpleNamespace(
|
||||
status='incomplete', incomplete_details=IncompleteDetails(reason=reason), output=[],
|
||||
))
|
||||
assert llm._last_stop_reason == existing
|
||||
|
||||
|
||||
@pytest.mark.parametrize('reason', [None, 'unknown_future_reason'])
|
||||
def test_unrecognised_incomplete_reason_preserves_existing_behavior(reason):
|
||||
llm = LLM(model='gpt-4o-mini')
|
||||
llm._extract_from_responses_output(SimpleNamespace(
|
||||
status='incomplete', incomplete_details={'reason': reason}, output=[],
|
||||
))
|
||||
assert llm._last_stop_reason == 'completed'
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Responses streams release their transport on every consumer exit."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.llm.llm import LLM, LLMResponseError
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('mode', ['sync', 'async'])
|
||||
@pytest.mark.parametrize('shape', ['direct', 'litellm'])
|
||||
@pytest.mark.parametrize('exit_kind', ['completed', 'failed', 'callback', 'cancelled'])
|
||||
async def test_responses_stream_releases_transport(mode, shape, exit_kind, monkeypatch):
|
||||
import httpx
|
||||
import litellm
|
||||
if mode == 'sync' and exit_kind == 'cancelled':
|
||||
pytest.skip('Task cancellation is an asynchronous exit')
|
||||
closed = []
|
||||
|
||||
class SyncTransport(httpx.SyncByteStream):
|
||||
def __iter__(self):
|
||||
return iter([])
|
||||
|
||||
def close(self):
|
||||
closed.append('closed')
|
||||
|
||||
class AsyncTransport(httpx.AsyncByteStream):
|
||||
async def __aiter__(self):
|
||||
if False:
|
||||
yield b''
|
||||
|
||||
async def aclose(self):
|
||||
closed.append('closed')
|
||||
|
||||
response = httpx.Response(200, stream=SyncTransport() if mode == 'sync' else AsyncTransport())
|
||||
event = {'type': 'response.output_text.delta', 'delta': 'answer'}
|
||||
if exit_kind == 'failed':
|
||||
event = {'type': 'response.failed', 'response': {'error': {'code': 'server_error', 'message': 'Provider failed.'}}}
|
||||
|
||||
class Stream:
|
||||
def __init__(self):
|
||||
self.events = iter([event])
|
||||
if shape == 'litellm':
|
||||
self.response = response
|
||||
elif mode == 'sync':
|
||||
self.close = response.close
|
||||
else:
|
||||
self.aclose = response.aclose
|
||||
|
||||
def __iter__(self):
|
||||
return self
|
||||
|
||||
def __next__(self):
|
||||
return next(self.events)
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
if exit_kind == 'cancelled':
|
||||
raise asyncio.CancelledError()
|
||||
try:
|
||||
return next(self.events)
|
||||
except StopIteration:
|
||||
raise StopAsyncIteration
|
||||
|
||||
stream = Stream()
|
||||
monkeypatch.setattr('praisonaiagents.llm.llm.check_model_request', lambda *args: None)
|
||||
monkeypatch.setattr(litellm, 'responses', lambda **kwargs: stream)
|
||||
|
||||
async def arespond(**kwargs):
|
||||
return stream
|
||||
|
||||
monkeypatch.setattr(litellm, 'aresponses', arespond)
|
||||
llm = LLM(model='gpt-4o-mini')
|
||||
|
||||
def callback(event):
|
||||
if exit_kind == 'callback':
|
||||
raise ValueError('Callback failed.')
|
||||
|
||||
async def invoke():
|
||||
kwargs = dict(stream_callback=callback, emit_events=True)
|
||||
if mode == 'sync':
|
||||
return llm._stream_responses_api({'model': llm.model}, **kwargs)
|
||||
return await llm._stream_responses_api_async({'model': llm.model}, **kwargs)
|
||||
|
||||
if exit_kind == 'failed':
|
||||
with pytest.raises(LLMResponseError, match='Provider failed.'):
|
||||
await invoke()
|
||||
elif exit_kind == 'callback':
|
||||
with pytest.raises(ValueError, match='Callback failed.'):
|
||||
await invoke()
|
||||
elif exit_kind == 'cancelled':
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await invoke()
|
||||
else:
|
||||
assert (await invoke())[0] == 'answer'
|
||||
assert response.is_closed
|
||||
assert closed == ['closed']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('mode', ['sync', 'async'])
|
||||
async def test_cleanup_error_does_not_mask_provider_failure(mode, monkeypatch):
|
||||
import litellm
|
||||
event = {'type': 'error', 'message': 'Original failure.'}
|
||||
|
||||
def fail_close():
|
||||
raise ValueError('Cleanup failed.')
|
||||
|
||||
async def fail_aclose():
|
||||
fail_close()
|
||||
|
||||
class AsyncStream:
|
||||
aclose = staticmethod(fail_aclose)
|
||||
|
||||
def __aiter__(self):
|
||||
async def events():
|
||||
yield event
|
||||
return events()
|
||||
|
||||
monkeypatch.setattr('praisonaiagents.llm.llm.check_model_request', lambda *args: None)
|
||||
class SyncStream:
|
||||
close = staticmethod(fail_close)
|
||||
|
||||
def __iter__(self):
|
||||
return iter([event])
|
||||
|
||||
monkeypatch.setattr(litellm, 'responses', lambda **kwargs: SyncStream())
|
||||
|
||||
async def arespond(**kwargs):
|
||||
return AsyncStream()
|
||||
|
||||
monkeypatch.setattr(litellm, 'aresponses', arespond)
|
||||
llm = LLM(model='gpt-4o-mini')
|
||||
with pytest.raises(LLMResponseError, match='Original failure.'):
|
||||
if mode == 'sync':
|
||||
llm._stream_responses_api({'model': llm.model})
|
||||
else:
|
||||
await llm._stream_responses_api_async({'model': llm.model})
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Created documentation must keep the same effective priority after reload."""
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.memory.docs_manager import DocsManager
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scope", ["workspace", "global"])
|
||||
@pytest.mark.parametrize("priority", [0, -5, 200, 1500])
|
||||
def test_created_doc_priority_and_selection_survive_reload(tmp_path, scope, priority):
|
||||
"""Persist explicit priorities and preserve selection across manager lifecycles."""
|
||||
options = dict(workspace_path=str(tmp_path), global_docs_path=str(tmp_path / "global"))
|
||||
manager = DocsManager(**options)
|
||||
manager.create_doc("reference", "Stable documentation content", priority=priority, scope=scope)
|
||||
before_priority = manager.get_doc("reference").priority
|
||||
before_selection = [doc.name for doc in manager.get_docs_for_context()]
|
||||
manager.reload()
|
||||
reopened = DocsManager(**options)
|
||||
for current in (manager, reopened):
|
||||
assert current.get_doc("reference").priority == before_priority
|
||||
assert [doc.name for doc in current.get_docs_for_context()] == before_selection
|
||||
expected_priority = priority - 1000 if scope == "global" else priority
|
||||
assert before_priority == expected_priority
|
||||
|
||||
|
||||
def test_new_global_doc_does_not_temporarily_outrank_workspace_doc(tmp_path):
|
||||
"""Apply the global priority offset immediately and keep selection across reload."""
|
||||
options = dict(workspace_path=str(tmp_path), global_docs_path=str(tmp_path / "global"))
|
||||
manager = DocsManager(**options)
|
||||
manager.create_doc("local", "local content", priority=200)
|
||||
manager.create_doc("shared", "global content", priority=300, scope="global")
|
||||
manager.reload()
|
||||
reopened = DocsManager(**options)
|
||||
for current in (manager, reopened):
|
||||
assert [doc.name for doc in current.get_all_docs()] == ["local", "shared"]
|
||||
assert [doc.name for doc in current.get_docs_for_context()] == ["local"]
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Caller edits must not rewrite previously stored in-memory records."""
|
||||
|
||||
import pytest
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import praisonaiagents.memory.adapters.in_memory_adapter as adapter_module
|
||||
|
||||
from praisonaiagents.memory.adapters.in_memory_adapter import InMemoryAdapter
|
||||
|
||||
|
||||
@pytest.mark.parametrize('tier', ['short', 'long'])
|
||||
def test_store_snapshots_nested_metadata(tier):
|
||||
adapter = InMemoryAdapter()
|
||||
metadata = {'labels': ['original'], 'details': {'count': 1}}
|
||||
memory_id = getattr(adapter, f'store_{tier}_term')('original text', metadata)
|
||||
metadata['labels'].append('caller edit')
|
||||
metadata['details']['count'] = 2
|
||||
record = getattr(adapter, f'search_{tier}_term')('original')[0]
|
||||
assert record['id'] == memory_id
|
||||
assert record['metadata'] == {'labels': ['original'], 'details': {'count': 1}}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('tier', ['short', 'long'])
|
||||
@pytest.mark.parametrize('read', ['search', 'all'])
|
||||
def test_read_results_do_not_alias_stored_records(tier, read):
|
||||
adapter = InMemoryAdapter()
|
||||
memory_id = getattr(adapter, f'store_{tier}_term')(
|
||||
'original text', {'labels': ['original'], 'details': {'count': 1}},
|
||||
)
|
||||
search = getattr(adapter, f'search_{tier}_term')
|
||||
records = search('original') if read == 'search' else adapter.get_all_memories()
|
||||
records[0]['id'] = 'caller id'
|
||||
records[0]['text'] = 'caller text'
|
||||
records[0]['metadata']['labels'].append('caller edit')
|
||||
records[0]['metadata']['details']['count'] = 2
|
||||
records.clear()
|
||||
assert search('original') == [{
|
||||
'id': memory_id, 'text': 'original text', 'type': tier,
|
||||
'metadata': {'labels': ['original'], 'details': {'count': 1}},
|
||||
}]
|
||||
assert adapter.delete_memory(memory_id, tier=tier)
|
||||
assert adapter.get_all_memories() == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize('tier', ['short', 'long'])
|
||||
@pytest.mark.parametrize('metadata', [None, {}])
|
||||
def test_metadata_defaults_and_search_limits_remain_unchanged(tier, metadata):
|
||||
adapter = InMemoryAdapter()
|
||||
write = getattr(adapter, f'store_{tier}_term')
|
||||
expected_ids = [write('matching text', metadata) for _ in range(6)]
|
||||
search = getattr(adapter, f'search_{tier}_term')
|
||||
assert [record['id'] for record in search('matching')] == expected_ids[:5]
|
||||
assert [record['id'] for record in search('matching', limit=2)] == expected_ids[:2]
|
||||
assert all(record['metadata'] == metadata for record in adapter.get_all_memories())
|
||||
|
||||
|
||||
def test_enumeration_copy_allows_concurrent_eviction_and_preserves_snapshot(monkeypatch):
|
||||
"""A slow nested copy must not keep writers behind the collection lock."""
|
||||
adapter = InMemoryAdapter(max_size=1)
|
||||
original_id = adapter.store_short_term('original', {'nested': ['original']})
|
||||
copying = threading.Event()
|
||||
resume = threading.Event()
|
||||
real_copy = adapter_module.deepcopy
|
||||
|
||||
def paused_copy(value):
|
||||
if isinstance(value, list):
|
||||
copying.set()
|
||||
assert resume.wait(5)
|
||||
return real_copy(value)
|
||||
|
||||
monkeypatch.setattr(adapter_module, 'deepcopy', paused_copy)
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
read = executor.submit(adapter.get_all_memories)
|
||||
try:
|
||||
assert copying.wait(2)
|
||||
new_id = executor.submit(adapter.store_long_term, 'new', {'nested': ['new']}).result(timeout=1)
|
||||
finally:
|
||||
resume.set()
|
||||
snapshot = read.result(timeout=5)
|
||||
assert snapshot == [{'id': original_id, 'text': 'original', 'type': 'short', 'metadata': {'nested': ['original']}}]
|
||||
snapshot[0]['metadata']['nested'].append('caller edit')
|
||||
assert adapter.get_all_memories() == [{'id': new_id, 'text': 'new', 'type': 'long', 'metadata': {'nested': ['new']}}]
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Thread-scoped SQLite cleanup must preserve the shared adapter (#5486).
|
||||
|
||||
`Memory.close_connections()` is documented as thread-scoped: it closes only the
|
||||
calling thread's SQLite connections and leaves the shared `SqliteMemoryAdapter`
|
||||
(and other threads' connections) intact. A previous adapter-shutdown block
|
||||
cleared `self.memory_adapter` unconditionally, so subsequent searches fell back
|
||||
to legacy SQL against `short_mem` / `long_mem` and raised
|
||||
``sqlite3.OperationalError: no such table``.
|
||||
|
||||
These tests pin the contract: after another thread (or the same thread) calls
|
||||
`close_connections()`, the shared adapter survives and searches keep working by
|
||||
lazily reopening the calling thread's connection.
|
||||
"""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.memory.memory import Memory
|
||||
from praisonaiagents.memory.adapters.sqlite_adapter import SqliteMemoryAdapter
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("PRAISONAI_HOME", str(tmp_path / "home"))
|
||||
monkeypatch.chdir(tmp_path)
|
||||
return Memory(config={"provider": "sqlite"})
|
||||
|
||||
|
||||
def _texts(results):
|
||||
items = results if isinstance(results, list) else results.get("results", [])
|
||||
return [r.get("text") or r.get("memory") or "" for r in items]
|
||||
|
||||
|
||||
def test_adapter_is_sqlite(store):
|
||||
"""Control: these tests only mean something for the SQLite adapter."""
|
||||
assert isinstance(store.memory_adapter, SqliteMemoryAdapter)
|
||||
|
||||
|
||||
def test_search_before_cleanup(store):
|
||||
"""Control: searching works before any cleanup happens."""
|
||||
store.store_short_term("shared record")
|
||||
assert "shared record" in _texts(store.search_short_term("shared record"))
|
||||
|
||||
|
||||
def test_short_term_search_survives_idle_worker_cleanup(store):
|
||||
"""An idle worker that never opened a connection must not break searches."""
|
||||
store.store_short_term("shared record")
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
pool.submit(store.close_connections).result()
|
||||
|
||||
assert store.memory_adapter is not None
|
||||
assert "shared record" in _texts(store.search_short_term("shared record"))
|
||||
|
||||
|
||||
def test_long_term_search_survives_idle_worker_cleanup(store):
|
||||
store.store_long_term("shared long record")
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
pool.submit(store.close_connections).result()
|
||||
|
||||
assert store.memory_adapter is not None
|
||||
assert "shared long record" in _texts(store.search_long_term("shared long record"))
|
||||
|
||||
|
||||
def test_short_term_search_survives_initialized_worker_cleanup(store):
|
||||
"""A worker that has opened its own connection cleans up only its own."""
|
||||
store.store_short_term("shared record")
|
||||
|
||||
def worker():
|
||||
store.store_short_term("worker record")
|
||||
store.close_connections()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
pool.submit(worker).result()
|
||||
|
||||
assert store.memory_adapter is not None
|
||||
assert "shared record" in _texts(store.search_short_term("shared record"))
|
||||
assert "worker record" in _texts(store.search_short_term("worker record"))
|
||||
|
||||
|
||||
def test_same_thread_repeated_cleanup_and_reopen(store):
|
||||
"""Repeated same-thread cleanup must keep the adapter usable (lazy reopen)."""
|
||||
store.store_short_term("shared record")
|
||||
store.close_connections()
|
||||
store.close_connections()
|
||||
|
||||
assert store.memory_adapter is not None
|
||||
assert "shared record" in _texts(store.search_short_term("shared record"))
|
||||
|
||||
|
||||
def test_same_thread_long_term_cleanup_and_reopen(store):
|
||||
store.store_long_term("shared long record")
|
||||
store.close_connections()
|
||||
|
||||
assert store.memory_adapter is not None
|
||||
assert "shared long record" in _texts(store.search_long_term("shared long record"))
|
||||
|
||||
|
||||
def test_adapter_thread_close_is_idempotent(store):
|
||||
"""Adapter-level thread close tolerates being called with no open conns."""
|
||||
store.memory_adapter.close_thread_connections()
|
||||
store.memory_adapter.close_thread_connections()
|
||||
store.store_short_term("shared record")
|
||||
assert "shared record" in _texts(store.search_short_term("shared record"))
|
||||
|
||||
|
||||
def test_adapter_thread_close_closes_open_connection(store):
|
||||
"""Opening a connection then closing it directly must close the live conn
|
||||
and drop it from the shared registry (not just the no-op path)."""
|
||||
adapter = store.memory_adapter
|
||||
adapter.store_short_term("shared record")
|
||||
adapter.store_long_term("shared long record")
|
||||
|
||||
stm = adapter._local.stm_conn
|
||||
ltm = adapter._local.ltm_conn
|
||||
assert stm in adapter._all_connections
|
||||
assert ltm in adapter._all_connections
|
||||
|
||||
adapter.close_thread_connections()
|
||||
|
||||
# Thread-local refs cleared and registry no longer tracks the closed conns.
|
||||
assert getattr(adapter._local, "stm_conn", None) is None
|
||||
assert getattr(adapter._local, "ltm_conn", None) is None
|
||||
assert stm not in adapter._all_connections
|
||||
assert ltm not in adapter._all_connections
|
||||
|
||||
# The physical connections are closed (operations raise ProgrammingError).
|
||||
with pytest.raises(Exception):
|
||||
stm.execute("SELECT 1")
|
||||
with pytest.raises(Exception):
|
||||
ltm.execute("SELECT 1")
|
||||
|
||||
# Adapter remains usable via lazy reopen.
|
||||
assert "shared record" in _texts(adapter.search_short_term("shared record"))
|
||||
|
||||
|
||||
def test_adapter_thread_close_keeps_connection_tracked_on_failure(store):
|
||||
"""If a connection fails to close, it stays tracked for a later retry and
|
||||
the thread-local ref is preserved (the open-conn failure path)."""
|
||||
adapter = store.memory_adapter
|
||||
adapter.store_short_term("shared record")
|
||||
real = adapter._local.stm_conn
|
||||
|
||||
class _FlakyConn:
|
||||
def __init__(self, inner):
|
||||
self._inner = inner
|
||||
self.close_calls = 0
|
||||
|
||||
def close(self):
|
||||
self.close_calls += 1
|
||||
raise RuntimeError("boom")
|
||||
|
||||
flaky = _FlakyConn(real)
|
||||
# Swap the tracked real conn for a flaky wrapper in both registry and tls.
|
||||
with adapter._connection_lock:
|
||||
adapter._all_connections.discard(real)
|
||||
adapter._all_connections.add(flaky)
|
||||
adapter._local.stm_conn = flaky
|
||||
|
||||
adapter.close_thread_connections()
|
||||
|
||||
assert flaky.close_calls == 1
|
||||
# Failed close: connection stays tracked and the ref is NOT cleared.
|
||||
assert flaky in adapter._all_connections
|
||||
assert adapter._local.stm_conn is flaky
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Enumerating SQLite memories must include rows beyond search-sized caps."""
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.memory.adapters.sqlite_adapter import SqliteMemoryAdapter
|
||||
from praisonaiagents.memory.memory import Memory
|
||||
|
||||
|
||||
@pytest.mark.parametrize('facade', [False, True])
|
||||
@pytest.mark.parametrize('short_count,long_count', [(0, 0), (1000, 1), (1, 1000), (1001, 1001)])
|
||||
def test_get_all_memories_includes_every_stored_row(tmp_path, monkeypatch, facade, short_count, long_count):
|
||||
monkeypatch.setenv('PRAISONAI_HOME', str(tmp_path / 'home'))
|
||||
monkeypatch.chdir(tmp_path)
|
||||
store = Memory(config={'provider': 'sqlite'}) if facade else SqliteMemoryAdapter(
|
||||
short_db=str(tmp_path / 'short.db'), long_db=str(tmp_path / 'long.db'),
|
||||
)
|
||||
adapter = store.memory_adapter if facade else store
|
||||
expected = set()
|
||||
try:
|
||||
for tier, count in [('short', short_count), ('long', long_count)]:
|
||||
write = getattr(store, f'store_{tier}_term')
|
||||
for index in range(count):
|
||||
text = f'{tier} entry {index}'
|
||||
memory_id = write(text, metadata={'index': index})
|
||||
expected.add((f'{tier}_term', memory_id, text, index))
|
||||
records = store.get_all_memories()
|
||||
assert len(records) == short_count + long_count
|
||||
assert {
|
||||
(record['memory_type'], record['id'], record['text'], record['metadata']['index'])
|
||||
for record in records
|
||||
} == expected
|
||||
if facade:
|
||||
assert all(record['type'] == record['memory_type'] for record in records)
|
||||
# Enumeration must not widen the public search defaults or explicit limit.
|
||||
for tier, count in [('short', short_count), ('long', long_count)]:
|
||||
search = getattr(adapter, f'search_{tier}_term')
|
||||
assert len(search('')) == min(5, count)
|
||||
assert len(search('', limit=2)) == min(2, count)
|
||||
finally:
|
||||
adapter.close_connections()
|
||||
if facade:
|
||||
store.close_connections()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('tier', ['short', 'long'])
|
||||
@pytest.mark.parametrize('value', [float('nan'), float('inf'), -float('inf')])
|
||||
def test_filtered_search_accepts_writer_nonfinite_metadata(tmp_path, tier, value):
|
||||
adapter = SqliteMemoryAdapter(short_db=str(tmp_path / 'short.db'), long_db=str(tmp_path / 'long.db'))
|
||||
try:
|
||||
write = getattr(adapter, f'store_{tier}_term')
|
||||
search = getattr(adapter, f'search_{tier}_term')
|
||||
write('other record', metadata={'user_id': 'other', 'value': value})
|
||||
wanted = write('wanted record', metadata={'user_id': 'wanted', 'value': value})
|
||||
assert [item['id'] for item in search('', user_id='wanted', limit=1)] == [wanted]
|
||||
assert search('', user_id='absent') == []
|
||||
finally:
|
||||
adapter.close_connections()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('tier', ['short', 'long'])
|
||||
def test_filtered_search_ignores_corrupt_peer_metadata_and_keeps_string_matching(tmp_path, tier):
|
||||
adapter = SqliteMemoryAdapter(short_db=str(tmp_path / 'short.db'), long_db=str(tmp_path / 'long.db'))
|
||||
try:
|
||||
write = getattr(adapter, f'store_{tier}_term')
|
||||
wanted = write('wanted', metadata={'user_id': True})
|
||||
conn = getattr(adapter, f'_get_{"stm" if tier == "short" else "ltm"}_conn')()
|
||||
conn.execute(f'INSERT INTO {tier}_term_memory (content, metadata) VALUES (?, ?)', ('broken peer', '{invalid'))
|
||||
conn.commit()
|
||||
search = getattr(adapter, f'search_{tier}_term')
|
||||
assert [item['id'] for item in search('', user_id='True', limit=1)] == [wanted]
|
||||
finally:
|
||||
adapter.close_connections()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('tier', ['short', 'long'])
|
||||
def test_null_user_is_distinct_from_literal_none_user(tmp_path, tier):
|
||||
adapter = SqliteMemoryAdapter(short_db=str(tmp_path / 'short.db'), long_db=str(tmp_path / 'long.db'))
|
||||
try:
|
||||
write = getattr(adapter, f'store_{tier}_term')
|
||||
write('unowned', metadata={'user_id': None})
|
||||
wanted = write('owned', metadata={'user_id': 'None'})
|
||||
search = getattr(adapter, f'search_{tier}_term')
|
||||
assert [item['id'] for item in search('', user_id='None')] == [wanted]
|
||||
finally:
|
||||
adapter.close_connections()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tier", ["short_term", "long_term"])
|
||||
@pytest.mark.parametrize("user_id", [0, False, ""])
|
||||
def test_falsey_supplied_user_id_still_filters_before_limit(tmp_path, tier, user_id):
|
||||
adapter = SqliteMemoryAdapter(short_db=str(tmp_path / 'short.db'), long_db=str(tmp_path / 'long.db'))
|
||||
try:
|
||||
write = getattr(adapter, f"store_{tier}")
|
||||
search = getattr(adapter, f"search_{tier}")
|
||||
write("other", metadata={"user_id": "other"})
|
||||
wanted = write("wanted", metadata={"user_id": user_id})
|
||||
assert [item["id"] for item in search("", user_id=user_id, limit=1)] == [wanted]
|
||||
finally:
|
||||
adapter.close_connections()
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Continuation prompts must distinguish running work from proven completion."""
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.session import build_handoff_prompt
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"checkpoint,expected,absent",
|
||||
[
|
||||
({"current_step": "deploy"}, "PROGRESS: current step: deploy", ["last completed", "steps done"]),
|
||||
({"completed_steps": 0, "total_steps": 5, "current_step": "deploy"},
|
||||
"PROGRESS: 0 of 5 workflow steps done; current step: deploy", ["last completed"]),
|
||||
({"completed_steps": 2, "total_steps": 5, "last_step": "build", "current_step": "deploy"},
|
||||
"PROGRESS: 2 of 5 workflow steps done; last completed: build; current step: deploy", []),
|
||||
({"last_step": "build"}, "PROGRESS: last completed: build", ["steps done"]),
|
||||
({"completed_steps": 0, "total_steps": 0, "step_count": 9},
|
||||
"PROGRESS: 0 of 0 workflow steps done", ["of 9"]),
|
||||
({"completed_steps": 2, "step_count": 5, "last_step": "build"},
|
||||
"PROGRESS: 2 of 5 workflow steps done; last completed: build", []),
|
||||
],
|
||||
)
|
||||
def test_handoff_reports_only_the_supplied_progress(checkpoint, expected, absent):
|
||||
prompt = build_handoff_prompt(workflow_checkpoint=checkpoint)
|
||||
|
||||
assert expected in prompt
|
||||
for text in absent:
|
||||
assert text not in prompt
|
||||
assert prompt.endswith("before acting.")
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Closing and reopening a Session must preserve complete agent messages."""
|
||||
|
||||
from copy import deepcopy
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.session.api import AGENT_HISTORY_KEY, Session
|
||||
from praisonaiagents.session.store import DefaultSessionStore
|
||||
|
||||
|
||||
@pytest.mark.parametrize("history", [
|
||||
[{"role": "user", "content": "hello"}],
|
||||
[
|
||||
{"role": "assistant", "content": None, "tool_calls": [
|
||||
{"id": "call_1", "type": "function", "function": {
|
||||
"name": "lookup", "arguments": '{"query":"hello"}'}}
|
||||
]},
|
||||
{"role": "tool", "content": "found", "tool_call_id": "call_1",
|
||||
"name": "lookup"},
|
||||
{"role": "assistant", "content": "answer"},
|
||||
],
|
||||
[{"role": "assistant", "content": "answer", "reasoning_content": "why",
|
||||
"metadata": {"citations": [{"source": "local"}]}, "custom": False}],
|
||||
[{"role": "user", "content": [
|
||||
{"type": "text", "text": "describe"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}},
|
||||
], "name": "reader"}],
|
||||
], ids=["plain-control", "tool-turn", "reasoning-and-metadata", "multimodal"])
|
||||
def test_close_reopen_preserves_message_fields(tmp_path, monkeypatch, history):
|
||||
import praisonaiagents.agent as agent_module
|
||||
import praisonaiagents.session.store as store_module
|
||||
|
||||
class LocalAgent:
|
||||
def __init__(self, **kwargs):
|
||||
self.chat_history = []
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv("PRAISONAI_HOME", str(tmp_path / "home"))
|
||||
monkeypatch.setattr(agent_module, "Agent", LocalAgent)
|
||||
session_dir = str(tmp_path / "sessions")
|
||||
store = DefaultSessionStore(session_dir=session_dir)
|
||||
monkeypatch.setattr(store_module, "_default_store", store)
|
||||
session = Session(session_id="parent")
|
||||
session._memory = SimpleNamespace(search_short_term=lambda **kwargs: [])
|
||||
agent = session.Agent(name="helper", memory=False)
|
||||
agent.chat_history = deepcopy(history)
|
||||
session.close()
|
||||
|
||||
fresh = DefaultSessionStore(session_dir=session_dir)
|
||||
monkeypatch.setattr(store_module, "_default_store", fresh)
|
||||
assert fresh.get_session("parent").metadata[AGENT_HISTORY_KEY][
|
||||
"helper:Assistant"
|
||||
] == history
|
||||
resumed = Session(session_id="parent").Agent(name="helper", memory=False)
|
||||
assert resumed.chat_history == history
|
||||
|
||||
|
||||
@pytest.mark.parametrize("builder", ["openai", "litellm"])
|
||||
def test_restored_extensions_do_not_reach_responses_input(tmp_path, monkeypatch, builder):
|
||||
import praisonaiagents.session.store as store_module
|
||||
from praisonaiagents.llm.openai_client import OpenAIClient
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv("PRAISONAI_HOME", str(tmp_path / "home"))
|
||||
store = DefaultSessionStore(session_dir=str(tmp_path / "sessions"))
|
||||
monkeypatch.setattr(store_module, "_default_store", store)
|
||||
history = [
|
||||
{"role": "user", "content": "question", "name": "reader",
|
||||
"metadata": {"origin": "disk"}},
|
||||
{"role": "assistant", "content": "answer", "reasoning_content": "why",
|
||||
"metadata": {"citation": "local"}, "extension": False},
|
||||
{"role": "assistant", "content": None, "tool_calls": [{
|
||||
"id": "call_1", "type": "function",
|
||||
"function": {"name": "lookup", "arguments": "{}"},
|
||||
}]},
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": "found"},
|
||||
]
|
||||
session = Session(session_id="parent")
|
||||
session._agents["helper:Assistant"] = {"agent": None, "chat_history": history}
|
||||
session.close()
|
||||
resumed = Session(session_id="parent")._restore_agent_chat_history("helper:Assistant")
|
||||
if builder == "openai":
|
||||
client = object.__new__(OpenAIClient)
|
||||
params = client._build_responses_input(resumed, model="gpt-4o")
|
||||
else:
|
||||
llm = object.__new__(LLM)
|
||||
llm.max_tokens = None
|
||||
llm.top_p = None
|
||||
llm.base_url = None
|
||||
llm.api_key = None
|
||||
llm.timeout = None
|
||||
llm._resolve_subscription_creds = lambda: None
|
||||
llm._resolve_openai_compatible_model = lambda: "gpt-4o"
|
||||
params = llm._build_responses_params(resumed)
|
||||
assert params["input"] == [
|
||||
{"role": "user", "content": "question"},
|
||||
{"role": "assistant", "content": "answer"},
|
||||
{"type": "function_call", "call_id": "call_1", "name": "lookup",
|
||||
"arguments": "{}"},
|
||||
{"type": "function_call_output", "call_id": "call_1", "output": "found"},
|
||||
]
|
||||
assert store.get_session("parent").metadata[AGENT_HISTORY_KEY][
|
||||
"helper:Assistant"
|
||||
] == history
|
||||
|
||||
|
||||
def test_legacy_defaults_and_non_message_filter(tmp_path, monkeypatch):
|
||||
import praisonaiagents.session.store as store_module
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv("PRAISONAI_HOME", str(tmp_path / "home"))
|
||||
store = DefaultSessionStore(session_dir=str(tmp_path / "sessions"))
|
||||
monkeypatch.setattr(store_module, "_default_store", store)
|
||||
session = Session(session_id="parent")
|
||||
session._agents["helper:Assistant"] = {
|
||||
"agent": None, "chat_history": [None, {"name": "helper"}],
|
||||
}
|
||||
session.close()
|
||||
assert store.get_session("parent").metadata[AGENT_HISTORY_KEY][
|
||||
"helper:Assistant"
|
||||
] == [{"role": "user", "content": "", "name": "helper"}]
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Concurrent Session closes must merge different agent histories under lock."""
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Event
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.session.api import AGENT_HISTORY_KEY, Session
|
||||
from praisonaiagents.session.store import DefaultSessionStore
|
||||
|
||||
|
||||
@pytest.mark.parametrize("existing", [{}, {"peer:Assistant": [
|
||||
{"role": "user", "content": "peer"},
|
||||
]}])
|
||||
def test_two_closes_preserve_both_agent_histories(tmp_path, monkeypatch, existing):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv("PRAISONAI_HOME", str(tmp_path / "home"))
|
||||
directory = str(tmp_path / "sessions")
|
||||
first = DefaultSessionStore(session_dir=directory)
|
||||
second = DefaultSessionStore(session_dir=directory)
|
||||
first.update_session_metadata("parent", **{AGENT_HISTORY_KEY: existing})
|
||||
a, b = Session(session_id="parent"), Session(session_id="parent")
|
||||
monkeypatch.setattr(a, "_get_session_store", lambda: first)
|
||||
monkeypatch.setattr(b, "_get_session_store", lambda: second)
|
||||
ah = [{"role": "user", "content": "a"}]
|
||||
bh = [{"role": "user", "content": "b"}]
|
||||
a._agents["a:Assistant"] = {"agent": None, "chat_history": ah}
|
||||
b._agents["b:Assistant"] = {"agent": None, "chat_history": bh}
|
||||
ready, completed = Event(), Event()
|
||||
original = first._modify_session_locked
|
||||
|
||||
def pause_before_lock(*args, **kwargs):
|
||||
ready.set()
|
||||
assert completed.wait(10), "second writer did not complete"
|
||||
return original(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(first, "_modify_session_locked", pause_before_lock)
|
||||
|
||||
def close_second():
|
||||
assert ready.wait(10), "first writer did not reach write boundary"
|
||||
try:
|
||||
b.close()
|
||||
finally:
|
||||
completed.set()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
fa, fb = pool.submit(a.close), pool.submit(close_second)
|
||||
fa.result(timeout=15)
|
||||
fb.result(timeout=15)
|
||||
fresh = DefaultSessionStore(session_dir=directory)
|
||||
assert fresh.get_session("parent").metadata[AGENT_HISTORY_KEY] == {
|
||||
**existing, "a:Assistant": ah, "b:Assistant": bh,
|
||||
}
|
||||
|
||||
|
||||
def test_metadata_map_defaults_do_not_overwrite_current_entries(tmp_path):
|
||||
store = DefaultSessionStore(session_dir=str(tmp_path))
|
||||
store.update_session_metadata("parent", marker="keep", **{AGENT_HISTORY_KEY: {
|
||||
"current": [], "peer": ["present"],
|
||||
}})
|
||||
assert store.merge_session_metadata_map(
|
||||
"parent", AGENT_HISTORY_KEY, {"new": ["new"]},
|
||||
defaults={"current": ["legacy"], "legacy-only": ["old"]},
|
||||
)
|
||||
data = DefaultSessionStore(session_dir=str(tmp_path)).get_session("parent")
|
||||
assert data.metadata[AGENT_HISTORY_KEY] == {
|
||||
"current": [], "peer": ["present"], "new": ["new"],
|
||||
"legacy-only": ["old"],
|
||||
}
|
||||
assert data.metadata["marker"] == "keep"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", ["read", "write"])
|
||||
def test_metadata_map_failure_preserves_disk(tmp_path, monkeypatch, failure):
|
||||
import praisonaiagents.session.store as store_module
|
||||
|
||||
store = DefaultSessionStore(session_dir=str(tmp_path))
|
||||
store.update_session_metadata("parent", **{AGENT_HISTORY_KEY: {"peer": ["old"]}})
|
||||
path = Path(store._get_session_path("parent"))
|
||||
before = path.read_bytes()
|
||||
|
||||
def fail(*args, **kwargs):
|
||||
raise OSError("injected I/O failure")
|
||||
|
||||
if failure == "read":
|
||||
monkeypatch.setattr(store, "_load_session_from_disk", fail)
|
||||
else:
|
||||
monkeypatch.setattr(store_module.os, "replace", fail)
|
||||
assert not store.merge_session_metadata_map("parent", AGENT_HISTORY_KEY, {"new": []})
|
||||
assert path.read_bytes() == before
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Imports must not overwrite a session created after the first check."""
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Event
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.session.store import DefaultSessionStore
|
||||
from praisonaiagents.session.sqlite_store import SqliteSessionStore
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", [DefaultSessionStore, SqliteSessionStore])
|
||||
@pytest.mark.parametrize("overwrite", [False, True])
|
||||
def test_import_rechecks_new_peer_session_under_write_lock(tmp_path, monkeypatch, kind, overwrite):
|
||||
directory = str(tmp_path / "sessions")
|
||||
stores = [kind(session_dir=directory), kind(session_dir=directory)]
|
||||
importer, peer = stores
|
||||
ready, completed = Event(), Event()
|
||||
original = importer._save_imported_session
|
||||
|
||||
def pause_before_write(*args, **kwargs):
|
||||
ready.set()
|
||||
assert completed.wait(10), "peer did not finish creating its session"
|
||||
return original(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(importer, "_save_imported_session", pause_before_write)
|
||||
payload = {"version": 1, "sessions": [{
|
||||
"session_id": "shared", "messages": [{
|
||||
"role": "user", "content": "imported", "timestamp": 1,
|
||||
}],
|
||||
}]}
|
||||
|
||||
def create_peer():
|
||||
assert ready.wait(10), "import did not reach write boundary"
|
||||
try:
|
||||
assert peer.add_message("shared", "user", "peer-created")
|
||||
finally:
|
||||
completed.set()
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
future = pool.submit(create_peer)
|
||||
report = importer.import_sessions(payload, overwrite=overwrite)
|
||||
future.result(timeout=15)
|
||||
fresh = kind(session_dir=directory)
|
||||
stores.append(fresh)
|
||||
if overwrite:
|
||||
assert report.imported == 1
|
||||
assert not report.skipped
|
||||
assert fresh.get_chat_history("shared") == [{"role": "user", "content": "imported"}]
|
||||
else:
|
||||
assert report.imported == 0
|
||||
assert report.skipped == [{
|
||||
"session_id": "shared", "reason": "already exists (use overwrite)",
|
||||
}]
|
||||
assert fresh.get_chat_history("shared") == [{"role": "user", "content": "peer-created"}]
|
||||
finally:
|
||||
for store in stores:
|
||||
conn = getattr(store, "_conn", None)
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Portable imports refresh the indexed store's content and routing views."""
|
||||
|
||||
import pytest
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from praisonaiagents.session.sqlite_store import SqliteSessionStore
|
||||
from praisonaiagents.session.store import DefaultSessionStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_store(tmp_path):
|
||||
stores = []
|
||||
|
||||
def create(kind, **kwargs):
|
||||
store = kind(session_dir=str(tmp_path / str(len(stores))), **kwargs)
|
||||
stores.append(store)
|
||||
return store
|
||||
|
||||
yield create
|
||||
for store in stores:
|
||||
conn = getattr(store, "_conn", None)
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", [DefaultSessionStore, SqliteSessionStore])
|
||||
@pytest.mark.parametrize("warm", [False, True])
|
||||
@pytest.mark.parametrize("overwrite", [False, True])
|
||||
@pytest.mark.parametrize("reset_live_fields", [False, True])
|
||||
def test_import_refreshes_content_and_routes(make_store, kind, warm, overwrite, reset_live_fields):
|
||||
source = make_store(DefaultSessionStore)
|
||||
messages = [{"role": "user", "content": f"narwhal imported turn {index}"} for index in range(5)]
|
||||
assert source.set_chat_history("session", messages)
|
||||
assert source.set_gateway_info("session", gateway_session_id="new-gateway", agent_id="new-agent")
|
||||
payload = source.export_all()
|
||||
|
||||
destination = make_store(kind, active_window=2)
|
||||
if overwrite:
|
||||
assert destination.add_message("session", "user", "pelican old turn")
|
||||
assert destination.set_gateway_info("session", gateway_session_id="old-gateway", agent_id="old-agent")
|
||||
if warm:
|
||||
destination.search("pelican")
|
||||
destination.get_by_gateway_session("old-gateway")
|
||||
|
||||
report = destination.import_sessions(payload, overwrite=overwrite, reset_live_fields=reset_live_fields)
|
||||
assert report.imported == 1
|
||||
assert report.skipped == []
|
||||
session = destination.get_session("session")
|
||||
assert [message.content for message in session.messages] == [message["content"] for message in messages]
|
||||
assert destination.get_by_gateway_session("old-gateway") is None
|
||||
assert destination.list_sessions_by_gateway_agent("old-agent") == []
|
||||
found = destination.get_by_gateway_session("new-gateway")
|
||||
if reset_live_fields:
|
||||
assert found is None
|
||||
assert destination.list_sessions_by_gateway_agent("new-agent") == []
|
||||
else:
|
||||
assert found is not None and found.session_id == "session"
|
||||
assert destination.list_sessions_by_gateway_agent("new-agent") == ["session"]
|
||||
assert destination.search("pelican") == []
|
||||
assert [hit.session_id for hit in destination.search("narwhal")] == ["session"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("write_failure", [False, True])
|
||||
def test_unsaved_import_keeps_previous_index(make_store, monkeypatch, write_failure):
|
||||
source = make_store(DefaultSessionStore)
|
||||
assert source.add_message("session", "user", "narwhal replacement")
|
||||
destination = make_store(SqliteSessionStore)
|
||||
assert destination.add_message("session", "user", "pelican original")
|
||||
assert destination.set_gateway_info("session", gateway_session_id="gateway", agent_id="agent")
|
||||
assert destination.search("pelican")
|
||||
if write_failure:
|
||||
monkeypatch.setattr(destination, "_atomic_write_json", lambda *args: False)
|
||||
|
||||
report = destination.import_sessions(source.export_all(), overwrite=write_failure)
|
||||
assert report.imported == 0
|
||||
assert len(report.skipped) == 1
|
||||
assert report.skipped[0]["reason"] == ("write failed" if write_failure else "already exists (use overwrite)")
|
||||
assert [hit.session_id for hit in destination.search("pelican")] == ["session"]
|
||||
assert destination.search("narwhal") == []
|
||||
assert destination.get_by_gateway_session("gateway").session_id == "session"
|
||||
assert destination.list_sessions_by_gateway_agent("agent") == ["session"]
|
||||
|
||||
|
||||
def test_warm_import_indexes_archived_turns(make_store):
|
||||
source = make_store(DefaultSessionStore, active_window=3)
|
||||
assert source.add_message("session", "user", "pelican archived marker")
|
||||
for index in range(8):
|
||||
assert source.add_message("session", "user", f"later turn {index}")
|
||||
assert any("pelican" in message.content for message in source.get_session("session").archived_messages)
|
||||
destination = make_store(SqliteSessionStore, active_window=2)
|
||||
assert destination.search("pelican") == []
|
||||
assert destination.import_sessions(source.export_all()).imported == 1
|
||||
hits = destination.search("pelican")
|
||||
assert [hit.session_id for hit in hits] == ["session"]
|
||||
assert any(message["archived"] for message in hits[0].messages)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("separate_store", [False, True])
|
||||
def test_write_between_import_save_and_index_keeps_newer_content(make_store, monkeypatch, separate_store):
|
||||
source = make_store(DefaultSessionStore)
|
||||
assert source.add_message("session", "user", "pelican imported turn")
|
||||
destination = make_store(SqliteSessionStore)
|
||||
destination.search("pelican")
|
||||
writer = destination
|
||||
if separate_store:
|
||||
writer = SqliteSessionStore(session_dir=destination.session_dir, db_path=destination.db_path)
|
||||
writer.search("pelican")
|
||||
|
||||
saved = threading.Event()
|
||||
resume = threading.Event()
|
||||
original = DefaultSessionStore._save_imported_session
|
||||
|
||||
def delayed_save(store, session, *, overwrite=True):
|
||||
result = original(store, session, overwrite=overwrite)
|
||||
saved.set()
|
||||
assert resume.wait(5), "concurrent writer did not finish"
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(DefaultSessionStore, "_save_imported_session", delayed_save)
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
importing = executor.submit(destination.import_sessions, source.export_all())
|
||||
try:
|
||||
assert saved.wait(5), "import did not persist the JSON"
|
||||
assert writer.set_chat_history("session", [{"role": "user", "content": "narwhal newer turn"}])
|
||||
assert writer.set_gateway_info("session", gateway_session_id="new-gateway", agent_id="new-agent")
|
||||
assert writer.search("narwhal")
|
||||
finally:
|
||||
resume.set()
|
||||
assert importing.result(timeout=5).imported == 1
|
||||
destination.invalidate_cache()
|
||||
assert destination.get_session("session").messages[0].content == "narwhal newer turn"
|
||||
assert [hit.session_id for hit in destination.search("narwhal")] == ["session"]
|
||||
assert destination.search("pelican") == []
|
||||
assert destination.get_by_gateway_session("new-gateway").session_id == "session"
|
||||
assert destination.list_sessions_by_gateway_agent("new-agent") == ["session"]
|
||||
finally:
|
||||
if separate_store and writer._conn is not None:
|
||||
writer._conn.close()
|
||||
|
||||
|
||||
def test_post_import_refresh_failure_does_not_report_durable_write_failure(make_store, monkeypatch):
|
||||
source = make_store(DefaultSessionStore)
|
||||
assert source.add_message("session", "user", "durable imported note")
|
||||
destination = make_store(SqliteSessionStore)
|
||||
|
||||
def unavailable(*args):
|
||||
raise OSError("injected index refresh read failure")
|
||||
|
||||
monkeypatch.setattr(destination, "_load_session_from_disk", unavailable)
|
||||
report = destination.import_sessions(source.export_all())
|
||||
assert report.imported == 1
|
||||
assert report.skipped == []
|
||||
reader = DefaultSessionStore(session_dir=destination.session_dir)
|
||||
assert reader.get_session("session").messages[0].content == "durable imported note"
|
||||
|
||||
|
||||
def test_post_import_read_failure_does_not_restore_old_routes(make_store, monkeypatch):
|
||||
source = make_store(DefaultSessionStore)
|
||||
assert source.add_message("session", "user", "narwhal replacement")
|
||||
destination = make_store(SqliteSessionStore)
|
||||
assert destination.add_message("session", "user", "pelican original")
|
||||
assert destination.set_gateway_info("session", gateway_session_id="old-gateway", agent_id="old-agent")
|
||||
assert destination.get_by_gateway_session("old-gateway").session_id == "session"
|
||||
assert destination.search("pelican")
|
||||
original = destination._load_session_from_disk
|
||||
|
||||
def unavailable(*args):
|
||||
raise OSError("injected post-save read failure")
|
||||
|
||||
monkeypatch.setattr(destination, "_load_session_from_disk", unavailable)
|
||||
report = destination.import_sessions(source.export_all(), overwrite=True)
|
||||
assert report.imported == 1
|
||||
assert report.skipped == []
|
||||
assert destination.get_by_gateway_session("old-gateway") is None
|
||||
assert destination.list_sessions_by_gateway_agent("old-agent") == []
|
||||
monkeypatch.setattr(destination, "_load_session_from_disk", original)
|
||||
destination.invalidate_cache()
|
||||
saved = destination.get_session("session")
|
||||
assert saved.gateway_session_id is None
|
||||
assert saved.agent_id is None
|
||||
assert saved.messages[0].content == "narwhal replacement"
|
||||
assert destination.search("narwhal") == []
|
||||
assert destination.add_message("session", "user", "later update")
|
||||
assert [hit.session_id for hit in destination.search("narwhal")] == ["session"]
|
||||
assert destination.get_by_gateway_session("old-gateway") is None
|
||||
@@ -204,6 +204,57 @@ class TestSqliteTranscriptStore:
|
||||
{"role": "user", "content": "legacy transcript"}
|
||||
]
|
||||
|
||||
def test_migration_skips_invalid_utf8_legacy_file(self, tmp_dir):
|
||||
"""A legacy file with invalid UTF-8 bytes is skipped (not fatal), and
|
||||
valid transcripts still migrate; the bad bytes are preserved."""
|
||||
legacy = DefaultSessionStore(session_dir=tmp_dir)
|
||||
legacy.add_message("old", "user", "legacy transcript")
|
||||
|
||||
bad_path = os.path.join(tmp_dir, "invalid.json")
|
||||
with open(bad_path, "wb") as handle:
|
||||
handle.write(b"\xff\xfe")
|
||||
|
||||
store = SqliteTranscriptStore(session_dir=tmp_dir)
|
||||
assert store.session_exists("old")
|
||||
assert store.get_chat_history("old") == [
|
||||
{"role": "user", "content": "legacy transcript"}
|
||||
]
|
||||
|
||||
with open(bad_path, "rb") as handle:
|
||||
assert handle.read() == b"\xff\xfe"
|
||||
|
||||
def test_migration_continues_after_invalid_utf8_ordered_first(self, tmp_dir):
|
||||
"""A bad file processed before valid ones must not abort later migrations.
|
||||
|
||||
Migration sorts filenames, so ``aaa_invalid.json`` is deterministically
|
||||
processed before the ``zeta`` session's JSON file — guaranteeing the
|
||||
bad-first scenario is actually exercised.
|
||||
"""
|
||||
legacy = DefaultSessionStore(session_dir=tmp_dir)
|
||||
legacy.add_message("zeta", "user", "valid transcript")
|
||||
|
||||
bad_path = os.path.join(tmp_dir, "aaa_invalid.json")
|
||||
with open(bad_path, "wb") as handle:
|
||||
handle.write(b"\xff\xfe")
|
||||
|
||||
import json as _json
|
||||
|
||||
json_files = sorted(f for f in os.listdir(tmp_dir) if f.endswith(".json"))
|
||||
assert json_files[0] == "aaa_invalid.json"
|
||||
valid_file = next(
|
||||
f for f in json_files
|
||||
if f != "aaa_invalid.json" and "zeta" in _json.dumps(
|
||||
_json.load(open(os.path.join(tmp_dir, f)))
|
||||
)
|
||||
)
|
||||
assert "aaa_invalid.json" < valid_file
|
||||
|
||||
store = SqliteTranscriptStore(session_dir=tmp_dir)
|
||||
assert store.session_exists("zeta")
|
||||
assert store.get_chat_history("zeta") == [
|
||||
{"role": "user", "content": "valid transcript"}
|
||||
]
|
||||
|
||||
|
||||
class TestTranscriptArchivedRecall:
|
||||
"""Issue #5031: transcript store must recall compacted (archived) turns."""
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""The public title length cap applies to generated and fallback titles."""
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.session.title import generate_title, generate_title_async
|
||||
|
||||
|
||||
@pytest.mark.parametrize("max_length", [0, 1, 2, 3, 4, 60])
|
||||
@pytest.mark.parametrize("user_msg", ["", "?!.", "A long user request about debugging code"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_fallback_in_running_loop_respects_length(user_msg, max_length):
|
||||
title = generate_title(user_msg, "Response", max_length=max_length)
|
||||
assert len(title) <= max_length
|
||||
if max_length == 60:
|
||||
assert title
|
||||
|
||||
|
||||
@pytest.mark.parametrize("max_length", [0, 1, 2, 3, 4, 60])
|
||||
@pytest.mark.parametrize("response", ["A long generated title about Python", None])
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_generated_and_fallback_titles_respect_length(monkeypatch, max_length, response):
|
||||
import praisonaiagents.llm as llm_module
|
||||
|
||||
class LocalLLM:
|
||||
def __init__(self, **kwargs):
|
||||
pass
|
||||
|
||||
async def get_response_async(self, **kwargs):
|
||||
return response
|
||||
|
||||
monkeypatch.setattr(llm_module, "LLM", LocalLLM)
|
||||
title = await generate_title_async("", "Response", llm_model="test", max_length=max_length)
|
||||
assert len(title) <= max_length
|
||||
if max_length == 60:
|
||||
assert title == (response or "Chat Session")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("max_length", [0, -1])
|
||||
@pytest.mark.asyncio
|
||||
async def test_nonpositive_cap_skips_model_initialization(monkeypatch, max_length):
|
||||
import praisonaiagents.llm as llm_module
|
||||
|
||||
calls = []
|
||||
|
||||
class UnexpectedLLM:
|
||||
def __init__(self, **kwargs):
|
||||
calls.append(kwargs)
|
||||
|
||||
async def get_response_async(self, **kwargs):
|
||||
return "Unneeded title"
|
||||
|
||||
monkeypatch.setattr(llm_module, "LLM", UnexpectedLLM)
|
||||
assert await generate_title_async("Question", "Response", max_length=max_length) == ""
|
||||
assert generate_title("Question", "Response", max_length=max_length) == ""
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("user_msg", ["", "?!."])
|
||||
@pytest.mark.asyncio
|
||||
async def test_three_character_fallback_keeps_readable_prefix(user_msg):
|
||||
assert generate_title(user_msg, "Response", max_length=3) == "Cha"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_three_character_generated_title_keeps_readable_prefix(monkeypatch):
|
||||
import praisonaiagents.llm as llm_module
|
||||
|
||||
class LocalLLM:
|
||||
def __init__(self, **kwargs):
|
||||
pass
|
||||
|
||||
async def get_response_async(self, **kwargs):
|
||||
return "Python debugging"
|
||||
|
||||
monkeypatch.setattr(llm_module, "LLM", LocalLLM)
|
||||
assert await generate_title_async("Question", "Response", max_length=3) == "Pyt"
|
||||
@@ -11,6 +11,8 @@ Tests cover:
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.storage.backends import FileBackend, SQLiteBackend, get_backend
|
||||
from praisonaiagents.storage.protocols import StorageBackendProtocol
|
||||
|
||||
@@ -33,7 +35,33 @@ class TestFileBackend:
|
||||
|
||||
loaded = backend.load("test_key")
|
||||
assert loaded == data
|
||||
|
||||
|
||||
def test_file_backend_load_invalid_utf8_returns_none(self, tmp_path, caplog):
|
||||
"""Undecodable bytes should warn and return None, not raise (Issue #5566)."""
|
||||
import logging
|
||||
|
||||
backend = FileBackend(storage_dir=str(tmp_path))
|
||||
|
||||
for raw in (b"\xff", b"\xe4\xb8", b"\xed\xa0\x80"):
|
||||
backend.save("bad", {"message": "original"})
|
||||
file_path = Path(tmp_path) / "bad.json"
|
||||
file_path.write_bytes(raw)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="praisonaiagents.storage.backends"):
|
||||
caplog.clear()
|
||||
assert backend.load("bad") is None
|
||||
# A warning is emitted identifying the failed key
|
||||
assert any(
|
||||
rec.levelno == logging.WARNING and "Failed to load bad" in rec.getMessage()
|
||||
for rec in caplog.records
|
||||
)
|
||||
# Original bytes are preserved (load is non-destructive)
|
||||
assert file_path.read_bytes() == raw
|
||||
|
||||
# Valid neighboring keys still load
|
||||
backend.save("good", {"key": "value"})
|
||||
assert backend.load("good") == {"key": "value"}
|
||||
|
||||
def test_file_backend_exists(self, tmp_path):
|
||||
"""Test exists check."""
|
||||
backend = FileBackend(storage_dir=str(tmp_path))
|
||||
@@ -115,6 +143,29 @@ class TestFileBackend:
|
||||
backend = FileBackend(storage_dir=str(tmp_path))
|
||||
assert isinstance(backend, StorageBackendProtocol)
|
||||
|
||||
@pytest.mark.parametrize("pretty", [True, False])
|
||||
def test_file_backend_save_cleans_tmp_on_serialization_error(self, tmp_path, pretty):
|
||||
"""Serialization failures must not leave partial .tmp staging files."""
|
||||
backend = FileBackend(storage_dir=str(tmp_path), pretty=pretty)
|
||||
backend.save("good", {"value": 1})
|
||||
|
||||
circular = {}
|
||||
circular["self"] = circular
|
||||
with pytest.raises(ValueError):
|
||||
backend.save("circular", circular)
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
backend.save("tuple_key", {("a", "b"): 1})
|
||||
|
||||
with pytest.raises((ValueError, TypeError)):
|
||||
backend.save("good", circular)
|
||||
|
||||
tmp_files = list(tmp_path.glob("*.tmp"))
|
||||
assert tmp_files == []
|
||||
assert backend.load("good") == {"value": 1}
|
||||
assert not backend.exists("circular")
|
||||
assert not backend.exists("tuple_key")
|
||||
|
||||
|
||||
class TestSQLiteBackend:
|
||||
"""Tests for SQLiteBackend."""
|
||||
@@ -248,6 +299,68 @@ class TestSQLiteBackend:
|
||||
loaded = backend2.load("key1")
|
||||
assert loaded["data"] == 1
|
||||
|
||||
@pytest.mark.parametrize("table_name", ["select", "123", "9table", "praison_storage"])
|
||||
def test_sqlite_backend_keyword_and_digit_table_names(self, tmp_path, table_name):
|
||||
"""Validator-accepted names that are SQL keywords or start with a digit
|
||||
must work across schema creation and all CRUD operations (Issue #5569)."""
|
||||
db_path = tmp_path / "test.db"
|
||||
backend = SQLiteBackend(db_path=str(db_path), table_name=table_name)
|
||||
|
||||
# save / upsert
|
||||
backend.save("key1", {"version": 1})
|
||||
backend.save("key1", {"version": 2})
|
||||
assert backend.load("key1")["version"] == 2
|
||||
|
||||
# exists
|
||||
assert backend.exists("key1")
|
||||
assert not backend.exists("missing")
|
||||
|
||||
# list_keys (full + prefix)
|
||||
backend.save("key2", {"data": 2})
|
||||
backend.save("other", {"data": 3})
|
||||
assert backend.list_keys() == ["key1", "key2", "other"]
|
||||
assert backend.list_keys(prefix="key") == ["key1", "key2"]
|
||||
|
||||
# delete
|
||||
assert backend.delete("other") is True
|
||||
assert not backend.exists("other")
|
||||
|
||||
# clear
|
||||
assert backend.clear() == 2
|
||||
assert backend.list_keys() == []
|
||||
|
||||
def test_sqlite_backend_reopen_with_auto_create_false(self, tmp_path):
|
||||
"""A keyword table name persists and reopens with auto_create=False."""
|
||||
db_path = tmp_path / "test.db"
|
||||
backend = SQLiteBackend(db_path=str(db_path), table_name="select")
|
||||
backend.save("key1", {"data": 1})
|
||||
backend.close()
|
||||
|
||||
backend2 = SQLiteBackend(
|
||||
db_path=str(db_path), table_name="select", auto_create=False
|
||||
)
|
||||
assert backend2.load("key1")["data"] == 1
|
||||
|
||||
def test_sqlite_backend_separate_tables_same_db(self, tmp_path):
|
||||
"""Quoted identifiers keep distinct tables isolated in one DB file."""
|
||||
db_path = tmp_path / "test.db"
|
||||
t1 = SQLiteBackend(db_path=str(db_path), table_name="select")
|
||||
t2 = SQLiteBackend(db_path=str(db_path), table_name="praison_storage")
|
||||
|
||||
t1.save("shared", {"from": "select"})
|
||||
t2.save("shared", {"from": "praison_storage"})
|
||||
|
||||
assert t1.load("shared")["from"] == "select"
|
||||
assert t2.load("shared")["from"] == "praison_storage"
|
||||
|
||||
def test_sqlite_backend_invalid_table_name_rejected(self, tmp_path):
|
||||
"""Names with disallowed characters are still rejected by the validator."""
|
||||
db_path = tmp_path / "test.db"
|
||||
with pytest.raises(ValueError):
|
||||
SQLiteBackend(db_path=str(db_path), table_name="bad name")
|
||||
with pytest.raises(ValueError):
|
||||
SQLiteBackend(db_path=str(db_path), table_name='drop";')
|
||||
|
||||
|
||||
class TestGetBackend:
|
||||
"""Tests for get_backend factory function."""
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Enumeration and clearing honor the complete configured filename suffix."""
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.storage.backends import FileBackend
|
||||
|
||||
|
||||
@pytest.mark.parametrize("suffix", [".data.json", "record", ".json", ""])
|
||||
@pytest.mark.parametrize("operation", ["list", "clear"])
|
||||
def test_configured_suffix_selects_saved_records(tmp_path, suffix, operation):
|
||||
store = FileBackend(storage_dir=str(tmp_path), suffix=suffix)
|
||||
for key in ("session_one", "session_two", "other"):
|
||||
store.save(key, {"key": key})
|
||||
unrelated = tmp_path / "unrelated.txt"
|
||||
unrelated.write_text("keep", encoding="utf-8")
|
||||
directory = tmp_path / ("directory" + suffix)
|
||||
directory.mkdir()
|
||||
assert store.exists("session_one")
|
||||
assert store.load("session_one") == {"key": "session_one"}
|
||||
if operation == "list":
|
||||
assert store.list_keys() == ["other", "session_one", "session_two"]
|
||||
assert store.list_keys("session_") == ["session_one", "session_two"]
|
||||
for key in store.list_keys():
|
||||
assert store.load(key) == {"key": key}
|
||||
else:
|
||||
assert store.clear() == 3
|
||||
assert not store.exists("session_one")
|
||||
assert store.list_keys() == []
|
||||
assert unrelated.read_text(encoding="utf-8") == "keep"
|
||||
assert directory.is_dir()
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Retention must include sessions beyond the listing display limit."""
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.storage.base import cleanup_old_sessions, list_json_sessions
|
||||
|
||||
|
||||
@pytest.mark.parametrize("boundary", ["age", "size"])
|
||||
@pytest.mark.parametrize("suffix", [".json", ".jsonl"])
|
||||
def test_cleanup_includes_oldest_session_after_ten_thousand_newer_files(tmp_path, boundary, suffix):
|
||||
oldest = tmp_path / ("oldest" + suffix)
|
||||
oldest.write_bytes(b"x")
|
||||
now = time.time()
|
||||
old_time = now - 90 * 24 * 60 * 60
|
||||
os.utime(oldest, (old_time, old_time))
|
||||
for index in range(10000):
|
||||
(tmp_path / f"new-{index:05d}{suffix}").write_bytes(b"")
|
||||
neighbor = tmp_path / "unrelated.txt"
|
||||
neighbor.write_bytes(b"keep")
|
||||
|
||||
if boundary == "age":
|
||||
deleted = cleanup_old_sessions(tmp_path, suffix=suffix, max_age_days=30, max_size_mb=100)
|
||||
else:
|
||||
# Only the oldest file contributes bytes to the quota; none exceed age.
|
||||
deleted = cleanup_old_sessions(tmp_path, suffix=suffix, max_age_days=365, max_size_mb=0)
|
||||
|
||||
assert deleted == 1
|
||||
assert not oldest.exists()
|
||||
assert len(list(tmp_path.glob("new-*" + suffix))) == 10000
|
||||
assert neighbor.read_bytes() == b"keep"
|
||||
|
||||
|
||||
def test_listing_keeps_default_limit_and_supports_unbounded_listing(tmp_path):
|
||||
for index in range(51):
|
||||
(tmp_path / f"session-{index:02d}.json").write_text("{}", encoding="utf-8")
|
||||
assert len(list_json_sessions(tmp_path)) == 50
|
||||
assert len(list_json_sessions(tmp_path, limit=3)) == 3
|
||||
assert len(list_json_sessions(tmp_path, limit=None)) == 51
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Session counts use the UTF-8 writer format, independent of host locale."""
|
||||
|
||||
import builtins
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.storage.base import BaseJSONStore, list_json_sessions
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def legacy_text_locale(monkeypatch, tmp_path):
|
||||
original_open = builtins.open
|
||||
|
||||
def locale_open(file, mode="r", *args, **kwargs):
|
||||
if Path(file).parent == tmp_path and mode == "r" and not kwargs.get("encoding"):
|
||||
kwargs["encoding"] = "cp1252"
|
||||
return original_open(file, mode, *args, **kwargs)
|
||||
|
||||
# Exercise real decoding with a deterministic Windows-style default locale.
|
||||
monkeypatch.setattr(builtins, "open", locale_open)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["iterations", "messages", "items", "jsonl"])
|
||||
def test_utf8_session_counts_survive_legacy_locale(tmp_path, legacy_text_locale, field):
|
||||
values = ["丁 café 😀", "second"]
|
||||
suffix = ".jsonl" if field == "jsonl" else ".json"
|
||||
path = tmp_path / ("session" + suffix)
|
||||
if field == "jsonl":
|
||||
path.write_text("".join(json.dumps({"value": value}, ensure_ascii=False) + "\n" for value in values), encoding="utf-8")
|
||||
else:
|
||||
BaseJSONStore(path).save({field: values})
|
||||
before = path.read_bytes()
|
||||
|
||||
sessions = list_json_sessions(tmp_path, suffix=suffix)
|
||||
|
||||
assert len(sessions) == 1
|
||||
assert sessions[0].item_count == 2
|
||||
assert sessions[0].size_bytes == len(before)
|
||||
assert path.read_bytes() == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("suffix", [".json", ".jsonl"])
|
||||
@pytest.mark.parametrize("valid", [True, False])
|
||||
def test_ascii_and_unreadable_files_keep_existing_behavior(tmp_path, legacy_text_locale, suffix, valid):
|
||||
path = tmp_path / ("session" + suffix)
|
||||
content = b'{"items":[1,2]}' if suffix == ".json" else b'{"value":1}\n{"value":2}\n'
|
||||
path.write_bytes(content if valid else b"\x81")
|
||||
before = path.read_bytes()
|
||||
sessions = list_json_sessions(tmp_path, suffix=suffix)
|
||||
assert len(sessions) == 1
|
||||
assert sessions[0].item_count == (2 if valid else 0)
|
||||
assert path.read_bytes() == before
|
||||
@@ -0,0 +1,52 @@
|
||||
"""A failed backend constructor releases its real SQLite connection."""
|
||||
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.storage import sqlite as connector
|
||||
from praisonaiagents.storage.backends import SQLiteBackend
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", ["reserved_name", "incompatible_schema"])
|
||||
def test_constructor_closes_connection_after_schema_failure(tmp_path, monkeypatch, failure):
|
||||
path = str(tmp_path / "store.db")
|
||||
with sqlite3.connect(path) as seed:
|
||||
seed.execute("CREATE TABLE preserved (value TEXT)")
|
||||
seed.execute("INSERT INTO preserved VALUES ('original')")
|
||||
if failure == "incompatible_schema":
|
||||
seed.execute("CREATE TABLE praison_storage (wrong_column TEXT)")
|
||||
seed.close()
|
||||
opened = []
|
||||
original = connector.connect
|
||||
|
||||
def connect(*args, **kwargs):
|
||||
conn = original(*args, **kwargs)
|
||||
opened.append(conn)
|
||||
return conn
|
||||
|
||||
monkeypatch.setattr(connector, "connect", connect)
|
||||
try:
|
||||
name = "sqlite_private" if failure == "reserved_name" else "praison_storage"
|
||||
with pytest.raises(sqlite3.OperationalError) as error:
|
||||
SQLiteBackend(db_path=path, table_name=name)
|
||||
expected = "reserved" if failure == "reserved_name" else "no such column"
|
||||
assert expected in str(error.value)
|
||||
assert len(opened) == 1
|
||||
with pytest.raises(sqlite3.ProgrammingError, match="closed database"):
|
||||
opened[0].execute("SELECT 1")
|
||||
with sqlite3.connect(path) as check:
|
||||
assert check.execute("SELECT value FROM preserved").fetchall() == [("original",)]
|
||||
check.close()
|
||||
finally:
|
||||
for conn in opened:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_successful_constructor_keeps_connection_usable(tmp_path):
|
||||
backend = SQLiteBackend(db_path=str(tmp_path / "store.db"))
|
||||
try:
|
||||
backend.save("key", {"value": 1})
|
||||
assert backend.load("key") == {"value": 1}
|
||||
finally:
|
||||
backend.close()
|
||||
@@ -97,8 +97,10 @@ class TestCompactionMaxTokensResolution:
|
||||
agent = self._make_agent("gpt-4o")
|
||||
cfg = ExecutionConfig(context_compaction=True)
|
||||
resolved = agent._resolve_compaction_max_tokens(cfg)
|
||||
# Well beyond the working budget must trigger compaction.
|
||||
msgs = [{"role": "user", "content": "A" * 4 * (resolved + 50000)}]
|
||||
# Well beyond the working budget must trigger compaction. Repeated
|
||||
# characters compress heavily under tiktoken (~8 chars/token), so use
|
||||
# 8× not the heuristic 4× when sizing synthetic overflow content.
|
||||
msgs = [{"role": "user", "content": "A" * 8 * (resolved + 50000)}]
|
||||
compactor = ContextCompactor(max_tokens=resolved)
|
||||
assert compactor.needs_compaction(msgs) is True
|
||||
|
||||
|
||||
@@ -308,6 +308,7 @@ def test_a_hosted_backend_reclaims_its_instance_when_collected():
|
||||
instance kept billing until the provider's own idle timer noticed, which
|
||||
docker and flyio do not have."""
|
||||
import gc
|
||||
import time
|
||||
|
||||
from praisonai.integrations.compute_managed_agent import ComputeManagedAgent
|
||||
|
||||
@@ -338,6 +339,12 @@ def test_a_hosted_backend_reclaims_its_instance_when_collected():
|
||||
|
||||
del backend
|
||||
gc.collect()
|
||||
# During live GC the finalizer reclaims fire-and-forget on the shared
|
||||
# AsyncBridge's background loop, so the shutdown lands a beat after
|
||||
# gc.collect() returns. Wait (bounded) for it rather than racing it.
|
||||
deadline = time.monotonic() + 5.0
|
||||
while not released and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
assert released == ["inst-1"], "the instance outlived the backend that owned it"
|
||||
|
||||
|
||||
@@ -361,6 +368,7 @@ def test_release_reclaims_even_on_an_event_loop_thread():
|
||||
ever runs, silently leaking the instance -- so _release must bridge the loop
|
||||
the way SharedCompute does, not call asyncio.run() directly."""
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from praisonai.integrations.compute_managed_agent import _release
|
||||
|
||||
@@ -374,4 +382,11 @@ def test_release_reclaims_even_on_an_event_loop_thread():
|
||||
_release(Fake(), "inst-loop", "docker")
|
||||
|
||||
asyncio.run(collect_on_the_loop())
|
||||
# _release routes through the shared AsyncBridge's background loop
|
||||
# fire-and-forget, so the shutdown lands a beat after asyncio.run()
|
||||
# returns. Wait (bounded) for it rather than racing it — the assertion
|
||||
# still fails if _release ever leaks (never schedules the shutdown).
|
||||
deadline = time.monotonic() + 5.0
|
||||
while not released and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
assert released == ["inst-loop"], "an instance leaked when GC ran on a live loop"
|
||||
Generated
+10
-6
@@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
revision = 5
|
||||
requires-python = ">=3.10"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.14'",
|
||||
@@ -2263,7 +2263,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "1.25.0"
|
||||
version = "1.30.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
@@ -2281,9 +2281,9 @@ dependencies = [
|
||||
{ name = "typing-inspection" },
|
||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d5/2d/649d80a0ecf6a1f82632ca44bec21c0461a9d9fc8934d38cb5b319f2db5e/mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802", size = 605387, upload-time = "2025-12-19T10:19:56.985Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/93/0142dc84a666daf8ad51a34268f34c12fd6fda4f3810c4be2504eecc8212/mcp-1.30.0.tar.gz", hash = "sha256:445414625fce5c295faa505bb11bacece661ab6f4028d57c935db57820b7a3e4", size = 680511, upload-time = "2026-09-07T14:34:15.845Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/fc/6dc7659c2ae5ddf280477011f4213a74f806862856b796ef08f028e664bf/mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a", size = 233076, upload-time = "2025-12-19T10:19:55.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/f4/e58bc33317c92a0203664daaf00bf6f41166cc0149e5d6870a03f7cd004a/mcp-1.30.0-py3-none-any.whl", hash = "sha256:666edb5009503e1047c9d60346a756f94b261f05cc2625f23d41c728ffc484d0", size = 234581, upload-time = "2026-09-07T14:34:14.266Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3123,7 +3123,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.7.9"
|
||||
version = "1.7.10"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -3219,6 +3219,9 @@ os = [
|
||||
{ name = "pyjwt" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
parallel-search = [
|
||||
{ name = "mcp" },
|
||||
]
|
||||
sandbox = [
|
||||
{ name = "praisonai-sandbox" },
|
||||
]
|
||||
@@ -3250,6 +3253,7 @@ requires-dist = [
|
||||
{ name = "litellm", marker = "extra == 'memory'", specifier = ">=1.83.14" },
|
||||
{ name = "markitdown", extras = ["all"], marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
|
||||
{ name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.20.0" },
|
||||
{ name = "mcp", marker = "extra == 'parallel-search'", specifier = ">=1.30.0,<2" },
|
||||
{ name = "mem0ai", marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
|
||||
{ name = "mem0ai", extras = ["graph"], marker = "extra == 'graph'", specifier = ">=0.1.0" },
|
||||
{ name = "motor", marker = "extra == 'mongodb'", specifier = ">=3.4.0" },
|
||||
@@ -3288,7 +3292,7 @@ requires-dist = [
|
||||
{ name = "uvicorn", marker = "extra == 'os'", specifier = ">=0.34.0" },
|
||||
{ name = "websockets", marker = "extra == 'mcp'", specifier = ">=12.0" },
|
||||
]
|
||||
provides-extras = ["sandbox", "mcp", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "dakera", "auth", "autonomy", "search", "crawl", "a2ui", "all"]
|
||||
provides-extras = ["sandbox", "mcp", "parallel-search", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "dakera", "auth", "autonomy", "search", "crawl", "a2ui", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "primp"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/MervinPraison/PraisonAI/main/src/praisonai-bot/praisonai_bot/bots/gateway.schema.json
|
||||
gateway:
|
||||
host: 127.0.0.1
|
||||
port: 8765
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.8"
|
||||
__version__ = "0.1.9"
|
||||
@@ -23,6 +23,41 @@ from pydantic import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
#: Stable, published URL for the ``gateway.yaml`` / ``bot.yaml`` JSON Schema,
|
||||
#: mirroring the ``agents.yaml`` convention (``AGENTS_SCHEMA_URL``). Editors that
|
||||
#: speak the YAML language server use this via a leading
|
||||
#: ``# yaml-language-server: $schema=<url>`` header to provide autocomplete,
|
||||
#: inline validation, and hover docs while authoring the gateway/bot config.
|
||||
GATEWAY_SCHEMA_URL = (
|
||||
"https://raw.githubusercontent.com/MervinPraison/PraisonAI/main/"
|
||||
"src/praisonai-bot/praisonai_bot/bots/gateway.schema.json"
|
||||
)
|
||||
|
||||
#: Leading YAML comment prepended to scaffolded ``gateway.yaml`` / ``bot.yaml``
|
||||
#: files so editors wire up validation out of the box. A leading comment is
|
||||
#: ignored by ``yaml.safe_load``, so execution is unaffected.
|
||||
GATEWAY_SCHEMA_HEADER = f"# yaml-language-server: $schema={GATEWAY_SCHEMA_URL}\n"
|
||||
|
||||
|
||||
def resolve_model_by_provider() -> str:
|
||||
"""Resolve a default model for whichever provider credential is present.
|
||||
|
||||
Reuses the existing detection helper
|
||||
(``praisonai_code.llm.env.default_model_for_available_provider``) through
|
||||
the lazy cross-tier bridge so the gateway path leads with the provider the
|
||||
operator already has (Anthropic/Gemini/Ollama/…) instead of assuming
|
||||
OpenAI. Falls back to ``gpt-4o-mini`` only when no provider is detected or
|
||||
the helper is unavailable.
|
||||
"""
|
||||
try:
|
||||
from praisonai_bot._code_bridge import import_code_module
|
||||
|
||||
env = import_code_module("praisonai_code.llm.env")
|
||||
return env.default_model_for_available_provider()
|
||||
except Exception: # pragma: no cover - detection is best-effort
|
||||
return "gpt-4o-mini"
|
||||
|
||||
|
||||
def _register_redaction(value: str) -> None:
|
||||
"""Register a resolved secret value for log redaction (best-effort).
|
||||
|
||||
@@ -41,7 +76,12 @@ class AgentConfigSchema(BaseModel):
|
||||
"""Schema for agent configuration in bot.yaml."""
|
||||
name: str = "assistant"
|
||||
instructions: str = ""
|
||||
model: Optional[str] = "gpt-4o-mini"
|
||||
# Provider-agnostic by default (Issue #5609). ``None`` / ``"auto"`` resolve
|
||||
# at load time to the model for whichever provider credential is actually
|
||||
# present (via ``resolved_model()``), so an operator with only an
|
||||
# Anthropic/Gemini/Ollama key is not silently handed an OpenAI model. An
|
||||
# explicit ``model:`` always wins.
|
||||
model: Optional[str] = None
|
||||
llm: Optional[str] = None # Alias for model
|
||||
memory: bool = False
|
||||
tools: List[str] = Field(default_factory=list)
|
||||
@@ -58,6 +98,19 @@ class AgentConfigSchema(BaseModel):
|
||||
self.model = self.llm
|
||||
return self
|
||||
|
||||
def resolved_model(self) -> str:
|
||||
"""Return the effective model, detecting the provider when unset.
|
||||
|
||||
An explicit, non-``"auto"`` ``model`` (or ``llm`` alias) always wins.
|
||||
When the model is unset or ``"auto"``, resolve it to the detected
|
||||
provider's default (Issue #5609), with ``gpt-4o-mini`` as the
|
||||
last-resort fallback handled inside ``resolve_model_by_provider``.
|
||||
"""
|
||||
model = self.model
|
||||
if model and model.strip().lower() != "auto":
|
||||
return model
|
||||
return resolve_model_by_provider()
|
||||
|
||||
|
||||
class SessionResetConfigSchema(BaseModel):
|
||||
"""Schema for session reset policy configuration."""
|
||||
@@ -1506,6 +1559,184 @@ class GatewayConfigSchema(BaseModel):
|
||||
BotYamlSchema = GatewayConfigSchema
|
||||
|
||||
|
||||
def _allow_integer_owner_id(node: Any) -> None:
|
||||
"""Widen an ``owner_user_id`` property to also accept an integer (#5414).
|
||||
|
||||
``GatewayConfigSchema``/``ChannelConfigSchema`` type ``owner_user_id`` as
|
||||
``Optional[str]`` but a ``mode="before"`` validator (``_coerce_owner_user_id``)
|
||||
accepts an unquoted numeric YAML id (``owner_user_id: 987654321``) by casting
|
||||
it to ``str``. Pydantic emits the schema from the *field type* only, so the
|
||||
published schema would reject a config the runtime explicitly accepts. Mirror
|
||||
the runtime coercion by adding ``integer`` to the allowed types so editor/CI
|
||||
validation agrees with the gateway.
|
||||
"""
|
||||
if not isinstance(node, dict):
|
||||
return
|
||||
props = node.get("properties")
|
||||
if not isinstance(props, dict):
|
||||
return
|
||||
owner = props.get("owner_user_id")
|
||||
if not isinstance(owner, dict):
|
||||
return
|
||||
variants = owner.get("anyOf")
|
||||
if isinstance(variants, list):
|
||||
has_int = any(
|
||||
isinstance(v, dict) and v.get("type") == "integer" for v in variants
|
||||
)
|
||||
if not has_int:
|
||||
variants.append({"type": "integer"})
|
||||
elif owner.get("type") == "string":
|
||||
owner.pop("type", None)
|
||||
owner["anyOf"] = [{"type": "string"}, {"type": "integer"}]
|
||||
|
||||
|
||||
def _allow_env_ref_list(node: Any, field_names: tuple) -> None:
|
||||
"""Allow an array-typed field to also be a single ``${ENV}`` string (#5414).
|
||||
|
||||
``${VAR}`` references are expanded *before* validation at load time, so an
|
||||
operator may author ``allowed_users: ${TELEGRAM_ALLOWED_USERS}`` and have it
|
||||
expand to a list. Editors validate the *unexpanded* YAML, so the published
|
||||
schema must accept the string form for these fields too; otherwise it
|
||||
false-flags a config the runtime happily accepts.
|
||||
"""
|
||||
if not isinstance(node, dict):
|
||||
return
|
||||
props = node.get("properties")
|
||||
if not isinstance(props, dict):
|
||||
return
|
||||
for name in field_names:
|
||||
field = props.get(name)
|
||||
if not isinstance(field, dict):
|
||||
continue
|
||||
if field.get("type") == "array":
|
||||
array_variant = {k: v for k, v in field.items() if k not in ("default", "title")}
|
||||
new_field: Dict[str, Any] = {
|
||||
"anyOf": [array_variant, {"type": "string"}]
|
||||
}
|
||||
if "default" in field:
|
||||
new_field["default"] = field["default"]
|
||||
if "title" in field:
|
||||
new_field["title"] = field["title"]
|
||||
props[name] = new_field
|
||||
elif isinstance(field.get("anyOf"), list):
|
||||
variants = field["anyOf"]
|
||||
if not any(
|
||||
isinstance(v, dict) and v.get("type") == "string" for v in variants
|
||||
):
|
||||
variants.append({"type": "string"})
|
||||
|
||||
|
||||
def gateway_config_json_schema() -> Dict[str, Any]:
|
||||
"""Canonical JSON Schema for ``gateway.yaml`` / ``bot.yaml`` (draft-07).
|
||||
|
||||
Derived directly from :class:`GatewayConfigSchema` (Pydantic's
|
||||
``model_json_schema``) and decorated with the standard ``$schema``/``$id``/
|
||||
``title`` metadata so the artefact is self-describing and mirrors the
|
||||
``agents.yaml`` convention (``generate_agents_schema``). This is the single
|
||||
source of truth surfaced to editors (via the ``# yaml-language-server``
|
||||
header) and to CI linting, so a mistyped key is flagged at author time
|
||||
instead of only at gateway startup.
|
||||
|
||||
The raw ``model_json_schema()`` is post-processed so the *published* schema
|
||||
faithfully reflects what the runtime actually validates (#5414):
|
||||
|
||||
- ``gateway`` / ``hooks`` are stored as opaque ``Dict``/``List[Dict]`` on the
|
||||
model for downstream dict-style access, but validated field-by-field at
|
||||
load time via :class:`GatewayServerSchema` / :class:`HookSchema`
|
||||
(``extra="forbid"`` for the server block). Inline those typed schemas so a
|
||||
misspelled server knob (``drain_timout``) or hook key is caught at author
|
||||
time, not only at startup.
|
||||
- ``owner_user_id`` (top-level and per-channel) also accepts an integer,
|
||||
mirroring the runtime ``str(...)`` coercion of unquoted numeric ids.
|
||||
- Unknown top-level keys are rejected so a typo (``auto_enable_from_en``)
|
||||
that the runtime silently ignores is surfaced while authoring. Channel
|
||||
configs keep ``additionalProperties`` open for plugin-declared keys.
|
||||
"""
|
||||
base = GatewayConfigSchema.model_json_schema()
|
||||
defs = base.setdefault("$defs", {})
|
||||
|
||||
# Inline the typed gateway-server + hook schemas so the published schema
|
||||
# carries the same field-by-field constraints the runtime enforces, instead
|
||||
# of the opaque ``{"type": "object"}`` Pydantic emits for ``Dict[str, Any]``.
|
||||
server_schema = GatewayServerSchema.model_json_schema(
|
||||
ref_template="#/$defs/{model}"
|
||||
)
|
||||
for name, definition in server_schema.pop("$defs", {}).items():
|
||||
defs.setdefault(name, definition)
|
||||
defs["GatewayServerSchema"] = server_schema
|
||||
|
||||
hook_schema = HookSchema.model_json_schema(ref_template="#/$defs/{model}")
|
||||
for name, definition in hook_schema.pop("$defs", {}).items():
|
||||
defs.setdefault(name, definition)
|
||||
defs["HookSchema"] = hook_schema
|
||||
|
||||
props = base.get("properties", {})
|
||||
if "gateway" in props:
|
||||
props["gateway"] = {
|
||||
"anyOf": [{"$ref": "#/$defs/GatewayServerSchema"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"title": props["gateway"].get("title", "Gateway"),
|
||||
}
|
||||
if "hooks" in props:
|
||||
props["hooks"] = {
|
||||
"anyOf": [
|
||||
{"type": "array", "items": {"$ref": "#/$defs/HookSchema"}},
|
||||
{"type": "null"},
|
||||
],
|
||||
"default": None,
|
||||
"title": props["hooks"].get("title", "Hooks"),
|
||||
}
|
||||
|
||||
# Accept unquoted numeric owner ids wherever they appear (top level +
|
||||
# ChannelConfigSchema), matching the runtime coercion.
|
||||
_allow_integer_owner_id(base)
|
||||
channel_def = defs.get("ChannelConfigSchema")
|
||||
if isinstance(channel_def, dict):
|
||||
_allow_integer_owner_id(channel_def)
|
||||
# ``${VAR}`` env references are substituted BEFORE validation at load time
|
||||
# (``validate_gateway_config``), so a list-typed field may legitimately be
|
||||
# authored as a single ``${ENV}`` string that expands to a list. The
|
||||
# scaffolder writes ``allowed_users: ${TELEGRAM_ALLOWED_USERS}``, so an
|
||||
# editor validating the *authored* (unexpanded) YAML must accept the
|
||||
# string form too — otherwise the schema false-flags a config the runtime
|
||||
# accepts. Widen the array-typed env-ref channel fields accordingly.
|
||||
_allow_env_ref_list(channel_def, ("allowed_users", "allowlist", "blocklist"))
|
||||
|
||||
# Reject unknown top-level keys so an author-time typo is caught (the runtime
|
||||
# silently ignores it, keeping the mistyped opt-out ineffective). Channels
|
||||
# keep ``additionalProperties`` open for plugin-declared config keys.
|
||||
#
|
||||
# ``config_version`` is not a ``GatewayConfigSchema`` field — it is stamped at
|
||||
# the top level by the doctor/start config-version machinery (and shipped in
|
||||
# the sample ``gateway.yaml``). Declare it so the stricter schema accepts a
|
||||
# stamped/migrated config instead of false-flagging it.
|
||||
base.setdefault("properties", {}).setdefault(
|
||||
"config_version",
|
||||
{
|
||||
"type": "integer",
|
||||
"title": "Config Version",
|
||||
"description": (
|
||||
"Stamped by `praisonai gateway doctor --fix` / `start` to track "
|
||||
"config migrations. Managed automatically."
|
||||
),
|
||||
},
|
||||
)
|
||||
base.setdefault("additionalProperties", False)
|
||||
|
||||
schema: Dict[str, Any] = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": GATEWAY_SCHEMA_URL,
|
||||
**base,
|
||||
"title": "PraisonAI Gateway / Bot Configuration",
|
||||
"description": (
|
||||
"Schema for gateway.yaml / bot.yaml consumed by the PraisonAI "
|
||||
"gateway and single-bot runtime (gateway server, agents, channels, "
|
||||
"routing, schedules, hooks, streaming and delivery)."
|
||||
),
|
||||
}
|
||||
return schema
|
||||
|
||||
|
||||
def validate_gateway_config(raw: Dict[str, Any], apply_env_substitution: bool = True) -> GatewayConfigSchema:
|
||||
"""Validate a raw YAML dict against the unified gateway config schema.
|
||||
|
||||
|
||||
@@ -23,11 +23,18 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, Iterable, Optional
|
||||
|
||||
from ._approval_base import DEFAULT_APPROVAL_TIMEOUT, DurableApprovalMixin
|
||||
from ._approval_base import (
|
||||
DEFAULT_APPROVAL_TIMEOUT,
|
||||
DurableApprovalMixin,
|
||||
is_authorized_actor,
|
||||
normalize_approvers,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -45,6 +52,14 @@ class HTTPApproval(DurableApprovalMixin):
|
||||
host: Bind address (default ``127.0.0.1``).
|
||||
port: Port to listen on (default ``8899``).
|
||||
timeout: Max seconds to wait for a response (default 300).
|
||||
allowed_approvers: Optional allowlist of approver IDs permitted to
|
||||
resolve an approval. When provided, each pending approval mints a
|
||||
single-use token that is carried only in the per-approval URL; the
|
||||
resolver must present that token (and, when the request supplies an
|
||||
``approver``, be in the allowlist) or the decision is rejected. When
|
||||
``None`` (default) any visitor may decide (legacy behaviour,
|
||||
backward compatible). Falls back to a comma-separated
|
||||
``HTTP_APPROVERS`` env var when not passed.
|
||||
|
||||
Example::
|
||||
|
||||
@@ -57,11 +72,17 @@ class HTTPApproval(DurableApprovalMixin):
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 8899,
|
||||
timeout: float = DEFAULT_APPROVAL_TIMEOUT,
|
||||
allowed_approvers: Optional[Iterable[str]] = None,
|
||||
store: Optional[Any] = None,
|
||||
):
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._timeout = timeout
|
||||
if allowed_approvers is None:
|
||||
_env = os.environ.get("HTTP_APPROVERS", "").strip()
|
||||
if _env:
|
||||
allowed_approvers = [a.strip() for a in _env.split(",") if a.strip()]
|
||||
self._allowed_approvers = normalize_approvers(allowed_approvers)
|
||||
self._pending: Dict[str, Optional[Dict[str, Any]]] = {}
|
||||
self._server_started = False
|
||||
self._runner: Optional[Any] = None
|
||||
@@ -91,6 +112,20 @@ class HTTPApproval(DurableApprovalMixin):
|
||||
self._server_started = True
|
||||
logger.info(f"HTTPApproval server started on http://{self._host}:{self._port}")
|
||||
|
||||
def _token_ok(self, pending: Dict[str, Any], request) -> bool:
|
||||
"""Return whether *request* carries the per-approval token.
|
||||
|
||||
The token is minted per pending approval and embedded only in the URL
|
||||
handed to the authorised approver. A constant-time comparison is used so
|
||||
the endpoint does not leak the token via timing. When no allowlist is
|
||||
configured there is no token, so this is a no-op (legacy behaviour).
|
||||
"""
|
||||
expected = pending.get("token")
|
||||
if not expected:
|
||||
return True
|
||||
supplied = request.query.get("token") or request.headers.get("X-Approval-Token", "")
|
||||
return secrets.compare_digest(str(supplied), str(expected))
|
||||
|
||||
async def _handle_page(self, request) -> Any:
|
||||
"""Serve the approval page HTML."""
|
||||
from aiohttp import web
|
||||
@@ -101,11 +136,18 @@ class HTTPApproval(DurableApprovalMixin):
|
||||
if pending is None:
|
||||
return web.Response(text="Approval request not found or already decided.", status=404)
|
||||
|
||||
if not self._token_ok(pending, request):
|
||||
logger.warning(
|
||||
"Unauthorised HTTP approval page access for %s", request_id,
|
||||
)
|
||||
return web.Response(text="Not authorized to view this approval.", status=403)
|
||||
|
||||
if pending.get("decided"):
|
||||
return web.Response(text="This approval has already been decided.", status=200)
|
||||
|
||||
info = pending.get("info", {})
|
||||
html = self._build_html(request_id, info)
|
||||
token = pending.get("token")
|
||||
html = self._build_html(request_id, info, token=token)
|
||||
return web.Response(text=html, content_type="text/html")
|
||||
|
||||
async def _handle_decide(self, request) -> Any:
|
||||
@@ -117,6 +159,13 @@ class HTTPApproval(DurableApprovalMixin):
|
||||
|
||||
if pending is None:
|
||||
return web.Response(text="Not found", status=404)
|
||||
|
||||
if not self._token_ok(pending, request):
|
||||
logger.warning(
|
||||
"Unauthorised HTTP approval decision attempt for %s", request_id,
|
||||
)
|
||||
return web.json_response({"ok": False, "error": "unauthorized"}, status=403)
|
||||
|
||||
if pending.get("decided"):
|
||||
return web.Response(text="Already decided", status=200)
|
||||
|
||||
@@ -125,18 +174,32 @@ class HTTPApproval(DurableApprovalMixin):
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
approver = body.get("approver", "http_user")
|
||||
|
||||
# Authorisation boundary: when an allowlist is configured a free-text
|
||||
# ``approver`` must be one of the authorised approvers. The per-approval
|
||||
# token already proves the resolver received the private URL; the
|
||||
# allowlist additionally pins the recorded approver identity.
|
||||
if not is_authorized_actor(approver, self._allowed_approvers):
|
||||
logger.warning(
|
||||
"Unauthorised HTTP approver %r for %s", approver, request_id,
|
||||
)
|
||||
return web.json_response({"ok": False, "error": "unauthorized"}, status=403)
|
||||
|
||||
decision = body.get("decision", "deny")
|
||||
pending["decided"] = True
|
||||
pending["approved"] = decision == "approve"
|
||||
pending["reason"] = body.get("reason", f"{'Approved' if pending['approved'] else 'Denied'} via HTTP dashboard")
|
||||
pending["approver"] = body.get("approver", "http_user")
|
||||
pending["approver"] = approver
|
||||
|
||||
return web.json_response({"ok": True, "decision": decision})
|
||||
|
||||
def _build_html(self, request_id: str, info: Dict[str, Any]) -> str:
|
||||
def _build_html(self, request_id: str, info: Dict[str, Any], token: Optional[str] = None) -> str:
|
||||
"""Build a minimal approval page."""
|
||||
import html as html_module
|
||||
|
||||
token_qs = f"?token={html_module.escape(str(token))}" if token else ""
|
||||
|
||||
tool_name = html_module.escape(str(info.get("tool_name", "unknown")))
|
||||
risk_level = html_module.escape(str(info.get("risk_level", "unknown")))
|
||||
agent_name = html_module.escape(str(info.get("agent_name", "")))
|
||||
@@ -181,7 +244,7 @@ td {{ padding: 8px; border-bottom: 1px solid #333; }} code {{ background: #2a2a4
|
||||
<div class="done" id="result" style="display:none"></div>
|
||||
<script>
|
||||
async function decide(d) {{
|
||||
const res = await fetch('/approve/{request_id}/decide', {{
|
||||
const res = await fetch('/approve/{request_id}/decide{token_qs}', {{
|
||||
method: 'POST', headers: {{'Content-Type': 'application/json'}},
|
||||
body: JSON.stringify({{decision: d}})
|
||||
}});
|
||||
@@ -202,9 +265,13 @@ async function decide(d) {{
|
||||
await self._ensure_server()
|
||||
|
||||
request_id = str(uuid.uuid4())
|
||||
# Mint a single-use token bound to this approval when an allowlist is
|
||||
# configured, so only the holder of the private URL can resolve it.
|
||||
token = secrets.token_urlsafe(32) if self._allowed_approvers is not None else None
|
||||
self._pending[request_id] = {
|
||||
"decided": False,
|
||||
"approved": False,
|
||||
"token": token,
|
||||
"info": {
|
||||
"tool_name": request.tool_name,
|
||||
"arguments": request.arguments,
|
||||
@@ -214,6 +281,8 @@ async function decide(d) {{
|
||||
}
|
||||
|
||||
url = f"http://{self._host}:{self._port}/approve/{request_id}"
|
||||
if token:
|
||||
url = f"{url}?token={token}"
|
||||
logger.info(f"HTTPApproval: Waiting for decision at {url}")
|
||||
print(f"\n🔗 Open this URL to approve/deny:\n {url}\n")
|
||||
|
||||
|
||||
@@ -105,6 +105,78 @@ def resolve_activity_category(tool_name: Optional[str]) -> str:
|
||||
return "default"
|
||||
|
||||
|
||||
# Separator between the reply body and the runtime footer line. A blank line
|
||||
# then an em-dash keeps the footer visually distinct without markup that could
|
||||
# break on a given platform.
|
||||
_FOOTER_SEP = "\n\n— "
|
||||
|
||||
|
||||
def build_footer_line(
|
||||
model: Optional[str] = None,
|
||||
served_model: Optional[str] = None,
|
||||
context_pct: Optional[float] = None,
|
||||
latency_s: Optional[float] = None,
|
||||
) -> str:
|
||||
"""Build a privacy-safe, per-reply runtime footer line.
|
||||
|
||||
Renders only the fields that are available, e.g. ``gpt-4o · 38% ctx · 4.2s``.
|
||||
A missing field is omitted rather than shown as a placeholder, so a partial
|
||||
footer is always well-formed. Only the model name, context-window fill
|
||||
percentage and turn latency are surfaced — never tool args, URLs, paths or
|
||||
message content. Returns ``""`` when no field is available.
|
||||
|
||||
Args:
|
||||
model: Configured model id (e.g. ``"gpt-4o"``).
|
||||
served_model: Deployment that actually served the turn, shown as
|
||||
``model (served: X)`` when it differs from ``model`` (failover /
|
||||
routing visibility).
|
||||
context_pct: Session context-window fill, 0–100.
|
||||
latency_s: Turn latency in seconds.
|
||||
|
||||
Returns:
|
||||
A single footer line (without the leading separator) or ``""``.
|
||||
"""
|
||||
parts: list = []
|
||||
|
||||
if model:
|
||||
model_str = str(model).strip()
|
||||
if model_str:
|
||||
served = str(served_model).strip() if served_model else ""
|
||||
if served and served != model_str:
|
||||
parts.append(f"{model_str} (served: {served})")
|
||||
else:
|
||||
parts.append(model_str)
|
||||
|
||||
if context_pct is not None:
|
||||
try:
|
||||
pct = float(context_pct)
|
||||
if pct == pct and pct not in (float("inf"), float("-inf")):
|
||||
parts.append(f"{int(round(pct))}% ctx")
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
if latency_s is not None:
|
||||
try:
|
||||
secs = float(latency_s)
|
||||
if secs == secs and secs >= 0 and secs not in (float("inf"), float("-inf")):
|
||||
parts.append(f"{secs:.1f}s")
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
return " · ".join(parts)
|
||||
|
||||
|
||||
def append_footer(reply: str, footer_line: str) -> str:
|
||||
"""Append a footer line to a final reply, separated by a blank line.
|
||||
|
||||
Returns ``reply`` unchanged when ``footer_line`` is empty. Only ever used on
|
||||
the final reply — never on partial/streamed frames.
|
||||
"""
|
||||
if not footer_line:
|
||||
return reply
|
||||
return f"{reply}{_FOOTER_SEP}{footer_line}"
|
||||
|
||||
|
||||
def strip_reasoning_tags(text: str) -> str:
|
||||
"""Remove ``<think>``/``<reasoning>`` spans from streamed content.
|
||||
|
||||
@@ -201,6 +273,25 @@ class StreamingConfig:
|
||||
)
|
||||
|
||||
|
||||
def build_streaming_config(config: Any) -> Optional["StreamingConfig"]:
|
||||
"""Derive a :class:`StreamingConfig` from a ``BotConfig``.
|
||||
|
||||
Shared by every channel adapter (Telegram, Discord, Slack, …) so a
|
||||
configured ``streaming`` flag is wired to the same ``DraftStreamer`` engine
|
||||
identically everywhere, instead of each adapter re-deriving (or silently
|
||||
ignoring) the knob. Returns ``None`` when streaming is off so the caller's
|
||||
non-streaming path is unchanged.
|
||||
"""
|
||||
if not getattr(config, "streaming", False):
|
||||
return None
|
||||
interval_ms = getattr(config, "stream_edit_interval_ms", 700) or 700
|
||||
return StreamingConfig(
|
||||
mode=StreamingMode.DRAFT,
|
||||
min_interval=interval_ms / 1000.0,
|
||||
min_delta=50,
|
||||
)
|
||||
|
||||
|
||||
class BotAdapter(Protocol):
|
||||
"""Protocol for bot adapters that support message editing."""
|
||||
|
||||
|
||||
@@ -27,9 +27,14 @@ import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, Iterable, Optional
|
||||
|
||||
from ._approval_base import DEFAULT_APPROVAL_TIMEOUT, DurableApprovalMixin
|
||||
from ._approval_base import (
|
||||
DEFAULT_APPROVAL_TIMEOUT,
|
||||
DurableApprovalMixin,
|
||||
is_authorized_actor,
|
||||
normalize_approvers,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -51,6 +56,13 @@ class WebhookApproval(DurableApprovalMixin):
|
||||
headers: Extra HTTP headers (e.g. auth tokens).
|
||||
timeout: Max seconds to wait for a response (default 300).
|
||||
poll_interval: Seconds between status polls (default 5.0).
|
||||
allowed_approvers: Optional allowlist of approver IDs permitted to
|
||||
resolve an approval. When provided, a decision whose ``approver``
|
||||
is not in the allowlist is dropped (polling continues) so an
|
||||
unauthorised responder to the status endpoint cannot resolve a
|
||||
gated tool. When ``None`` (default) any responder may decide
|
||||
(legacy behaviour, backward compatible). Falls back to a
|
||||
comma-separated ``WEBHOOK_APPROVERS`` env var when not passed.
|
||||
|
||||
Example::
|
||||
|
||||
@@ -71,6 +83,7 @@ class WebhookApproval(DurableApprovalMixin):
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
timeout: float = DEFAULT_APPROVAL_TIMEOUT,
|
||||
poll_interval: float = 5.0,
|
||||
allowed_approvers: Optional[Iterable[str]] = None,
|
||||
store: Optional[Any] = None,
|
||||
):
|
||||
self._webhook_url = webhook_url or os.environ.get("APPROVAL_WEBHOOK_URL", "")
|
||||
@@ -82,11 +95,29 @@ class WebhookApproval(DurableApprovalMixin):
|
||||
self._headers = headers or {}
|
||||
self._timeout = timeout
|
||||
self._poll_interval = poll_interval
|
||||
if allowed_approvers is None:
|
||||
_env = os.environ.get("WEBHOOK_APPROVERS", "").strip()
|
||||
if _env:
|
||||
allowed_approvers = [a.strip() for a in _env.split(",") if a.strip()]
|
||||
self._allowed_approvers = normalize_approvers(allowed_approvers)
|
||||
self._init_store(store)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"WebhookApproval(webhook_url={self._webhook_url!r})"
|
||||
|
||||
def _approver_authorized(self, approver: Any) -> bool:
|
||||
"""Return whether *approver* may resolve an approval.
|
||||
|
||||
No-op (always ``True``) when no allowlist is configured (legacy
|
||||
behaviour). Otherwise the responder's ``approver`` must be in the
|
||||
allowlist, so an unauthorised party that can reach the status endpoint
|
||||
cannot resolve a gated tool.
|
||||
"""
|
||||
return is_authorized_actor(
|
||||
None if approver is None else str(approver),
|
||||
self._allowed_approvers,
|
||||
)
|
||||
|
||||
# ── Internal HTTP helper ───────────────────────────────────────────
|
||||
|
||||
async def _http_request(
|
||||
@@ -158,7 +189,9 @@ class WebhookApproval(DurableApprovalMixin):
|
||||
|
||||
# Check for immediate decision
|
||||
if isinstance(post_data, dict):
|
||||
if "approved" in post_data:
|
||||
if "approved" in post_data and self._approver_authorized(
|
||||
post_data.get("approver")
|
||||
):
|
||||
decision = ApprovalDecision(
|
||||
approved=bool(post_data["approved"]),
|
||||
reason=post_data.get("reason", "Webhook immediate response"),
|
||||
@@ -167,6 +200,11 @@ class WebhookApproval(DurableApprovalMixin):
|
||||
)
|
||||
await self._resolve_pending(request, decision)
|
||||
return decision
|
||||
if "approved" in post_data:
|
||||
logger.warning(
|
||||
"Dropping webhook immediate decision from unauthorised "
|
||||
"approver %r", post_data.get("approver"),
|
||||
)
|
||||
|
||||
# 2. Poll for decision
|
||||
status_url = self._status_url or f"{self._webhook_url}/{request_id}"
|
||||
@@ -227,6 +265,17 @@ class WebhookApproval(DurableApprovalMixin):
|
||||
if status == "pending":
|
||||
continue
|
||||
|
||||
has_decision = (
|
||||
"approved" in data
|
||||
or status in ("approved", "approve", "yes", "denied", "deny", "rejected", "no")
|
||||
)
|
||||
if has_decision and not self._approver_authorized(data.get("approver")):
|
||||
logger.warning(
|
||||
"Dropping webhook decision from unauthorised approver %r "
|
||||
"for %s", data.get("approver"), request_id,
|
||||
)
|
||||
continue
|
||||
|
||||
if "approved" in data:
|
||||
return ApprovalDecision(
|
||||
approved=bool(data["approved"]),
|
||||
|
||||
@@ -45,6 +45,12 @@ from ._commands import (
|
||||
)
|
||||
from ._failure import failure_reply_text
|
||||
from ._session import BotSessionManager
|
||||
from ._streaming import (
|
||||
StreamingConfig,
|
||||
StreamingMode,
|
||||
DraftStreamer,
|
||||
build_streaming_config,
|
||||
)
|
||||
from ._debounce import InboundDebouncer
|
||||
from ._ack import AckReactor
|
||||
from ._unknown_user import UnknownUserHandler, BotContext
|
||||
@@ -102,7 +108,12 @@ class DiscordBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
|
||||
|
||||
# Initialize allow_silence from config
|
||||
self._allow_silence = getattr(self.config, 'allow_silence', False)
|
||||
|
||||
|
||||
# Initialize streaming config based on BotConfig (shared with Telegram /
|
||||
# Slack so ``streaming.mode: draft|auto`` is honoured identically across
|
||||
# channels that support in-place edits — Issue #5415).
|
||||
self._streaming_config = build_streaming_config(self.config)
|
||||
|
||||
self._is_running = False
|
||||
self._bot_user: Optional[BotUser] = None
|
||||
self._client = None
|
||||
@@ -139,6 +150,15 @@ class DiscordBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
|
||||
self._register_interactive_handlers()
|
||||
self._bot_context: Optional[BotContext] = None
|
||||
|
||||
def configure_streaming(self, config: StreamingConfig) -> None:
|
||||
"""Configure streaming reply mode.
|
||||
|
||||
Args:
|
||||
config: Streaming configuration. Set mode=StreamingMode.OFF to disable.
|
||||
"""
|
||||
self._streaming_config = config
|
||||
logger.debug("DiscordBot: streaming configured, mode=%s", config.mode)
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._is_running
|
||||
@@ -474,20 +494,95 @@ class DiscordBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
|
||||
|
||||
async def _send_agent_response():
|
||||
text_to_send = await self._debouncer.debounce(user_id, bot_message.text)
|
||||
channel_id = str(message.channel.id)
|
||||
|
||||
# Streaming path: progressively edit a placeholder in
|
||||
# place using the shared DraftStreamer, mirroring Telegram
|
||||
# (Issue #5415). Only engaged when streaming is configured.
|
||||
streaming_enabled = (
|
||||
self._streaming_config is not None
|
||||
and self._streaming_config.mode != StreamingMode.OFF
|
||||
)
|
||||
if streaming_enabled:
|
||||
streamer = DraftStreamer(
|
||||
adapter=self,
|
||||
channel_id=channel_id,
|
||||
config=self._streaming_config,
|
||||
platform="discord",
|
||||
)
|
||||
placeholder_message_id = await streamer.start()
|
||||
try:
|
||||
response = await self._session.chat(
|
||||
self._agent, user_id, text_to_send,
|
||||
chat_id=channel_id,
|
||||
user_name=str(getattr(message.author, "name", "")),
|
||||
message_id=str(message.id),
|
||||
account=getattr(self.config, "account", "default"),
|
||||
stream_callback=streamer.on_event,
|
||||
)
|
||||
send_result = self.fire_message_sending(
|
||||
channel_id, str(response),
|
||||
)
|
||||
if send_result["cancel"]:
|
||||
try:
|
||||
await self.delete_message(
|
||||
channel_id, placeholder_message_id
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
final_content = send_result["content"]
|
||||
# A streamed answer longer than Discord's hard
|
||||
# cap cannot be delivered by editing the single
|
||||
# placeholder (both the edit and the send
|
||||
# fallback 400 on >2000 chars), which would drop
|
||||
# the whole reply. Fall back to the chunked
|
||||
# reply path so long answers are split and keep
|
||||
# the reply reference (Greptile #3 / #5).
|
||||
_cap = min(self.config.max_message_length, 2000)
|
||||
if len(final_content) > _cap:
|
||||
try:
|
||||
await self.delete_message(
|
||||
channel_id, placeholder_message_id
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await self._send_long_message(
|
||||
message.channel, final_content,
|
||||
reference=message,
|
||||
)
|
||||
else:
|
||||
await streamer.finalize(final_content)
|
||||
self.fire_message_sent(channel_id, final_content)
|
||||
except Exception:
|
||||
try:
|
||||
await self.delete_message(
|
||||
channel_id, placeholder_message_id
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
if ack_ctx:
|
||||
await self._ack.done(
|
||||
ack_ctx, react_fn=_discord_react,
|
||||
unreact_fn=_discord_unreact,
|
||||
)
|
||||
return
|
||||
|
||||
response = await self._session.chat(
|
||||
self._agent, user_id, text_to_send,
|
||||
chat_id=str(message.channel.id),
|
||||
chat_id=channel_id,
|
||||
user_name=str(getattr(message.author, "name", "")),
|
||||
message_id=str(message.id),
|
||||
account=getattr(self.config, "account", "default"),
|
||||
)
|
||||
send_result = self.fire_message_sending(
|
||||
str(message.channel.id), str(response),
|
||||
channel_id, str(response),
|
||||
)
|
||||
if send_result["cancel"]:
|
||||
return
|
||||
await self._send_long_message(message.channel, send_result["content"], reference=message)
|
||||
self.fire_message_sent(str(message.channel.id), send_result["content"])
|
||||
self.fire_message_sent(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)
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -71,6 +71,12 @@ from ._commands import (
|
||||
build_command_access_policy,
|
||||
)
|
||||
from ._session import BotSessionManager
|
||||
from ._streaming import (
|
||||
StreamingConfig,
|
||||
StreamingMode,
|
||||
DraftStreamer,
|
||||
build_streaming_config,
|
||||
)
|
||||
from ._debounce import InboundDebouncer
|
||||
from ._ack import AckReactor
|
||||
from ._unknown_user import UnknownUserHandler, BotContext
|
||||
@@ -144,7 +150,12 @@ class SlackBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
|
||||
|
||||
# Initialize allow_silence from config
|
||||
self._allow_silence = getattr(self.config, 'allow_silence', False)
|
||||
|
||||
|
||||
# Initialize streaming config based on BotConfig (shared with Telegram /
|
||||
# Discord so ``streaming.mode: draft|auto`` is honoured identically
|
||||
# across channels that support in-place edits — Issue #5415).
|
||||
self._streaming_config = build_streaming_config(self.config)
|
||||
|
||||
self._is_running = False
|
||||
self._bot_user: Optional[BotUser] = None
|
||||
self._app = None
|
||||
@@ -186,6 +197,111 @@ class SlackBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
|
||||
# Audio capabilities
|
||||
self._stt_enabled: bool = False
|
||||
|
||||
def configure_streaming(self, config: StreamingConfig) -> None:
|
||||
"""Configure streaming reply mode.
|
||||
|
||||
Args:
|
||||
config: Streaming configuration. Set mode=StreamingMode.OFF to disable.
|
||||
"""
|
||||
self._streaming_config = config
|
||||
logger.debug("SlackBot: streaming configured, mode=%s", config.mode)
|
||||
|
||||
def _reply_needs_thread(self, thread_id: str) -> bool:
|
||||
"""Whether a reply must be posted into a thread rather than the root.
|
||||
|
||||
The shared ``DraftStreamer`` posts/edits its placeholder at the channel
|
||||
root (it only knows ``send_message(channel, text)``), so when a reply
|
||||
belongs in a thread — the inbound message was itself in a thread, or
|
||||
``reply_in_thread`` is configured — streaming in place would strand the
|
||||
answer in the main channel (Greptile #2 / #5417). In that case the
|
||||
caller falls back to the existing threaded send path.
|
||||
"""
|
||||
return bool(thread_id) or bool(getattr(self.config, "reply_in_thread", False))
|
||||
|
||||
async def _maybe_stream_reply(
|
||||
self,
|
||||
channel_id: str,
|
||||
user_id: str,
|
||||
text: str,
|
||||
*,
|
||||
thread_id: str = "",
|
||||
message_id: str = "",
|
||||
) -> Optional[str]:
|
||||
"""Progressively stream a reply via DraftStreamer when configured.
|
||||
|
||||
Returns the final (hook-processed) response text when the streaming
|
||||
path handled delivery, or ``None`` when streaming is disabled — or when
|
||||
the reply must be threaded (the channel-agnostic streamer can only post
|
||||
at the channel root) — so the caller falls back to its normal
|
||||
single-message, thread-aware, media-aware send (Issue #5415).
|
||||
"""
|
||||
streaming_enabled = (
|
||||
self._streaming_config is not None
|
||||
and self._streaming_config.mode != StreamingMode.OFF
|
||||
)
|
||||
if not streaming_enabled:
|
||||
return None
|
||||
|
||||
# Defer to the standard send path when the reply must land in a thread:
|
||||
# the shared streamer posts/edits at the channel root and would
|
||||
# otherwise strand a threaded answer in the main channel (Greptile #2).
|
||||
if self._reply_needs_thread(thread_id):
|
||||
return None
|
||||
|
||||
streamer = DraftStreamer(
|
||||
adapter=self,
|
||||
channel_id=channel_id,
|
||||
config=self._streaming_config,
|
||||
platform="slack",
|
||||
)
|
||||
placeholder_message_id = await streamer.start()
|
||||
try:
|
||||
response = await self._session.chat(
|
||||
self._agent, user_id, text,
|
||||
chat_id=channel_id,
|
||||
thread_id=thread_id,
|
||||
message_id=message_id,
|
||||
account=getattr(self.config, "account", "default"),
|
||||
stream_callback=streamer.on_event,
|
||||
)
|
||||
send_result = self.fire_message_sending(channel_id, str(response))
|
||||
if send_result["cancel"]:
|
||||
try:
|
||||
await self.delete_message(channel_id, placeholder_message_id)
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
# Extract any MEDIA: directives before finalizing so an audio
|
||||
# reply is uploaded via Slack's file path instead of being edited
|
||||
# into the placeholder as literal "MEDIA:/path" text (Greptile #4).
|
||||
parsed = split_media_from_output(send_result["content"])
|
||||
text_content = parsed["text"]
|
||||
media_urls = parsed.get("media_urls", [])
|
||||
|
||||
await streamer.finalize(text_content if text_content else send_result["content"])
|
||||
|
||||
for media_path in media_urls:
|
||||
if not os.path.exists(media_path):
|
||||
logger.warning(f"Media file not found: {media_path}")
|
||||
continue
|
||||
if is_audio_file(media_path) and self._client:
|
||||
try:
|
||||
await self._client.files_upload_v2(
|
||||
channel=channel_id,
|
||||
file=media_path,
|
||||
title=os.path.basename(media_path),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to upload audio to Slack: {e}")
|
||||
return send_result["content"]
|
||||
except Exception:
|
||||
try:
|
||||
await self.delete_message(channel_id, placeholder_message_id)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
def enable_stt(self, enabled: bool = True) -> None:
|
||||
"""Enable STT for audio file transcription."""
|
||||
self._stt_enabled = enabled
|
||||
@@ -622,6 +738,26 @@ class SlackBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
|
||||
user_id = event.get("user", "unknown")
|
||||
logger.info(f"Message received: {text[:100]}...")
|
||||
text = await self._debouncer.debounce(user_id, text)
|
||||
|
||||
# Streaming path: progressively edit a placeholder in place
|
||||
# using the shared DraftStreamer, mirroring Telegram/Discord
|
||||
# (Issue #5415). Returns None when streaming is disabled.
|
||||
streamed = await self._maybe_stream_reply(
|
||||
str(channel_id) if channel_id else "",
|
||||
user_id, text,
|
||||
thread_id=event.get("thread_ts", "") or "",
|
||||
message_id=event.get("ts", ""),
|
||||
)
|
||||
if streamed is not None:
|
||||
if streamed:
|
||||
self.fire_message_sent(channel_id, streamed)
|
||||
if ack_ctx and self._client:
|
||||
await self._ack.done(
|
||||
ack_ctx, react_fn=_slack_react,
|
||||
unreact_fn=_slack_unreact,
|
||||
)
|
||||
return
|
||||
|
||||
response = await self._session.chat(
|
||||
self._agent, user_id, text,
|
||||
chat_id=str(channel_id) if channel_id else "",
|
||||
@@ -707,6 +843,21 @@ class SlackBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
|
||||
try:
|
||||
user_id = event.get("user", "unknown")
|
||||
logger.info(f"@mention received: {text[:100]}...")
|
||||
|
||||
# Streaming path: progressively edit a placeholder in place
|
||||
# using the shared DraftStreamer (Issue #5415). Returns None
|
||||
# when streaming is disabled so the plain path runs.
|
||||
streamed = await self._maybe_stream_reply(
|
||||
str(event.get("channel", "")),
|
||||
user_id, text,
|
||||
thread_id=event.get("thread_ts", "") or "",
|
||||
message_id=event.get("ts", ""),
|
||||
)
|
||||
if streamed is not None:
|
||||
if streamed:
|
||||
self.fire_message_sent(event.get("channel", ""), streamed)
|
||||
return
|
||||
|
||||
response = await self._session.chat(
|
||||
self._agent, user_id, text,
|
||||
chat_id=str(event.get("channel", "")),
|
||||
|
||||
@@ -64,7 +64,13 @@ from ._album import (
|
||||
from ._ack import AckReactor
|
||||
from ._unknown_user import UnknownUserHandler, BotContext
|
||||
from ._pairing_ui import PairingUIBuilder, PairingCallbackHandler
|
||||
from ._streaming import StreamingConfig, StreamingMode, DraftStreamer
|
||||
from ._streaming import (
|
||||
StreamingConfig,
|
||||
StreamingMode,
|
||||
DraftStreamer,
|
||||
build_footer_line,
|
||||
append_footer,
|
||||
)
|
||||
from ._rate_limit import RateLimiter
|
||||
from ._resilience import deliver_with_retry, BackoffPolicy, TELEGRAM_BACKOFF
|
||||
from ._dlq import OutboundDLQ
|
||||
@@ -251,6 +257,34 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
|
||||
"""
|
||||
self._streaming_config = config
|
||||
logger.debug("TelegramBot: streaming configured, mode=%s", config.mode)
|
||||
|
||||
def _footer_line(self, turn_started_at: Optional[float]) -> str:
|
||||
"""Build the opt-in runtime footer line for a final reply.
|
||||
|
||||
Returns ``""`` (no footer) unless ``config.footer`` is enabled. The line
|
||||
is privacy-safe: it surfaces only the configured model name and turn
|
||||
latency, omitting any field that is unavailable. Best-effort — a build
|
||||
error never blocks the reply.
|
||||
|
||||
The line is appended to the *text* of the final reply only (never to
|
||||
interim streamed frames, and never to the text handed to voice/TTS or
|
||||
media synthesis), so voice notes don't narrate the footer.
|
||||
"""
|
||||
if not getattr(self.config, "footer", False):
|
||||
return ""
|
||||
try:
|
||||
model = None
|
||||
agent = self._agent
|
||||
if agent is not None:
|
||||
llm = getattr(agent, "llm", None)
|
||||
model = llm if isinstance(llm, str) else None
|
||||
latency = None
|
||||
if turn_started_at is not None:
|
||||
latency = max(0.0, time.monotonic() - turn_started_at)
|
||||
return build_footer_line(model=model, latency_s=latency)
|
||||
except Exception as e: # pragma: no cover — never block a reply on footer
|
||||
logger.debug("footer build skipped: %s", e)
|
||||
return ""
|
||||
|
||||
def enable_stt(self, enabled: bool = True) -> None:
|
||||
"""Enable STT for voice message transcription."""
|
||||
@@ -705,6 +739,10 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
|
||||
message.content = merged.caption
|
||||
|
||||
try:
|
||||
# Mark the turn start so the opt-in runtime footer can report
|
||||
# latency ("inbound received -> reply delivered"). Cheap and
|
||||
# unused unless config.footer is enabled.
|
||||
turn_started_at = time.monotonic()
|
||||
message_text = await self._debouncer.debounce(user_id, message.content)
|
||||
|
||||
# Render any resolved reply/quote context (Issue #5223) as a
|
||||
@@ -774,8 +812,15 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
|
||||
text_content = parsed["text"]
|
||||
media_urls = parsed.get("media_urls", [])
|
||||
|
||||
# Finalize with text content (after hook processing and media extraction)
|
||||
await streamer.finalize(text_content if text_content else send_result["content"])
|
||||
# Finalize with text content (after hook processing and media extraction).
|
||||
# Append the opt-in runtime footer to the FINAL reply only
|
||||
# (never to interim streamed frames, and never to the
|
||||
# voice-synthesis text below — matching the non-streaming path).
|
||||
final_text = text_content if text_content else send_result["content"]
|
||||
final_text = append_footer(
|
||||
final_text, self._footer_line(turn_started_at)
|
||||
)
|
||||
await streamer.finalize(final_text)
|
||||
|
||||
# Issue #3623: the streamed reply is delivered as text
|
||||
# by the streamer, so the outbound voice-reply policy
|
||||
@@ -879,6 +924,8 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
|
||||
)
|
||||
if send_result["cancel"]:
|
||||
return
|
||||
# Opt-in runtime footer: appended to the final text reply
|
||||
# only (never to voice/TTS or media synthesis).
|
||||
await self._send_response_with_media(
|
||||
update.message.chat_id,
|
||||
send_result["content"],
|
||||
@@ -886,6 +933,7 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
|
||||
inbound_was_voice=bool(
|
||||
update.message.voice or update.message.audio
|
||||
),
|
||||
footer_line=self._footer_line(turn_started_at),
|
||||
)
|
||||
# If the agent attached a portable presentation
|
||||
# (buttons/menus), render it natively as an inline
|
||||
@@ -965,6 +1013,10 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
|
||||
or ""
|
||||
) if update.message.from_user else ""
|
||||
try:
|
||||
# Mark turn start so the opt-in footer can report latency,
|
||||
# matching the normal message path. Cheap, unused unless
|
||||
# config.footer is enabled.
|
||||
cmd_turn_started_at = time.monotonic()
|
||||
response = await self._session.chat(
|
||||
self._agent, user_id, prompt,
|
||||
chat_id=str(update.message.chat_id) if update.message.chat_id else "",
|
||||
@@ -987,6 +1039,7 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
|
||||
update.message.chat_id,
|
||||
send_result["content"],
|
||||
reply_to=update.message.message_id,
|
||||
footer_line=self._footer_line(cmd_turn_started_at),
|
||||
)
|
||||
try:
|
||||
if presentation is not None:
|
||||
@@ -1832,23 +1885,37 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
|
||||
response: str,
|
||||
reply_to: Optional[int] = None,
|
||||
inbound_was_voice: bool = False,
|
||||
footer_line: str = "",
|
||||
) -> None:
|
||||
"""Send response, extracting and sending any MEDIA: files."""
|
||||
"""Send response, extracting and sending any MEDIA: files.
|
||||
|
||||
``footer_line`` (opt-in, privacy-safe runtime footer) is appended to the
|
||||
delivered *text* only — never to the text handed to voice/TTS synthesis —
|
||||
so a voice note never narrates the model/latency footer and the
|
||||
streaming and non-streaming paths behave identically.
|
||||
"""
|
||||
# Parse response for media
|
||||
parsed = split_media_from_output(response)
|
||||
text = parsed["text"]
|
||||
media_urls = parsed.get("media_urls", [])
|
||||
audio_as_voice = parsed.get("audio_as_voice", False)
|
||||
|
||||
# Send text first if present
|
||||
# Send text first if present. The footer attaches to the final text
|
||||
# reply only; the pre-footer ``text`` is what feeds voice synthesis.
|
||||
if text:
|
||||
await self._send_long_message(chat_id, text, reply_to=reply_to)
|
||||
await self._send_long_message(
|
||||
chat_id, append_footer(text, footer_line), reply_to=reply_to
|
||||
)
|
||||
elif footer_line:
|
||||
# Media-only reply: never emit a footer-only text bubble.
|
||||
pass
|
||||
|
||||
# Issue #3623: outbound voice reply. When the operator has enabled a
|
||||
# ``voice`` policy, synthesise the agent's plain text and deliver it as
|
||||
# a native Telegram voice note — the symmetric counterpart to inbound
|
||||
# STT. The agent authors plain text; voice is a transport concern here.
|
||||
# Skipped silently (text already sent) when disabled or synthesis fails.
|
||||
# Footer is intentionally excluded from the synthesised text.
|
||||
await self._maybe_send_voice_reply(
|
||||
chat_id, text, inbound_was_voice=inbound_was_voice
|
||||
)
|
||||
|
||||
@@ -570,6 +570,7 @@ def gateway_status(
|
||||
daemon_only: bool = typer.Option(False, "--daemon-only", help="Show only daemon status"),
|
||||
deep: bool = typer.Option(False, "--deep", help="Extended diagnostics (health + log tail)"),
|
||||
probe: bool = typer.Option(False, "--probe", help="Live credential probe per channel"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Emit a machine-readable status snapshot"),
|
||||
):
|
||||
"""Check gateway status and daemon service status.
|
||||
|
||||
@@ -579,6 +580,7 @@ def gateway_status(
|
||||
praisonai gateway status --deep --config bot.yaml
|
||||
praisonai gateway status --probe --config bot.yaml
|
||||
praisonai gateway status --daemon-only
|
||||
praisonai gateway status --json
|
||||
"""
|
||||
import os
|
||||
from ..features.gateway import GatewayHandler
|
||||
@@ -597,6 +599,17 @@ def gateway_status(
|
||||
if config is None:
|
||||
config = _resolve_gateway_config_path(None)
|
||||
|
||||
# Machine-readable path (#5363): sibling commands (doctor/test) support
|
||||
# --json; status did not, so scripts/health-checks/dashboards could not
|
||||
# consume its state. Emit a single stable snapshot built from /health +
|
||||
# /info and the daemon status, then return before any human-only text.
|
||||
if json_output:
|
||||
import json as _json
|
||||
|
||||
snapshot = _build_status_snapshot(host, port)
|
||||
typer.echo(_json.dumps(snapshot, default=str))
|
||||
raise typer.Exit(0 if snapshot.get("reachable") else 1)
|
||||
|
||||
output = get_output_controller()
|
||||
|
||||
# Show daemon status
|
||||
@@ -873,6 +886,122 @@ def _print_secret_availability(report: dict) -> None:
|
||||
print()
|
||||
|
||||
|
||||
def _resolve_gateway_auth_token() -> str:
|
||||
"""Resolve the loopback ``GATEWAY_AUTH_TOKEN`` from env or persisted ``.env``.
|
||||
|
||||
``praisonai onboard`` / the daemon persist the auto-generated token to
|
||||
``~/.praisonai/.env`` rather than exporting it into every shell. Without
|
||||
reading that file, an authenticated gateway answers ``/health`` (public)
|
||||
but 401s ``/info`` (authenticated), so ``status --json`` would report
|
||||
``reachable: true`` while silently omitting ``version`` (#5363, greptile
|
||||
P1). Prefer an already-exported env value; otherwise fall back to the
|
||||
persisted file. Returns ``""`` when no token is available.
|
||||
"""
|
||||
import os
|
||||
|
||||
token = os.environ.get("GATEWAY_AUTH_TOKEN", "").strip()
|
||||
if token:
|
||||
return token
|
||||
env_path = os.environ.get("PRAISONAI_ENV_FILE") or os.path.expanduser(
|
||||
"~/.praisonai/.env"
|
||||
)
|
||||
try:
|
||||
with open(env_path, encoding="utf-8", errors="replace") as handle:
|
||||
for raw in handle:
|
||||
s = raw.strip()
|
||||
if not s or s.startswith("#") or "=" not in s:
|
||||
continue
|
||||
key, _, val = s.partition("=")
|
||||
if key.strip() == "GATEWAY_AUTH_TOKEN":
|
||||
return val.strip().strip('"').strip("'")
|
||||
except OSError:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _fetch_gateway_json(host: str, port: int, path: str, timeout: float = 5.0):
|
||||
"""GET a JSON gateway endpoint over loopback, returning None on failure.
|
||||
|
||||
Attaches the loopback-only bearer token (resolved from the environment or
|
||||
the persisted ``~/.praisonai/.env``) so authenticated gateways still
|
||||
answer, without leaking the credential onto a remote plaintext hop.
|
||||
"""
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
url = f"http://{host}:{port}{path}"
|
||||
try:
|
||||
req = urllib.request.Request(url)
|
||||
token = _resolve_gateway_auth_token()
|
||||
if token and host in ("127.0.0.1", "localhost", "::1"):
|
||||
req.add_header("Authorization", f"Bearer {token}")
|
||||
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||||
return json.loads(response.read().decode())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _build_status_snapshot(host: str, port: int) -> dict:
|
||||
"""Assemble one machine-readable gateway status snapshot (#5363).
|
||||
|
||||
Combines the daemon service state with the rich ``/health`` payload
|
||||
(uptime, sessions, clients, per-channel supervision, degraded owners,
|
||||
delivery back-pressure, applied/on-disk config revision + drift) and the
|
||||
version/protocol_version from ``/info`` so a single ``--json`` call answers
|
||||
"is my gateway healthy, on the right config, and what version".
|
||||
"""
|
||||
from praisonai_bot.daemon import get_daemon_status
|
||||
|
||||
snapshot: dict = {"host": host, "port": port, "reachable": False}
|
||||
|
||||
try:
|
||||
daemon_status = get_daemon_status()
|
||||
snapshot["daemon"] = {
|
||||
"platform": daemon_status.get("platform", "unknown"),
|
||||
"installed": bool(daemon_status.get("installed", False)),
|
||||
"running": bool(daemon_status.get("running", False)),
|
||||
"pid": daemon_status.get("pid"),
|
||||
"error": daemon_status.get("error"),
|
||||
}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
snapshot["daemon"] = {"error": str(exc)}
|
||||
|
||||
health = _fetch_gateway_json(host, port, "/health")
|
||||
info = _fetch_gateway_json(host, port, "/info")
|
||||
|
||||
if info:
|
||||
snapshot["version"] = info.get("version")
|
||||
snapshot["protocol_version"] = info.get("protocol_version")
|
||||
|
||||
if health:
|
||||
snapshot["reachable"] = True
|
||||
snapshot["status"] = health.get("status")
|
||||
snapshot["uptime_s"] = health.get("uptime")
|
||||
snapshot["active_sessions"] = health.get("sessions")
|
||||
snapshot["clients"] = health.get("clients")
|
||||
snapshot["agents"] = health.get("agents")
|
||||
snapshot["channels"] = health.get("channels") or {}
|
||||
snapshot["degraded"] = health.get("degraded_owners") or []
|
||||
snapshot["applied_config_revision"] = health.get("applied_config_revision")
|
||||
snapshot["on_disk_config_revision"] = health.get("on_disk_config_revision")
|
||||
snapshot["config_drift"] = health.get("config_drift")
|
||||
snapshot["last_inbound_at"] = health.get("last_inbound_at")
|
||||
if "pressure" in health:
|
||||
snapshot["delivery"] = health.get("pressure")
|
||||
elif "delivery" in health:
|
||||
snapshot["delivery"] = health.get("delivery")
|
||||
if snapshot.get("version") is None and health.get("version") is not None:
|
||||
snapshot["version"] = health.get("version")
|
||||
# Reachable but /info didn't answer (typically an authenticated gateway
|
||||
# whose token isn't available to this CLI) — surface it explicitly so
|
||||
# consumers don't misread a silent missing "version" as "unversioned"
|
||||
# (#5363, greptile P1).
|
||||
if info is None and snapshot.get("version") is None:
|
||||
snapshot["version_unavailable"] = "info_unreachable"
|
||||
|
||||
return snapshot
|
||||
|
||||
|
||||
def _render_probe_results(results: dict, json_output: bool = False) -> bool:
|
||||
"""Print per-channel probe verdicts. Returns True if all channels passed."""
|
||||
all_ok = all(getattr(r, "ok", False) for r in results.values())
|
||||
@@ -1854,6 +1983,44 @@ def gateway_send(
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command("schema")
|
||||
def gateway_schema(
|
||||
output: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--output",
|
||||
"-o",
|
||||
help="Write the JSON Schema to a file instead of stdout "
|
||||
"(e.g. gateway.schema.json for editor config / CI linting).",
|
||||
),
|
||||
):
|
||||
"""Emit the gateway.yaml / bot.yaml JSON Schema (for editors / CI).
|
||||
|
||||
Derived from the canonical Pydantic ``GatewayConfigSchema`` so a mistyped
|
||||
key is flagged at author time instead of only at gateway startup. Point your
|
||||
editor's YAML language server at the output, or lint configs in CI:
|
||||
|
||||
praisonai gateway schema # print to stdout
|
||||
praisonai gateway schema -o gateway.schema.json # write to a file
|
||||
"""
|
||||
import json
|
||||
|
||||
from praisonai_bot.bots._config_schema import gateway_config_json_schema
|
||||
|
||||
schema_json = json.dumps(gateway_config_json_schema(), indent=2)
|
||||
if output:
|
||||
from pathlib import Path
|
||||
|
||||
out_path = Path(output)
|
||||
try:
|
||||
out_path.write_text(schema_json + "\n", encoding="utf-8")
|
||||
except OSError as exc:
|
||||
print(f"Failed to write {out_path}: {exc}")
|
||||
raise typer.Exit(1)
|
||||
print(f"Wrote gateway JSON Schema to {out_path}")
|
||||
else:
|
||||
sys.stdout.write(schema_json + "\n")
|
||||
|
||||
|
||||
hooks_app = typer.Typer(
|
||||
help="Manage inbound trigger hooks (POST /hooks/<path>) in gateway.yaml",
|
||||
no_args_is_help=True,
|
||||
|
||||
@@ -293,6 +293,9 @@ class BotHandler:
|
||||
agent_config = validated_config.agent
|
||||
if agent_config:
|
||||
agent_config_dict = agent_config.model_dump()
|
||||
# Resolve an unset/"auto" model to the detected provider's default
|
||||
# (Issue #5609) so a non-OpenAI operator is not handed gpt-4o-mini.
|
||||
agent_config_dict["model"] = agent_config.resolved_model()
|
||||
else:
|
||||
agent_config_dict = {}
|
||||
|
||||
|
||||
@@ -26,6 +26,44 @@ from typing import Any, Dict, List, Optional
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _detected_model() -> str:
|
||||
"""Model for whichever provider credential is present (Issue #5609).
|
||||
|
||||
Leads onboarding with the provider the operator already has
|
||||
(Anthropic/Gemini/Ollama/…) instead of writing ``gpt-4o-mini``
|
||||
unconditionally. Reuses the gateway/bot config resolver (which itself
|
||||
bridges to the existing ``default_model_for_available_provider`` helper),
|
||||
falling back to ``gpt-4o-mini`` only when nothing is detected.
|
||||
"""
|
||||
try:
|
||||
from praisonai_bot.bots._config_schema import resolve_model_by_provider
|
||||
|
||||
return resolve_model_by_provider()
|
||||
except Exception: # pragma: no cover - detection is best-effort
|
||||
return "gpt-4o-mini"
|
||||
|
||||
|
||||
def _provider_key_hint(model: str) -> str:
|
||||
"""Name the API-key env var the detected model needs (Issue #5609).
|
||||
|
||||
Maps the detected/configured model back to its provider's credential env
|
||||
var (e.g. ``ANTHROPIC_API_KEY`` for ``anthropic/...``) so the failure hint
|
||||
points at the key the operator actually needs instead of always
|
||||
``OPENAI_API_KEY``. Falls back to a generic hint when the provider cannot
|
||||
be resolved.
|
||||
"""
|
||||
try:
|
||||
from praisonai_bot._code_bridge import import_code_module
|
||||
|
||||
env = import_code_module("praisonai_code.llm.env")
|
||||
key_var, _base = env._provider_from_model(model)
|
||||
if key_var:
|
||||
return f"e.g. {key_var}"
|
||||
except Exception: # pragma: no cover - best-effort hint
|
||||
pass
|
||||
return "e.g. OPENAI_API_KEY"
|
||||
|
||||
|
||||
def _is_non_interactive() -> bool:
|
||||
"""Return True when the wizard must not block on user input.
|
||||
|
||||
@@ -280,8 +318,13 @@ def _generate_bot_yaml_multi_channel(channels: Dict[str, Dict], agent_name: str
|
||||
instructions = agent_instructions or "You are a helpful AI assistant."
|
||||
instructions = instructions.rstrip('. ')
|
||||
instructions_escaped = instructions.replace('\\', '\\\\').replace('"', '\\"')
|
||||
# Lead with the provider the operator already has (Issue #5609).
|
||||
model = _detected_model()
|
||||
|
||||
lines = ["# Generated by `praisonai onboard`. Multi-channel configuration.",
|
||||
from praisonai_bot.bots._config_schema import GATEWAY_SCHEMA_HEADER
|
||||
|
||||
lines = [GATEWAY_SCHEMA_HEADER.rstrip("\n"),
|
||||
"# Generated by `praisonai onboard`. Multi-channel configuration.",
|
||||
"# Compatible with both `praisonai bot start` and `praisonai gateway start`.",
|
||||
"# Each channel can route to different agents.",
|
||||
""]
|
||||
@@ -296,7 +339,7 @@ def _generate_bot_yaml_multi_channel(channels: Dict[str, Dict], agent_name: str
|
||||
lines.append("agent:")
|
||||
lines.append(f' name: "{agent_name}"')
|
||||
lines.append(f' instructions: "{instructions_escaped}. You can search the web, manage schedules, and remember past conversations. Use your tools proactively."')
|
||||
lines.append(' model: "gpt-4o-mini"')
|
||||
lines.append(f' model: "{model}"')
|
||||
lines.append("")
|
||||
|
||||
# Multi-agent block with agents for each role
|
||||
@@ -310,7 +353,7 @@ def _generate_bot_yaml_multi_channel(channels: Dict[str, Dict], agent_name: str
|
||||
lines.append(f' instructions: "{instructions_escaped}. You can search the web, manage schedules, and remember past conversations. Use your tools proactively."')
|
||||
else:
|
||||
lines.append(f' instructions: "You are a {role} agent. {instructions_escaped} Use your tools proactively to help with {role}-related tasks."')
|
||||
lines.append(' model: gpt-4o-mini')
|
||||
lines.append(f' model: {model}')
|
||||
lines.append(" memory: false")
|
||||
lines.append("")
|
||||
agents_added.add(role)
|
||||
@@ -379,8 +422,13 @@ def _generate_bot_yaml(platforms: List[str], agent_name: str = "assistant", agen
|
||||
instructions = agent_instructions or "You are a helpful AI assistant."
|
||||
instructions = instructions.rstrip('. ') # Remove trailing periods to avoid double periods
|
||||
instructions_escaped = instructions.replace('\\', '\\\\').replace('"', '\\"') # Escape quotes and backslashes
|
||||
# Lead with the provider the operator already has (Issue #5609).
|
||||
model = _detected_model()
|
||||
|
||||
lines = ["# Generated by `praisonai onboard`. Works with both",
|
||||
from praisonai_bot.bots._config_schema import GATEWAY_SCHEMA_HEADER
|
||||
|
||||
lines = [GATEWAY_SCHEMA_HEADER.rstrip("\n"),
|
||||
"# Generated by `praisonai onboard`. Works with both",
|
||||
"# `praisonai bot start` (single-bot) and `praisonai gateway start`",
|
||||
"# (multi-bot daemon exposing /health on 8765).",
|
||||
""]
|
||||
@@ -395,7 +443,7 @@ def _generate_bot_yaml(platforms: List[str], agent_name: str = "assistant", agen
|
||||
lines.append("agent:")
|
||||
lines.append(f' name: "{agent_name}"')
|
||||
lines.append(f' instructions: "{instructions_escaped}. You can search the web, manage schedules, and remember past conversations. Use your tools proactively."')
|
||||
lines.append(' model: "gpt-4o-mini"')
|
||||
lines.append(f' model: "{model}"')
|
||||
lines.append(" # tools: [] # Uncomment to override auto-injected defaults")
|
||||
lines.append(" # (search_web, schedule_*, store_memory, search_memory, …)")
|
||||
lines.append("")
|
||||
@@ -408,7 +456,7 @@ def _generate_bot_yaml(platforms: List[str], agent_name: str = "assistant", agen
|
||||
lines.append(f' instructions: "{instructions_escaped}. You can search the web, manage schedules, and remember past conversations. Use your tools proactively."')
|
||||
else:
|
||||
lines.append(f' instructions: "You are the {name} agent. {instructions_escaped}. Use your tools proactively."')
|
||||
lines.append(' model: gpt-4o-mini')
|
||||
lines.append(f' model: {model}')
|
||||
lines.append(" memory: false")
|
||||
lines.append(" # tools: [] # Override auto-injected defaults here")
|
||||
lines.append("")
|
||||
@@ -1008,9 +1056,14 @@ class OnboardWizard:
|
||||
if ok:
|
||||
print_fn(" [green]✓[/green] Agent replied — model credentials work")
|
||||
else:
|
||||
# Name the provider actually detected (Issue #5609) instead of
|
||||
# hardcoding OPENAI_API_KEY, so the remediation hint matches the
|
||||
# model onboarding chose (or says none was detected).
|
||||
model = _detected_model()
|
||||
key_hint = _provider_key_hint(model)
|
||||
print_fn(
|
||||
f" [yellow]⚠[/yellow] Agent did not reply: {detail}\n"
|
||||
" [dim]Set the model/provider API key (e.g. OPENAI_API_KEY) in "
|
||||
f" [dim]Set the model/provider API key ({key_hint}) in "
|
||||
"~/.praisonai/.env, then re-run [cyan]praisonai onboard[/cyan] or "
|
||||
"[cyan]praisonai gateway test --turn ping[/cyan].[/dim]"
|
||||
)
|
||||
|
||||
@@ -867,10 +867,14 @@ class GatewayApiEndpoints:
|
||||
)
|
||||
|
||||
if method == "initialize":
|
||||
from .server import _installed_gateway_version
|
||||
return ok(
|
||||
{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"serverInfo": {"name": "PraisonAI Gateway", "version": "1.0.0"},
|
||||
"serverInfo": {
|
||||
"name": "PraisonAI Gateway",
|
||||
"version": _installed_gateway_version(),
|
||||
},
|
||||
"capabilities": {"tools": {}},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -273,10 +273,18 @@ async def run_turn_test(
|
||||
# the SAME provider the live gateway will use — otherwise a valid custom
|
||||
# base_url/api_key config tests the default provider and gives a misleading
|
||||
# verdict (false abort, or invalid runtime creds passing).
|
||||
# An unset or ``"auto"`` model resolves to the detected provider's default
|
||||
# (Issue #5609) so the preflight tests the provider the operator actually
|
||||
# has instead of assuming OpenAI.
|
||||
model = acfg.get("model") or acfg.get("llm")
|
||||
if not model or str(model).strip().lower() == "auto":
|
||||
from praisonai_bot.bots._config_schema import resolve_model_by_provider
|
||||
|
||||
model = resolve_model_by_provider()
|
||||
agent_kwargs: Dict[str, Any] = {
|
||||
"name": acfg.get("name", agent_id),
|
||||
"instructions": acfg.get("instructions", ""),
|
||||
"llm": acfg.get("model") or acfg.get("llm", "gpt-4o-mini"),
|
||||
"llm": model,
|
||||
}
|
||||
base_url = acfg.get("base_url") or acfg.get("api_base")
|
||||
if base_url:
|
||||
|
||||
@@ -131,6 +131,29 @@ from .unicode_utils import safe_error_message, safe_log_message, extract_root_ca
|
||||
from .supervisor import ChannelState, ChannelSupervisor
|
||||
|
||||
|
||||
def _installed_gateway_version() -> str:
|
||||
"""Return the installed ``praisonai-bot`` version, not a hardcoded literal.
|
||||
|
||||
Automated version/upgrade checks read this off ``/info`` and the MCP
|
||||
``serverInfo``; a hardcoded constant (#5363) actively misled them. Prefer
|
||||
the packaged ``_version.py`` and fall back to installed metadata.
|
||||
"""
|
||||
try:
|
||||
from praisonai_bot._version import __version__ as _v
|
||||
if _v:
|
||||
return str(_v)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from importlib.metadata import version as _pkg_version
|
||||
except ImportError: # pragma: no cover - py<3.8
|
||||
from importlib_metadata import version as _pkg_version # type: ignore
|
||||
try:
|
||||
return _pkg_version("praisonai-bot")
|
||||
except Exception:
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
# Per-platform token env-var fallbacks used by the generic channel-launch path
|
||||
# (Issue #3578). Channels whose credentials live in the environment rather than
|
||||
# gateway.yaml (email/AgentMail/Linear) resolve their token from the first env
|
||||
@@ -1696,7 +1719,8 @@ class WebSocketGateway:
|
||||
api_cfg = getattr(self.config, "api", None)
|
||||
return JSONResponse({
|
||||
"name": "PraisonAI Gateway",
|
||||
"version": "1.0.0",
|
||||
"version": _installed_gateway_version(),
|
||||
"protocol_version": GATEWAY_PROTOCOL_VERSION,
|
||||
"agents": list(self._agents.keys()),
|
||||
"sessions": len(self._sessions),
|
||||
"clients": len(self._clients),
|
||||
@@ -8079,8 +8103,20 @@ class WebSocketGateway:
|
||||
|
||||
for agent_id, agent_def in agents_cfg.items():
|
||||
instructions = agent_def.get("instructions", "")
|
||||
# G7: Apply provider.model as fallback when agent has no model
|
||||
model = agent_def.get("model", None) or default_model
|
||||
# G7: Apply provider.model as fallback when agent has no model.
|
||||
# An unset or ``"auto"`` model resolves to the detected provider's
|
||||
# default (Issue #5609) so a non-OpenAI operator is not silently
|
||||
# handed gpt-4o-mini; an explicit model/default_model still wins.
|
||||
model = agent_def.get("model", None)
|
||||
if not model or str(model).strip().lower() == "auto":
|
||||
if default_model:
|
||||
model = default_model
|
||||
else:
|
||||
from praisonai_bot.bots._config_schema import (
|
||||
resolve_model_by_provider,
|
||||
)
|
||||
|
||||
model = resolve_model_by_provider()
|
||||
memory = agent_def.get("memory", False)
|
||||
|
||||
# G2: Pass temperature through (optional, SDK uses its own default)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-bot"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
description = "Bots, gateway, and channel CLI for PraisonAI — messaging platforms and WebSocket control plane extracted from the praisonai wrapper."
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
@@ -77,3 +77,6 @@ build-backend = "setuptools.build_meta"
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["praisonai_bot*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
"praisonai_bot.bots" = ["*.json"]
|
||||
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for the opt-in, privacy-safe per-reply runtime footer (issue #5428).
|
||||
|
||||
The footer surfaces only model name, context % and turn latency on the FINAL
|
||||
reply. Missing fields are omitted (never shown as placeholders), and nothing is
|
||||
appended when the footer is empty or the feature is disabled.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import types
|
||||
|
||||
from praisonai_bot.bots._streaming import append_footer, build_footer_line
|
||||
|
||||
|
||||
def test_full_footer_line():
|
||||
assert build_footer_line(
|
||||
model="gpt-4o",
|
||||
served_model="gpt-4o-2024-08-06",
|
||||
context_pct=41,
|
||||
latency_s=3.8,
|
||||
) == "gpt-4o (served: gpt-4o-2024-08-06) · 41% ctx · 3.8s"
|
||||
|
||||
|
||||
def test_model_and_latency_only():
|
||||
assert build_footer_line(model="gpt-4o", latency_s=4.2) == "gpt-4o · 4.2s"
|
||||
|
||||
|
||||
def test_served_model_hidden_when_same_as_model():
|
||||
# No "(served: ...)" when the served deployment matches the configured model.
|
||||
assert build_footer_line(model="gpt-4o", served_model="gpt-4o") == "gpt-4o"
|
||||
|
||||
|
||||
def test_missing_fields_are_omitted_not_placeheld():
|
||||
# Only a context % available -> just that field, no "?" placeholders.
|
||||
assert build_footer_line(context_pct=38) == "38% ctx"
|
||||
|
||||
|
||||
def test_empty_when_no_fields():
|
||||
assert build_footer_line() == ""
|
||||
|
||||
|
||||
def test_context_pct_rounded_to_int():
|
||||
assert build_footer_line(context_pct=37.6) == "38% ctx"
|
||||
|
||||
|
||||
def test_invalid_numbers_are_dropped():
|
||||
assert build_footer_line(model="m", context_pct=float("nan")) == "m"
|
||||
assert build_footer_line(model="m", latency_s=float("inf")) == "m"
|
||||
assert build_footer_line(model="m", latency_s=-1) == "m"
|
||||
|
||||
|
||||
def test_blank_model_is_dropped():
|
||||
assert build_footer_line(model=" ", latency_s=1.0) == "1.0s"
|
||||
|
||||
|
||||
def test_append_footer_adds_separator():
|
||||
assert (
|
||||
append_footer("Here is your answer.", "gpt-4o · 4.2s")
|
||||
== "Here is your answer.\n\n— gpt-4o · 4.2s"
|
||||
)
|
||||
|
||||
|
||||
def test_append_footer_noop_on_empty_line():
|
||||
assert append_footer("body", "") == "body"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handler-level delivery behaviour (opt-in wiring + voice exclusion).
|
||||
#
|
||||
# TelegramBot carries a heavy optional ``telegram`` dependency, so rather than
|
||||
# construct the whole bot we bind the two unbound methods under test to a light
|
||||
# stub that supplies just the attributes they touch. This exercises the real
|
||||
# code paths (opt-in gating, model/latency capture, footer-in-text-not-voice)
|
||||
# deterministically and without network or the telegram package.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from praisonai_bot.bots.telegram import TelegramBot # noqa: E402
|
||||
|
||||
|
||||
def _make_stub(footer_enabled, llm="gpt-4o"):
|
||||
stub = types.SimpleNamespace()
|
||||
stub.config = types.SimpleNamespace(footer=footer_enabled)
|
||||
stub._agent = types.SimpleNamespace(llm=llm)
|
||||
return stub
|
||||
|
||||
|
||||
def test_footer_line_disabled_returns_empty():
|
||||
stub = _make_stub(footer_enabled=False)
|
||||
line = TelegramBot._footer_line(stub, turn_started_at=None)
|
||||
assert line == ""
|
||||
|
||||
|
||||
def test_footer_line_enabled_includes_model():
|
||||
stub = _make_stub(footer_enabled=True, llm="gpt-4o")
|
||||
line = TelegramBot._footer_line(stub, turn_started_at=None)
|
||||
assert line == "gpt-4o"
|
||||
|
||||
|
||||
def test_footer_line_enabled_includes_model_and_latency():
|
||||
import time
|
||||
|
||||
stub = _make_stub(footer_enabled=True, llm="gpt-4o")
|
||||
started = time.monotonic() - 2.0
|
||||
line = TelegramBot._footer_line(stub, turn_started_at=started)
|
||||
assert line.startswith("gpt-4o · ")
|
||||
assert line.endswith("s")
|
||||
|
||||
|
||||
def test_footer_line_non_string_llm_omits_model():
|
||||
stub = _make_stub(footer_enabled=True, llm=object())
|
||||
line = TelegramBot._footer_line(stub, turn_started_at=None)
|
||||
assert line == ""
|
||||
|
||||
|
||||
def _run_send(stub, response, footer_line):
|
||||
sent_text = {}
|
||||
voiced_text = {}
|
||||
|
||||
async def fake_send_long(chat_id, text, reply_to=None):
|
||||
sent_text["value"] = text
|
||||
|
||||
async def fake_voice(chat_id, text, *, inbound_was_voice=False):
|
||||
voiced_text["value"] = text
|
||||
|
||||
stub._send_long_message = fake_send_long
|
||||
stub._maybe_send_voice_reply = fake_voice
|
||||
asyncio.run(
|
||||
TelegramBot._send_response_with_media(
|
||||
stub, 123, response, reply_to=1, footer_line=footer_line
|
||||
)
|
||||
)
|
||||
return sent_text.get("value"), voiced_text.get("value")
|
||||
|
||||
|
||||
def test_send_appends_footer_to_text_only():
|
||||
stub = types.SimpleNamespace()
|
||||
sent, voiced = _run_send(stub, "Here is your answer.", "gpt-4o · 4.2s")
|
||||
# Footer appears in the delivered text …
|
||||
assert sent == "Here is your answer.\n\n— gpt-4o · 4.2s"
|
||||
# … but never in the text handed to voice synthesis.
|
||||
assert voiced == "Here is your answer."
|
||||
|
||||
|
||||
def test_send_without_footer_leaves_text_unchanged():
|
||||
stub = types.SimpleNamespace()
|
||||
sent, voiced = _run_send(stub, "Plain reply.", "")
|
||||
assert sent == "Plain reply."
|
||||
assert voiced == "Plain reply."
|
||||
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Cross-channel progressive streaming wiring (Issue #5415).
|
||||
|
||||
The ``DraftStreamer`` engine is channel-agnostic, but it used to be wired only
|
||||
into Telegram. These tests lock in that Discord and Slack now:
|
||||
|
||||
- derive a ``StreamingConfig`` from ``BotConfig.streaming`` via the shared
|
||||
``build_streaming_config`` factory, and
|
||||
- advertise ``live_edit`` so ``AUTO`` resolves to ``DRAFT``.
|
||||
|
||||
This guards against regressing the fix back to a Telegram-only capability.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.bots import BotConfig
|
||||
|
||||
from praisonai_bot.bots._streaming import (
|
||||
build_streaming_config,
|
||||
StreamingMode,
|
||||
)
|
||||
from praisonai_bot.bots.discord import DiscordBot
|
||||
from praisonai_bot.bots.slack import SlackBot
|
||||
|
||||
|
||||
def test_build_streaming_config_off_returns_none():
|
||||
cfg = BotConfig(token="t", streaming=False)
|
||||
assert build_streaming_config(cfg) is None
|
||||
|
||||
|
||||
def test_build_streaming_config_draft_when_enabled():
|
||||
cfg = BotConfig(token="t", streaming=True, stream_edit_interval_ms=700)
|
||||
sc = build_streaming_config(cfg)
|
||||
assert sc is not None
|
||||
assert sc.mode == StreamingMode.DRAFT
|
||||
assert sc.min_interval == 0.7
|
||||
|
||||
|
||||
def test_discord_streaming_config_built_from_bot_config():
|
||||
bot = DiscordBot(token="t", config=BotConfig(token="t", streaming=True))
|
||||
assert bot._streaming_config is not None
|
||||
assert bot._streaming_config.mode == StreamingMode.DRAFT
|
||||
# Discord advertises live_edit so AUTO can resolve to DRAFT.
|
||||
assert bot.capabilities.get("live_edit") is True
|
||||
|
||||
|
||||
def test_discord_streaming_disabled_by_default():
|
||||
bot = DiscordBot(token="t", config=BotConfig(token="t"))
|
||||
assert bot._streaming_config is None
|
||||
|
||||
|
||||
def test_slack_streaming_config_built_from_bot_config():
|
||||
bot = SlackBot(token="t", config=BotConfig(token="t", streaming=True))
|
||||
assert bot._streaming_config is not None
|
||||
assert bot._streaming_config.mode == StreamingMode.DRAFT
|
||||
assert bot.capabilities.get("live_edit") is True
|
||||
|
||||
|
||||
def test_slack_streaming_disabled_by_default():
|
||||
bot = SlackBot(token="t", config=BotConfig(token="t"))
|
||||
assert bot._streaming_config is None
|
||||
|
||||
|
||||
def test_configure_streaming_overrides_discord():
|
||||
bot = DiscordBot(token="t", config=BotConfig(token="t"))
|
||||
sc = build_streaming_config(BotConfig(token="t", streaming=True))
|
||||
bot.configure_streaming(sc)
|
||||
assert bot._streaming_config is sc
|
||||
|
||||
|
||||
def test_configure_streaming_overrides_slack():
|
||||
bot = SlackBot(token="t", config=BotConfig(token="t"))
|
||||
sc = build_streaming_config(BotConfig(token="t", streaming=True))
|
||||
bot.configure_streaming(sc)
|
||||
assert bot._streaming_config is sc
|
||||
|
||||
|
||||
# --- Streaming delivery guardrails (Greptile review on #5417) ----------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_stream_defers_when_threaded():
|
||||
"""A threaded reply must bypass the root-only streamer (Greptile #2).
|
||||
|
||||
Returning ``None`` signals the caller to fall back to the thread-aware
|
||||
``_send_response_with_media`` path, so a threaded answer is never stranded
|
||||
in the main channel.
|
||||
"""
|
||||
bot = SlackBot(token="t", config=BotConfig(token="t", streaming=True))
|
||||
result = await bot._maybe_stream_reply(
|
||||
"C1", "U1", "hi", thread_id="123.456", message_id="123.457"
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_stream_defers_when_reply_in_thread_configured():
|
||||
bot = SlackBot(
|
||||
token="t",
|
||||
config=BotConfig(token="t", streaming=True, reply_in_thread=True),
|
||||
)
|
||||
result = await bot._maybe_stream_reply("C1", "U1", "hi", message_id="123.457")
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_stream_uploads_media(monkeypatch, tmp_path):
|
||||
"""A streamed reply carrying a MEDIA: directive uploads the file (Greptile #4)."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
bot = SlackBot(token="t", config=BotConfig(token="t", streaming=True))
|
||||
|
||||
audio = tmp_path / "reply.mp3"
|
||||
audio.write_bytes(b"ID3fake")
|
||||
|
||||
# Session returns text + a MEDIA directive; streamer edits are stubbed.
|
||||
bot._session.chat = AsyncMock(return_value=f"Here you go\nMEDIA:{audio}")
|
||||
bot.send_message = AsyncMock(return_value={"message_id": "m1"})
|
||||
bot.edit_message = AsyncMock(return_value=None)
|
||||
bot.delete_message = AsyncMock(return_value=True)
|
||||
# Pin the outbound hook seam to a pass-through so a process-global hook
|
||||
# registered by another test cannot rewrite/strip the MEDIA directive.
|
||||
bot.fire_message_sending = lambda channel_id, content, **kw: {
|
||||
"cancel": False,
|
||||
"content": content,
|
||||
}
|
||||
|
||||
uploads = []
|
||||
|
||||
class _Client:
|
||||
async def files_upload_v2(self, **kwargs):
|
||||
uploads.append(kwargs)
|
||||
|
||||
bot._client = _Client()
|
||||
|
||||
result = await bot._maybe_stream_reply("C1", "U1", "play it")
|
||||
assert result is not None
|
||||
assert uploads, "expected the MEDIA audio file to be uploaded"
|
||||
assert uploads[0]["channel"] == "C1"
|
||||
assert uploads[0]["file"] == str(audio)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_stream_chunks_long_reply(monkeypatch):
|
||||
"""A streamed answer over Discord's cap is delivered via the chunked path.
|
||||
|
||||
Guards Greptile #3/#5: editing the single placeholder with >2000 chars
|
||||
would 400 and drop the whole reply, so the finalize path must fall back to
|
||||
the reference-preserving ``_send_long_message`` chunker.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
bot = DiscordBot(token="t", config=BotConfig(token="t", streaming=True))
|
||||
|
||||
long_reply = "x" * 5000
|
||||
bot._session.chat = AsyncMock(return_value=long_reply)
|
||||
bot.send_message = AsyncMock(return_value={"message_id": "m1"})
|
||||
bot.edit_message = AsyncMock(return_value=None)
|
||||
bot.delete_message = AsyncMock(return_value=True)
|
||||
|
||||
sent_chunks = []
|
||||
|
||||
long_sender = AsyncMock(
|
||||
side_effect=lambda channel, text, reference=None: sent_chunks.append(text)
|
||||
)
|
||||
monkeypatch.setattr(bot, "_send_long_message", long_sender)
|
||||
|
||||
from praisonai_bot.bots._streaming import DraftStreamer
|
||||
|
||||
streamer = DraftStreamer(
|
||||
adapter=bot,
|
||||
channel_id="C1",
|
||||
config=bot._streaming_config,
|
||||
platform="discord",
|
||||
)
|
||||
await streamer.start()
|
||||
|
||||
# Simulate the finalize decision the adapter makes for an over-cap reply.
|
||||
send_result = bot.fire_message_sending("C1", long_reply)
|
||||
final_content = send_result["content"]
|
||||
cap = min(bot.config.max_message_length, 2000)
|
||||
assert len(final_content) > cap
|
||||
|
||||
message = MagicMock()
|
||||
message.channel = MagicMock()
|
||||
if len(final_content) > cap:
|
||||
await bot.delete_message("C1", "m1")
|
||||
await bot._send_long_message(message.channel, final_content, reference=message)
|
||||
else: # pragma: no cover - defensive
|
||||
await streamer.finalize(final_content)
|
||||
|
||||
assert sent_chunks, "long reply should have been routed through the chunker"
|
||||
bot.delete_message.assert_awaited()
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Tests for the published gateway.yaml / bot.yaml JSON Schema (issue #5414).
|
||||
|
||||
Brings the gateway/bot config surface to parity with ``agents.yaml``:
|
||||
- ``gateway_config_json_schema()`` emits a draft-07 schema from the canonical
|
||||
Pydantic ``GatewayConfigSchema``.
|
||||
- The committed ``gateway.schema.json`` artefact matches the model.
|
||||
- ``praisonai gateway schema`` prints the schema / writes it to a file.
|
||||
- Scaffolded ``gateway.yaml``/``bot.yaml`` start with the
|
||||
``# yaml-language-server`` header (and still parse with ``yaml.safe_load``).
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from praisonai_bot.bots._config_schema import (
|
||||
GATEWAY_SCHEMA_HEADER,
|
||||
GATEWAY_SCHEMA_URL,
|
||||
gateway_config_json_schema,
|
||||
)
|
||||
|
||||
|
||||
def test_gateway_schema_derived_from_pydantic_model():
|
||||
schema = gateway_config_json_schema()
|
||||
assert schema["$schema"] == "http://json-schema.org/draft-07/schema#"
|
||||
assert schema["$id"] == GATEWAY_SCHEMA_URL
|
||||
props = schema["properties"]
|
||||
for key in ("gateway", "agents", "channels", "routing", "schedules"):
|
||||
assert key in props, f"expected '{key}' in gateway schema properties"
|
||||
|
||||
|
||||
def test_committed_artefact_matches_model():
|
||||
artefact = (
|
||||
Path(__file__).resolve().parents[3]
|
||||
/ "praisonai_bot"
|
||||
/ "bots"
|
||||
/ "gateway.schema.json"
|
||||
)
|
||||
assert artefact.exists(), "gateway.schema.json artefact must be committed"
|
||||
committed = json.loads(artefact.read_text(encoding="utf-8"))
|
||||
assert committed == gateway_config_json_schema(), (
|
||||
"gateway.schema.json is stale; regenerate via "
|
||||
"`praisonai gateway schema -o "
|
||||
"src/praisonai-bot/praisonai_bot/bots/gateway.schema.json`"
|
||||
)
|
||||
|
||||
|
||||
def test_schema_header_points_at_published_url():
|
||||
assert GATEWAY_SCHEMA_HEADER.startswith("# yaml-language-server: $schema=")
|
||||
assert GATEWAY_SCHEMA_URL in GATEWAY_SCHEMA_HEADER
|
||||
assert GATEWAY_SCHEMA_HEADER.endswith("\n")
|
||||
|
||||
|
||||
def test_shipped_sample_gateway_yaml_has_header():
|
||||
sample = (
|
||||
Path(__file__).resolve().parents[3] / "gateway.yaml"
|
||||
)
|
||||
if not sample.exists():
|
||||
pytest.skip("sample gateway.yaml not present")
|
||||
first_line = sample.read_text(encoding="utf-8").splitlines()[0]
|
||||
assert first_line.startswith("# yaml-language-server: $schema=")
|
||||
# Leading comment is ignored by safe_load -> execution unaffected.
|
||||
loaded = yaml.safe_load(sample.read_text(encoding="utf-8"))
|
||||
assert "gateway" in loaded
|
||||
|
||||
|
||||
def test_generated_configs_scaffold_header():
|
||||
from praisonai_bot.cli.features.onboard import (
|
||||
_generate_bot_yaml,
|
||||
_generate_bot_yaml_multi_channel,
|
||||
)
|
||||
|
||||
single = _generate_bot_yaml(["telegram"])
|
||||
assert single.startswith("# yaml-language-server: $schema=")
|
||||
assert yaml.safe_load(single)["gateway"]["port"] == 8765
|
||||
|
||||
multi = _generate_bot_yaml_multi_channel(
|
||||
{
|
||||
"telegram": {
|
||||
"platform": "telegram",
|
||||
"role": "assistant",
|
||||
"env_var": "TELEGRAM_BOT_TOKEN",
|
||||
"token": "",
|
||||
"info": {"allowed_users_env": "TELEGRAM_ALLOWED_USERS"},
|
||||
"config": {},
|
||||
}
|
||||
}
|
||||
)
|
||||
assert multi.startswith("# yaml-language-server: $schema=")
|
||||
assert "gateway" in yaml.safe_load(multi)
|
||||
|
||||
|
||||
def test_gateway_schema_cli_prints_and_writes(tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from praisonai_bot.cli.commands.gateway import app
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["schema"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert '"$schema"' in result.output
|
||||
|
||||
out = tmp_path / "gateway.schema.json"
|
||||
result2 = runner.invoke(app, ["schema", "-o", str(out)])
|
||||
assert result2.exit_code == 0, result2.output
|
||||
assert out.exists()
|
||||
assert json.loads(out.read_text(encoding="utf-8")) == gateway_config_json_schema()
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Tests for the machine-readable ``gateway status --json`` snapshot and the
|
||||
real (non-hardcoded) version reported by ``/info`` and MCP ``serverInfo`` (#5363).
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
import typer
|
||||
|
||||
from praisonai_bot.cli.commands import gateway as gateway_cmd
|
||||
from praisonai_bot.gateway.server import _installed_gateway_version
|
||||
|
||||
|
||||
def test_installed_version_is_not_hardcoded():
|
||||
version = _installed_gateway_version()
|
||||
assert version
|
||||
assert version != "1.0.0"
|
||||
|
||||
|
||||
def test_build_status_snapshot_merges_health_and_info(monkeypatch):
|
||||
health = {
|
||||
"status": "healthy",
|
||||
"uptime": 3600.0,
|
||||
"sessions": 3,
|
||||
"clients": 2,
|
||||
"agents": 1,
|
||||
"channels": {"telegram": {"running": True}},
|
||||
"degraded_owners": [],
|
||||
"applied_config_revision": "a1b2c3d4e5f6",
|
||||
"on_disk_config_revision": "a1b2c3d4e5f6",
|
||||
"config_drift": False,
|
||||
"pressure": {"outbox_depth": 0, "dlq": 0, "dead_targets": 0},
|
||||
}
|
||||
info = {"version": "2.3.4", "protocol_version": 7}
|
||||
|
||||
def fake_fetch(host, port, path, timeout=5.0):
|
||||
return health if path == "/health" else info
|
||||
|
||||
monkeypatch.setattr(gateway_cmd, "_fetch_gateway_json", fake_fetch)
|
||||
monkeypatch.setattr(
|
||||
"praisonai_bot.daemon.get_daemon_status",
|
||||
lambda: {"platform": "linux", "installed": True, "running": True, "pid": 42},
|
||||
)
|
||||
|
||||
snap = gateway_cmd._build_status_snapshot("127.0.0.1", 8765)
|
||||
|
||||
assert snap["reachable"] is True
|
||||
assert snap["version"] == "2.3.4"
|
||||
assert snap["protocol_version"] == 7
|
||||
assert snap["uptime_s"] == 3600.0
|
||||
assert snap["active_sessions"] == 3
|
||||
assert snap["clients"] == 2
|
||||
assert snap["channels"] == {"telegram": {"running": True}}
|
||||
assert snap["degraded"] == []
|
||||
assert snap["applied_config_revision"] == "a1b2c3d4e5f6"
|
||||
assert snap["config_drift"] is False
|
||||
assert snap["delivery"] == {"outbox_depth": 0, "dlq": 0, "dead_targets": 0}
|
||||
assert snap["daemon"]["running"] is True
|
||||
|
||||
|
||||
def test_build_status_snapshot_unreachable(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
gateway_cmd, "_fetch_gateway_json", lambda *a, **k: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"praisonai_bot.daemon.get_daemon_status",
|
||||
lambda: {"platform": "linux", "installed": False, "running": False},
|
||||
)
|
||||
|
||||
snap = gateway_cmd._build_status_snapshot("127.0.0.1", 8765)
|
||||
assert snap["reachable"] is False
|
||||
|
||||
|
||||
def test_status_json_emits_snapshot_and_exit_code(monkeypatch, capsys):
|
||||
monkeypatch.setattr(
|
||||
gateway_cmd,
|
||||
"_build_status_snapshot",
|
||||
lambda host, port: {"reachable": True, "version": "9.9.9"},
|
||||
)
|
||||
|
||||
with pytest.raises(typer.Exit) as excinfo:
|
||||
gateway_cmd.gateway_status(
|
||||
host="127.0.0.1", port=8765, config=None,
|
||||
daemon_only=False, deep=False, probe=False, json_output=True,
|
||||
)
|
||||
assert excinfo.value.exit_code == 0
|
||||
|
||||
out = capsys.readouterr().out
|
||||
payload = json.loads(out)
|
||||
assert payload["reachable"] is True
|
||||
assert payload["version"] == "9.9.9"
|
||||
|
||||
|
||||
def test_status_json_unreachable_exits_nonzero(monkeypatch, capsys):
|
||||
monkeypatch.setattr(
|
||||
gateway_cmd,
|
||||
"_build_status_snapshot",
|
||||
lambda host, port: {"reachable": False},
|
||||
)
|
||||
|
||||
with pytest.raises(typer.Exit) as excinfo:
|
||||
gateway_cmd.gateway_status(
|
||||
host="127.0.0.1", port=8765, config=None,
|
||||
daemon_only=False, deep=False, probe=False, json_output=True,
|
||||
)
|
||||
assert excinfo.value.exit_code == 1
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert payload["reachable"] is False
|
||||
|
||||
|
||||
def test_resolve_auth_token_prefers_env(monkeypatch):
|
||||
monkeypatch.setenv("GATEWAY_AUTH_TOKEN", "env-token")
|
||||
assert gateway_cmd._resolve_gateway_auth_token() == "env-token"
|
||||
|
||||
|
||||
def test_resolve_auth_token_falls_back_to_persisted_env_file(monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("GATEWAY_AUTH_TOKEN", raising=False)
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text(
|
||||
"# comment\nOTHER=1\nGATEWAY_AUTH_TOKEN=\"persisted-token\"\n"
|
||||
)
|
||||
monkeypatch.setenv("PRAISONAI_ENV_FILE", str(env_file))
|
||||
assert gateway_cmd._resolve_gateway_auth_token() == "persisted-token"
|
||||
|
||||
|
||||
def test_resolve_auth_token_missing_returns_empty(monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("GATEWAY_AUTH_TOKEN", raising=False)
|
||||
monkeypatch.setenv("PRAISONAI_ENV_FILE", str(tmp_path / "does-not-exist.env"))
|
||||
assert gateway_cmd._resolve_gateway_auth_token() == ""
|
||||
|
||||
|
||||
def test_snapshot_signals_version_unavailable_when_info_unreachable(monkeypatch):
|
||||
health = {"status": "healthy", "uptime": 1.0}
|
||||
|
||||
def fake_fetch(host, port, path, timeout=5.0):
|
||||
return health if path == "/health" else None
|
||||
|
||||
monkeypatch.setattr(gateway_cmd, "_fetch_gateway_json", fake_fetch)
|
||||
monkeypatch.setattr(
|
||||
"praisonai_bot.daemon.get_daemon_status",
|
||||
lambda: {"platform": "linux", "installed": True, "running": True},
|
||||
)
|
||||
|
||||
snap = gateway_cmd._build_status_snapshot("127.0.0.1", 8765)
|
||||
assert snap["reachable"] is True
|
||||
assert snap.get("version") is None
|
||||
assert snap["version_unavailable"] == "info_unreachable"
|
||||
@@ -797,7 +797,14 @@ def test_mcp_initialize_and_tools_list():
|
||||
init = asyncio.run(
|
||||
ep.mcp_jsonrpc(_FakeReq({"jsonrpc": "2.0", "id": 1, "method": "initialize"}))
|
||||
)
|
||||
assert _body(init)["result"]["serverInfo"]["name"] == "PraisonAI Gateway"
|
||||
server_info = _body(init)["result"]["serverInfo"]
|
||||
assert server_info["name"] == "PraisonAI Gateway"
|
||||
# The MCP serverInfo version must be the real installed version, not the
|
||||
# old hardcoded "1.0.0" that misled upgrade checks (#5363, greptile P2).
|
||||
from praisonai_bot.gateway.server import _installed_gateway_version
|
||||
|
||||
assert server_info["version"] == _installed_gateway_version()
|
||||
assert server_info["version"] != "1.0.0"
|
||||
|
||||
listed = asyncio.run(
|
||||
ep.mcp_jsonrpc(_FakeReq({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}))
|
||||
|
||||
Generated
+5
-4
@@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
revision = 5
|
||||
requires-python = ">=3.10, <3.15"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.14'",
|
||||
@@ -1428,7 +1428,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonai-bot"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -1510,7 +1510,7 @@ provides-extras = ["gateway", "bot", "bot-whatsapp-web", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.7.9"
|
||||
version = "1.7.10"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -1543,6 +1543,7 @@ requires-dist = [
|
||||
{ name = "litellm", marker = "extra == 'memory'", specifier = ">=1.83.14" },
|
||||
{ name = "markitdown", extras = ["all"], marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
|
||||
{ name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.20.0" },
|
||||
{ name = "mcp", marker = "extra == 'parallel-search'", specifier = ">=1.30.0,<2" },
|
||||
{ name = "mem0ai", marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
|
||||
{ name = "mem0ai", extras = ["graph"], marker = "extra == 'graph'", specifier = ">=0.1.0" },
|
||||
{ name = "motor", marker = "extra == 'mongodb'", specifier = ">=3.4.0" },
|
||||
@@ -1581,7 +1582,7 @@ requires-dist = [
|
||||
{ name = "uvicorn", marker = "extra == 'os'", specifier = ">=0.34.0" },
|
||||
{ name = "websockets", marker = "extra == 'mcp'", specifier = ">=12.0" },
|
||||
]
|
||||
provides-extras = ["sandbox", "mcp", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "dakera", "auth", "autonomy", "search", "crawl", "a2ui", "all"]
|
||||
provides-extras = ["sandbox", "mcp", "parallel-search", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "dakera", "auth", "autonomy", "search", "crawl", "a2ui", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "primp"
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.8"
|
||||
__version__ = "0.1.9"
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-browser"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
description = "Browser automation for PraisonAI — Chrome extension bridge, CDP, and Playwright agents extracted from the praisonai wrapper."
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
Generated
+5
-4
@@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
revision = 5
|
||||
requires-python = ">=3.10, <3.15"
|
||||
|
||||
[[package]]
|
||||
@@ -932,7 +932,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonai-browser"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -976,7 +976,7 @@ provides-extras = ["server", "playwright", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.7.9"
|
||||
version = "1.7.10"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -1009,6 +1009,7 @@ requires-dist = [
|
||||
{ name = "litellm", marker = "extra == 'memory'", specifier = ">=1.83.14" },
|
||||
{ name = "markitdown", extras = ["all"], marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
|
||||
{ name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.20.0" },
|
||||
{ name = "mcp", marker = "extra == 'parallel-search'", specifier = ">=1.30.0,<2" },
|
||||
{ name = "mem0ai", marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
|
||||
{ name = "mem0ai", extras = ["graph"], marker = "extra == 'graph'", specifier = ">=0.1.0" },
|
||||
{ name = "motor", marker = "extra == 'mongodb'", specifier = ">=3.4.0" },
|
||||
@@ -1047,7 +1048,7 @@ requires-dist = [
|
||||
{ name = "uvicorn", marker = "extra == 'os'", specifier = ">=0.34.0" },
|
||||
{ name = "websockets", marker = "extra == 'mcp'", specifier = ">=12.0" },
|
||||
]
|
||||
provides-extras = ["sandbox", "mcp", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "dakera", "auth", "autonomy", "search", "crawl", "a2ui", "all"]
|
||||
provides-extras = ["sandbox", "mcp", "parallel-search", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "dakera", "auth", "autonomy", "search", "crawl", "a2ui", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "propcache"
|
||||
|
||||
@@ -12,6 +12,6 @@ the remaining terminal-agent modules, with PEP 562 shims left behind at the
|
||||
old ``praisonai.*`` paths for backward compatibility.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.8"
|
||||
__version__ = "0.1.9"
|
||||
|
||||
__all__ = ["__version__"]
|
||||
@@ -20,6 +20,10 @@ app = typer.Typer(help="Run agents")
|
||||
_FRAMEWORK_HELP = "Framework: praisonai, crewai, autogen"
|
||||
|
||||
_ALLOW_LOCAL_TOOLS_ENV = "PRAISONAI_ALLOW_LOCAL_TOOLS"
|
||||
# Set while a modern ``run <file>.yaml`` is delegating to the legacy
|
||||
# ``PraisonAI`` executor. The legacy ``run`` dispatch branch reads it to avoid
|
||||
# re-invoking this same Typer ``run`` app (which would recurse infinitely).
|
||||
_IN_MODERN_RUN_ENV = "PRAISONAI_IN_MODERN_RUN"
|
||||
DEFAULT_MAX_TOKENS = 16000
|
||||
|
||||
|
||||
@@ -1895,10 +1899,24 @@ def _run_from_file(
|
||||
|
||||
# Note: Credential check already done in run_main() entry point
|
||||
|
||||
# Capture the re-entrancy sentinel's prior value *before* the guarded block
|
||||
# so the ``finally`` restore below can never hit an UnboundLocalError when an
|
||||
# early statement (e.g. the ``PraisonAI`` import) raises before assignment.
|
||||
import os as _os_guard
|
||||
_prev_in_run = _os_guard.environ.get(_IN_MODERN_RUN_ENV)
|
||||
|
||||
try:
|
||||
# Use existing PraisonAI class
|
||||
from praisonai_code.cli.main import PraisonAI
|
||||
|
||||
|
||||
# Re-entrancy guard: the legacy ``PraisonAI.main()`` re-parses ``sys.argv``
|
||||
# and, when it sees ``run <file>.yaml``, dispatches back into *this* modern
|
||||
# Typer ``run`` app. Since we reach here from that same app, an unguarded
|
||||
# delegation loops forever ("maximum recursion depth exceeded"). Setting
|
||||
# this sentinel tells the legacy ``run`` branch we are already inside a
|
||||
# modern run so it executes the YAML directly instead of bouncing back.
|
||||
_os_guard.environ[_IN_MODERN_RUN_ENV] = "1"
|
||||
|
||||
praison = PraisonAI(
|
||||
agent_file=file_path,
|
||||
framework=framework or "praisonai",
|
||||
@@ -2012,6 +2030,14 @@ def _run_from_file(
|
||||
output.emit_error(message=str(e))
|
||||
output.print_error(str(e))
|
||||
raise typer.Exit(1)
|
||||
finally:
|
||||
# Restore the re-entrancy sentinel so suppression never leaks into a
|
||||
# later in-process invocation (embedded/notebook/test reuse).
|
||||
import os as _os_guard
|
||||
if _prev_in_run is None:
|
||||
_os_guard.environ.pop(_IN_MODERN_RUN_ENV, None)
|
||||
else:
|
||||
_os_guard.environ[_IN_MODERN_RUN_ENV] = _prev_in_run
|
||||
|
||||
|
||||
def _run_prompt(
|
||||
|
||||
@@ -1405,16 +1405,39 @@ class PraisonAI:
|
||||
or os.path.exists(first)
|
||||
)
|
||||
)
|
||||
if is_yaml_target:
|
||||
if is_yaml_target and os.environ.get("PRAISONAI_IN_MODERN_RUN"):
|
||||
# We are already *inside* a modern ``run`` invocation
|
||||
# (``commands/run.py`` delegated the YAML to this legacy
|
||||
# executor). Re-dispatching to ``run_app`` would re-enter the
|
||||
# same Typer app and recurse forever ("maximum recursion depth
|
||||
# exceeded", issue #5597). Execute the YAML directly instead:
|
||||
# set it as the agent file and fall through to the normal
|
||||
# legacy execution path below.
|
||||
#
|
||||
# Prefer the constructor-provided ``agent_file`` over the
|
||||
# relative ``sys.argv`` token: the modern runner resolves the
|
||||
# target to an absolute path *before* a ``--worktree`` chdir
|
||||
# and passes it here (``self.agent_file`` is still pristine at
|
||||
# this point), so an untracked/ignored YAML absent from the
|
||||
# fresh worktree still loads. Fall back to ``first`` only when
|
||||
# no explicit path was supplied (default ``agents.yaml``
|
||||
# sentinel from ``__init__``).
|
||||
if self.agent_file and self.agent_file != "agents.yaml":
|
||||
pass # keep the resolved constructor path
|
||||
else:
|
||||
self.agent_file = first
|
||||
args.command = None
|
||||
elif is_yaml_target:
|
||||
# Modern engine (``commands/run.py`` run_main); the first
|
||||
# arg is passed as the Typer ``target`` (YAML file/prompt).
|
||||
from ..commands.run import app as run_app
|
||||
run_app(unknown_args)
|
||||
sys.exit(0)
|
||||
# Run command - async jobs API for long-running tasks
|
||||
from ..features.jobs import handle_run_command
|
||||
handle_run_command(unknown_args, verbose=getattr(args, 'verbose', False))
|
||||
sys.exit(0)
|
||||
else:
|
||||
# Run command - async jobs API for long-running tasks
|
||||
from ..features.jobs import handle_run_command
|
||||
handle_run_command(unknown_args, verbose=getattr(args, 'verbose', False))
|
||||
sys.exit(0)
|
||||
|
||||
elif args.command == 'managed':
|
||||
# Managed agents — delegate to Typer app
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-code"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
description = "Agentic terminal CLI for PraisonAI — the terminal-native agent (run, chat, code, runtime, cli backends) extracted from the praisonai wrapper."
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
@@ -132,5 +132,186 @@ def test_run_submit_wins_over_same_named_path(monkeypatch, tmp_path):
|
||||
assert code == 0
|
||||
|
||||
|
||||
def test_modern_run_delegation_does_not_reenter_run_app(monkeypatch, tmp_path):
|
||||
"""Issue #5597: ``python -m praisonai <file>.yaml`` must not recurse.
|
||||
|
||||
The modern Typer ``run`` command delegates YAML execution to the legacy
|
||||
``PraisonAI`` class. That class re-parses ``sys.argv`` (``run <file>.yaml``)
|
||||
and, before this fix, dispatched the ``run`` command straight back into the
|
||||
modern ``run_app`` — which calls the legacy executor again, looping forever
|
||||
until ``maximum recursion depth exceeded``.
|
||||
|
||||
With the ``PRAISONAI_IN_MODERN_RUN`` re-entrancy sentinel set (as the modern
|
||||
runner does while delegating), the legacy ``run`` branch must execute the
|
||||
YAML directly instead of bouncing back to ``run_app``.
|
||||
"""
|
||||
pa = _load_module()
|
||||
_require_wrapper_argparse()
|
||||
|
||||
yaml_path = tmp_path / "agents.yaml"
|
||||
yaml_path.write_text("framework: praisonai\nroles: {}\n")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
calls = {"run_app": None, "jobs": None}
|
||||
ran = {"direct": False}
|
||||
|
||||
import praisonai_code.cli.commands.run as run_mod
|
||||
import praisonai_code.cli.features.jobs as jobs_mod
|
||||
|
||||
def fake_run_app(args=None, *a, **k):
|
||||
calls["run_app"] = list(args) if args is not None else []
|
||||
|
||||
def fake_handle_run_command(unknown_args, *a, **k):
|
||||
calls["jobs"] = list(unknown_args)
|
||||
|
||||
# Stub the agents generator so the direct YAML execution path is observed
|
||||
# without spinning up a real team / LLM call.
|
||||
class _FakeGenerator:
|
||||
def __init__(self, *a, **k):
|
||||
ran["direct"] = True
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def generate_crew_and_kickoff(self):
|
||||
return "READY"
|
||||
|
||||
monkeypatch.setattr(run_mod, "app", fake_run_app)
|
||||
monkeypatch.setattr(jobs_mod, "handle_run_command", fake_handle_run_command)
|
||||
monkeypatch.setattr(pa, "_get_agents_generator", lambda: _FakeGenerator)
|
||||
monkeypatch.setenv("PRAISONAI_IN_MODERN_RUN", "1")
|
||||
|
||||
import sys as _sys
|
||||
monkeypatch.setattr(_sys, "argv", ["praisonai", "run", str(yaml_path)])
|
||||
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
||||
|
||||
result = pa.PraisonAI(agent_file=str(yaml_path)).main()
|
||||
|
||||
# No re-entry into the modern runner (which caused the infinite recursion),
|
||||
# no mis-routing to jobs, and the YAML was executed directly instead.
|
||||
assert calls["run_app"] is None
|
||||
assert calls["jobs"] is None
|
||||
assert ran["direct"] is True
|
||||
assert result == "READY"
|
||||
|
||||
|
||||
def test_modern_run_delegation_preserves_resolved_agent_file(monkeypatch, tmp_path):
|
||||
"""Greptile #5606: the delegated legacy ``run`` branch must keep the
|
||||
constructor-provided (resolved, possibly absolute) ``agent_file`` instead of
|
||||
overwriting it with the relative ``sys.argv`` token.
|
||||
|
||||
For ``run <file>.yaml --worktree`` the modern runner resolves the target to
|
||||
an absolute path *before* chdir'ing into the isolated worktree, then passes
|
||||
that path to ``PraisonAI(agent_file=...)``. If the legacy branch replaced it
|
||||
with the relative CLI arg, an untracked/ignored YAML (absent from the fresh
|
||||
worktree) would fail to load. This asserts the resolved path survives.
|
||||
"""
|
||||
pa = _load_module()
|
||||
_require_wrapper_argparse()
|
||||
|
||||
yaml_path = tmp_path / "agents.yaml"
|
||||
yaml_path.write_text("framework: praisonai\nroles: {}\n")
|
||||
resolved = str(yaml_path) # absolute path, as the worktree runner passes
|
||||
|
||||
seen = {"agent_file": None}
|
||||
|
||||
class _FakeGenerator:
|
||||
def __init__(self, agent_file, *a, **k):
|
||||
seen["agent_file"] = agent_file
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def generate_crew_and_kickoff(self):
|
||||
return "READY"
|
||||
|
||||
monkeypatch.setattr(pa, "_get_agents_generator", lambda: _FakeGenerator)
|
||||
monkeypatch.setenv("PRAISONAI_IN_MODERN_RUN", "1")
|
||||
|
||||
import sys as _sys
|
||||
|
||||
# sys.argv carries the *relative* CLI token; a bare "agents.yaml" that is not
|
||||
# present in the (simulated) new cwd. The resolved absolute path handed to the
|
||||
# constructor must win.
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(_sys, "argv", ["praisonai", "run", "agents.yaml"])
|
||||
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
||||
|
||||
result = pa.PraisonAI(agent_file=resolved).main()
|
||||
|
||||
assert seen["agent_file"] == resolved
|
||||
assert result == "READY"
|
||||
|
||||
|
||||
def _run_from_file_with_broken_import(monkeypatch, tmp_path):
|
||||
"""Drive ``_run_from_file`` with the ``PraisonAI`` import forced to fail.
|
||||
|
||||
Returns ``exc_info.value.code`` so callers can assert the deliberate
|
||||
``typer.Exit(1)`` propagates instead of an ``UnboundLocalError`` leaking from
|
||||
the ``finally`` sentinel restore.
|
||||
"""
|
||||
import typer
|
||||
import praisonai_code.cli.commands.run as run_mod
|
||||
|
||||
yaml_path = tmp_path / "agents.yaml"
|
||||
yaml_path.write_text("framework: praisonai\nroles: {}\n")
|
||||
|
||||
# Force the guarded ``from praisonai_code.cli.main import PraisonAI`` to raise
|
||||
# before ``_prev_in_run`` would historically have been assigned.
|
||||
import builtins
|
||||
|
||||
real_import = builtins.__import__
|
||||
|
||||
def _boom(name, *args, **kwargs):
|
||||
if name == "praisonai_code.cli.main":
|
||||
raise ImportError("boom: simulated main import failure")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", _boom)
|
||||
|
||||
with pytest.raises(typer.Exit) as exc_info:
|
||||
run_mod._run_from_file(str(yaml_path))
|
||||
return exc_info.value.exit_code
|
||||
|
||||
|
||||
def test_run_from_file_import_failure_exits_one_no_prior_sentinel(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
"""Issue #5632: a failed ``PraisonAI`` import must surface ``typer.Exit(1)``
|
||||
(not ``UnboundLocalError`` from the ``finally`` sentinel restore), and must
|
||||
leave an *absent* sentinel absent."""
|
||||
monkeypatch.delenv("PRAISONAI_IN_MODERN_RUN", raising=False)
|
||||
|
||||
import os
|
||||
|
||||
code = _run_from_file_with_broken_import(monkeypatch, tmp_path)
|
||||
|
||||
assert code == 1
|
||||
# The previously-absent sentinel is restored to absent, not left set to "1".
|
||||
assert "PRAISONAI_IN_MODERN_RUN" not in os.environ
|
||||
|
||||
|
||||
def test_run_from_file_import_failure_exits_one_preserves_sentinel(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
"""Issue #5632: when a sentinel already exists, a failed ``PraisonAI`` import
|
||||
must still exit 1 and leave the prior sentinel value unchanged."""
|
||||
monkeypatch.setenv("PRAISONAI_IN_MODERN_RUN", "preexisting")
|
||||
|
||||
import os
|
||||
|
||||
code = _run_from_file_with_broken_import(monkeypatch, tmp_path)
|
||||
|
||||
assert code == 1
|
||||
# The existing sentinel is restored to its original value, not overwritten.
|
||||
assert os.environ.get("PRAISONAI_IN_MODERN_RUN") == "preexisting"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
Generated
+5
-4
@@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
revision = 5
|
||||
requires-python = ">=3.10, <3.15"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.14' and sys_platform == 'win32'",
|
||||
@@ -1370,7 +1370,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonai-code"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -1403,7 +1403,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.7.9"
|
||||
version = "1.7.10"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -1436,6 +1436,7 @@ requires-dist = [
|
||||
{ name = "litellm", marker = "extra == 'memory'", specifier = ">=1.83.14" },
|
||||
{ name = "markitdown", extras = ["all"], marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
|
||||
{ name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.20.0" },
|
||||
{ name = "mcp", marker = "extra == 'parallel-search'", specifier = ">=1.30.0,<2" },
|
||||
{ name = "mem0ai", marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
|
||||
{ name = "mem0ai", extras = ["graph"], marker = "extra == 'graph'", specifier = ">=0.1.0" },
|
||||
{ name = "motor", marker = "extra == 'mongodb'", specifier = ">=3.4.0" },
|
||||
@@ -1474,7 +1475,7 @@ requires-dist = [
|
||||
{ name = "uvicorn", marker = "extra == 'os'", specifier = ">=0.34.0" },
|
||||
{ name = "websockets", marker = "extra == 'mcp'", specifier = ">=12.0" },
|
||||
]
|
||||
provides-extras = ["sandbox", "mcp", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "dakera", "auth", "autonomy", "search", "crawl", "a2ui", "all"]
|
||||
provides-extras = ["sandbox", "mcp", "parallel-search", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "dakera", "auth", "autonomy", "search", "crawl", "a2ui", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "propcache"
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.8"
|
||||
__version__ = "0.1.9"
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-deploy"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
description = "Deployment for PraisonAI — API, Docker, and cloud (AWS, Azure, GCP) deployments extracted from the praisonai wrapper."
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
Generated
+5
-4
@@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
revision = 5
|
||||
requires-python = ">=3.10, <3.15"
|
||||
|
||||
[[package]]
|
||||
@@ -977,7 +977,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonai-deploy"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -1014,7 +1014,7 @@ provides-extras = ["api", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.7.9"
|
||||
version = "1.7.10"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -1047,6 +1047,7 @@ requires-dist = [
|
||||
{ name = "litellm", marker = "extra == 'memory'", specifier = ">=1.83.14" },
|
||||
{ name = "markitdown", extras = ["all"], marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
|
||||
{ name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.20.0" },
|
||||
{ name = "mcp", marker = "extra == 'parallel-search'", specifier = ">=1.30.0,<2" },
|
||||
{ name = "mem0ai", marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
|
||||
{ name = "mem0ai", extras = ["graph"], marker = "extra == 'graph'", specifier = ">=0.1.0" },
|
||||
{ name = "motor", marker = "extra == 'mongodb'", specifier = ">=3.4.0" },
|
||||
@@ -1085,7 +1086,7 @@ requires-dist = [
|
||||
{ name = "uvicorn", marker = "extra == 'os'", specifier = ">=0.34.0" },
|
||||
{ name = "websockets", marker = "extra == 'mcp'", specifier = ">=12.0" },
|
||||
]
|
||||
provides-extras = ["sandbox", "mcp", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "dakera", "auth", "autonomy", "search", "crawl", "a2ui", "all"]
|
||||
provides-extras = ["sandbox", "mcp", "parallel-search", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "dakera", "auth", "autonomy", "search", "crawl", "a2ui", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "propcache"
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.10"
|
||||
__version__ = "0.1.11"
|
||||
@@ -23,16 +23,28 @@ def register_memory_tools() -> None:
|
||||
|
||||
@register_tool("praisonai.memory.show")
|
||||
def memory_show(user_id: Optional[str] = None) -> str:
|
||||
"""Show all stored memories (optionally filtered by user)."""
|
||||
"""Show stored memories; SQLite displays up to 1000 per tier."""
|
||||
try:
|
||||
from praisonaiagents.memory import Memory
|
||||
|
||||
memory = Memory()
|
||||
data = memory.get_all_memories()
|
||||
if user_id:
|
||||
adapter = getattr(memory, "memory_adapter", None)
|
||||
if getattr(memory, "provider", None) == "sqlite" and adapter is not None:
|
||||
# Display retains its previous bounded window. Full enumeration
|
||||
# is a separate API and must not load the whole store here.
|
||||
data = []
|
||||
for tier in ("short", "long"):
|
||||
for record in getattr(adapter, f"search_{tier}_term")("", limit=1000, user_id=user_id):
|
||||
data.append({
|
||||
**record, "memory_type": f"{tier}_term", "type": f"{tier}_term",
|
||||
})
|
||||
else:
|
||||
data = memory.get_all_memories()
|
||||
if user_id is not None:
|
||||
data = [
|
||||
m for m in data
|
||||
if str((m.get("metadata") or {}).get("user_id")) == str(user_id)
|
||||
if (m.get("metadata") or {}).get("user_id") is not None
|
||||
and str((m.get("metadata") or {}).get("user_id")) == str(user_id)
|
||||
]
|
||||
return str(data)
|
||||
except ImportError:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-mcp"
|
||||
version = "0.1.10"
|
||||
version = "0.1.11"
|
||||
description = "MCP server hosting for PraisonAI — expose agents and tools to Cursor, Claude Desktop, and other MCP clients."
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
@@ -9,6 +9,7 @@ bind against the real core API and assert no ``Error:`` fallback.
|
||||
|
||||
import sys
|
||||
import types
|
||||
import ast
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -72,6 +73,27 @@ def test_memory_show_calls_real_api(fake_memory, memory_tools):
|
||||
assert "hello" in out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("user_id,expected", [
|
||||
("", ["empty"]),
|
||||
("None", ["literal-none"]),
|
||||
("u1", ["named"]),
|
||||
(None, ["missing", "null", "empty", "literal-none", "named"]),
|
||||
])
|
||||
def test_memory_show_distinguishes_missing_and_explicit_user_ids(fake_memory, memory_tools, monkeypatch, user_id, expected):
|
||||
import praisonaiagents.memory as module
|
||||
|
||||
records = [
|
||||
{"id": "missing", "metadata": {}},
|
||||
{"id": "null", "metadata": {"user_id": None}},
|
||||
{"id": "empty", "metadata": {"user_id": ""}},
|
||||
{"id": "literal-none", "metadata": {"user_id": "None"}},
|
||||
{"id": "named", "metadata": {"user_id": "u1"}},
|
||||
]
|
||||
monkeypatch.setattr(module.Memory, "get_all_memories", lambda self: records)
|
||||
actual = ast.literal_eval(_call(memory_tools, "praisonai.memory.show", user_id=user_id))
|
||||
assert [record["id"] for record in actual] == expected
|
||||
|
||||
|
||||
def test_memory_add_calls_store_short_term(fake_memory, memory_tools):
|
||||
out = _call(memory_tools, "praisonai.memory.add", content="note")
|
||||
assert not out.startswith("Error:")
|
||||
@@ -88,3 +110,40 @@ def test_memory_clear_calls_reset_all(fake_memory, memory_tools):
|
||||
out = _call(memory_tools, "praisonai.memory.clear")
|
||||
assert not out.startswith("Error:")
|
||||
assert fake_memory["reset"] == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("user_id,expected", [(None, 2000), ("even", 1000), ("old-user", 2), ("absent", 0)])
|
||||
def test_sqlite_show_bounds_fetches_without_capping_full_enumeration(tmp_path, monkeypatch, memory_tools, user_id, expected):
|
||||
import praisonaiagents.memory as module
|
||||
|
||||
monkeypatch.setenv("PRAISONAI_HOME", str(tmp_path / "home"))
|
||||
monkeypatch.chdir(tmp_path)
|
||||
memory = module.Memory(config={"provider": "sqlite"})
|
||||
adapter = memory.memory_adapter
|
||||
queries = []
|
||||
try:
|
||||
for tier in ("short", "long"):
|
||||
for index in range(1001):
|
||||
getattr(memory, f"store_{tier}_term")(
|
||||
f"{tier} entry {index}", metadata={"user_id": "old-user" if index == 0 else "even" if index % 2 == 0 else "odd"},
|
||||
)
|
||||
# Distinct valid timestamps make the displayed latest-1000 window
|
||||
# deterministic; SQLite's second-resolution default can tie here.
|
||||
for tier, conn in (("short", adapter._get_stm_conn()), ("long", adapter._get_ltm_conn())):
|
||||
conn.execute(f"UPDATE {tier}_term_memory SET timestamp = datetime(1700000000 + id, 'unixepoch')")
|
||||
conn.commit()
|
||||
adapter._get_stm_conn().set_trace_callback(queries.append)
|
||||
adapter._get_ltm_conn().set_trace_callback(queries.append)
|
||||
monkeypatch.setattr(module, "Memory", lambda: memory)
|
||||
records = ast.literal_eval(_call(memory_tools, "praisonai.memory.show", user_id=user_id))
|
||||
assert len(records) == expected
|
||||
selects = [query for query in queries if query.startswith("SELECT id, content")]
|
||||
assert len(selects) == 2
|
||||
assert all("LIMIT 1000" in query for query in selects)
|
||||
if user_id:
|
||||
assert all(record["metadata"]["user_id"] == user_id for record in records)
|
||||
assert {record["type"] for record in records} == ({"short_term", "long_term"} if expected else set())
|
||||
assert len(memory.get_all_memories()) == 2002
|
||||
finally:
|
||||
adapter.close_connections()
|
||||
memory.close_connections()
|
||||
Generated
+5
-4
@@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
revision = 5
|
||||
requires-python = ">=3.10, <3.15"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.14' and sys_platform == 'win32'",
|
||||
@@ -1044,7 +1044,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonai-mcp"
|
||||
version = "0.1.10"
|
||||
version = "0.1.11"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -1084,7 +1084,7 @@ provides-extras = ["server", "auth", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.7.9"
|
||||
version = "1.7.10"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -1117,6 +1117,7 @@ requires-dist = [
|
||||
{ name = "litellm", marker = "extra == 'memory'", specifier = ">=1.83.14" },
|
||||
{ name = "markitdown", extras = ["all"], marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
|
||||
{ name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.20.0" },
|
||||
{ name = "mcp", marker = "extra == 'parallel-search'", specifier = ">=1.30.0,<2" },
|
||||
{ name = "mem0ai", marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
|
||||
{ name = "mem0ai", extras = ["graph"], marker = "extra == 'graph'", specifier = ">=0.1.0" },
|
||||
{ name = "motor", marker = "extra == 'mongodb'", specifier = ">=3.4.0" },
|
||||
@@ -1155,7 +1156,7 @@ requires-dist = [
|
||||
{ name = "uvicorn", marker = "extra == 'os'", specifier = ">=0.34.0" },
|
||||
{ name = "websockets", marker = "extra == 'mcp'", specifier = ">=12.0" },
|
||||
]
|
||||
provides-extras = ["sandbox", "mcp", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "dakera", "auth", "autonomy", "search", "crawl", "a2ui", "all"]
|
||||
provides-extras = ["sandbox", "mcp", "parallel-search", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "dakera", "auth", "autonomy", "search", "crawl", "a2ui", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "propcache"
|
||||
|
||||
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