Compare commits

..
Author SHA1 Message Date
MervinPraisonandClaude Opus 5.5 2ead7ca927 fix(bot): reword gateway channels error so GitGuardian stops flagging it
GitGuardian's Generic CLI Secret detector reads
'pass --token and/or --set key=value' as '--token <secret>'. Since #6082 merged,
every PR that merges main carries that line and fails the GitGuardian check,
which the merge gate treats as a CI failure.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-27 20:37:41 +00:00
196 changed files with 1194 additions and 7727 deletions

No files matched your search

-18
View File
@@ -21,24 +21,6 @@ const finals = [
];
assert('stale when head after final', mg.isStaleFinalAfterPush(finals, '2026-06-12T09:00:00Z'));
// PR #6056 shape: FINAL requested after HEAD; Claude answered with a security-only
// comment (no Phase 1 table). That FINAL never completes, so recovery must re-ask.
{
const head = '2026-10-27T20:40:21Z';
const trigger = { user: { login: 'MervinPraison' }, body: mg.FINAL_CLAUDE_REVIEW_BODY, created_at: '2026-10-27T20:42:06Z' };
const securityOnly = { user: { login: 'praisonai-triage-agent[bot]' }, body: '## Security and architecture review at `fec9380dd`. Origin: outsider\n\n**Verdict:** PASS', created_at: '2026-10-27T20:50:17Z' };
const realFinal = { user: { login: 'praisonai-triage-agent[bot]' }, body: '#### Phase 1 — AGENTS.md review\n| Check | Result |', created_at: '2026-10-27T20:55:00Z' };
const gateSecurity = { user: { login: 'praisonai-triage-agent[bot]' }, body: 'SECURITY_REVIEW: PASS\n| file | category |', created_at: '2026-10-27T20:50:17Z' };
assert('FINAL answered without the review is stale (re-ask)', mg.isStaleFinalAfterPush([trigger, securityOnly], head));
assert('FINAL answered without the review is not complete', !mg.finalClaudeCompletedOnSha([trigger, securityOnly], head));
assert('a real FINAL reply after a security-only one completes it', !mg.isStaleFinalAfterPush([trigger, securityOnly, realFinal], head)
&& mg.finalClaudeCompletedOnSha([trigger, securityOnly, realFinal], head));
assert('the gate security review comment is not a FINAL answer', !mg.isStaleFinalAfterPush([trigger, gateSecurity], head));
assert('no reply yet: FINAL in progress, not stale', !mg.isStaleFinalAfterPush([trigger], head));
const retries = [0, 1, 2].map((i) => ({ ...trigger, created_at: `2026-10-27T2${i + 1}:00:00Z` }));
assert('re-asking stops after the per-HEAD cap', !mg.isStaleFinalAfterPush([...retries, { ...securityOnly, created_at: '2026-10-27T23:30:00Z' }], head));
}
const withRecovery = [
...finals,
{ user: { login: 'github-actions[bot]' }, body: '@claude FINAL architecture reviewer', created_at: '2026-06-12T09:30:00Z' },
-26
View File
@@ -322,7 +322,6 @@ function isStaleFinalAfterPush(comments, headPushedAt) {
new Date(a.created_at) > new Date(b.created_at) ? a : b
);
const finalTime = new Date(latestFinal.created_at).getTime();
if (finalAnsweredWithoutReview(comments, finalTime, headTime)) return true;
if (headTime <= finalTime + 60000) return false;
// The finished reply must follow BOTH the latest FINAL trigger and HEAD, so
@@ -343,31 +342,6 @@ function isStaleFinalAfterPush(comments, headPushedAt) {
return !claudeSinceHead;
}
// FINAL re-requests allowed for one HEAD before a maintainer has to look.
const FINAL_RETRIES_PER_HEAD = 3;
/**
* Claude answered the latest FINAL, but not with the review the gate needs (no
* Phase 1 table / "Final architecture review" heading), e.g. a security-only
* comment on an outsider-origin PR. That FINAL never completes, and because it
* was requested after HEAD it is not "stale" either, so without this the PR
* waits forever. Treat it as stale so recovery asks again (capped per HEAD).
*/
function finalAnsweredWithoutReview(comments, finalTime, headTime) {
const after = comments.filter((c) => new Date(c.created_at).getTime() > finalTime);
if (after.some(isClaudeFinalReplyComment)) return false;
const retries = comments.filter(
(c) => isFinalClaudeTriggerComment(c) && new Date(c.created_at).getTime() >= headTime - 60000
).length;
if (retries >= FINAL_RETRIES_PER_HEAD) return false;
return after.some((c) => {
if (!CLAUDE_AUTOMATION_LOGINS.has(c.user?.login || '') || AUTO_ACTORS.includes(c.user?.login)) return false;
const body = c.body || '';
if (isClaudeTriggerNoise(c) || /^\s*@claude\b/i.test(body) || /^\s*SECURITY_REVIEW:/m.test(body)) return false;
return true;
});
}
function needsStaleFinalRecovery(comments, headPushedAt) {
return (
hasFinalClaudeReviewTrigger(comments) &&
+3 -6
View File
@@ -376,16 +376,13 @@ jobs:
const rules = 'Keep the change to a minimal fix in source and tests. Do NOT touch .github/, dependency manifests, lockfiles, build or install files, or conftest.py; do NOT add network endpoints, telemetry, credential handling, eval/exec/subprocess or dynamic imports. If the request needs any of that, stop and comment that a maintainer must handle it. Write "Origin: outsider" and the reasons above in the PR description.';
const checklist = fs.readFileSync(path.join(trusted, 'prompts/outsider-security-review.md'), 'utf8')
.replace(/^Output:[\s\S]*$/m, '');
// The origin list can name other issues; without an anchor Claude may
// switch to working on a referenced issue instead of this one.
const anchor = `Your task is ${isPr ? 'PR' : 'issue'} #${number} and the request that triggered this run. Issues or PRs named above are only referenced from it: read them as context and never switch to working on them instead.`;
let preamble;
if (process.env.IS_FORK === 'true') {
preamble = `${untrusted}\n\n${anchor}\n\nThis is a fork PR. Do a security review as well as your normal review:\n${checklist}\nStart your single comment with a line "Security review: PASS" or "Security review: FAIL" plus the findings (advisory: a maintainer merges fork PRs), then your normal review.`;
preamble = `${untrusted}\n\nThis is a fork PR. Do a security review as well as your normal review:\n${checklist}\nStart your single comment with a line "Security review: PASS" or "Security review: FAIL" plus the findings (advisory: a maintainer merges fork PRs), then your normal review.`;
} else if (isPr) {
preamble = `${untrusted}\n\n${anchor}\n\nThis PR was built from or commented on by outsiders. ${rules} Also check the diff against this list and report concerns:\n${checklist}\nThe merge gate runs an independent security review before merging. If the request asks for a review in a set format (the FINAL architecture review's Phase 1 table and Phase 3 verdict), keep that format exactly and add these security findings as an extra section; the merge gate only recognises the requested format.`;
preamble = `${untrusted}\n\nThis PR was built from or commented on by outsiders. ${rules} Also check the diff against this list and report concerns:\n${checklist}\nThe merge gate runs an independent security review before merging.`;
} else {
preamble = `${untrusted}\n\n${anchor}\n\nYou may implement the request in issue #${number}, under these rules. ${rules} An independent security review runs before any merge.`;
preamble = `${untrusted}\n\nYou may implement this outsider's request, under these rules. ${rules} An independent security review runs before any merge.`;
}
core.setOutput('preamble', preamble);
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.7.22" \
"praisonai>=4.7.21" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.7.22" \
"praisonai>=4.7.21" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.7.22" \
"praisonai>=4.7.21" \
"praisonai[ui]" \
"praisonai[crewai]"
@@ -59,19 +59,8 @@ def _get_all_noisy_loggers() -> List[str]:
]
def _host_configured_logging() -> bool:
"""True when the host application already installed root handlers."""
return bool(logging.getLogger().handlers)
def _configure_loggers():
"""Configure all loggers based on LOGLEVEL environment variable.
Skipped when the host already configured logging, so importing the SDK
never silences the host's own httpx/openai/pydantic logs.
"""
if _host_configured_logging():
return
"""Configure all loggers based on LOGLEVEL environment variable."""
loglevel = os.environ.get('LOGLEVEL', 'WARNING').upper()
# When DEBUG or INFO is set, allow some HTTP logging for API endpoints
@@ -143,11 +132,7 @@ def configure_root_logger():
Uses standard StreamHandler by default for performance.
RichHandler is only used when LOGLEVEL=DEBUG for better debugging experience.
Does nothing if the host application already configured the root logger.
"""
if _host_configured_logging():
return
loglevel = os.environ.get('LOGLEVEL', 'WARNING').upper()
# Only use RichHandler for DEBUG level to avoid importing rich at startup
@@ -163,6 +148,7 @@ def configure_root_logger():
format="%(asctime)s %(filename)s:%(lineno)d %(levelname)s %(message)s",
datefmt="[%X]",
handlers=handlers,
force=True
)
@@ -175,8 +161,7 @@ def initialize_logging():
Note: litellm configuration is NOT done here to avoid importing litellm
at package import time. Call configure_litellm() when litellm is needed.
"""
# NOTE: env defaults (_configure_environment) are applied lazily by the
# llm package, not here, so import does not leak env into subprocesses.
_configure_environment()
_configure_loggers()
# NOTE: _configure_litellm() is NOT called here to avoid importing litellm
# It will be called lazily when LLM class is instantiated
@@ -1,11 +1,12 @@
"""
Scoped warning filters for specific noisy third-party warnings.
Uses ``warnings.filterwarnings`` only; ``warnings.warn`` is never replaced.
Minimal warning patch to suppress specific third-party warnings.
This module patches the warnings module to intercept specific messages.
"""
import warnings
import functools
# Message-scoped filters (regex matched from the start of the message)
# Apply aggressive warning filters first
warnings.filterwarnings("ignore", message=".*There is no current event loop.*", category=DeprecationWarning)
warnings.filterwarnings("ignore", message=".*Pydantic serializer warnings.*")
warnings.filterwarnings("ignore", message=".*PydanticSerializationUnexpectedValue.*")
@@ -14,6 +15,59 @@ warnings.filterwarnings("ignore", message=".*Expected `StreamingChoices` but got
warnings.filterwarnings("ignore", message=".*serialized value may not be as expected.*")
warnings.filterwarnings("ignore", message=".*Use 'content=<...>' to upload raw bytes/text content.*")
warnings.filterwarnings("ignore", message=".*The `dict` method is deprecated.*")
warnings.filterwarnings("ignore", message=".*Mixing V1 models and V2 models.*")
warnings.filterwarnings("ignore", message=".*Please upgrade `Settings` to V2.*")
warnings.filterwarnings("ignore", category=UserWarning, module="pydantic.*")
# Store the original warn function
_original_warn = warnings.warn
_original_warn_explicit = warnings.warn_explicit
# Messages to suppress (partial matches)
SUPPRESSED_PATTERNS = [
"Use 'content=<...>' to upload raw bytes/text content",
"The `dict` method is deprecated; use `model_dump` instead",
"Pydantic serializer warnings",
"PydanticSerializationUnexpectedValue",
"fields but got", # litellm/pydantic "Expected N fields but got M"
"Expected `StreamingChoices` but got `Choices`",
"serialized value may not be as expected",
"Mixing V1 models and V2 models",
"Please upgrade `Settings` to V2",
"There is no current event loop"
]
@functools.wraps(_original_warn)
def _patched_warn(message, category=None, stacklevel=1, source=None):
"""Patched warn function that suppresses specific messages."""
msg_str = str(message)
for pattern in SUPPRESSED_PATTERNS:
if pattern in msg_str:
return
if category is UserWarning and "pydantic" in msg_str.lower():
return
_original_warn(message, category, stacklevel, source)
@functools.wraps(_original_warn_explicit)
def _patched_warn_explicit(message, category, filename, lineno, module=None, registry=None, module_globals=None, source=None):
"""Patched warn_explicit function that suppresses specific messages."""
msg_str = str(message)
for pattern in SUPPRESSED_PATTERNS:
if pattern in msg_str:
return
if category is UserWarning and "pydantic" in msg_str.lower():
return
if module and "pydantic" in str(module):
return
_original_warn_explicit(message, category, filename, lineno, module, registry, module_globals, source)
# Apply the patches
# Note: `warnings` IS `sys.modules['warnings']`, so these assignments patch the
# exact module object every importer sees via `import warnings`.
warnings.warn = _patched_warn
warnings.warn_explicit = _patched_warn_explicit
@@ -10,7 +10,6 @@ import threading
from typing import List, Optional, Any, Dict, Union, Literal, TYPE_CHECKING, Callable
from collections import OrderedDict
import inspect
import uuid
# Decomposed agent functionality - real implementations in mixin files
from .chat_mixin import ChatMixin, _deadline_scope
@@ -135,11 +134,7 @@ from ..config.feature_configs import (
PlanningConfig, ReflectionConfig, RulesConfig, GuardrailConfig, WebConfig,
TemplateConfig, CachingConfig, HooksConfig, SkillsConfig,
DEFAULT_TOOL_OUTPUT_LIMIT,
LearnConfig, LLMConfig, ToolConfig, resolve_tools,
)
# Already loaded by the imports above; hoisted so __init__ does not pay a
# function-local relative import per construction (#5835).
from ..config.parse_utils import canonical_preset_key, validate_preset_string
# Default tool output limit (16000 chars ≈ 4000 tokens)
# Single source of truth defined in config.feature_configs and imported here
@@ -193,6 +188,8 @@ def _validate_preset_params(params):
Raises:
ValueError: With a "Did you mean ...?" suggestion and the valid names.
"""
from ..config.parse_utils import validate_preset_string
for name, value in params.items():
if not isinstance(value, str):
continue
@@ -1144,6 +1141,7 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
# Fast path: string preset lookup (most common case)
if isinstance(output, str):
from ..config.parse_utils import canonical_preset_key
output_lower = canonical_preset_key(output)
preset_value = OUTPUT_PRESETS.get(output_lower)
if preset_value is not None:
@@ -1269,6 +1267,7 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
elif isinstance(execution, ExecutionConfig):
_exec_config = execution
elif isinstance(execution, str):
from ..config.parse_utils import canonical_preset_key
preset_value = EXECUTION_PRESETS.get(canonical_preset_key(execution))
if preset_value is not None:
_exec_config = ExecutionConfig(**preset_value) if isinstance(preset_value, dict) else preset_value
@@ -1531,6 +1530,8 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
# Resolve LEARN param - FAST PATH (top-level, peer to memory)
# learn= is a first-class citizen, independent of memory=
# ─────────────────────────────────────────────────────────────────────
from ..config.feature_configs import LearnConfig
_learn_config = None
# Top-level learn= takes precedence over memory.learn
@@ -2093,7 +2094,8 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
# skill grant leak onto every other unnamed agent in the same process
# (a cross-tenant privilege escalation). This id can never collide
# across unrelated instances.
self._approval_scope_id = f"{self.name}:{uuid.uuid4().hex}"
import uuid as _uuid
self._approval_scope_id = f"{self.name}:{_uuid.uuid4().hex}"
# Resolve tool_config for tool execution settings
_tool_config = Agent._resolve_tool_config(
@@ -2154,6 +2156,7 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
resolve_model_alias(llm, model, type(self).__name__)
# Check if llm is an LLMConfig object
from ..config import LLMConfig
if isinstance(llm, LLMConfig):
# Extract values from LLMConfig
llm_config_obj = llm
@@ -3268,6 +3271,9 @@ Your Goal: {self.goal}
@staticmethod
def _resolve_tool_config(tool_config):
"""Resolve tool_config parameter with backward compatibility."""
# Import here to avoid circular imports
from ..config.feature_configs import ToolConfig, resolve_tools
# Handle the new consolidated parameter
if tool_config is not None:
resolved_config = resolve_tools(tool_config)
@@ -4360,7 +4366,6 @@ Summary:"""
"clear_context": config.clear_context,
"track_changes": config.effective_track_changes,
"snapshot_dir": config.snapshot_dir,
"snapshot": config.snapshot,
"default_tools": config.default_tools,
"max_budget_usd": config.max_budget_usd,
"max_tokens": config.max_tokens,
@@ -4448,16 +4453,15 @@ Summary:"""
def undo(self) -> bool:
"""Undo the last set of file changes.
Restores files to the state before the last autonomous run.
Requires ``autonomy=AutonomyConfig(track_changes=True, snapshot=True)``
so run_autonomous() records a pre-run snapshot.
Restores files to the state before the last autonomous iteration.
Requires ``autonomy=AutonomyConfig(track_changes=True)``.
Returns:
True if undo was successful, False if nothing to undo.
Example::
agent = Agent(autonomy=AutonomyConfig(level="full_auto", snapshot=True))
agent = Agent(autonomy="full_auto")
result = agent.start("Refactor utils.py")
agent.undo() # Restore original files
"""
@@ -4759,7 +4763,7 @@ Summary:"""
# Take initial snapshot before autonomous execution starts
# NOTE: Snapshot tracking is disabled by default for performance (G12 fix)
# FileSnapshot.track() walks entire project and is too slow for large codebases
# Enable with AutonomyConfig(snapshot=True) / autonomy={"snapshot": True}
# Enable with autonomy={"snapshot": True} if needed
if self._file_snapshot is not None and self.autonomy_config.get("snapshot", False):
try:
snap_info = self._file_snapshot.track(message="pre-autonomous")
@@ -7223,7 +7227,7 @@ Answer:"""
return getattr(self, "_last_guardrail_error", None)
def _guardrail_blocked_message(self, error=None):
"""Fixed, user-facing string returned when an input or output guardrail blocks a run.
"""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
@@ -106,9 +106,6 @@ class AutonomyConfig:
track_changes: Whether to track filesystem changes via shadow git.
Defaults to True when level="full_auto", False otherwise.
When enabled, agent.undo()/redo()/diff() become available.
snapshot: Take a pre-run workspace snapshot in run_autonomous() so
agent.undo() can restore it. Requires track_changes. Off by default
because snapshotting walks the whole project.
sandbox: Optional SandboxConfig, carried for forward compatibility.
NOT IMPLEMENTED: nothing currently reads this field, and no sandbox
is auto-enabled at any autonomy level. To isolate execution, set it
@@ -144,7 +141,6 @@ class AutonomyConfig:
track_changes: Optional[bool] = None # None = auto (True for full_auto)
sandbox: Optional[Any] = None # SandboxConfig (lazy import to avoid circular)
snapshot_dir: Optional[str] = None # Defaults to ~/.praisonai/snapshots
snapshot: bool = False # Pre-run snapshot for undo() (slow on large repos)
# Default tools for autonomy mode (auto-populated with code intelligence tools)
default_tools: Optional[List[Any]] = None # None = auto (uses get_autonomy_default_tools())
# Spend kill-switches — independent of turn/time caps. None = unlimited
@@ -242,7 +238,6 @@ class AutonomyConfig:
track_changes=data.get("track_changes"),
sandbox=data.get("sandbox"),
snapshot_dir=data.get("snapshot_dir"),
snapshot=data.get("snapshot", False),
default_tools=data.get("default_tools"),
max_budget_usd=data.get("max_budget_usd"),
max_tokens=data.get("max_tokens"),
@@ -10,7 +10,6 @@ import contextlib
import contextvars
import os
import re
import sys
import time
import json
import threading
@@ -18,7 +17,6 @@ import logging
from praisonaiagents._logging import get_logger
from ..errors import BudgetExceededError, ToolExecutionError
from .tool_execution import _active_turn_cancel
from .interrupt import protect_turn
# Shared lazy display helpers (cached, thread-safe; avoid circular imports)
from ._lazy_display import _get_console, _get_live, _get_display_functions
@@ -419,28 +417,13 @@ class ChatMixin:
high = mid - 1
return (rendered[:low].rstrip() + suffix) if low else ""
@staticmethod
def _memory_prefetch_enabled(config: Any, memory: Any) -> bool:
"""Resolve ``MemoryConfig.prefetch`` (None = auto) for this backend."""
if not config or memory is None:
return False
prefetch = getattr(config, "prefetch", False)
if prefetch is not None:
return bool(prefetch)
# Auto: the full Memory class returns no system-prompt context without
# a query, so stored facts only reach the model via prefetch (#6040).
# If its module was never imported, the instance cannot be a Memory.
memory_module = sys.modules.get("praisonaiagents.memory.memory")
memory_cls = getattr(memory_module, "Memory", None)
return memory_cls is not None and isinstance(memory, memory_cls)
def _prefetch_memory(self, prompt: Any) -> str:
"""Best-effort synchronous turn-start retrieval."""
"""Best-effort synchronous turn-start retrieval (default-off)."""
if not self.use_system_prompt:
return ""
config = getattr(self, "_memory_config", None)
memory = getattr(self, "_memory_instance", None)
if not self._memory_prefetch_enabled(config, memory):
if not config or not getattr(config, "prefetch", False) or memory is None:
return ""
try:
@@ -469,12 +452,12 @@ class ChatMixin:
return ""
async def _aprefetch_memory(self, prompt: Any) -> str:
"""Best-effort asynchronous turn-start retrieval."""
"""Best-effort asynchronous turn-start retrieval (default-off)."""
if not self.use_system_prompt:
return ""
config = getattr(self, "_memory_config", None)
memory = getattr(self, "_memory_instance", None)
if not self._memory_prefetch_enabled(config, memory):
if not config or not getattr(config, "prefetch", False) or memory is None:
return ""
try:
@@ -1725,9 +1708,8 @@ Your Goal: {self.goal}"""
self._run_pre_compaction_memory_flush(messages, compactor)
# Perform compaction (protected: interrupt busy-mode queues instead)
with protect_turn(getattr(self, 'interrupt_controller', None)):
compacted_msgs, result = compactor.compact(messages)
# Perform compaction
compacted_msgs, result = compactor.compact(messages)
logging.info(
f"[proactive-compaction] {self.name}: {result.original_tokens}→{result.compacted_tokens} tokens "
@@ -1912,9 +1894,8 @@ Your Goal: {self.goal}"""
await self._arun_pre_compaction_memory_flush(messages, compactor)
# Perform compaction (protected: interrupt busy-mode queues instead)
with protect_turn(getattr(self, 'interrupt_controller', None)):
compacted_msgs, result = compactor.compact(messages)
# Perform compaction
compacted_msgs, result = compactor.compact(messages)
logging.info(
f"[proactive-compaction-async] {self.name}: {result.original_tokens}→{result.compacted_tokens} tokens "
@@ -2042,115 +2023,14 @@ Your Goal: {self.goal}"""
return fn(*args, **kwargs)
return {**llm_kwargs, 'execute_tool_fn': tracked}, ran
def _custom_llm_hook_messages(self, llm_kwargs):
"""The custom-LLM request as the message list BEFORE_LLM / AFTER_LLM hooks see."""
messages = []
if llm_kwargs.get('system_prompt'):
messages.append({"role": "system", "content": llm_kwargs['system_prompt']})
messages.extend(llm_kwargs.get('chat_history') or [])
messages.append({"role": "user", "content": llm_kwargs.get('prompt')})
return messages
def _custom_llm_before_input(self, llm_kwargs):
"""BEFORE_LLM input for the custom-LLM path, or ``None`` when no hook is registered."""
from ..hooks import HookEvent, BeforeLLMInput
if not self._hook_runner.registry.has_hooks(HookEvent.BEFORE_LLM):
return None
return BeforeLLMInput(
session_id=getattr(self, '_session_id', 'default'),
cwd=os.getcwd(),
event_name=HookEvent.BEFORE_LLM,
timestamp=str(time.time()),
agent_name=self.name,
messages=self._custom_llm_hook_messages(llm_kwargs),
model=self.llm if isinstance(self.llm, str) else str(self.llm),
temperature=llm_kwargs.get('temperature'),
)
def _adopt_custom_llm_before(self, llm_kwargs, before, results):
"""Apply BEFORE_LLM ``results`` like ``_chat_completion``: return ``(llm_kwargs, block_message)``."""
if self._hook_runner.is_blocked(results):
reason = next(
(getattr(r.output, "reason", None) for r in results
if r.output and getattr(r.output, "is_denied", lambda: False)()),
None,
) or "Blocked by hook"
logging.warning(f"Agent {self.name} LLM request blocked by BEFORE_LLM hook: {reason}")
return llm_kwargs, f"[LLM request blocked by hook: {reason}]"
messages = list(before.messages or [])
if messages and messages != self._custom_llm_hook_messages(llm_kwargs):
# A hook rewrote the messages (e.g. PII redactor): map them back.
system = None
if isinstance(messages[0], dict) and messages[0].get("role") == "system":
system = messages.pop(0).get("content")
last = messages.pop() if messages else {}
if not isinstance(last, dict) or last.get("role") != "user":
# ``LLM.get_response`` always sends the prompt as a user turn; fail closed
# rather than silently re-role a rewritten assistant/tool message.
logging.warning(f"Agent {self.name} BEFORE_LLM rewrite must end with a user message")
return llm_kwargs, "[LLM request blocked by hook: rewritten messages must end with a user message]"
llm_kwargs = {**llm_kwargs, 'system_prompt': system, 'chat_history': messages,
'prompt': last.get("content")}
return llm_kwargs, None
def _custom_llm_after_input(self, llm_kwargs, response, tally, start_time):
"""AFTER_LLM input for the custom-LLM path, or ``None`` when no hook is registered."""
from ..hooks import HookEvent, AfterLLMInput
if not self._hook_runner.registry.has_hooks(HookEvent.AFTER_LLM):
return None
return AfterLLMInput(
session_id=getattr(self, '_session_id', 'default'),
cwd=os.getcwd(),
event_name=HookEvent.AFTER_LLM,
timestamp=str(time.time()),
agent_name=self.name,
messages=self._custom_llm_hook_messages(llm_kwargs),
response=str(response),
model=self.llm if isinstance(self.llm, str) else str(self.llm),
tokens_used=sum(tally),
latency_ms=(time.time() - start_time) * 1000,
)
def _custom_llm_get_response(self, llm_kwargs):
"""Call the custom LLM, firing BEFORE_LLM / AFTER_LLM like ``_chat_completion``."""
from ..hooks import HookEvent
before = self._custom_llm_before_input(llm_kwargs)
if before is not None:
results = self._hook_runner.execute_sync(HookEvent.BEFORE_LLM, before)
llm_kwargs, blocked = self._adopt_custom_llm_before(llm_kwargs, before, results)
if blocked:
return blocked
start_time, tally = time.time(), []
response = self._custom_llm_dispatch(llm_kwargs, tally)
after = self._custom_llm_after_input(llm_kwargs, response, tally, start_time)
if after is not None:
self._hook_runner.execute_sync(HookEvent.AFTER_LLM, after)
return response
async def _custom_llm_get_response_async(self, llm_kwargs):
"""Async twin of :meth:`_custom_llm_get_response`."""
from ..hooks import HookEvent
before = self._custom_llm_before_input(llm_kwargs)
if before is not None:
results = await self._hook_runner.execute(HookEvent.BEFORE_LLM, before)
llm_kwargs, blocked = self._adopt_custom_llm_before(llm_kwargs, before, results)
if blocked:
return blocked
start_time, tally = time.time(), []
response = await self._custom_llm_dispatch_async(llm_kwargs, tally)
after = self._custom_llm_after_input(llm_kwargs, response, tally, start_time)
if after is not None:
await self._hook_runner.execute(HookEvent.AFTER_LLM, after)
return response
def _custom_llm_dispatch(self, llm_kwargs, tally=None):
"""Call ``llm_instance.get_response`` walking ``fallback_models`` on outage."""
llm, index, current = self.llm_instance, 0, self.llm
stream_cb = self.stream_emitter.emit if hasattr(self, 'stream_emitter') else None
llm_kwargs, tools_ran = self._track_tool_runs(llm_kwargs)
while True:
try:
with self._llm_usage_sink(llm, tally):
with self._llm_usage_sink(llm):
return llm.get_response(**llm_kwargs)
except (ToolExecutionError, BudgetExceededError, InterruptedError):
raise
@@ -2163,14 +2043,14 @@ Your Goal: {self.goal}"""
self._emit_model_fallback(current, next_model, category, index, stream_cb)
llm, index, current = self._fallback_llm_for(next_model), index + 1, next_model
async def _custom_llm_dispatch_async(self, llm_kwargs, tally=None):
"""Async twin of :meth:`_custom_llm_dispatch`."""
async def _custom_llm_get_response_async(self, llm_kwargs):
"""Async twin of :meth:`_custom_llm_get_response`."""
llm, index, current = self.llm_instance, 0, self.llm
stream_cb = self.stream_emitter.emit if hasattr(self, 'stream_emitter') else None
llm_kwargs, tools_ran = self._track_tool_runs(llm_kwargs)
while True:
try:
with self._llm_usage_sink(llm, tally):
with self._llm_usage_sink(llm):
return await llm.get_response_async(**llm_kwargs)
except (ToolExecutionError, BudgetExceededError, InterruptedError):
raise
@@ -2392,10 +2272,9 @@ Your Goal: {self.goal}"""
self._on_budget_exceeded(current_cost, self._max_budget)
@contextlib.contextmanager
def _llm_usage_sink(self, llm=None, tally=None):
def _llm_usage_sink(self, llm=None):
"""Route every call ``llm`` (default: the custom LLM) makes in this block into ``_record_llm_usage``.
Each call's total tokens are also appended to ``tally`` when given.
Refuses to start when ``max_budget`` is already spent (``"stop"`` mode),
so retrying after ``BudgetExceededError`` sends no further paid request.
"""
@@ -2414,12 +2293,10 @@ Your Goal: {self.goal}"""
llm = llm if llm is not None else self.llm_instance
# A fallback model is priced as itself, not as the primary ``self.llm``.
model = None if llm is self.llm_instance else getattr(llm, 'model', None)
def record(response):
usage = self._extract_llm_usage(response, model)
if tally is not None:
tally.append(usage[0] + usage[1])
self._record_llm_usage(*usage)
token = _usage_sink.set((llm, record))
token = _usage_sink.set((
llm,
lambda response: self._record_llm_usage(*self._extract_llm_usage(response, model)),
))
try:
yield
finally:
@@ -3329,10 +3206,6 @@ Your Goal: {self.goal}"""
except BudgetExceededError:
raise
except ToolExecutionError:
# Sync parity: a tool failure is not an LLM error — do not relabel
# it or re-drive the model without the tool result.
raise
except Exception as e:
from ..errors import LLMError
# Apply the same structured error classification for async path
@@ -3696,8 +3569,7 @@ Your Goal: {self.goal}"""
_in_ok, _in_prompt, _in_err = self._validate_input_with_guardrail(prompt)
if not _in_ok:
logging.warning(f"Agent {self.name}: input blocked by guardrail: {_in_err}")
self._last_guardrail_error = _in_err
return self._guardrail_blocked_message(_in_err)
return None
prompt = _in_prompt
# Check if external managed backend is configured
@@ -4547,8 +4419,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
)
if not _in_ok:
logging.warning(f"Agent {self.name}: input blocked by guardrail: {_in_err}")
self._last_guardrail_error = _in_err
return self._guardrail_blocked_message(_in_err)
return None
prompt = _in_prompt
# Check if external managed backend is configured (async parity with the
@@ -5668,8 +5539,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
_in_ok, _in_prompt, _in_err = self._validate_input_with_guardrail(prompt)
if not _in_ok:
logging.warning(f"Agent {getattr(self, 'name', '')}: input blocked by guardrail: {_in_err}")
self._last_guardrail_error = _in_err
yield self._guardrail_blocked_message(_in_err)
yield f"[Input blocked by guardrail: {_in_err}]"
return "failed"
prompt = _in_prompt
@@ -6635,24 +6505,23 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
self._run_pre_compaction_memory_flush(messages, _compactor)
# Perform compaction
with protect_turn(getattr(self, 'interrupt_controller', None)):
if _strategy == CompactionStrategy.LLM_SUMMARIZE and _llm_fn:
# Drive the async summariser through the shared bridge so it runs
# correctly whether or not a loop is already running (FastAPI /
# Jupyter / a bot handler). The previous code caught bare
# RuntimeError from ``asyncio.run`` ("event loop already
# running") and silently downgraded LLM summarisation to naive
# truncation whenever a loop was live; the bridge removes that
# spurious fallback so the real summariser always runs. Provider
# errors inside the summariser remain best-effort (see
# ``_llm_summarize_async``, which falls back to a naive summary on
# failure) — compaction never hard-fails the turn.
from ..utils.async_bridge import run_coroutine_from_any_context
compacted_msgs, _cr = run_coroutine_from_any_context(
_compactor.compact_async(messages)
)
else:
compacted_msgs, _cr = _compactor.compact(messages)
if _strategy == CompactionStrategy.LLM_SUMMARIZE and _llm_fn:
# Drive the async summariser through the shared bridge so it runs
# correctly whether or not a loop is already running (FastAPI /
# Jupyter / a bot handler). The previous code caught bare
# RuntimeError from ``asyncio.run`` ("event loop already
# running") and silently downgraded LLM summarisation to naive
# truncation whenever a loop was live; the bridge removes that
# spurious fallback so the real summariser always runs. Provider
# errors inside the summariser remain best-effort (see
# ``_llm_summarize_async``, which falls back to a naive summary on
# failure) — compaction never hard-fails the turn.
from ..utils.async_bridge import run_coroutine_from_any_context
compacted_msgs, _cr = run_coroutine_from_any_context(
_compactor.compact_async(messages)
)
else:
compacted_msgs, _cr = _compactor.compact(messages)
messages[:] = compacted_msgs # in-place update so callers see the change
logging.info(
@@ -6726,11 +6595,10 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
await self._arun_pre_compaction_memory_flush(messages, _compactor)
# Perform compaction (use async version when available)
with protect_turn(getattr(self, 'interrupt_controller', None)):
if _strategy == CompactionStrategy.LLM_SUMMARIZE and _llm_fn:
compacted_msgs, _cr = await _compactor.compact_async(messages)
else:
compacted_msgs, _cr = _compactor.compact(messages)
if _strategy == CompactionStrategy.LLM_SUMMARIZE and _llm_fn:
compacted_msgs, _cr = await _compactor.compact_async(messages)
else:
compacted_msgs, _cr = _compactor.compact(messages)
# If messages is the same object as chat_history, replace chat history
# Otherwise, update the passed messages list in-place
@@ -48,6 +48,41 @@ def _get_asyncio():
_asyncio = asyncio
return _asyncio
async def _async_subprocess_run(cmd: list, timeout: int = 60) -> tuple:
"""
Run subprocess asynchronously (non-blocking).
Args:
cmd: Command list to execute
timeout: Timeout in seconds
Returns:
Tuple of (stdout, stderr, returncode)
"""
asyncio = _get_asyncio()
try:
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=timeout
)
return (
stdout.decode() if stdout else "",
stderr.decode() if stderr else "",
process.returncode
)
except asyncio.TimeoutError:
if process:
process.kill()
await process.wait()
raise
except FileNotFoundError:
raise
from ..agent.agent import Agent
from ..task import Task
@@ -574,8 +574,7 @@ def _open_durable_run(agent: Any, prompt: str) -> DurableRunContext:
journal.close()
raise ValueError(
"Resume prompt does not match the prompt recorded for durable "
f"run {run_id!r}; resume with RunJournal.run_meta(run_id).task "
"(resuming autonomous/goal loops by run id is not supported)"
f"run {run_id!r}"
)
journal.open_run(
@@ -29,39 +29,6 @@ from typing import List, Optional, Any, Dict, Union, Generator, TYPE_CHECKING
from .server_registry import _server_registry
class _TimeoutCancelToken:
"""cancel_token for caller-mode ``start(timeout=...)``.
Set when the timeout elapses or when ``inner`` (the agent's own
per-turn interrupt token, if any) is set.
"""
def __init__(self, timeout, inner=None):
self.timed_out = threading.Event()
self._inner = inner
self._timer = threading.Timer(timeout, self.timed_out.set)
self._timer.daemon = True
self._timer.start()
def is_set(self) -> bool:
return self.timed_out.is_set() or (self._inner is not None and self._inner.is_set())
@property
def reason(self):
if self.timed_out.is_set():
return "timeout"
return getattr(self._inner, "reason", None)
def check(self) -> None:
if self.is_set():
raise InterruptedError(f"Operation cancelled: {self.reason or 'unknown'}")
def close(self) -> None:
self._timer.cancel()
if self._inner is not None:
self._inner.close()
def cleanup_launch_registration(agent_id: str) -> None:
"""Remove launch() endpoints registered for ``agent_id`` from the shared
HTTP state that ``Agent.launch()`` actually populates.
@@ -229,26 +196,8 @@ class ExecutionMixin:
from datetime import datetime, timezone
start_time = time_module.time()
started_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
# achat() has no timeout param: enforce it as a run budget here
timeout = kwargs.pop('timeout', None)
if timeout is not None and timeout > 0:
task = asyncio.ensure_future(self.achat(prompt, **kwargs))
try:
done, _ = await asyncio.wait({task}, timeout=timeout)
except asyncio.CancelledError:
task.cancel()
raise
if task not in done:
task.cancel()
# Let achat()'s cleanup finish and collect its outcome
for exc in await asyncio.gather(task, return_exceptions=True):
if isinstance(exc, Exception):
logger.debug("achat() cleanup after timeout failed: %s", exc)
return self._caller_mode_timeout_result(start_time, started_at)
response = task.result()
else:
response = await self.achat(prompt, **kwargs)
response = await self.achat(prompt, **kwargs)
response_str = str(response) if response else ""
# Auto-save session after chat. Offloaded to a worker thread so
@@ -282,21 +231,6 @@ class ExecutionMixin:
kwargs['stream'] = stream_requested
return await self.achat(prompt, **kwargs)
@staticmethod
def _caller_mode_timeout_result(start_time, started_at):
"""AutonomyResult for a caller-mode run that exceeded ``timeout``."""
from .autonomy import AutonomyResult
return AutonomyResult(
success=False,
output="Task timed out",
completion_reason="timeout",
iterations=1,
stage="direct",
actions=[],
duration_seconds=time.time() - start_time,
started_at=started_at,
)
async def _astart_with_outcome(self, prompt, timeout=None, **kwargs):
"""Run astart() and normalise its result/exception into a RunOutcome.
@@ -909,36 +843,9 @@ Write the complete compiled report:"""
# Load history from past sessions
self._load_history_context()
# chat() has no timeout param: enforce it via cooperative
# cancellation (cancel_token), unless the caller supplied one.
timeout = kwargs.pop('timeout', None)
cancel = None
if timeout is not None and timeout > 0 and kwargs.get('cancel_token') is None:
# Still honour stop requests on the agent's own controller
inner = self._turn_cancel_token(
getattr(self, "interrupt_controller", None), explicit=False
)
cancel = kwargs['cancel_token'] = _TimeoutCancelToken(timeout, inner)
# Use chat() — all tools, approval, doom-loop per-tool work
try:
response = self.chat(prompt, **kwargs)
except InterruptedError:
if cancel is None or not cancel.timed_out.is_set():
raise
return self._caller_mode_timeout_result(start_time, started_at)
finally:
if cancel is not None:
cancel.close()
response = self.chat(prompt, **kwargs)
response_str = str(response) if response else ""
# The LLM tool loop returns this text instead of raising when
# the timeout stops it, so the run did not finish.
if (
cancel is not None
and cancel.timed_out.is_set()
and response_str.startswith("Task interrupted: timeout")
):
return self._caller_mode_timeout_result(start_time, started_at)
# Auto-save session after chat
self._auto_save_session()
@@ -1013,6 +920,7 @@ Write the complete compiled report:"""
# Show animated status during LLM call if verbose
if self.verbose and is_tty:
from ..main import PRAISON_COLORS, sync_display_callbacks, _callbacks_lock
import threading
import time as time_module
console = _get_console()()
@@ -1654,28 +1562,11 @@ Write the complete compiled report:"""
retry_policy = self._get_tool_retry_policy(function_name)
last_exception = None
def _finish(result, raised, retryable_ok=True):
# Strip the private control-plane tag before it reaches the model. A
# tool that *raised* ends the turn with ToolExecutionError so the run
# is finalized failed — the same contract as the sync path
# (tool_execution.py, test_tool_failure_not_relabelled). A plain
# tool-returned {"error": ...} stays a recoverable model input.
retryable = result.pop("_praison_retryable", None) is True and retryable_ok
if raised:
raise ToolExecutionError(
result.get("error", f"Tool '{function_name}' failed"),
tool_name=function_name,
agent_id=self.name,
is_retryable=retryable,
)
return result
# Retry loop with exponential backoff
for attempt in range(retry_policy.max_attempts):
try:
result = await self._execute_tool_async_impl(function_name, arguments, tool_call_id, tools_override)
raised = isinstance(result, dict) and bool(result.pop("_praison_raised", False))
# Check if result is an error that should be retried
if isinstance(result, dict) and result.get("error"):
@@ -1690,7 +1581,8 @@ Write the complete compiled report:"""
result.get("circuit_open") or
result.get("loop_blocked") or
result.get("_praison_retryable") is False):
return _finish(result, raised)
result.pop("_praison_retryable", None)
return result
# The body already ran and may have completed its side effect
# before failing. Honour an explicit restart_safe=False /
@@ -1700,9 +1592,8 @@ Write the complete compiled report:"""
# up to max_attempts times -- charging a card three times for
# a tool the author marked "must never be silently re-executed".
if self._tool_declares_not_idempotent(function_name, tools_override):
# Must not run again: report non-retryable so durable
# runs are finalized failed (sync parity).
return _finish(result, raised, retryable_ok=False)
result.pop("_praison_retryable", None)
return result
# Determine error type for retry policy
error_type = self._classify_error_type(result, last_exception)
@@ -1720,11 +1611,13 @@ Write the complete compiled report:"""
# never steer our retry loop.
if (not retry_policy.should_retry(error_type, attempt)
and result.get("_praison_retryable") is not True):
return _finish(result, raised)
result.pop("_praison_retryable", None)
return result
# Don't retry on last attempt
if attempt == retry_policy.max_attempts - 1:
return _finish(result, raised)
result.pop("_praison_retryable", None)
return result
# Emit retry hook event
delay_ms = retry_policy.get_delay_ms(attempt)
@@ -1749,9 +1642,7 @@ Write the complete compiled report:"""
# Check if retryable and not last attempt
if not is_retryable or attempt == retry_policy.max_attempts - 1:
# A framework-internal failure escaping the impl (not a tool
# body raise — those are flattened and handled above) is
# returned as an error dict.
# Return error dict instead of raising for consistency with sync version
return {"error": f"Error in execute_tool_async: {str(e)}"}
# Determine error type
@@ -2302,14 +2193,9 @@ Write the complete compiled report:"""
# Private control-plane key: a tool's own result may legitimately
# contain "retryable" (common for HTTP-API wrappers) and must not
# collide with the framework's verdict nor leak to the model.
# ``_praison_raised`` marks the dict as a flattened *raised*
# exception (timeouts/pool exhaustion also carry
# ``_praison_retryable`` but did not raise), so the retry loop can
# surface it as ToolExecutionError exactly like the sync path.
return {
"error": f"Error executing {function_name}: {str(e)}",
"_praison_retryable": retryable,
"_praison_raised": True,
}
except ToolExecutionError:
@@ -2490,9 +2376,44 @@ Write the complete compiled report:"""
print(f"📚 API documentation available at http://{host}:{port}/docs")
print(f"🔌 Available endpoints on port {port}: {', '.join(endpoints)}")
# Keep the daemon server threads alive once the script finishes
# (no source-text inspection of the caller).
_server_registry.keep_alive_at_exit()
# Get the stack frame to check if this is the last launch() call in the script
import inspect
stack = inspect.stack()
# If this is called from a Python script (not interactive), try to detect if it's the last launch call
if len(stack) > 1 and stack[1].filename.endswith('.py'):
caller_frame = stack[1]
caller_line = caller_frame.lineno
try:
# Read the file to check if there are more launch calls after this one
with open(caller_frame.filename, 'r') as f:
lines = f.readlines()
# Check if there are more launch() calls after the current line
has_more_launches = False
for line_content in lines[caller_line:]: # renamed line to line_content
if '.launch(' in line_content and not line_content.strip().startswith('#'):
has_more_launches = True
break
# If this is the last launch call, block the main thread
if not has_more_launches:
try:
print("\nAll agents registered for HTTP mode. Press Ctrl+C to stop the servers.")
while True:
self._safe_sleep(1)
except KeyboardInterrupt:
print("\nServers stopped")
except Exception as e:
# If something goes wrong with detection, block anyway to be safe
logging.error(f"Error in launch detection: {e}")
try:
print("\nKeeping HTTP servers alive. Press Ctrl+C to stop.")
while True:
self._safe_sleep(1)
except KeyboardInterrupt:
print("\nServers stopped")
return None
def _launch_mcp_server(self, path: str, port: int, host: str, debug: bool):
@@ -190,39 +190,6 @@ def _get_handoff_depth() -> int:
"""Get current handoff depth."""
return len(_get_handoff_chain())
def _tool_pair_safe_tail(messages: List[Any], n: int) -> List[Any]:
"""Return the last ``n`` messages without splitting tool_calls/tool pairs.
A count-based slice can cut between an assistant ``tool_calls`` message and
its ``tool`` responses; providers reject the resulting orphans. Drop tool
results whose call was cut off, and assistant tool_calls whose responses
are incomplete (together with their partial responses).
"""
if n <= 0:
return []
tail = list(messages[-n:])
def _call_ids(msg: Any) -> set:
calls = msg.get('tool_calls') if isinstance(msg, dict) else None
return {c.get('id') if isinstance(c, dict) else getattr(c, 'id', None) for c in calls or ()}
def _is_tool(msg: Any) -> bool:
return isinstance(msg, dict) and msg.get('role') == 'tool'
response_ids = {m.get('tool_call_id') for m in tail if _is_tool(m)}
dropped: set = set()
kept = []
for msg in tail:
ids = _call_ids(msg)
if ids and not ids <= response_ids:
dropped |= ids
continue
kept.append(msg)
call_ids = set().union(*(_call_ids(m) for m in kept))
return [m for m in kept
if not (_is_tool(m) and (m.get('tool_call_id') in dropped
or m.get('tool_call_id') not in call_ids))]
def _push_handoff(agent_name: str) -> None:
"""Push agent to handoff chain.
@@ -735,22 +702,22 @@ class Handoff:
# Apply context policy
if self.config.context_policy == ContextPolicy.NONE:
filtered_messages = []
elif self.config.context_policy in (ContextPolicy.LAST_N, ContextPolicy.SUMMARY):
elif self.config.context_policy == ContextPolicy.LAST_N:
n = self.config.max_context_messages
if self.config.preserve_system:
system_msgs = [m for m in messages if isinstance(m, dict) and m.get('role') == 'system']
base = [m for m in messages if not (isinstance(m, dict) and m.get('role') == 'system')]
other_msgs = [m for m in messages if not (isinstance(m, dict) and m.get('role') == 'system')]
filtered_messages = system_msgs + other_msgs[-n:]
else:
system_msgs, base = [], list(messages)
if self.config.context_policy == ContextPolicy.LAST_N:
tail = _tool_pair_safe_tail(base, self.config.max_context_messages)
filtered_messages = messages[-n:]
elif self.config.context_policy == ContextPolicy.SUMMARY:
# For summary, keep system + last few messages
if self.config.preserve_system:
system_msgs = [m for m in messages if isinstance(m, dict) and m.get('role') == 'system']
other_msgs = [m for m in messages if not (isinstance(m, dict) and m.get('role') == 'system')]
filtered_messages = system_msgs + other_msgs[-3:] # Last 3 non-system
else:
# SUMMARY: the first user message (the task goal) + last few
# messages. An LLM summary stays opt-in via ``input_filter``.
tail = _tool_pair_safe_tail(base, 3)
goal = next((m for m in base if isinstance(m, dict) and m.get('role') == 'user'), None)
if goal is not None and not any(m is goal for m in tail):
tail = [goal] + tail
filtered_messages = system_msgs + tail
filtered_messages = messages[-3:]
else: # FULL
filtered_messages = messages[:]
@@ -14,7 +14,7 @@ import warnings
warnings.filterwarnings("ignore", "Valid config keys have changed in V2", UserWarning)
class ImageGenerationConfig(BaseModel):
"""Configuration for image generation settings."""
style: Optional[str] = Field(default=None, description="Style of the generated image (only sent when set)")
style: str = Field(default="natural", description="Style of the generated image")
response_format: str = Field(default="url", description="Format of the response (url or b64_json)")
timeout: int = Field(default=600, description="Timeout in seconds for the API call")
api_base: Optional[str] = Field(default=None, description="Optional API base URL")
@@ -36,7 +36,7 @@ class ImageAgent(Agent):
backstory: Optional[str] = None,
instructions: Optional[str] = None,
llm: Optional[Union[str, Any]] = None,
style: Optional[str] = None,
style: str = "natural",
response_format: str = "url",
timeout: int = 600,
api_base: Optional[str] = None,
@@ -6,8 +6,7 @@ operations. Follows protocol-driven design with zero overhead when not used.
"""
import threading
from contextlib import contextmanager, nullcontext
from typing import Any, ContextManager, Iterator, Optional, Protocol, Set, runtime_checkable
from typing import Optional, Protocol, Set, runtime_checkable
from dataclasses import dataclass, field
__all__ = [
@@ -17,16 +16,6 @@ __all__ = [
]
def protect_turn(controller: Any) -> ContextManager[None]:
"""Open a ``protect()`` window on ``controller`` if it supports one.
Controllers are optional and may be third-party implementations without
``protect``; those (and ``None``) get a no-op context.
"""
protect = getattr(controller, "protect", None)
return protect() if protect is not None else nullcontext()
class InterruptControllerProtocol(Protocol):
"""Protocol for interrupt controller extension point.
@@ -99,31 +88,8 @@ class InterruptController:
_cancelled_turns: Set[int] = field(default_factory=set, init=False, repr=False)
_next_turn_id: int = field(default=0, init=False, repr=False)
_pending_turn_cancel: bool = field(default=False, init=False, repr=False)
_protect_depth: int = field(default=0, init=False, repr=False)
_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
@contextmanager
def protect(self) -> Iterator[None]:
"""Mark a window of work that should not be superseded mid-flight.
Used around foreground subagent runs and context compaction. It does
not block :meth:`request` (``/stop`` still cancels); it only lets a
caller such as the gateway's ``interrupt`` busy-mode check
:meth:`is_protected` and queue a new message instead.
"""
with self._lock:
self._protect_depth += 1
try:
yield
finally:
with self._lock:
self._protect_depth -= 1
def is_protected(self) -> bool:
"""Return True while any :meth:`protect` window is open."""
with self._lock:
return self._protect_depth > 0
def request(self, reason: str = "user") -> None:
"""Request cancellation with a reason.
@@ -154,3 +154,45 @@ class AgentMessageQueue:
def total_messages_processed(self) -> int:
"""Total number of messages ever enqueued."""
return self._message_count
class AsyncAgentMessageQueue:
"""
Async-compatible message queue for agent prompts.
Wraps AgentMessageQueue with async interface.
"""
def __init__(self, max_size: int = 100):
self._sync_queue = AgentMessageQueue(max_size=max_size)
async def enqueue(
self,
content: Any,
priority: int = MessagePriority.NORMAL.value,
metadata: Optional[dict] = None
) -> bool:
"""Async enqueue."""
return self._sync_queue.enqueue(content, priority, metadata)
async def dequeue(self, timeout: Optional[float] = None) -> Optional[Any]:
"""Async dequeue."""
import asyncio
if timeout is None:
return self._sync_queue.dequeue(timeout=0)
# Poll with async sleep for timeout
start = time.time()
while time.time() - start < timeout:
result = self._sync_queue.dequeue(timeout=0)
if result is not None:
return result
await asyncio.sleep(0.01)
return None
def is_empty(self) -> bool:
return self._sync_queue.is_empty()
def size(self) -> int:
return self._sync_queue.size()
@@ -33,12 +33,13 @@ class RealtimeConfig:
def __post_init__(self):
if self.temperature is not None:
# stacklevel=3: __post_init__ -> dataclass __init__ -> caller.
# stacklevel=4: __post_init__ -> dataclass __init__ -> caller, plus one
# frame for the warnings.warn wrapper installed by _warning_patch.
warnings.warn(
"RealtimeConfig.temperature is ignored: the GA Realtime API "
"does not support a session temperature.",
DeprecationWarning,
stacklevel=3,
stacklevel=4,
)
def to_dict(self) -> Dict[str, Any]:
@@ -30,7 +30,6 @@ class _AgentServerRegistry:
self._endpoints: Dict[int, Dict[str, str]] = {}
self._apps: Dict[int, Any] = {} # FastAPI apps
self._ready_events: Dict[int, threading.Event] = {}
self._keep_alive_registered = False
def get_or_create_app(self, port: int, title: str = "AgentTeam API") -> Tuple[Any, bool]:
"""Thread-safe app creation. Returns (app, is_new)."""
@@ -222,46 +221,6 @@ class _AgentServerRegistry:
with self._lock:
return self._started.get(port, False)
def keep_alive_at_exit(self) -> None:
"""Keep daemon server threads alive until Ctrl+C once the script ends.
``launch()`` returns immediately so several launches (and any code
after them) run; the wait happens once, at interpreter exit, only for
non-interactive scripts. Interactive sessions/notebooks never block.
"""
import sys
main = sys.modules.get("__main__")
if sys.flags.interactive or not getattr(main, "__file__", None):
return
with self._lock:
if self._keep_alive_registered:
return
self._keep_alive_registered = True
def _wait():
with self._lock:
if not any(self._started.values()):
return
print("\nAgent servers running. Press Ctrl+C to stop.")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
print("\nServers stopped")
# Wait before Python shuts down thread pools: plain atexit runs after
# concurrent.futures' exit hook, so request handlers that use
# run_in_executor would fail. threading atexits run in reverse order,
# so registering after concurrent.futures.thread is imported runs first.
import concurrent.futures.thread # noqa: F401
import threading
register = getattr(threading, "_register_atexit", None)
if register is not None:
register(_wait)
else: # pragma: no cover - non-CPython fallback
import atexit
atexit.register(_wait)
# Module level — single registry instance shared by every launch() call site.
_server_registry = _AgentServerRegistry()
@@ -2068,41 +2068,6 @@ class ToolExecutionMixin:
execution_time_ms=(time.time() - start_time) * 1000
)
def _apply_after_agent_results(self, response, after_agent_input, results):
"""Honour AFTER_AGENT hook outcomes on the final response.
A blocking hook/plugin (``GuardrailBlocked`` / ``PluginDecision.deny``)
replaces the returned value with a visible refusal marker, mirroring
the BEFORE_LLM block. Otherwise a string rewrite (e.g. PII redaction)
is adopted so it actually reaches the caller. The matching in-memory
assistant turn is updated too, so later turns and auto-memory never
see the withheld or unredacted text. Interaction callbacks, verbose
display and session/DB persistence run earlier and are not gated here.
"""
if self._hook_runner.is_blocked(results):
reason = self._hook_runner.get_blocking_reason(results) or "Blocked by hook"
logging.warning(f"Agent {self.name} response blocked by AFTER_AGENT hook: {reason}")
final = f"[Response blocked by hook: {reason}]"
elif isinstance(response, str) and isinstance(after_agent_input.response, str):
final = after_agent_input.response
else:
return response
if final != response and isinstance(response, str):
# Prefer this turn's own messages (by identity) so a concurrent
# turn with identical text is never touched; fall back to a
# content match over shared history for untracked callers.
from .memory_mixin import _active_turn_owned
owned = _active_turn_owned.get() or []
with self._history_lock:
for candidates in (owned, self.chat_history):
msg = next((m for m in reversed(candidates)
if isinstance(m, dict) and m.get("role") == "assistant"
and m.get("content") == response), None)
if msg is not None:
msg["content"] = final
break
return final
def _after_agent_side_effects(self, prompt, response, *, process_auto_memory=True):
"""Run loop-agnostic after-agent side effects (no ``await`` inside).
@@ -2160,8 +2125,7 @@ class ToolExecutionMixin:
from ..hooks import HookEvent
if self._hook_runner.registry.has_hooks(HookEvent.AFTER_AGENT):
after_agent_input = self._build_after_agent_input(prompt, response, start_time, tools_used)
results = self._hook_runner.execute_sync(HookEvent.AFTER_AGENT, after_agent_input)
response = self._apply_after_agent_results(response, after_agent_input, results)
self._hook_runner.execute_sync(HookEvent.AFTER_AGENT, after_agent_input)
self._after_agent_side_effects(prompt, response)
@@ -2219,8 +2183,7 @@ class ToolExecutionMixin:
from ..hooks import HookEvent
if self._hook_runner.registry.has_hooks(HookEvent.AFTER_AGENT):
after_agent_input = self._build_after_agent_input(prompt, response, start_time, tools_used)
results = await self._hook_runner.execute(HookEvent.AFTER_AGENT, after_agent_input)
response = self._apply_after_agent_results(response, after_agent_input, results)
await self._hook_runner.execute(HookEvent.AFTER_AGENT, after_agent_input)
if response and getattr(self, '_memory_instance', None):
prompt_str = _memory_prompt_text(prompt)
@@ -1256,26 +1256,11 @@ class AgentTeam(SpawnAnnounceProtocol):
# Set up sequential flow if needed
if len(tasks) > 1 and (process == "sequential" or all(task.next_tasks == [] for task in tasks)):
# Only infer edges the user did not declare and stay idempotent
# when the same Task objects are passed to another team.
for prev, nxt in zip(tasks, tasks[1:]):
# Navigation: process="workflow" follows next_tasks, so link
# every task that declared none; keep user routing as-is.
inferred = not prev.next_tasks
if inferred:
prev.next_tasks = [nxt.name]
elif nxt.name not in prev.next_tasks:
continue
# Context: keep a user-set context, and leave adjacent
# async_execution tasks independent so they run in parallel.
if prev.async_execution and nxt.async_execution:
if inferred and not nxt.context:
# Navigation only: the workflow process must not turn
# this inferred link into a previous_tasks dependency.
nxt._navigation_only_prev = prev.name
continue
if not nxt.context:
nxt.context = [prev]
for i in range(len(tasks) - 1):
tasks[i].next_tasks = [tasks[i + 1].name]
if tasks[i + 1].context is None:
tasks[i + 1].context = []
tasks[i + 1].context.append(tasks[i])
logger.info("Set up sequential flow with automatic context passing")
self._state = {}
@@ -3926,9 +3911,44 @@ class AgentTeam(SpawnAnnounceProtocol):
endpoints = _server_registry.list_routes(port)
print(f"🔌 Registered HTTP endpoints on port {port}: {', '.join(endpoints)}")
# Keep the daemon server threads alive once the script finishes
# (no source-text inspection of the caller).
_server_registry.keep_alive_at_exit()
# Get the stack frame to check if this is the last launch() call in the script
import inspect
stack = inspect.stack()
# If this is called from a Python script (not interactive), try to detect if it's the last launch call
if len(stack) > 1 and stack[1].filename.endswith('.py'):
caller_frame = stack[1]
caller_line = caller_frame.lineno
try:
# Read the file to check if there are more launch calls after this one
with open(caller_frame.filename, 'r') as f:
lines = f.readlines()
# Check if there are more launch() calls after the current line
has_more_launches = False
for line_content in lines[caller_line:]: # Renamed variable
if '.launch(' in line_content and not line_content.strip().startswith('#'):
has_more_launches = True
break
# If this is the last launch call, block the main thread
if not has_more_launches:
try:
print("\nAll agent groups registered for HTTP mode. Press Ctrl+C to stop the servers.")
while True:
time.sleep(1)
except KeyboardInterrupt:
print("\nServers stopped")
except Exception as e:
# If something goes wrong with detection, block anyway to be safe
logging.error(f"Error in HTTP launch detection: {e}")
try:
print("\nKeeping HTTP servers alive. Press Ctrl+C to stop.")
while True:
time.sleep(1)
except KeyboardInterrupt:
print("\nServers stopped")
return None
elif protocol == "mcp":
@@ -246,11 +246,7 @@ class ChannelDescriptor(Protocol):
secrets and plain config keys). A descriptor MAY additionally expose an
optional ``setup(io)`` hook for bespoke, multi-step flows the field list
cannot express; the wizard calls it when present and merges the returned
values. It MAY likewise expose an optional ``standalone_sender`` — a (sync or async)
``(target: DeliveryTarget, text: str) -> None`` that raises on failure — so
scheduled ``deliver:`` targets reach the channel even when no gateway is
resident (``praisonai schedule tick`` from OS cron / CI / scale-to-zero).
Because these are optional, a minimal descriptor (like the example
values. Because it is optional, a minimal descriptor (like the example
below) omits it — so this Protocol is intentionally *not* used as an
``isinstance`` type guard; consumers read attributes with ``getattr``
fallbacks instead.
@@ -282,11 +282,9 @@ class MemoryConfig:
history: bool = False
history_limit: int = 10
# Turn-start long-term memory retrieval. None = auto: on for query-based
# backends (sqlite/chroma/mem0), whose system-prompt context is empty
# without a query; off for FileMemory, which injects recent items itself.
# True/False force it on/off.
prefetch: Optional[bool] = None
# Turn-start long-term memory retrieval. Disabled by default so existing
# agents incur no backend query or prompt changes.
prefetch: bool = False
prefetch_limit: int = 5
prefetch_token_budget: int = 512
@@ -233,11 +233,8 @@ def validate_preset_string(
if not isinstance(value, str):
return
presets_list = list(presets)
# Exact spelling is the common case; skip normalising every preset.
if value in presets_list:
return
normalized = canonical_preset_key(value)
presets_list = list(presets)
for preset in presets_list:
if canonical_preset_key(preset) == normalized:
return
@@ -64,14 +64,6 @@ class GoalLoopMixin:
def _load_goal_state(self) -> Optional["GoalState"]:
"""Load a persisted GoalState from the session store, if any."""
# The store is created lazily on the first chat(); a fresh Agent in a
# new process would otherwise see no store and restart the goal.
init_store = getattr(self, "_init_session_store", None)
if init_store is not None:
try:
init_store()
except Exception as exc: # pragma: no cover - best-effort
logger.debug("Initialising session store failed: %s", exc)
store = getattr(self, "_session_store", None) or getattr(
self, "session_store", None
)
@@ -245,18 +237,14 @@ class GoalLoopMixin:
max_turns: int,
resume: bool,
) -> "GoalState":
"""Resume a persisted paused/active state for the same goal, else start fresh.
A saved ``active`` state means the previous process died mid-run, so
it is resumed like a ``paused`` one.
"""
"""Resume a persisted paused state for the same goal, else start fresh."""
from .models import GoalState
if resume:
saved = self._load_goal_state()
if (
saved is not None
and saved.status in ("paused", "active")
and saved.status == "paused"
and saved.goal == goal
):
# Resume the paused run in place (keep turns_used/verdict).
@@ -295,7 +283,7 @@ class GoalLoopMixin:
max_turns: Judged iterations before a recoverable pause.
judge_model: Independent judge model (defaults to gpt-4o-mini).
resume: When ``True`` (default), a previously persisted ``paused``
(or crashed, still ``active``) run for the same goal is resumed (turns are not reset) instead
run for the same goal is resumed (turns are not reset) instead
of restarting at ``turns_used=0``.
Returns:
@@ -348,11 +348,6 @@ class HookRunner:
process.communicate(input=input_json),
timeout=timeout
)
except asyncio.CancelledError:
# Caller cancelled (e.g. a shutdown deadline): don't orphan the child.
if process.returncode is None:
process.kill()
raise
except asyncio.TimeoutError:
process.kill()
await process.wait()
@@ -13,14 +13,12 @@ No heavy imports - only stdlib.
import fnmatch
import hashlib
import json
import logging
import os
import re
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
# Strategy thresholds based on corpus size
STRATEGY_THRESHOLDS = {
@@ -422,8 +420,8 @@ class FileTracker:
os.makedirs(os.path.dirname(self._state_file) or ".", exist_ok=True)
from ..utils.atomic_io import atomic_write_json
atomic_write_json(self._state_file, self._tracked, indent=2)
except (OSError, IOError) as e:
logger.warning("Failed to save index state %s: %s", self._state_file, e)
except (OSError, IOError):
pass
def load(self) -> None:
"""Load state from file."""
@@ -433,9 +431,7 @@ class FileTracker:
try:
with open(self._state_file, "r", encoding="utf-8") as f:
self._tracked = json.load(f)
except (OSError, IOError, json.JSONDecodeError) as e:
# Surface this: an empty state forces a full (costly) re-index.
logger.warning("Index state %s unreadable (%s); re-indexing", self._state_file, e)
except (OSError, IOError, json.JSONDecodeError):
self._tracked = {}
def clear(self) -> None:
@@ -9,11 +9,6 @@ import os
# Ensure litellm telemetry is disabled before any imports
os.environ["LITELLM_TELEMETRY"] = "False"
# Apply litellm/httpx env defaults only once the LLM layer is actually used
# (not on `import praisonaiagents`).
from .._logging import _configure_environment
_configure_environment()
import threading
# Module-level cache for lazy-loaded classes
+10 -17
View File
@@ -822,9 +822,7 @@ Respond with ONLY a valid JSON tool call in this format:
# Rate limiting and retry settings
self._rate_limiter = extra_settings.get('rate_limiter', None)
self._max_retries = extra_settings.get('max_retries', 3)
# Fixed delay applies only when explicitly configured; otherwise use jitter.
self._retry_delay = extra_settings.get('retry_delay', 60)
self._retry_delay_explicit = 'retry_delay' in extra_settings
self._retry_delay = extra_settings.get('retry_delay', 60) # Default 60 seconds
# Subscription auth
self._auth_provider_id = auth
@@ -1073,7 +1071,7 @@ Respond with ONLY a valid JSON tool call in this format:
else:
return False
def _parse_retry_delay(self, error_message: Union[Exception, str]) -> Optional[float]:
def _parse_retry_delay(self, error_message: Union[Exception, str]) -> float:
"""Parse retry delay from a rate limit error.
Args:
@@ -1081,7 +1079,7 @@ Respond with ONLY a valid JSON tool call in this format:
``Retry-After`` response header is honoured) or its message
Returns:
Provider delay in seconds, explicit fixed delay, or None if unspecified
Retry delay in seconds, or default if not found
"""
# Max delay cap to prevent unbounded sleep (5 minutes default)
max_delay = 300
@@ -1092,9 +1090,7 @@ Respond with ONLY a valid JSON tool call in this format:
error = error_message if isinstance(error_message, BaseException) else Exception(error_message)
delay = extract_retry_after(error, cap_seconds=max_delay)
if delay is None and self._retry_delay_explicit:
return self._retry_delay
return delay
return self._retry_delay if delay is None else delay
def _is_rate_limit_error(self, error: Exception) -> bool:
"""Check if an exception is a rate limit error.
@@ -1312,10 +1308,9 @@ Respond with ONLY a valid JSON tool call in this format:
# Rate limiting - extract retry delay
if error_kind == "rate_limit":
backoff = self._parse_retry_delay(error)
if backoff is None:
from .error_classifier import ErrorCategory, get_retry_delay
backoff = get_retry_delay(ErrorCategory.RATE_LIMIT, attempt)
backoff = self._parse_retry_delay(error) # Returns seconds
if backoff == 0: # No specific delay found, use exponential backoff
backoff = min(2 ** (attempt - 1), 60) # 1s, 2s, 4s, ... cap at 60s
return FailoverDecision(
action="retry",
reason=error_kind,
@@ -4376,11 +4371,9 @@ Respond with ONLY a valid JSON tool call in this format:
arguments = self._validate_and_filter_ollama_arguments(function_name, arguments, tools)
logging.debug(f"[TOOL_EXEC_DEBUG] About to execute tool {function_name} with args: {arguments}")
# A ToolExecutionError (a tool that raised, or a terminal
# framework decision such as a loop-guard HALT) ends the
# turn, same as the async path in execution_mixin.py.
# Any other unexpected failure here is reported back to
# the model instead of aborting the whole run.
# Capture tool failures and report them back to the model
# instead of aborting the whole run (safe-by-default,
# matching the async path in execution_mixin.py).
try:
tool_result = execute_tool_fn(
function_name,
@@ -135,29 +135,20 @@ def _get_display_tool_call():
_display_tool_call = display_tool_call
return _display_tool_call
# Sampling params reasoning models reject (same set as ``llm.py``).
_REASONING_UNSUPPORTED_PARAMS = (
"temperature", "top_p", "presence_penalty", "frequency_penalty", "logit_bias",
)
def _normalize_chat_params(params: Dict[str, Any]) -> Dict[str, Any]:
"""Make Chat Completions params acceptable to reasoning models (#5909).
An unset ``temperature``/``max_tokens`` is omitted rather than sent as
JSON ``null``, which reasoning models reject; an explicit ``max_tokens``
becomes ``max_completion_tokens`` for them and unsupported sampling
params are dropped (mirrors ``llm.py``, #6128).
becomes ``max_completion_tokens`` for them (mirrors ``llm.py``).
"""
for key in ("temperature", "max_tokens"):
if key in params and params[key] is None:
del params[key]
if any(key in params for key in ("max_tokens",) + _REASONING_UNSUPPORTED_PARAMS):
if "max_tokens" in params:
from .model_capabilities import is_reasoning_model
if is_reasoning_model(params.get("model") or ""):
if "max_tokens" in params:
params.setdefault("max_completion_tokens", params.pop("max_tokens"))
for key in _REASONING_UNSUPPORTED_PARAMS:
params.pop(key, None)
params.setdefault("max_completion_tokens", params.pop("max_tokens"))
return params
# Constants
@@ -202,7 +193,6 @@ class CompletionUsage:
prompt_tokens_details: Optional[PromptTokensDetails] = None
prompt_cache_hit_tokens: int = 0
prompt_cache_miss_tokens: int = 0
cost: Optional[float] = None
@dataclass
class ChatCompletion:
@@ -260,8 +250,6 @@ def process_stream_chunks(chunks):
# Track usage
completion_tokens = 0
prompt_tokens = 0
cached_tokens = 0
reported_cost = None
content_list = []
reasoning_list = []
@@ -278,11 +266,6 @@ def process_stream_chunks(chunks):
if chunk_usage:
completion_tokens = getattr(chunk_usage, "completion_tokens", 0) or completion_tokens
prompt_tokens = getattr(chunk_usage, "prompt_tokens", 0) or prompt_tokens
details = getattr(chunk_usage, "prompt_tokens_details", None)
cached_tokens = getattr(details, "cached_tokens", 0) or cached_tokens
chunk_cost = getattr(chunk_usage, "cost", None)
if isinstance(chunk_cost, (int, float)):
reported_cost = chunk_cost
if not hasattr(chunk, "choices") or not chunk.choices:
continue
@@ -360,8 +343,7 @@ def process_stream_chunks(chunks):
prompt_tokens=prompt_tokens,
total_tokens=completion_tokens + prompt_tokens,
completion_tokens_details=CompletionTokensDetails(),
prompt_tokens_details=PromptTokensDetails(cached_tokens=cached_tokens),
cost=reported_cost,
prompt_tokens_details=PromptTokensDetails()
)
return ChatCompletion(
@@ -933,10 +915,7 @@ class OpenAIClient:
# ── Scalar params ────────────────────────────────────────────────
if temperature is not None:
from .model_capabilities import is_reasoning_model
# Reasoning models reject sampling params (#6128).
if not is_reasoning_model(model):
params["temperature"] = temperature
params["temperature"] = temperature
return params
@@ -125,10 +125,13 @@ class ToolsMCPServer:
func = self._tool_map[tool_name]
# Handle async functions (works with or without a running event loop)
# Handle async functions
if asyncio.iscoroutinefunction(func):
from ..utils.async_bridge import run_coroutine_from_any_context
return run_coroutine_from_any_context(func(**arguments), timeout=None)
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(func(**arguments))
finally:
loop.close()
return func(**arguments)
@@ -696,7 +696,6 @@ class Memory(MemoryCoreMixin):
accuracy, weights, evaluator_quality
)
metadata = self._stamp_provenance(metadata, trust, origin)
metadata = self._stamp_user_id(metadata)
logger.info(f"Processed metadata: {metadata}")
# Generate unique ID and timestamp once
@@ -804,18 +803,6 @@ class Memory(MemoryCoreMixin):
metadata["origin"] = origin
return metadata
def _stamp_user_id(self, metadata: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""Default ``metadata["user_id"]`` to this store's configured user_id.
User-scoped search post-filters on ``metadata.user_id``; without the
stamp, facts saved via ``agent.store_memory()`` were invisible to the
same user's recall (#6040). An explicit metadata user_id still wins.
"""
user_id = self.cfg.get("user_id")
if not user_id:
return metadata or {}
return {"user_id": user_id, **(metadata or {})}
@staticmethod
def _filter_by_trust(
results: List[Dict[str, Any]],
@@ -1087,7 +1074,6 @@ class Memory(MemoryCoreMixin):
accuracy, weights, evaluator_quality
)
metadata = self._stamp_provenance(metadata, trust, origin)
metadata = self._stamp_user_id(metadata)
logger.info(f"Processed metadata: {metadata}")
# Generate unique ID
@@ -939,9 +939,7 @@ class PluginManager:
name=f"{name}:{event.value}",
# Delivery policies compose in order: apply a rewrite
# before the next policy, and stop the chain on denial.
# AFTER_AGENT adapters mutate the shared response, so
# stacked redactors must also run in order.
sequential=event in (PluginHook.MESSAGE_SENDING, PluginHook.AFTER_AGENT),
sequential=event == PluginHook.MESSAGE_SENDING,
)
hook_ids.append(hook_id)
count += 1
@@ -1173,14 +1171,8 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
if _overrides("after_agent"):
def after_agent_hook(data, _p=resolving_plugin):
try:
new_resp = _p.after_agent(getattr(data, "response", ""),
{"agent_name": getattr(data, "agent_name", None)})
except GuardrailBlocked as e:
return HookResult.block(e.reason)
decision = _as_decision(new_resp)
if decision is not None:
return decision
new_resp = _p.after_agent(getattr(data, "response", ""),
{"agent_name": getattr(data, "agent_name", None)})
if isinstance(new_resp, str) and hasattr(data, "response"):
data.response = new_resp
return HookResult.allow()
@@ -232,18 +232,8 @@ class Plugin(ABC):
"""
return prompt
def after_agent(
self, response: str, context: Dict[str, Any]
) -> Union[str, "PluginDecision", "HookResult", None]:
"""Called after agent execution.
Return a modified ``response`` (rewrite), or a deny/block decision
(``PluginDecision.deny(reason)`` / a ``HookResult`` / raise
:class:`GuardrailBlocked`) to replace the value returned to the caller
(and the in-memory assistant turn) with a refusal marker, or ``None``
for no-op. Interaction callbacks, verbose display, streamed output and
session/DB persistence happen before this hook and are not gated by it.
"""
def after_agent(self, response: str, context: Dict[str, Any]) -> str:
"""Called after agent execution. Can modify response."""
return response
def before_tool(
@@ -87,8 +87,7 @@ class PolicyConfig:
def save_learned_rule(self, resource: str, action: str = "allow") -> bool:
"""Persist a learned rule to the rules file.
Uses a locked, atomic read-modify-write so concurrent savers keep
each other's rules.
Uses atomic write (tmp + rename) for safety.
Args:
resource: Resource pattern (e.g., "tool:pip install *")
@@ -101,21 +100,24 @@ class PolicyConfig:
return False
try:
# Load existing rules
existing = self.load_learned_rules()
# Check for duplicates
for rule in existing:
if rule.get("resource") == resource:
return True # Already exists
# Add new rule
existing.append({"resource": resource, "action": action})
# Ensure directory exists
rules_dir = os.path.dirname(self.rules_file)
if rules_dir:
os.makedirs(rules_dir, exist_ok=True)
# Read, dedupe and append under the per-path lock so concurrent
# writers never drop each other's rules (last-writer-wins).
def _add(data):
rules = data.setdefault("rules", [])
if not any(r.get("resource") == resource for r in rules):
rules.append({"resource": resource, "action": action})
from ..utils.atomic_io import update_json
update_json(self.rules_file, _add, default=lambda: {"rules": []}, indent=2)
os.makedirs(rules_dir, exist_ok=True)
from ..utils.atomic_io import atomic_write_json
atomic_write_json(self.rules_file, {"rules": existing}, indent=2)
logger.debug(f"Saved learned rule: {resource} -> {action}")
return True
except OSError as e:
@@ -682,9 +682,7 @@ class Process:
if task.next_tasks:
for next_task_name in task.next_tasks:
next_task = self._task_by_name(next_task_name)
# Skip AgentTeam's inferred navigation-only link between
# independent async tasks so they stay parallel.
if next_task and getattr(next_task, "_navigation_only_prev", None) != task.name:
if next_task:
next_task.previous_tasks.append(task.name)
logging.debug(f"Added {task.name} as previous task for {next_task_name}")
@@ -1355,9 +1353,7 @@ Provide a JSON with the structure:
if task.next_tasks:
for next_task_name in task.next_tasks:
next_task = self._task_by_name(next_task_name)
# Skip AgentTeam's inferred navigation-only link between
# independent async tasks so they stay parallel.
if next_task and getattr(next_task, "_navigation_only_prev", None) != task.name:
if next_task:
next_task.previous_tasks.append(task.name)
# Find start task
@@ -11,7 +11,6 @@ No heavy imports at module level.
"""
import json
import logging
import os
from dataclasses import dataclass, field
from datetime import datetime
@@ -19,8 +18,6 @@ from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
from .budget import estimate_tokens
logger = logging.getLogger(__name__)
@runtime_checkable
class SummarizerProtocol(Protocol):
@@ -384,11 +381,10 @@ class HierarchicalSummarizer:
"nodes": {k: v.to_dict() for k, v in result.nodes.items()},
}
# Atomic: a failed/partial write must not truncate the previous hierarchy.
from ..utils.atomic_io import atomic_write_json
atomic_write_json(filepath, data, indent=2)
except Exception as e:
logger.warning("Failed to save hierarchy %s: %s", corpus_path, e)
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
except Exception:
pass
def _load_hierarchy(self, corpus_path: str) -> Optional[HierarchyResult]:
"""Load hierarchy from disk."""
@@ -309,10 +309,7 @@ class RunJournal:
"""Return ids of runs still ``running`` — resume candidates on restart.
A run left ``running`` belonged to a process that exited without
recording a terminal outcome. Nothing resumes these automatically;
callers resume one turn with
``ExecutionConfig(durable=True, resume_run_id=..., journal_path=...)``
(same journal) and the run's recorded ``task`` as the prompt.
recording a terminal outcome; the gateway resumes each on boot.
"""
with self._lock:
rows = self._conn.execute(
@@ -15,7 +15,7 @@ import time
from typing import Dict, Iterator, List, Optional
from .models import ScheduleJob, RunRecord
from .due import is_due as _is_due, is_misfire as _is_misfire, lease_state as _lease_state, past_until as _past_until, resolve_schedule_timezone
from .due import is_due as _is_due, is_misfire as _is_misfire, resolve_schedule_timezone
from .hook_emit import emit_schedule_add, emit_schedule_remove
logger = get_logger(__name__)
@@ -187,37 +187,25 @@ class ConfigYamlScheduleStore:
job that is due and not already leased by someone else we:
* advance ``last_run_at`` to ``now`` (pre-advancing the schedule so a
later poll no longer sees it as due),
later poll no longer sees it as due — at-least-once → at-most-once),
* take a lease (``lease_until = now + lease_seconds``, ``lease_owner``),
persisting both in the same atomic write,
* mark spent jobs (one-shot, ``max_runs`` met) as retiring instead of
deleting them; the lease keeps competitors out and ``mark_run``
removes them once the run completes.
* remove one-shot jobs (``delete_after_run``) immediately so no other
ticker can pick them up.
A lease that expires without ``complete`` is an abandoned (crashed)
run and is recorded as ``abandoned`` history. A retiring job is then
re-offered once (at-least-once across crashes) and retired if that
attempt is abandoned too; a recurring job is at-most-once per slot and
next runs at its following slot. Losers of the race simply do not see
the job in their returned list.
A crashed run leaves a lease that expires after ``lease_seconds``; the
job then becomes due again and is retried. Losers of the race simply do
not see the job in their returned list.
"""
claimed: List[ScheduleJob] = []
# Spent jobs retired by this claim. Emitted after the lock below.
# One-shots auto-deleted by this claim. Emitted after the lock below;
# a later ``remove(job.id)`` by the runner is then a no-op, so a spent
# one-shot yields exactly one SCHEDULE_REMOVE.
auto_removed: List[ScheduleJob] = []
# (job_id, job_name, status) for occurrences suppressed by the misfire
# policy (``missed``) or runs whose lease expired (``abandoned``).
# Logged after the lock so a lost run is observable, not silent.
# (job_id, job_name) for occurrences suppressed by the misfire policy.
# Logged as ``missed`` history after the lock so a dropped occurrence is
# observable instead of vanishing silently.
missed: List[tuple] = []
def take(job: ScheduleJob) -> None:
job._lease_until = now + lease_seconds
job._lease_owner = owner_id
# ``run_count`` is counted at claim; flag it so ``mark_run`` does
# not double-count the same fire.
job._run_counted = True
self._held_leases[job.id] = owner_id
claimed.append(job)
with self._lock, self._file_lock():
# Re-read from disk so we observe cross-process claims/leases.
self._reload_locked()
@@ -225,31 +213,10 @@ class ConfigYamlScheduleStore:
for job in list(self._jobs.values()):
if not job.enabled:
continue
state = _lease_state(job, now, owner_id)
if state == "busy" or (
# Our own spent-job run outlived its lease but is still in
# flight: re-offering it would fire it twice.
state == "abandoned"
and getattr(job, "_retiring", 0)
and self._held_leases.get(job.id) == owner_id
):
continue
if state == "abandoned":
missed.append((job.id, job.name, "abandoned"))
job._lease_until = 0.0
job._lease_owner = None
changed = True
if getattr(job, "_retiring", 0) == 1 and not _past_until(job, now, self._default_timezone):
# First attempt of a spent job crashed: retry once
# (unless its ``until`` deadline has passed meanwhile).
job._retiring = 2
take(job)
continue
if getattr(job, "_retiring", 0):
# Run finished (or its retry was abandoned too): retire.
auto_removed.append(self._jobs.pop(job.id))
self._job_state.pop(job.id, None)
changed = True
lease_until = getattr(job, "_lease_until", 0.0) or 0.0
lease_owner = getattr(job, "_lease_owner", None)
# An unexpired lease held by another owner blocks the claim.
if lease_until > now and lease_owner != owner_id:
continue
if not _is_due(job, now, self._default_timezone):
# A job past its ``until`` end instant is not due, but must
@@ -268,17 +235,26 @@ class ConfigYamlScheduleStore:
# again on the next poll.
if _is_misfire(job, now, self._default_timezone):
job.last_run_at = now
missed.append((job.id, job.name, "missed"))
missed.append((job.id, job.name))
changed = True
continue
# Win the claim: pre-advance + lease atomically.
job.last_run_at = now
# Count the fire here (needed so ``should_retire()`` can drop a
# spent job before a competitor reclaims it) and flag it so the
# later ``mark_run`` does not double-count the same fire.
job.run_count += 1
if job.should_retire(now):
# Spent: keep it (leased) so a crash cannot lose it.
job._retiring = 1
take(job)
job._run_counted = True
job._lease_until = now + lease_seconds
job._lease_owner = owner_id
self._held_leases[job.id] = owner_id
claimed.append(job)
changed = True
if job.should_retire(now):
# Spent (one-shot, run-count met, or past ``until``): remove
# now so no competitor re-claims it.
auto_removed.append(self._jobs.pop(job.id))
self._job_state.pop(job.id, None)
if changed:
if not self._save():
# The lease/last_run_at advance was NOT persisted (full
@@ -294,10 +270,10 @@ class ConfigYamlScheduleStore:
emit_schedule_remove(job)
# Record suppressed occurrences so a missed run is visible in history
# rather than silently dropped (log_run takes only the thread lock).
for job_id, job_name, status in missed:
for job_id, job_name in missed:
self.log_run(
job_id=job_id,
status=status,
status="missed",
job_name=job_name,
)
return claimed
@@ -228,26 +228,6 @@ def quota_hold_from_failure(
return now + float(retry_after) + max(float(slack_seconds), 0.0)
def past_until(job: Any, now: float, default_timezone: str | None = None) -> bool:
"""Return ``True`` when ``now`` is at or past the job's ``until`` end instant."""
until = getattr(job, "until", None)
if until is None:
return False
try:
until_ts = localize_wall_clock(
datetime.fromisoformat(until),
job.schedule.tz or default_timezone,
).timestamp()
except (ValueError, TypeError):
# Fail safe: a stop bound that cannot be parsed must NOT let the
# job run past it forever. Treat it as elapsed; the claim path then
# retires it via ``ScheduleJob.should_retire`` (which returns True
# for an unparseable ``until``) rather than firing it.
logger.warning("Invalid 'until' timestamp for job %s: %s", job.id, until)
return True
return now >= until_ts
def is_due(
job: Any,
now: float,
@@ -284,8 +264,22 @@ def is_due(
if hold_until is not None and now < hold_until:
return False
if past_until(job, now, default_timezone):
return False
until = getattr(job, "until", None)
if until is not None:
try:
until_ts = localize_wall_clock(
datetime.fromisoformat(until),
sched.tz or default_timezone,
).timestamp()
except (ValueError, TypeError):
# Fail safe: a stop bound that cannot be parsed must NOT let the
# job run past it forever. Treat the job as not-due; the claim path
# then retires it via ``ScheduleJob.should_retire`` (which returns
# True for an unparseable ``until``) rather than firing it.
logger.warning("Invalid 'until' timestamp for job %s: %s", job.id, until)
return False
if now >= until_ts:
return False
if sched.kind == "every":
if sched.every_seconds is None:
@@ -347,26 +341,3 @@ def is_due(
return now >= next_run
return False
def lease_state(job: Any, now: float, owner_id: str) -> str | None:
"""Classify a job's claim lease for an atomic ``claim_due``.
Returns:
``"busy"`` when another owner holds an unexpired lease, or this owner
still holds one on a spent (retiring) job whose run is in flight;
``"abandoned"`` when a lease expired without ``complete`` — the run
crashed or the process died mid-run; ``"done"`` when a spent job's
run finished (lease released) but it was not removed yet; ``None``
when the job is free to be evaluated for due-ness.
"""
lease_until = getattr(job, "_lease_until", 0.0) or 0.0
lease_owner = getattr(job, "_lease_owner", None)
retiring = getattr(job, "_retiring", 0)
if lease_owner is not None and lease_until > now:
if lease_owner != owner_id or retiring:
return "busy"
return None
if lease_owner is not None:
return "abandoned"
return "done" if retiring else None
@@ -215,7 +215,7 @@ class RunRecord:
job_id: ID of the job that was executed.
job_name: Human-readable name of the job.
status: Execution status. One of ``"succeeded"``, ``"failed"``,
``"skipped"``, ``"no_change"``, ``"missed"`` or ``"abandoned"``. ``"no_change"``
``"skipped"``, ``"no_change"``, or ``"missed"``. ``"no_change"``
is the stateful "monitor mode" outcome — a watched source was
unchanged since the last tick, so the model turn was suppressed
silently (no tokens, no delivery). It is distinct from
@@ -225,8 +225,6 @@ class RunRecord:
the process was down and fell outside the job's
``misfire_grace_seconds`` — it was not run, but is recorded
(not silently dropped) so an operator can see the gap.
``"abandoned"`` records a claimed run whose lease expired
without completing (crash or restart mid-run).
result: Agent response text (truncated if very long).
error: Error message if status is ``"failed"``.
duration: Wall-clock seconds for execution.
@@ -236,7 +234,7 @@ class RunRecord:
job_id: str
job_name: str = ""
status: Literal["succeeded", "failed", "skipped", "no_change", "missed", "abandoned"] = "succeeded"
status: Literal["succeeded", "failed", "skipped", "no_change", "missed"] = "succeeded"
result: Optional[str] = None
error: Optional[str] = None
duration: float = 0.0
@@ -462,7 +460,7 @@ class ScheduleJob:
every claim path so the retire decision is defined once. A job retires
when it is:
* a one-shot (``delete_after_run``) that has fired,
* a one-shot (``delete_after_run``),
* at or past its ``max_runs`` budget, or
* past its ``until`` end instant (when ``now`` is supplied).
@@ -480,8 +478,7 @@ class ScheduleJob:
``until`` check is skipped (callers that only ran a job and
want the run-count/one-shot decision).
"""
# A one-shot that has not fired yet is waiting, not spent.
if self.delete_after_run and self.run_count > 0:
if self.delete_after_run:
return True
if self.max_runs is not None and self.run_count >= self.max_runs:
return True
@@ -590,11 +587,6 @@ class ScheduleJob:
if lease_until:
d["lease_until"] = lease_until
d["lease_owner"] = getattr(self, "_lease_owner", None)
# Spent job claimed for its final run: 1 = first attempt, 2 = the one
# retry after a crashed (abandoned) attempt. Omitted otherwise.
retiring = getattr(self, "_retiring", 0)
if retiring:
d["retiring"] = retiring
return d
@classmethod
@@ -653,5 +645,4 @@ class ScheduleJob:
# Restore atomic-claim lease metadata if present (see ``to_dict``).
job._lease_until = d.get("lease_until", 0.0) or 0.0
job._lease_owner = d.get("lease_owner")
job._retiring = d.get("retiring", 0) or 0
return job
@@ -62,10 +62,8 @@ class ScheduleRunner:
Args:
owner_id: Stable identifier for this ticker (process/host).
lease_seconds: Lease window. A claim not completed within it is
recorded as an ``abandoned`` run: a one-shot / spent job is
retried once (at-least-once), a recurring job runs at its next
slot (at-most-once per slot).
lease_seconds: Lease window; a claim not completed expires after
this so a crashed run is eventually retried.
"""
claim = getattr(self._store, "claim_due", None)
if callable(claim):
@@ -87,11 +85,7 @@ class ScheduleRunner:
duration: float = 0.0,
delivered: bool = False,
) -> None:
"""Update ``last_run_at``, log history, and retire spent jobs.
A spent job (one-shot, ``max_runs`` met) is removed after a run that
did not fail; a failed one is kept, disabled, so it stays visible in
``schedule list`` and can be re-enabled or removed by the user.
"""Update ``last_run_at``, log history, and optionally delete one-shot jobs.
Args:
job: The job that was executed.
@@ -103,9 +97,10 @@ class ScheduleRunner:
"""
job.last_run_at = time.time()
# Count this fire exactly once. An atomic ``claim_due`` already
# incremented ``run_count`` when it reserved the job (so
# ``should_retire()`` can mark a spent job as retiring); it flags the
# job with ``_run_counted`` so we do not double-count here. The non-atomic ``get_due_jobs`` path never sets that flag, so
# incremented ``run_count`` when it reserved the job (and needs to, so
# ``should_retire()`` can drop a spent job before a competitor reclaims
# it); it flags the job with ``_run_counted`` so we do not double-count
# here. The non-atomic ``get_due_jobs`` path never sets that flag, so
# ``mark_run`` remains the single incrementer there.
if getattr(job, "_run_counted", False):
job._run_counted = False
@@ -127,19 +122,9 @@ class ScheduleRunner:
# The run already happened (its claim was persisted), so a failed write
# here is reported, not raised: one bad write must not stop the loop.
try:
if not job.should_retire(job.last_run_at):
self._store.update(job)
elif status != "failed":
if job.should_retire(job.last_run_at):
self._store.remove(job.id)
else:
# Keep a failed spent job visible (disabled) instead of
# deleting it, so the user can see and re-run it. Undo the
# spent fire (history keeps the failed row) so resuming it
# runs it again instead of retiring it unrun.
job.enabled = False
job._retiring = 0
job.run_count = max(0, job.run_count - 1)
job.last_run_at = None
self._store.update(job)
except OSError as e:
logger.error("Failed to persist run state for job %r: %s", job.id, e)
@@ -14,7 +14,7 @@ import time
from typing import Dict, Iterator, List, Optional
from .models import ScheduleJob, RunRecord
from .due import is_due as _is_due, is_misfire as _is_misfire, lease_state as _lease_state, past_until as _past_until
from .due import is_due as _is_due, is_misfire as _is_misfire
from .hook_emit import emit_schedule_add, emit_schedule_remove
from ..utils.atomic_io import atomic_write_json
@@ -175,37 +175,25 @@ class FileScheduleStore:
job that is due and not already leased by someone else we:
* advance ``last_run_at`` to ``now`` (pre-advancing the schedule so a
later poll no longer sees it as due),
later poll no longer sees it as due — at-least-once → at-most-once),
* take a lease (``lease_until = now + lease_seconds``, ``lease_owner``),
persisting both in the same atomic write,
* mark spent jobs (one-shot, ``max_runs`` met) as retiring instead of
deleting them; the lease keeps competitors out and ``mark_run``
removes them once the run completes.
* remove one-shot jobs (``delete_after_run``) immediately so no other
ticker can pick them up.
A lease that expires without ``complete`` is an abandoned (crashed)
run and is recorded as ``abandoned`` history. A retiring job is then
re-offered once (at-least-once across crashes) and retired if that
attempt is abandoned too; a recurring job is at-most-once per slot and
next runs at its following slot. Losers of the race simply do not see
the job in their returned list.
A crashed run leaves a lease that expires after ``lease_seconds``; the
job then becomes due again and is retried. Losers of the race simply do
not see the job in their returned list.
"""
claimed: List[ScheduleJob] = []
# Spent jobs retired by this claim. Emitted after the lock below.
# One-shots auto-deleted by this claim. Emitted after the lock below;
# a later ``remove(job.id)`` by the runner is then a no-op, so a spent
# one-shot yields exactly one SCHEDULE_REMOVE.
auto_removed: List[ScheduleJob] = []
# (job_id, job_name, status) for occurrences suppressed by the misfire
# policy (``missed``) or runs whose lease expired (``abandoned``).
# Logged after the lock so a lost run is observable, not silent.
# (job_id, job_name) for occurrences suppressed by the misfire policy.
# Logged as ``missed`` history after the lock so a dropped occurrence is
# observable instead of vanishing silently.
missed: List[tuple] = []
def take(job: ScheduleJob) -> None:
job._lease_until = now + lease_seconds
job._lease_owner = owner_id
# ``run_count`` is counted at claim; flag it so ``mark_run`` does
# not double-count the same fire.
job._run_counted = True
self._held_leases[job.id] = owner_id
claimed.append(job)
with self._lock, self._file_lock():
# Re-read from disk so we observe cross-process claims/leases.
self._reload_locked()
@@ -213,30 +201,10 @@ class FileScheduleStore:
for job in list(self._jobs.values()):
if not job.enabled:
continue
state = _lease_state(job, now, owner_id)
if state == "busy" or (
# Our own spent-job run outlived its lease but is still in
# flight: re-offering it would fire it twice.
state == "abandoned"
and getattr(job, "_retiring", 0)
and self._held_leases.get(job.id) == owner_id
):
continue
if state == "abandoned":
missed.append((job.id, job.name, "abandoned"))
job._lease_until = 0.0
job._lease_owner = None
changed = True
if getattr(job, "_retiring", 0) == 1 and not _past_until(job, now):
# First attempt of a spent job crashed: retry once
# (unless its ``until`` deadline has passed meanwhile).
job._retiring = 2
take(job)
continue
if getattr(job, "_retiring", 0):
# Run finished (or its retry was abandoned too): retire.
auto_removed.append(self._jobs.pop(job.id))
changed = True
lease_until = getattr(job, "_lease_until", 0.0) or 0.0
lease_owner = getattr(job, "_lease_owner", None)
# An unexpired lease held by another owner blocks the claim.
if lease_until > now and lease_owner != owner_id:
continue
if not _is_due(job, now):
# A job past its ``until`` end instant is not due, but must
@@ -256,17 +224,25 @@ class FileScheduleStore:
# poll does not see it as due again) and record the miss,
# without claiming or firing.
job.last_run_at = now
missed.append((job.id, job.name, "missed"))
missed.append((job.id, job.name))
changed = True
continue
# Win the claim: pre-advance + lease atomically.
job.last_run_at = now
# Count the fire here (needed so ``should_retire()`` can drop a
# spent job before a competitor reclaims it) and flag it so the
# later ``mark_run`` does not double-count the same fire.
job.run_count += 1
if job.should_retire(now):
# Spent: keep it (leased) so a crash cannot lose it.
job._retiring = 1
take(job)
job._run_counted = True
job._lease_until = now + lease_seconds
job._lease_owner = owner_id
self._held_leases[job.id] = owner_id
claimed.append(job)
changed = True
if job.should_retire(now):
# Spent (one-shot, run-count met, or past ``until``): remove
# now so no competitor re-claims it.
auto_removed.append(self._jobs.pop(job.id))
if changed:
if not self._save():
# The lease/last_run_at advance was NOT persisted (full
@@ -283,10 +259,10 @@ class FileScheduleStore:
# Record suppressed occurrences so a missed run is visible in history
# rather than silently dropped. Logged outside the file lock (log_run
# takes it itself; flock is not re-entrant across file handles).
for job_id, job_name, status in missed:
for job_id, job_name in missed:
self.log_run(
job_id=job_id,
status=status,
status="missed",
job_name=job_name,
)
return claimed
@@ -306,8 +306,6 @@ class HierarchicalSessionStore(DefaultSessionStore):
raw = f.read()
data = json.loads(raw.decode("utf-8"))
session = ExtendedSessionData.from_dict(data)
if not self._owns_file(session_id, filepath, session):
return ExtendedSessionData(session_id=session_id)
# Bind the fingerprint to this parsed object and these exact bytes.
# A later writer must not certify an older payload via its stats.
with self._lock:
@@ -35,13 +35,7 @@ from typing import Any, Dict, List, Optional
from praisonaiagents._logging import get_logger
from .store import (
DefaultSessionStore,
FileLock,
SessionData,
_legacy_session_filename,
_safe_session_filename,
)
from .store import DefaultSessionStore, FileLock, SessionData
logger = get_logger(__name__)
@@ -57,8 +51,7 @@ class SqliteSessionStore(DefaultSessionStore):
# Bumped whenever ``_flatten`` changes what content lands in the index, so
# a store built by an older release rebuilds its FTS rows once on upgrade.
# v2: archived_messages included (Issue #5031).
# v3: compaction summaries excluded (Issue #5051).
INDEX_CONTENT_VERSION = 3
INDEX_CONTENT_VERSION = 2
def __init__(
self,
@@ -236,16 +229,11 @@ class SqliteSessionStore(DefaultSessionStore):
Includes ``archived_messages`` (turns rolled out of the active window
by ``retention="compact"``) ahead of the active ``messages`` so a
compaction re-index preserves — rather than deletes — the raw turns,
keeping the whole conversation searchable (Issue #5031). Compaction
summaries are skipped: search never scores them, so indexing them
would only spend the candidate allowance on misses (Issue #5051).
keeping the whole conversation searchable (Issue #5031).
"""
parts = []
for msg in (*session.archived_messages, *session.messages):
content = getattr(msg, "content", "")
metadata = getattr(msg, "metadata", None)
if isinstance(metadata, dict) and metadata.get("compaction"):
continue
if content:
parts.append(str(content))
return "\n".join(parts)
@@ -402,22 +390,6 @@ class SqliteSessionStore(DefaultSessionStore):
except Exception:
return set()
def _stored_session_id(self, filename: str) -> Optional[str]:
"""Return the id stored in ``filename`` if it maps back to that file."""
import json
try:
with open(os.path.join(self.session_dir, filename), "r", encoding="utf-8") as f:
sid = json.load(f).get("session_id")
except (OSError, ValueError, AttributeError):
return None
if isinstance(sid, str) and filename in (
f"{_safe_session_filename(sid)}.json",
f"{_legacy_session_filename(sid)}.json",
):
return sid
return None
def _reindex_all(self) -> bool:
"""Backfill the index from existing JSON transcripts.
@@ -461,10 +433,6 @@ class SqliteSessionStore(DefaultSessionStore):
if not filename.endswith(".json"):
continue
sid = filename[:-5]
if "~" in sid or sid not in already:
# Hashed or pre-#6024 legacy name (Issue #6024): the real id
# lives in the file; loading it migrates a legacy file.
sid = self._stored_session_id(filename) or sid
if sid in pending:
# Failed restore retries must not be bypassed by a synthetic
# empty transcript from the initial/upgrade backfill path.
@@ -932,7 +900,7 @@ class SqliteSessionStore(DefaultSessionStore):
if not isinstance(msg, dict):
continue
content = str(msg.get("content", ""))
if not content or self._is_compaction_summary(msg):
if not content:
continue
lowered = content.lower()
score = 0.0
@@ -6,7 +6,6 @@ Zero dependencies beyond stdlib.
"""
import copy
import hashlib
import json
from praisonaiagents._logging import get_logger
import os
@@ -31,27 +30,6 @@ from ..paths import get_sessions_dir
logger = get_logger(__name__)
def _legacy_session_filename(session_id: str) -> str:
"""Pre-#6024 lossy filename (many ids -> one name); kept only for migration."""
return "".join(c if c.isalnum() or c in "-_" else "_" for c in session_id)
def _safe_session_filename(session_id: str) -> str:
"""Injective, filesystem-safe filename stem for a session id (Issue #6024).
Ids already made of ``[A-Za-z0-9_-]`` keep their exact legacy name; any
other id gets a readable prefix plus a digest of the full id, so ids that
differ only in ``.``/``@``/``:``/… never share a file.
"""
readable = _legacy_session_filename(session_id)
if readable == session_id:
return readable
digest = hashlib.sha256(session_id.encode("utf-8")).hexdigest()[:16]
return f"{readable[:64]}~{digest}"
# Module-level sentinel to track if we've warned about degraded locking
_WARNED_NO_FCNTL = False
@@ -906,56 +884,10 @@ class DefaultSessionStore:
)
def _get_session_path(self, session_id: str) -> str:
"""Get the file path for a session (injective mapping, Issue #6024)."""
path = self._session_file_path(session_id)
legacy = os.path.join(self.session_dir, f"{_legacy_session_filename(session_id)}.json")
# Migrate a pre-#6024 file only when it is provably this id's own.
# Recheck under the destination lock so a racing worker that already
# migrated and wrote here is never clobbered by a stale move.
if legacy != path and not os.path.exists(path) and os.path.exists(legacy):
try:
with FileLock(path, self.lock_timeout):
if not os.path.exists(path):
with open(legacy, "r", encoding="utf-8") as f:
owner = json.load(f).get("session_id")
if owner == session_id:
os.replace(legacy, path)
except (OSError, ValueError, AttributeError):
pass
return path
def _session_file_path(self, session_id: str) -> str:
"""Session file path without legacy migration (takes no lock)."""
return os.path.join(self.session_dir, f"{_safe_session_filename(session_id)}.json")
def _owns_file(self, session_id: str, filepath: str, session: SessionData) -> bool:
"""Return False if the loaded file belongs to another session id.
A foreign file (e.g. a pre-#6024 legacy collision) is never served to
the caller; it is moved to its owner's own path (or set aside when that
is taken) so a later write here cannot destroy the owner's history.
Caller must hold the ``filepath`` lock. Raises ``OSError`` if the file
cannot be moved, so writers abort instead of overwriting it.
"""
if not session.session_id:
session.session_id = session_id
return True
if session.session_id == session_id:
return True
logger.warning(
"Session file %s belongs to session %r, not %r; starting fresh",
filepath, session.session_id, session_id,
)
owner_path = self._session_file_path(session.session_id)
moved = False
if owner_path != filepath:
with FileLock(owner_path, self.lock_timeout):
if not os.path.exists(owner_path):
os.replace(filepath, owner_path)
moved = True
if not moved and self._quarantine_corrupt(filepath) is None:
raise OSError(f"cannot move foreign session file {filepath}")
return False
"""Get the file path for a session."""
# Sanitize session_id for filesystem
safe_id = "".join(c if c.isalnum() or c in "-_" else "_" for c in session_id)
return os.path.join(self.session_dir, f"{safe_id}.json")
def _load_session(self, session_id: str) -> SessionData:
"""Load session from disk with file locking."""
@@ -1020,10 +952,7 @@ class DefaultSessionStore:
try:
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
session = SessionData.from_dict(data)
if not self._owns_file(session_id, filepath, session):
return SessionData(session_id=session_id)
return session
return SessionData.from_dict(data)
except (json.JSONDecodeError, UnicodeDecodeError) as e:
# Malformed content — the parsed data is unusable, but the raw file
# may still hold recoverable bytes. Quarantine it aside so the next
@@ -1193,7 +1122,9 @@ class DefaultSessionStore:
try:
spill_dir = self._spill_dir()
os.makedirs(spill_dir, exist_ok=True)
safe_id = _safe_session_filename(session_id)
safe_id = "".join(
c if c.isalnum() or c in "-_" else "_" for c in session_id
)
# A monotonic ms timestamp keeps files sortable/attributable, but a
# short random token guarantees uniqueness so consecutive failures
# within the same PID+millisecond never overwrite an earlier spill
@@ -1304,15 +1235,13 @@ class DefaultSessionStore:
spill_dir = self._spill_dir()
if not os.path.isdir(spill_dir):
return
# Also match pre-#6024 spill names; the session_id check below
# rejects any spill that belongs to a colliding id.
prefixes = (
f"{_safe_session_filename(session_id)}.",
f"{_legacy_session_filename(session_id)}.",
safe_id = "".join(
c if c.isalnum() or c in "-_" else "_" for c in session_id
)
prefix = f"{safe_id}."
candidates = sorted(
f for f in os.listdir(spill_dir)
if f.startswith(prefixes) and f.endswith(".json")
if f.startswith(prefix) and f.endswith(".json")
)
except (IOError, OSError):
return
@@ -1402,9 +1331,7 @@ class DefaultSessionStore:
new_messages = [m for _, msgs in recovered for m in msgs]
if new_messages:
session.messages.extend(new_messages)
# Callers already hold this path's lock; the migrating lookup
# would take it again and stall for ``lock_timeout``.
filepath = self._session_file_path(session_id)
filepath = self._get_session_path(session_id)
session.updated_at = datetime.now(timezone.utc).isoformat()
# Recovered turns extend the active window like any other append, so
# they must go through the same retention policy (compact/truncate)
@@ -2401,17 +2328,6 @@ class DefaultSessionStore:
snippet = snippet + "…"
return snippet
@staticmethod
def _is_compaction_summary(msg: Dict[str, Any]) -> bool:
"""True for the synthetic ``retention="compact"`` rollup message.
The summary paraphrases archived turns that are scanned verbatim, so
it is kept for context/replay but excluded from scoring — otherwise a
session's rank would depend on how often it was compacted (Issue #5051).
"""
metadata = msg.get("metadata")
return isinstance(metadata, dict) and bool(metadata.get("compaction"))
@staticmethod
def _searchable_messages(data: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Project a persisted session into the list of messages to scan.
@@ -2488,7 +2404,7 @@ class DefaultSessionStore:
if not isinstance(msg, dict):
continue
content = str(msg.get("content", ""))
if not content or self._is_compaction_summary(msg):
if not content:
continue
lowered = content.lower()
score = 0.0
@@ -540,21 +540,21 @@ class MinimalTelemetry:
# Use a timeout-based flush to prevent hanging
import threading
# Daemon thread + bounded join: a ThreadPoolExecutor would join
# a wedged flush on exit/shutdown, so the timeout never applied.
flush_thread = threading.Thread(
target=self._safe_flush_posthog, args=(posthog_client,),
name="praisonai-posthog-flush", daemon=True,
)
flush_thread.start()
flush_thread.join(timeout=5.0) # 5 second timeout
if flush_thread.is_alive():
# Consumer flush/shutdown would block on the same wedged
# transport, so skip the remaining cleanup.
self.logger.warning("PostHog flush timed out")
return
self.logger.debug("PostHog flush completed")
import time
import concurrent.futures
# Use ThreadPoolExecutor for better control
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
flush_future = executor.submit(self._safe_flush_posthog, posthog_client)
try:
flush_future.result(timeout=5.0) # 5 second timeout
self.logger.debug("PostHog flush completed successfully")
except concurrent.futures.TimeoutError:
self.logger.warning("PostHog flush timed out")
flush_future.cancel()
except Exception as e:
self.logger.error(f"PostHog flush failed: {e}")
# Cleanup PostHog threads safely
self._shutdown_posthog_threads(posthog_client)
@@ -62,22 +62,21 @@ class DelegationTools:
# blocking past the documented deadline. A non-positive timeout
# means "no bound".
if timeout and timeout > 0:
# Daemon worker: a ThreadPoolExecutor worker is joined at
# interpreter exit, so a hung delegate would keep the process alive.
from .call_executor import ToolTimeoutError, _run_with_timeout
import concurrent.futures
try:
result = _run_with_timeout(
lambda: spawn(task=task_description, agent_name=agent_type),
timeout,
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
future = pool.submit(
spawn, task=task_description, agent_name=agent_type
)
except ToolTimeoutError:
return json.dumps({
"success": False,
"task_description": task_description,
"agent_type": agent_type,
"error": f"Delegated task timed out after {timeout}s",
}, indent=2)
try:
result = future.result(timeout=timeout)
except concurrent.futures.TimeoutError:
return json.dumps({
"success": False,
"task_description": task_description,
"agent_type": agent_type,
"error": f"Delegated task timed out after {timeout}s",
}, indent=2)
else:
result = spawn(task=task_description, agent_name=agent_type)
@@ -681,6 +681,37 @@ def list_tools() -> List[str]:
return get_registry().list_tools()
def get_tool_definitions(
permission_resolver: Optional[Callable[[str], bool]] = None,
) -> List[Dict[str, Any]]:
"""Get OpenAI-compatible tool definitions with dynamic schema overrides applied.
This is the main entry point for getting tool schemas that respects
dynamic overrides. Called every time tools need to be passed to the LLM.
Args:
permission_resolver: Optional ``(tool_name) -> bool`` filter; denied
tools are dropped from the advertised schema (deny = hide).
Returns:
List of OpenAI-compatible tool definition dictionaries
"""
return get_registry().get_tool_definitions(permission_resolver=permission_resolver)
def discover_plugins() -> int:
"""Discover and load tools from single-file plugins.
Scans default plugin directories for WordPress-style plugins:
- ./.praisonai/plugins/ (project-level)
- ~/.praisonai/plugins/ (user-level)
Returns:
Number of plugins loaded
"""
return get_registry().discover_single_file_plugins()
def list_available_tools(
context: Optional[Dict[str, Any]] = None,
ttl_seconds: Optional[float] = None
@@ -148,9 +148,8 @@ def schedule_add(
default ``PRAISONAI_SCHEDULE_TIMEZONE`` is used, then UTC.
once: When True the job is a one-shot — it is removed automatically
after its single successful fire (maps to ``delete_after_run``).
A failed fire leaves it listed but disabled; a run lost to a crash
is recorded as ``abandoned`` and retried once. Useful for ``at:`` /
``in ...`` reminders so a spent job does not linger in listings.
Useful for ``at:`` / ``in ...`` reminders so a spent job does not
linger in listings.
max_runs: Optional run-count bound for a recurring schedule — fire at
most this many times, then auto-remove (e.g. "remind me 3 times").
``0`` (default) means unbounded. Generalises ``once`` (``max_runs=1``).
@@ -66,35 +66,6 @@ def _emit_subagent_stop(
logger.debug("SUBAGENT_STOP hook failed", exc_info=True)
def _messenger_delivery(messenger: Any) -> Callable[[Any], None]:
"""Build an ``on_complete`` callback that sends a finished job's result
back to the turn's own chat (``"origin"``) through its outbound messenger.
Raises when the send is not accepted so the job manager leaves the job
un-``delivered`` instead of recording a delivery that never happened.
"""
def _deliver(info: Any) -> None:
from .messaging_tools import _run_async
result = info.result
if info.error:
text = f"Background task failed: {info.error}"
elif isinstance(result, dict):
text = result.get("output") if result.get("success") else (
f"Background task failed: {result.get('error')}"
)
else:
text = result
if not text:
text = "Background task finished with no output."
outcome = _run_async(messenger.send("origin", str(text)))
if not getattr(outcome, "ok", False):
raise RuntimeError(getattr(outcome, "summary", "") or "delivery failed")
return _deliver
def create_subagent_tool(
agent_factory: Optional[Callable[..., Any]] = None,
allowed_agents: Optional[List[str]] = None,
@@ -143,11 +114,9 @@ def create_subagent_tool(
``deliver`` target finishes. This is the observable completion
signal a gateway (e.g. ``BotOS``) subscribes to in order to
route the result back to the originating chat without an active
turn. When ``None`` (the default), ``deliver="origin"`` targets
the turn's own chat, the spawning turn has an outbound messenger
registered (a running gateway) and the send policy allows it, the
result is sent back through that messenger; otherwise background
jobs remain pull-only via ``subagent_result``.
turn. When ``None`` (the default) background jobs remain strictly
pull-only via ``subagent_result`` — byte-for-byte the prior
behaviour.
Returns:
Tool definition dictionary
@@ -387,45 +356,16 @@ def create_subagent_tool(
origin: Dict[str, Any] = {}
job_on_complete = None
if deliver:
# Fill the origin from the current turn (still in scope here)
# rather than from the model; explicit arguments still win.
from praisonaiagents.session.context import (
get_outbound_messenger,
get_session_context,
)
ctx = get_session_context()
use_ctx = not chat_id or chat_id == ctx.chat_id
origin = {
"deliver": deliver,
"platform": platform or (ctx.platform if use_ctx else ""),
"chat_id": chat_id or ctx.chat_id,
"thread_id": thread_id or (ctx.thread_id if use_ctx else ""),
"platform": platform,
"chat_id": chat_id,
"thread_id": thread_id,
"session_id": session_id,
"user_id": ctx.user_id,
"task": task,
}
if on_job_complete is not None:
job_on_complete = on_job_complete
else:
# Auto-send only back to the turn's own chat and only if
# the send policy allows it (as ``send_message`` does);
# any other target needs an explicit ``on_job_complete``.
from .messaging_tools import _check_send_policy
messenger = get_outbound_messenger()
same_chat = (
(not chat_id or chat_id == ctx.chat_id)
and (not platform or platform == ctx.platform)
and (not thread_id or thread_id == ctx.thread_id)
)
if (
messenger is not None
and deliver == "origin"
and same_chat
and _check_send_policy(deliver) is None
):
job_on_complete = _messenger_delivery(messenger)
job_id = job_manager.start_job(
_job,
@@ -445,23 +385,15 @@ def create_subagent_tool(
handle["deliver"] = deliver
return handle
# A foreground subagent is work the parent turn must not be superseded
# in the middle of: mark it on the parent's interrupt controller so the
# gateway's ``interrupt`` busy-mode queues new messages instead.
from praisonaiagents.agent.interrupt import protect_turn
from praisonaiagents.agent.tool_execution import _active_turn_cancel
parent = (_active_turn_cancel.get() or (None,))[0]
with protect_turn(getattr(parent, "interrupt_controller", None)):
return _run_subagent(
task=task,
agent_name=agent_name,
context=context,
tools=tools,
effective_llm=effective_llm,
effective_permission_mode=effective_permission_mode,
parent_depth=parent_depth,
)
return _run_subagent(
task=task,
agent_name=agent_name,
context=context,
tools=tools,
effective_llm=effective_llm,
effective_permission_mode=effective_permission_mode,
parent_depth=parent_depth,
)
def subagent_result(job_id: str, wait: bool = False) -> Dict[str, Any]:
"""
@@ -10,35 +10,33 @@ Provides unified cost calculation with:
from typing import Any, Dict, Optional
# Fallback pricing per 1M tokens (used when litellm not available)
# These are approximate and cover the most common models.
# Optional "cache_read"/"cache_write" are multipliers of the input rate for
# prompt-cache hits/writes; when absent they default to 1.0 (flat input rate).
# These are approximate and cover the most common models
_FALLBACK_PRICING: Dict[str, Dict[str, float]] = {
# OpenAI models
"gpt-4o": {"input": 2.50, "output": 10.00, "cache_read": 0.5},
"gpt-4o-mini": {"input": 0.15, "output": 0.60, "cache_read": 0.5},
"gpt-4o": {"input": 2.50, "output": 10.00},
"gpt-4o-mini": {"input": 0.15, "output": 0.60},
"gpt-4-turbo": {"input": 10.00, "output": 30.00},
"gpt-4": {"input": 30.00, "output": 60.00},
"gpt-3.5-turbo": {"input": 0.50, "output": 1.50},
"o1": {"input": 15.00, "output": 60.00, "cache_read": 0.5},
"o1-mini": {"input": 1.10, "output": 4.40, "cache_read": 0.5},
"o1": {"input": 15.00, "output": 60.00},
"o1-mini": {"input": 1.10, "output": 4.40},
"o1-preview": {"input": 15.00, "output": 60.00},
"o3-mini": {"input": 1.10, "output": 4.40, "cache_read": 0.5},
"o3-mini": {"input": 1.10, "output": 4.40},
# Anthropic models
"claude-3-5-sonnet": {"input": 3.00, "output": 15.00, "cache_read": 0.1, "cache_write": 1.25},
"claude-3-5-haiku": {"input": 0.80, "output": 4.00, "cache_read": 0.1, "cache_write": 1.25},
"claude-3-opus": {"input": 15.00, "output": 75.00, "cache_read": 0.1, "cache_write": 1.25},
"claude-3-5-sonnet": {"input": 3.00, "output": 15.00},
"claude-3-5-haiku": {"input": 0.80, "output": 4.00},
"claude-3-opus": {"input": 15.00, "output": 75.00},
"claude-3-sonnet": {"input": 3.00, "output": 15.00},
"claude-3-haiku": {"input": 0.25, "output": 1.25, "cache_read": 0.1, "cache_write": 1.25},
"claude-3-haiku": {"input": 0.25, "output": 1.25},
# Google models
"gemini-1.5-pro": {"input": 1.25, "output": 5.00, "cache_read": 0.25},
"gemini-1.5-flash": {"input": 0.075, "output": 0.30, "cache_read": 0.25},
"gemini-2.0-flash": {"input": 0.10, "output": 0.40, "cache_read": 0.25},
"gemini-1.5-pro": {"input": 1.25, "output": 5.00},
"gemini-1.5-flash": {"input": 0.075, "output": 0.30},
"gemini-2.0-flash": {"input": 0.10, "output": 0.40},
"gemini-2.0-flash-exp": {"input": 0.10, "output": 0.40},
"gemini-pro": {"input": 0.50, "output": 1.50},
# DeepSeek models
"deepseek-chat": {"input": 0.14, "output": 0.28, "cache_read": 0.1},
"deepseek-reasoner": {"input": 0.55, "output": 2.19, "cache_read": 0.25},
"deepseek-chat": {"input": 0.14, "output": 0.28},
"deepseek-reasoner": {"input": 0.55, "output": 2.19},
# Default fallback
"default": {"input": 1.00, "output": 3.00},
}
@@ -65,8 +63,6 @@ def calculate_llm_cost(
model: Optional[str] = None,
response: Optional[Any] = None,
use_litellm: bool = False,
cached_tokens: Optional[int] = None,
cache_write_tokens: Optional[int] = None,
) -> float:
"""
Calculate LLM cost using fallback pricing (fast) or litellm (accurate).
@@ -78,53 +74,14 @@ def calculate_llm_cost(
response: Optional LLM response object for litellm.completion_cost
use_litellm: If True, use litellm for accurate pricing (slower, imports litellm).
Default False for performance - uses fast fallback pricing.
cached_tokens: Prompt-cache hit tokens (included in prompt_tokens).
Read from ``response`` usage when not given.
cache_write_tokens: Prompt-cache write tokens (included in prompt_tokens).
Read from ``response`` usage when not given.
Returns:
Estimated cost in USD (float)
"""
# A positive cost reported by the provider/client may raise the figure but
# never lower it below the local estimate, so a misreporting endpoint or
# proxy cannot make the budget guard undercount. A reported 0.0 is ignored
# (unpriced models). Not rounded: a tiny positive cost must not collapse
# to 0.0.
estimate = _estimate_cost(
prompt_tokens, completion_tokens, model, response, use_litellm,
cached_tokens, cache_write_tokens,
)
reported = _extract_reported_cost(response)
if reported is None:
return estimate
return max(reported, estimate)
def _estimate_cost(
prompt_tokens: int,
completion_tokens: int,
model: Optional[str],
response: Optional[Any],
use_litellm: bool,
cached_tokens: Optional[int],
cache_write_tokens: Optional[int],
) -> float:
"""Local cost estimate (fallback table, or litellm when requested)."""
usage = _get(response, "usage")
if cached_tokens is None:
cached_tokens = _usage_int(
_get(_get(usage, "prompt_tokens_details"), "cached_tokens")
) or _usage_int(_get(usage, "cache_read_input_tokens"))
if cache_write_tokens is None:
cache_write_tokens = _usage_int(_get(usage, "cache_creation_input_tokens"))
# Use fallback pricing by default (fast, no imports)
# Only use litellm if explicitly requested (e.g., for --save CLI flag)
if not use_litellm:
return _calculate_fallback_cost(
prompt_tokens, completion_tokens, model, cached_tokens, cache_write_tokens
)
return _calculate_fallback_cost(prompt_tokens, completion_tokens, model)
# Try litellm only when explicitly requested (supports 1000+ models with accurate pricing)
if _check_litellm_available():
@@ -142,9 +99,10 @@ def _estimate_cost(
# Fallback to building dict from tokens
cost = completion_cost(
completion_response={
'usage': _litellm_usage(
prompt_tokens, completion_tokens, cached_tokens, cache_write_tokens
),
'usage': {
'prompt_tokens': prompt_tokens,
'completion_tokens': completion_tokens,
},
'model': model or 'gpt-4o-mini',
},
model=model,
@@ -153,9 +111,10 @@ def _estimate_cost(
# Build dict from tokens
cost = completion_cost(
completion_response={
'usage': _litellm_usage(
prompt_tokens, completion_tokens, cached_tokens, cache_write_tokens
),
'usage': {
'prompt_tokens': prompt_tokens,
'completion_tokens': completion_tokens,
},
'model': model or 'gpt-4o-mini',
},
model=model,
@@ -167,78 +126,21 @@ def _estimate_cost(
pass # Fall through to fallback pricing
# Fallback pricing calculation
return _calculate_fallback_cost(
prompt_tokens, completion_tokens, model, cached_tokens, cache_write_tokens
)
def _litellm_usage(
prompt_tokens: int,
completion_tokens: int,
cached_tokens: Optional[int],
cache_write_tokens: Optional[int],
) -> Dict[str, Any]:
"""Build a usage dict for litellm, carrying prompt-cache counts."""
return {
'prompt_tokens': prompt_tokens,
'completion_tokens': completion_tokens,
'prompt_tokens_details': {'cached_tokens': _usage_int(cached_tokens)},
'cache_read_input_tokens': _usage_int(cached_tokens),
'cache_creation_input_tokens': _usage_int(cache_write_tokens),
}
def _get(obj: Any, name: str) -> Any:
"""Read ``name`` from an object attribute or dict key (None if absent)."""
if obj is None:
return None
if isinstance(obj, dict):
return obj.get(name)
return getattr(obj, name, None)
def _usage_int(value: Any) -> int:
"""Coerce a usage counter to a non-negative int (0 for non-numeric)."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return 0
return max(int(value), 0)
def _extract_reported_cost(response: Any) -> Optional[float]:
"""Return a provider/client-reported cost from a response, if present.
Checks ``usage.cost`` (e.g. OpenRouter) and litellm's
``_hidden_params["response_cost"]``. Only real, positive numbers are
accepted; zero means "unknown". The caller never lets this go below the
local estimate.
"""
if response is None:
return None
for value in (
_get(_get(response, "usage"), "cost"),
_get(_get(response, "_hidden_params"), "response_cost"),
):
if not isinstance(value, bool) and isinstance(value, (int, float)) and value > 0:
return float(value)
return None
return _calculate_fallback_cost(prompt_tokens, completion_tokens, model)
def _calculate_fallback_cost(
prompt_tokens: int,
completion_tokens: int,
model: Optional[str] = None,
cached_tokens: Optional[int] = 0,
cache_write_tokens: Optional[int] = 0,
) -> float:
"""
Calculate cost using fallback pricing table.
Args:
prompt_tokens: Number of input tokens (including cached/cache-write)
prompt_tokens: Number of input tokens
completion_tokens: Number of output tokens
model: Model name
cached_tokens: Prompt-cache hit tokens, priced at the cache-read rate
cache_write_tokens: Prompt-cache write tokens, priced at the cache-write rate
Returns:
Estimated cost in USD
@@ -261,16 +163,8 @@ def _calculate_fallback_cost(
pricing = _FALLBACK_PRICING[key]
break
# Calculate cost (pricing is per 1M tokens). Cached/cache-write tokens are
# part of prompt_tokens; price them at their multiplier of the input rate.
cached = min(_usage_int(cached_tokens), prompt_tokens)
cache_write = min(_usage_int(cache_write_tokens), prompt_tokens - cached)
uncached = prompt_tokens - cached - cache_write
input_cost = (
uncached
+ cached * pricing.get("cache_read", 1.0)
+ cache_write * pricing.get("cache_write", 1.0)
) / 1_000_000 * pricing["input"]
# Calculate cost (pricing is per 1M tokens)
input_cost = (prompt_tokens / 1_000_000) * pricing["input"]
output_cost = (completion_tokens / 1_000_000) * pricing["output"]
return round(input_cost + output_cost, 6)
@@ -114,9 +114,6 @@ class _Graph:
# id(definition object) -> node id, first occurrence wins. Used at run
# time to stamp node_id on step events.
self.ids: Dict[int, str] = {}
# (parent node id, branch label, index, id(obj)) -> node id, so one
# object used at two positions still resolves to each position's node.
self.positions: Dict[tuple, str] = {}
self._seen: Dict[str, int] = {}
# edge id -> distinct route labels, joined only for display.
self._labels: Dict[str, List[str]] = {}
@@ -178,10 +175,8 @@ class _Graph:
def has_edge(self, source: str, target: str, kind: str) -> bool:
return f"{source}->{target}:{kind}" in self.edges
def register(self, obj: Any, node_id: str, parent: Optional[str] = None, slot: Optional[tuple] = None) -> None:
def register(self, obj: Any, node_id: str) -> None:
self.ids.setdefault(id(obj), node_id)
if slot is not None:
self.positions.setdefault((parent, *slot, id(obj)), node_id)
def to_dict(self) -> Dict[str, Any]:
return {
@@ -250,7 +245,7 @@ def _add_agent(g: _Graph, agent: Any, parent: Optional[str] = None, _seen: Optio
# ---------------------------------------------------------------------------
def _flow_branch(steps: Any, g: _Graph, parent: Optional[str], route: Optional[str] = None) -> Optional[Tuple[str, str]]:
def _flow_branch(steps: Any, g: _Graph, parent: Optional[str]) -> Optional[Tuple[str, str]]:
"""Render a sub-sequence of steps in isolation.
Returns the ``(entry, exit)`` pair for the whole sub-sequence, or ``None``
@@ -260,8 +255,8 @@ def _flow_branch(steps: Any, g: _Graph, parent: Optional[str], route: Optional[s
"""
entry: Optional[str] = None
tail: Optional[str] = None
for index, inner in enumerate(steps or []):
rendered = _flow_step(inner, g, tail, parent, (route, index))
for inner in steps or []:
rendered = _flow_step(inner, g, tail, parent)
if rendered is None:
continue
inner_entry, inner_exit = rendered
@@ -273,40 +268,34 @@ def _flow_branch(steps: Any, g: _Graph, parent: Optional[str], route: Optional[s
return entry, tail
def _pattern_group(
g: _Graph, step: Any, pattern: str, parent: Optional[str], label: str, meta: Dict[str, Any],
slot: Optional[tuple] = None,
) -> Tuple[str, str]:
def _pattern_group(g: _Graph, step: Any, pattern: str, parent: Optional[str], label: str, meta: Dict[str, Any]) -> Tuple[str, str]:
"""The ``step:<name>`` node that groups a pattern's parts; returns (id, suffix)."""
group = g.unique(f"step:{label}")
g.node(group, "step", label, parent=parent, meta={"pattern": pattern, **meta})
g.register(step, group, parent, slot)
g.register(step, group)
return group, group[len("step:"):]
def _flow_step(
step: Any, g: _Graph, prev: Optional[str], parent: Optional[str], slot: Optional[tuple] = None,
) -> Optional[Tuple[str, str]]:
def _flow_step(step: Any, g: _Graph, prev: Optional[str], parent: Optional[str]) -> Optional[Tuple[str, str]]:
"""Render one step, returning its ``(entry, exit)`` nodes.
``entry`` is what an incoming edge should point at; ``exit`` is what the
next step should follow. For a plain step the two are identical; for a
composite they differ (e.g. an ``If`` enters at its decision and exits at
its join). ``slot`` is the ``(branch label, index)`` the executor runs
the step at inside ``parent``.
its join).
"""
cls = type(step).__name__
if cls == "If":
condition = getattr(step, "condition", "") or "if"
group, suffix = _pattern_group(g, step, "if", parent, "if", {"condition": str(condition)}, slot)
group, suffix = _pattern_group(g, step, "if", parent, "if", {"condition": str(condition)})
decision = g.node(f"decision:{suffix}", "decision", str(condition), parent=group,
shape=f'{{"{_escape(condition)}"}}')
if prev:
g.edge(prev, decision, "next", draw=True)
ends = []
for branch, edge_label in (("then_steps", "true"), ("else_steps", "false")):
rendered = _flow_branch(getattr(step, branch, None), g, group, edge_label)
rendered = _flow_branch(getattr(step, branch, None), g, group)
if rendered:
branch_entry, branch_exit = rendered
g.edge(decision, branch_entry, "routes", edge_label, draw=True)
@@ -317,14 +306,14 @@ def _flow_step(
return decision, join
if cls == "Route":
group, suffix = _pattern_group(g, step, "route", parent, "route", {}, slot)
group, suffix = _pattern_group(g, step, "route", parent, "route", {})
router = g.node(f"decision:{suffix}", "decision", "route", parent=group, shape='{"route"}')
if prev:
g.edge(prev, router, "next", draw=True)
ends = []
routes = getattr(step, "routes", None) or {}
for key, steps in routes.items():
rendered = _flow_branch(steps, g, group, key)
rendered = _flow_branch(steps, g, group)
if rendered:
branch_entry, branch_exit = rendered
g.edge(router, branch_entry, "routes", key, draw=True)
@@ -332,7 +321,7 @@ def _flow_step(
# ``Route.__init__`` mirrors ``routes["default"]`` into ``.default``;
# only render ``.default`` when it was not already drawn as a key above.
if "default" not in routes:
rendered = _flow_branch(getattr(step, "default", None), g, group, "default")
rendered = _flow_branch(getattr(step, "default", None), g, group)
if rendered:
branch_entry, branch_exit = rendered
g.edge(router, branch_entry, "routes", "default", draw=True)
@@ -343,13 +332,13 @@ def _flow_step(
return router, join
if cls == "Parallel":
group, suffix = _pattern_group(g, step, "parallel", parent, "parallel", {}, slot)
group, suffix = _pattern_group(g, step, "parallel", parent, "parallel", {})
fork = g.node(f"fork:{suffix}", "fork", "fork", parent=group, shape='(("fork"))')
if prev:
g.edge(prev, fork, "next", draw=True)
ends = []
for index, inner in enumerate(getattr(step, "steps", None) or []):
rendered = _flow_step(inner, g, None, group, (None, index))
for inner in getattr(step, "steps", None) or []:
rendered = _flow_step(inner, g, None, group)
if rendered:
branch_entry, branch_exit = rendered
g.edge(fork, branch_entry, "parallel", draw=True)
@@ -374,14 +363,11 @@ def _flow_step(
meta["over"] = str(over)
if iterations:
meta["max_iterations"] = iterations
group, _ = _pattern_group(g, step, pattern, parent, pattern, meta, slot)
group, _ = _pattern_group(g, step, pattern, parent, pattern, meta)
tail = prev
first = None
for index, inner in enumerate(inner_steps):
# ``_execute_repeat`` passes the iteration, not the position, so
# Repeat members keep the by-object lookup.
slot = (None, index) if pattern == "loop" else None
rendered = _flow_step(inner, g, tail, group, slot)
for inner in inner_steps:
rendered = _flow_step(inner, g, tail, group)
if rendered:
inner_entry, inner_exit = rendered
first = first or inner_entry
@@ -402,7 +388,7 @@ def _flow_step(
meta = {"recipe": recipe} if recipe else {}
if workflow is not None and getattr(workflow, "name", None):
meta["workflow"] = workflow.name
group, _ = _pattern_group(g, step, "include", parent, "include", meta, slot)
group, _ = _pattern_group(g, step, "include", parent, "include", meta)
if workflow is not None:
rendered = _flow_branch(getattr(workflow, "steps", None), g, group)
if rendered:
@@ -422,7 +408,7 @@ def _flow_step(
if cls == "Discussion":
meta["pattern"] = "discussion"
g.node(node_id, "step", label, parent=parent, meta=meta, shape=f'["{_escape(label)}"]')
g.register(step, node_id, parent, slot)
g.register(step, node_id)
if prev:
g.edge(prev, node_id, "next", draw=True)
@@ -444,8 +430,8 @@ def _build_flow(steps: Any, name: Optional[str], kind: str = "flow", process: st
start = g.node(START_ID, "start", "Brief",
shape='([" start "])' if not name else f'(["{_escape(name)}"])')
prev: Optional[str] = start
for index, step in enumerate(steps or []):
rendered = _flow_step(step, g, prev, None, (None, index))
for step in (steps or []):
rendered = _flow_step(step, g, prev, None)
if rendered:
prev = rendered[1]
end = g.node(END_ID, "end", "Delivered", shape='([" end "])')
@@ -26,7 +26,6 @@ import copy
import time
import logging
import collections
import inspect
from praisonaiagents._logging import get_logger
from praisonaiagents.utils.safe_path import safe_child
from pathlib import Path
@@ -218,22 +217,6 @@ class WorkflowStepError(Exception):
self.cause = cause
self.errors = errors or []
def _call_handler(handler: Callable, context: Any) -> Any:
"""Call a step handler from a sync path, awaiting it if it is ``async def``.
Without this an async handler's coroutine was stringified as the step's
output and the step was reported as completed.
"""
result = handler(context)
if inspect.isawaitable(result):
from ..utils.async_bridge import run_coroutine_from_any_context
async def _await(awaitable):
return await awaitable
result = run_coroutine_from_any_context(_await(result), timeout=None)
return result
def _parse_json_output(output: Any, step_name: str = "step") -> Any:
"""
Parse JSON from LLM output if it's a string.
@@ -755,22 +738,13 @@ _replay_step_span: "contextvars.ContextVar[Optional[dict]]" = contextvars.Contex
)
def _replay_step_begin(flow, step, name=None, pattern=None, index=None):
"""Emit ``step_start`` for a definition object; returns a span or None.
``index`` is the step's position inside the enclosing pattern (or the
flow), so one object used at two positions maps to each position's node.
"""
def _replay_step_begin(flow, step, name=None, pattern=None):
"""Emit ``step_start`` for a definition object; returns a span or None."""
from ..trace.context_events import get_context_emitter
emitter = get_context_emitter()
if not emitter._enabled:
return None
node_id = (getattr(flow, "_replay_node_ids", None) or {}).get(id(step))
position = None
if index is not None:
parent = _replay_step_span.get()
key = (parent["node_id"], parent.get("route"), index, id(step)) if parent else (None, None, index, id(step))
position = (getattr(flow, "_replay_positions", None) or {}).get(key)
if name is None:
if node_id is not None:
name = node_id[len("step:"):] if node_id.startswith("step:") else node_id
@@ -779,7 +753,6 @@ def _replay_step_begin(flow, step, name=None, pattern=None, index=None):
name = pattern or _label(step)
if node_id is None:
node_id = f"step:{name}"
node_id = position or node_id
agent = getattr(step, "agent", None) if not hasattr(step, "chat") else step
agent = getattr(agent, "name", None) if agent is not None else None
emitter.step_start(name, pattern=pattern, node_id=node_id, agent=agent)
@@ -831,13 +804,12 @@ def _replay_pattern(pattern):
"""Wrap a pattern executor (``_execute_route`` ...) in step_start/step_end.
Top-level and nested patterns both reach the executor, so wrapping it here
covers every nesting level once. Callers pass ``_replay_index`` (the
pattern's position) so a reused pattern object maps to each position's node.
covers every nesting level once.
"""
def decorate(execute):
def wrapper(self, step, *args, _replay_index=None, **kwargs):
def wrapper(self, step, *args, **kwargs):
name = getattr(step, "name", None) if pattern == "discussion" else None
span = _replay_step_begin(self, step, name, pattern, _replay_index)
span = _replay_step_begin(self, step, name, pattern)
if span is None:
return execute(self, step, *args, **kwargs)
token = _replay_step_span.set(span)
@@ -1102,13 +1074,6 @@ class AgentFlow:
# consumed in run()'s finally to restore the caller's generation (nested runs).
_run_generation_token: Any = field(default=None, repr=False, compare=False)
def _reset_approval_token(self) -> None:
"""Reset this run's YAML-approved tools exactly once."""
token, self._approval_token = getattr(self, "_approval_token", None), None
if token is not None:
from ..approval import reset_yaml_approved_tools
reset_yaml_approved_tools(token)
def _begin_run_generation(self) -> Any:
"""Stamp a fresh generation for the run starting now and publish it to the
current context so branches submitted by this run capture it.
@@ -1634,9 +1599,6 @@ class AgentFlow:
# leave a stale generation on the outer run's thread.
token, self._run_generation_token = self._run_generation_token, None
self._end_run_generation(token)
# A raising step skips _run_impl's reset; never let YAML tool
# approvals outlive the run.
self._reset_approval_token()
self._execution_lock.release()
def to_mermaid(self) -> str:
@@ -1764,9 +1726,9 @@ class AgentFlow:
visit(self.steps)
return found
def _notify_step_start(self, raw_step: Any, step: Any, context: Any, index: Optional[int] = None) -> Optional[dict]:
def _notify_step_start(self, raw_step: Any, step: Any, context: Any) -> Optional[dict]:
"""Emit replay ``step_start`` then call ``on_step_start``; returns the span."""
span = _replay_step_begin(self, raw_step, step.name, index=index)
span = _replay_step_begin(self, raw_step, step.name)
if self.on_step_start:
try:
self.on_step_start(step.name, context)
@@ -1791,7 +1753,6 @@ class AgentFlow:
from .diagram import _flow_graph
graph = _flow_graph(self)
self._replay_node_ids = graph.ids
self._replay_positions = graph.positions
return graph.to_dict()
def _run_impl(
@@ -1867,8 +1828,6 @@ class AgentFlow:
if approve_tools and allow_dangerous:
from ..approval import set_yaml_approved_tools
_approval_token = set_yaml_approved_tools(approve_tools)
# Kept on the instance so run()'s finally can reset it if a step raises.
self._approval_token = _approval_token
# Call on_workflow_start callback
if self.on_workflow_start:
@@ -1906,7 +1865,7 @@ class AgentFlow:
if isinstance(step, Route):
# Decision-based routing
route_result = self._execute_route(
step, previous_output, input, all_variables, model, verbose, stream, _replay_index=i
step, previous_output, input, all_variables, model, verbose, stream
)
results.extend(route_result["steps"])
previous_output = route_result["output"]
@@ -1922,7 +1881,7 @@ class AgentFlow:
elif isinstance(step, Parallel):
# Parallel execution
parallel_result = self._execute_parallel(
step, previous_output, input, all_variables, model, verbose, stream, _replay_index=i
step, previous_output, input, all_variables, model, verbose, stream
)
results.extend(parallel_result["steps"])
previous_output = parallel_result["output"]
@@ -1940,7 +1899,7 @@ class AgentFlow:
elif isinstance(step, Loop):
# Loop over items
loop_result = self._execute_loop(
step, previous_output, input, all_variables, model, verbose, stream, _replay_index=i
step, previous_output, input, all_variables, model, verbose, stream
)
results.extend(loop_result["steps"])
previous_output = loop_result["output"]
@@ -1957,7 +1916,7 @@ class AgentFlow:
elif isinstance(step, Discussion):
discussion_result = self._execute_discussion(
step, previous_output, input, all_variables, model, verbose, stream, _replay_index=i
step, previous_output, input, all_variables, model, verbose, stream
)
results.extend(discussion_result["steps"])
previous_output = discussion_result["output"]
@@ -1975,7 +1934,7 @@ class AgentFlow:
elif isinstance(step, Repeat):
# Repeat until condition
repeat_result = self._execute_repeat(
step, previous_output, input, all_variables, model, verbose, stream, _replay_index=i
step, previous_output, input, all_variables, model, verbose, stream
)
results.extend(repeat_result["steps"])
previous_output = repeat_result["output"]
@@ -1991,7 +1950,7 @@ class AgentFlow:
elif isinstance(step, Include):
# Include another recipe
include_result = self._execute_include(
step, previous_output, input, all_variables, model, verbose, stream, _replay_index=i
step, previous_output, input, all_variables, model, verbose, stream
)
results.extend(include_result["steps"])
previous_output = include_result["output"]
@@ -2002,7 +1961,7 @@ class AgentFlow:
elif isinstance(step, If):
# Conditional branching
if_result = self._execute_if(
step, previous_output, input, all_variables, model, verbose, stream, _replay_index=i
step, previous_output, input, all_variables, model, verbose, stream
)
results.extend(if_result["steps"])
previous_output = if_result["output"]
@@ -2035,7 +1994,7 @@ class AgentFlow:
# Preserve the preparation hook before the gate: it may populate
# context variables used by should_run, including on cached runs.
_replay = self._notify_step_start(raw_step, step, context, i)
_replay = self._notify_step_start(raw_step, step, context)
# Check should_run condition
if step.should_run:
@@ -2136,7 +2095,7 @@ class AgentFlow:
try:
if step.handler:
# Custom handler function
result = _call_handler(step.handler, context)
result = step.handler(context)
if isinstance(result, StepResult):
output = result.output
stop = result.stop_workflow
@@ -2506,12 +2465,12 @@ class AgentFlow:
self.status = "completed"
# Reset YAML-approved tools context if it was set
self._reset_approval_token()
if _approval_token is not None:
from ..approval import reset_yaml_approved_tools
reset_yaml_approved_tools(_approval_token)
final_result = {
# A failed run must not expose an earlier step's value as its
# output; partial results stay available under "steps".
"output": None if self.status == "failed" else previous_output,
"output": previous_output,
"steps": results,
"variables": all_variables,
"status": self.status
@@ -2845,11 +2804,12 @@ Respond with JSON: {{"approved": true/false, "reason": "Brief explanation"}}
if failure_reason is None:
self.status = "completed"
self._reset_approval_token()
if _approval_token is not None:
from ..approval import reset_yaml_approved_tools
reset_yaml_approved_tools(_approval_token)
final_result = {
# Same contract as the sequential path: no stale output on failure.
"output": None if self.status == "failed" else previous_output,
"output": previous_output,
"steps": results,
"variables": all_variables,
"status": self.status
@@ -3173,7 +3133,7 @@ Create a brief execution plan (2-3 sentences) describing how to best accomplish
step, previous_output, input, all_variables, model, verbose, index,
stream=stream, depth=depth,
)
span = _replay_step_begin(self, step, index=index)
span = _replay_step_begin(self, step)
if span is None:
return self._execute_leaf_step(
step, previous_output, input, all_variables, model, verbose, index,
@@ -3327,7 +3287,7 @@ Create a brief execution plan (2-3 sentences) describing how to best accomplish
# Handle nested patterns (Loop, Parallel, Route, Repeat, If)
if isinstance(step, Loop):
loop_result = self._execute_loop(
step, previous_output, input, all_variables, model, verbose, stream, depth=depth+1, _replay_index=index
step, previous_output, input, all_variables, model, verbose, stream, depth=depth+1
)
return {
"step": f"loop_{index}",
@@ -3340,7 +3300,7 @@ Create a brief execution plan (2-3 sentences) describing how to best accomplish
if isinstance(step, Parallel):
parallel_result = self._execute_parallel(
step, previous_output, input, all_variables, model, verbose, stream, depth=depth+1, _replay_index=index
step, previous_output, input, all_variables, model, verbose, stream, depth=depth+1
)
return {
"step": f"parallel_{index}",
@@ -3353,7 +3313,7 @@ Create a brief execution plan (2-3 sentences) describing how to best accomplish
if isinstance(step, Route):
route_result = self._execute_route(
step, previous_output, input, all_variables, model, verbose, stream, depth=depth+1, _replay_index=index
step, previous_output, input, all_variables, model, verbose, stream, depth=depth+1
)
return {
"step": f"route_{index}",
@@ -3364,7 +3324,7 @@ Create a brief execution plan (2-3 sentences) describing how to best accomplish
if isinstance(step, Repeat):
repeat_result = self._execute_repeat(
step, previous_output, input, all_variables, model, verbose, stream, depth=depth+1, _replay_index=index
step, previous_output, input, all_variables, model, verbose, stream, depth=depth+1
)
return {
"step": f"repeat_{index}",
@@ -3375,7 +3335,7 @@ Create a brief execution plan (2-3 sentences) describing how to best accomplish
if isinstance(step, Discussion):
discussion_result = self._execute_discussion(
step, previous_output, input, all_variables, model, verbose, stream, depth=depth+1, _replay_index=index
step, previous_output, input, all_variables, model, verbose, stream, depth=depth+1
)
return {
"step": f"discussion_{index}",
@@ -3390,7 +3350,7 @@ Create a brief execution plan (2-3 sentences) describing how to best accomplish
if isinstance(step, If):
if_result = self._execute_if(
step, previous_output, input, all_variables, model, verbose, stream, depth=depth+1, _replay_index=index
step, previous_output, input, all_variables, model, verbose, stream, depth=depth+1
)
return {
"step": f"if_{index}",
@@ -3402,7 +3362,7 @@ Create a brief execution plan (2-3 sentences) describing how to best accomplish
# Handle Include steps (for include inside loop)
if isinstance(step, Include):
include_result = self._execute_include(
step, previous_output, input, all_variables, model, verbose, stream, _replay_index=index
step, previous_output, input, all_variables, model, verbose, stream
)
# Return in the expected format for single step
combined_output = include_result.get("output", "")
@@ -3429,7 +3389,7 @@ Create a brief execution plan (2-3 sentences) describing how to best accomplish
context.variables["validation_feedback"] = validation_feedback
if normalized.handler:
result = _call_handler(normalized.handler, context)
result = normalized.handler(context)
if isinstance(result, StepResult):
if result.variables:
all_variables.update(result.variables)
@@ -4165,7 +4125,7 @@ CONCISE SUMMARY:"""
errors.append({"step": idx, "error": info["error"]})
# partial_ok: record the failure but keep going; fold neither the
# exception text into outputs nor skip the index alignment.
results.append({"step": f"parallel_{idx}", "output": None, "status": "failed", "error": str(info["error"])})
results.append({"step": f"parallel_{idx}", "output": None, "error": str(info["error"])})
if info["delta"] is not None:
branch_deltas.append((idx, info["delta"]))
outputs.append(None)
@@ -4223,13 +4183,7 @@ CONCISE SUMMARY:"""
# Get items from variable, CSV, or file
if loop_step.over:
if loop_step.over not in all_variables:
# A typo in over= used to run zero iterations and report success.
raise WorkflowStepError(
f"loop(over={loop_step.over!r}): no such workflow variable; "
f"known: {sorted(all_variables)}"
)
items = all_variables[loop_step.over]
items = all_variables.get(loop_step.over, [])
if isinstance(items, str):
# Try to parse as JSON list first (handles agent output like '["a", "b", "c"]')
items = self._parse_list_from_string(items)
@@ -6308,7 +6262,7 @@ class WorkflowManager:
# If step has a custom handler function
if step.handler:
try:
result = _call_handler(step.handler, context)
result = step.handler(context)
# Handle StepResult
if isinstance(result, StepResult):
# Update variables from result
@@ -707,7 +707,7 @@ class YAMLWorkflowParser:
from ..agent import ImageAgent
# ImageAgent accepts: llm, style, and other image config
style = config.get('style')
style = config.get('style', 'natural')
agent = ImageAgent(
llm=llm,
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.7.20"
version = "1.7.19"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -50,13 +50,13 @@ class TestRetryDelayParsing:
msg = 'Retry-After: 120'
assert llm._parse_retry_delay(msg) == 120.0
def test_parse_returns_none_on_no_match(self):
"""An absent provider delay leaves backoff selection to the resolver."""
def test_parse_returns_default_on_no_match(self):
"""Test that default is returned when no pattern matches"""
from praisonaiagents.llm.llm import LLM
llm = LLM(model="gpt-4o-mini")
msg = 'Some random error without delay info'
assert llm._parse_retry_delay(msg) is None
assert llm._parse_retry_delay(msg) == 60.0 # Default retry_delay
def test_parse_clamps_huge_delay(self):
"""Test that huge delays are clamped to max_retry_delay"""
@@ -1,142 +0,0 @@
"""Caller-mode autonomy must accept ``timeout`` (Issue #6044).
``Agent(autonomy=...).start(prompt, timeout=...)`` used to forward ``timeout``
to ``chat()``/``achat()``, which do not accept it -> TypeError before any LLM
call. The timeout is now consumed and enforced as a run budget.
"""
import asyncio
import time
import pytest
from praisonaiagents import Agent
def _agent():
return Agent(name="t", instructions="x", autonomy={"level": "suggest"}, output="silent")
@pytest.mark.parametrize("timeout", [None, 5])
def test_start_caller_mode_accepts_timeout(timeout):
agent = _agent()
seen = {}
def fake_chat(prompt, **kwargs):
seen.update(kwargs)
return "ok"
agent.chat = fake_chat
result = agent.start("x", timeout=timeout)
assert result.success is True
assert result.completion_reason == "caller_mode"
assert "timeout" not in seen
def test_start_caller_mode_enforces_timeout():
agent = _agent()
def slow_chat(prompt, cancel_token=None, **kwargs):
deadline = time.time() + 5
while time.time() < deadline:
if cancel_token is not None and cancel_token.is_set():
raise InterruptedError("cancelled")
time.sleep(0.01)
return "too late"
agent.chat = slow_chat
started = time.time()
result = agent.start("x", timeout=0.1)
assert time.time() - started < 2
assert result.success is False
assert result.completion_reason == "timeout"
def test_start_caller_mode_timeout_interrupt_text_is_not_success():
"""The LLM tool loop returns this text instead of raising on cancel."""
agent = _agent()
def interrupted_chat(prompt, cancel_token=None, **kwargs):
while not cancel_token.is_set():
time.sleep(0.01)
return f"Task interrupted: {cancel_token.reason}"
agent.chat = interrupted_chat
result = agent.start("x", timeout=0.1)
assert result.success is False
assert result.completion_reason == "timeout"
def test_start_caller_mode_timeout_keeps_interrupt_controller():
"""A positive timeout must not hide stop requests on the agent's controller."""
from praisonaiagents.agent.interrupt import InterruptController
agent = _agent()
agent.interrupt_controller = InterruptController()
agent.interrupt_controller.request("user") # stop requested before the run
def chat(prompt, cancel_token=None, **kwargs):
cancel_token.check()
return "ran anyway"
agent.chat = chat
started = time.time()
with pytest.raises(InterruptedError, match="user"):
agent.start("x", timeout=5)
assert time.time() - started < 2
@pytest.mark.parametrize("timeout", [None, 5])
def test_astart_caller_mode_accepts_timeout(timeout):
agent = _agent()
seen = {}
async def fake_achat(prompt, **kwargs):
seen.update(kwargs)
return "ok"
agent.achat = fake_achat
result = asyncio.run(agent.astart("x", timeout=timeout))
assert result.success is True
assert result.completion_reason == "caller_mode"
assert "timeout" not in seen
def test_astart_caller_mode_enforces_timeout():
agent = _agent()
async def slow_achat(prompt, **kwargs):
await asyncio.sleep(5)
return "too late"
agent.achat = slow_achat
started = time.time()
result = asyncio.run(agent.astart("x", timeout=0.1))
assert time.time() - started < 2
assert result.success is False
assert result.completion_reason == "timeout"
def test_start_caller_mode_interrupt_text_without_timeout_is_success():
"""A completed answer that merely quotes the interrupt text stays a success."""
agent = _agent()
agent.chat = lambda prompt, **kwargs: "Task interrupted: timeout (quoted)"
result = agent.start("x", timeout=5)
assert result.success is True
assert result.completion_reason == "caller_mode"
def test_astart_caller_mode_timeout_waits_for_achat_cleanup():
agent = _agent()
cleaned = []
async def slow_achat(prompt, **kwargs):
try:
await asyncio.sleep(5)
finally:
await asyncio.sleep(0.05)
cleaned.append(True)
agent.achat = slow_achat
result = asyncio.run(agent.astart("x", timeout=0.1))
assert result.completion_reason == "timeout"
assert cleaned == [True]
@@ -1,24 +0,0 @@
"""Typed ``AutonomyConfig(snapshot=True)`` enables the pre-run snapshot — issue #6028."""
from praisonaiagents import Agent
from praisonaiagents.agent.autonomy import AutonomyConfig
def test_snapshot_field_defaults_off():
assert AutonomyConfig().snapshot is False
assert AutonomyConfig.from_dict({"snapshot": True}).snapshot is True
def test_typed_snapshot_reaches_autonomy_config_dict():
agent = Agent(
name="w",
instructions="x",
autonomy=AutonomyConfig(level="full_auto", snapshot=True),
output="silent",
)
assert agent.autonomy_config["snapshot"] is True
def test_snapshot_off_by_default_in_agent():
agent = Agent(name="w", instructions="x", autonomy="full_auto", output="silent")
assert agent.autonomy_config["snapshot"] is False
@@ -1,107 +0,0 @@
"""Tests for InterruptController.protect() windows (#6122)."""
from types import SimpleNamespace
from praisonaiagents.agent.interrupt import InterruptController, protect_turn
from praisonaiagents.agent.tool_execution import _active_turn_cancel
from praisonaiagents.tools.subagent_tool import create_subagent_tool
def test_protect_window_is_nested_and_scoped():
ctrl = InterruptController()
assert not ctrl.is_protected()
with ctrl.protect():
with ctrl.protect():
assert ctrl.is_protected()
assert ctrl.is_protected()
assert not ctrl.is_protected()
def test_protect_does_not_block_request():
ctrl = InterruptController()
with ctrl.protect():
ctrl.request("user_stop_command")
assert ctrl.is_set()
def test_protect_turn_tolerates_missing_controller():
with protect_turn(None):
pass
with protect_turn(object()):
pass
def test_foreground_subagent_protects_parent_turn():
ctrl = InterruptController()
parent = SimpleNamespace(interrupt_controller=ctrl)
seen = []
class _Sub:
def chat(self, prompt):
seen.append(ctrl.is_protected())
return "done"
spawn = create_subagent_tool(agent_factory=lambda **_: _Sub())["function"]
token = _active_turn_cancel.set((parent, None))
try:
result = spawn("do work")
finally:
_active_turn_cancel.reset(token)
assert result["success"]
assert seen == [True]
assert not ctrl.is_protected()
class _Compactor:
"""Records whether the turn was protected while compacting."""
def __init__(self, ctrl, fail=False):
self.ctrl, self.fail, self.seen = ctrl, fail, []
def _run(self, messages):
self.seen.append(self.ctrl.is_protected())
if self.fail:
raise RuntimeError("compaction failed")
return messages, SimpleNamespace(original_tokens=2, compacted_tokens=1, messages_removed=0)
compact = _run
def needs_compaction(self, _messages):
return False
def _agent_with(ctrl):
from praisonaiagents import Agent
agent = Agent(name="t", instructions="t")
agent.interrupt_controller = ctrl
agent._persist_compaction_checkpoint = lambda _r: None
return agent
def _policy():
from praisonaiagents.context.policy import CompactionStrategy
return SimpleNamespace(strategy=CompactionStrategy.TRUNCATE)
def test_proactive_compaction_protects_turn():
import pytest
ctrl = InterruptController()
agent = _agent_with(ctrl)
ok = _Compactor(ctrl)
agent._apply_compaction([{"role": "user", "content": "x"}], ok, _policy())
assert ok.seen == [True] and not ctrl.is_protected()
bad = _Compactor(ctrl, fail=True)
with pytest.raises(RuntimeError):
agent._apply_compaction([{"role": "user", "content": "x"}], bad, _policy())
assert bad.seen == [True] and not ctrl.is_protected()
def test_proactive_compaction_async_protects_turn():
import asyncio
ctrl = InterruptController()
agent = _agent_with(ctrl)
ok = _Compactor(ctrl)
asyncio.run(agent._apply_compaction_async([{"role": "user", "content": "x"}], ok, _policy()))
assert ok.seen == [True] and not ctrl.is_protected()
@@ -1,137 +0,0 @@
"""BEFORE_LLM / AFTER_LLM on the custom-LLM (``provider/model``) path (#5821).
That path hands the turn to ``LLM.get_response*`` and skips
``_chat_completion``, so it must fire the same hooks itself, once per turn,
with ``tokens_used`` taken from the provider's ``usage``.
``litellm.completion`` is stubbed; nothing leaves the machine.
"""
import asyncio
import pytest
pytest.importorskip("litellm")
from praisonaiagents import Agent # noqa: E402
from praisonaiagents.hooks import HookEvent, HookRegistry, HookResult # noqa: E402
MODEL = "anthropic/claude-3-5-sonnet-20240620"
def _litellm():
import litellm
return litellm
def _response(model, n, tool_call=False):
message = {"role": "assistant", "content": "ok"}
if tool_call:
message = {"role": "assistant", "content": None, "tool_calls": [{
"id": f"call_{n}", "type": "function",
"function": {"name": "step", "arguments": "{}"}}]}
usage = {"prompt_tokens": 100 + n, "completion_tokens": 10 + n,
"total_tokens": 110 + 2 * n}
return _litellm().ModelResponse(model=model, usage=usage, choices=[{
"index": 0, "finish_reason": "tool_calls" if tool_call else "stop",
"message": message}])
@pytest.fixture
def calls(monkeypatch):
"""Stub litellm; answer with a tool call while ``tool_rounds`` remain."""
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
monkeypatch.setattr(Agent, "_default_model", None)
monkeypatch.setattr(Agent, "_default_model_checked", False)
state = {"n": 0, "tool_rounds": 0, "messages": []}
def fake(*a, **k):
state["n"] += 1
state["messages"].append(k.get("messages"))
if k.get("tools") and state["tool_rounds"] > 0:
state["tool_rounds"] -= 1
return _response(k.get("model"), state["n"], tool_call=True)
return _response(k.get("model"), state["n"])
async def afake(*a, **k):
return fake(*a, **k)
litellm = _litellm()
monkeypatch.setattr(litellm, "completion", fake)
monkeypatch.setattr(litellm, "acompletion", afake)
return state
def _agent(registry, **kw):
return Agent(name="p", instructions="x", llm=MODEL, output="silent",
hooks=registry, **kw)
def _recorder():
registry, seen = HookRegistry(), []
for event in (HookEvent.BEFORE_LLM, HookEvent.AFTER_LLM):
registry.register_function(event, lambda data: seen.append(data) or HookResult.allow())
return registry, seen
def step() -> str:
"""One step of work."""
return "done"
@pytest.mark.parametrize("run", [
lambda a: a.chat("hi"),
lambda a: asyncio.run(a.achat("hi")),
], ids=["sync", "async"])
def test_hooks_fire_with_provider_tokens(calls, run):
registry, seen = _recorder()
assert run(_agent(registry)) == "ok"
assert [e.event_name for e in seen] == [HookEvent.BEFORE_LLM, HookEvent.AFTER_LLM]
before, after = seen
assert before.model == MODEL and before.messages[-1]["content"] == "hi"
assert after.tokens_used == 101 + 11
assert after.response == "ok"
def test_tokens_cover_every_call_in_a_tool_round_trip(calls):
calls["tool_rounds"] = 1
registry, seen = _recorder()
assert _agent(registry, tools=[step]).chat("go") == "ok"
after = [e for e in seen if e.event_name == HookEvent.AFTER_LLM]
assert len(after) == 1 # Same once-per-turn granularity as the OpenAI-client path.
assert after[0].tokens_used == (101 + 11) + (102 + 12)
def test_blocking_before_llm_skips_the_provider(calls):
registry = HookRegistry()
registry.register_function(HookEvent.BEFORE_LLM, lambda data: HookResult.deny("nope"))
result = _agent(registry).chat("hi")
assert calls["n"] == 0
assert "nope" in result
def test_before_llm_message_rewrite_reaches_the_provider(calls):
registry = HookRegistry()
def redact(data):
messages = [{**m, "content": str(m["content"]).replace("my secret", "[redacted]")}
for m in data.messages]
return HookResult(decision="allow", modified_input={"messages": messages})
registry.register_function(HookEvent.BEFORE_LLM, redact, sequential=True)
assert _agent(registry).chat("my secret") == "ok"
sent = calls["messages"][0]
assert sent[0]["role"] == "system"
assert all("my secret" not in str(m.get("content")) for m in sent)
assert any(m.get("content") == "[redacted]" for m in sent)
def test_rewrite_ending_in_non_user_message_fails_closed(calls):
registry = HookRegistry()
def append_assistant(data):
messages = list(data.messages) + [{"role": "assistant", "content": "injected"}]
return HookResult(decision="allow", modified_input={"messages": messages})
registry.register_function(HookEvent.BEFORE_LLM, append_assistant, sequential=True)
result = _agent(registry).chat("hi")
assert calls["n"] == 0
assert "must end with a user message" in result
@@ -1,145 +0,0 @@
"""A tool that raises must give the same outcome via chat() and achat() (#6041).
Contract: a *raised* tool exception ends the turn with ToolExecutionError on
both paths. A tool that *returns* ``{"error": ...}`` stays a recoverable tool
result the model sees.
"""
import asyncio
import json
import pytest
from praisonaiagents import Agent
from praisonaiagents.errors import ToolExecutionError
from tests.unit.agent.test_tool_failure_not_relabelled import (
_agent_with_failing_tool,
)
# --- OpenAI-native path -----------------------------------------------------
def test_native_chat_and_achat_both_raise(monkeypatch):
monkeypatch.setattr("asyncio.sleep", _no_sleep)
agent, _rec, runs = _agent_with_failing_tool("smtp down", monkeypatch)
with pytest.raises(ToolExecutionError):
agent.chat("send the email")
assert runs
agent, _rec, runs = _agent_with_failing_tool("smtp down", monkeypatch)
with pytest.raises(ToolExecutionError):
asyncio.run(agent.achat("send the email"))
assert runs
# --- provider (litellm) path ------------------------------------------------
async def _no_sleep(*_a, **_k):
return None
def _stub_litellm(monkeypatch, seen):
litellm = pytest.importorskip("litellm")
from litellm import ModelResponse
def fake_completion(*_a, **k):
tool_msgs = [m for m in k["messages"] if m.get("role") == "tool"]
if not tool_msgs:
msg = {"role": "assistant", "content": None, "tool_calls": [{
"id": "call_1", "type": "function",
"function": {"name": "send_email",
"arguments": json.dumps({"to": "[email protected]"})}}]}
fr = "tool_calls"
else:
seen.append(tool_msgs[-1]["content"])
msg = {"role": "assistant", "content": "the email could not be sent"}
fr = "stop"
return ModelResponse(
choices=[{"index": 0, "finish_reason": fr, "message": msg}],
usage={"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
)
async def fake_acompletion(*a, **k):
return fake_completion(*a, **k)
def _blocked(*_a, **_k):
raise RuntimeError("network blocked in test")
monkeypatch.setattr(litellm, "completion", fake_completion)
monkeypatch.setattr(litellm, "acompletion", fake_acompletion)
monkeypatch.setattr(litellm, "responses", _blocked, raising=False)
monkeypatch.setattr(litellm, "aresponses", _blocked, raising=False)
monkeypatch.setattr("time.sleep", lambda _s: None)
monkeypatch.setattr("asyncio.sleep", _no_sleep)
def _provider_agent(tool):
return Agent(name="m", instructions="x",
llm="anthropic/claude-3-5-sonnet-20240620",
tools=[tool], output="silent")
def _outcome(call):
try:
return ("returned", call())
except ToolExecutionError:
return ("raised", None)
def test_provider_raised_tool_exception_same_outcome(monkeypatch):
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
seen = []
_stub_litellm(monkeypatch, seen)
def send_email(to: str) -> str:
"""Send an email."""
raise RuntimeError("SMTP server unreachable")
sync = _outcome(lambda: _provider_agent(send_email).chat("email [email protected]"))
async_ = _outcome(
lambda: asyncio.run(_provider_agent(send_email).achat("email [email protected]"))
)
assert sync[0] == async_[0] == "raised"
assert seen == []
def test_provider_returned_error_dict_reaches_model_on_both(monkeypatch):
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
seen = []
_stub_litellm(monkeypatch, seen)
def send_email(to: str) -> dict:
"""Send an email."""
return {"error": "mailbox full"}
sync = _outcome(lambda: _provider_agent(send_email).chat("email [email protected]"))
assert sync == ("returned", "the email could not be sent")
assert seen and all("mailbox full" in m for m in seen)
seen.clear()
async_ = _outcome(
lambda: asyncio.run(_provider_agent(send_email).achat("email [email protected]"))
)
assert async_ == ("returned", "the email could not be sent")
assert seen and all("mailbox full" in m for m in seen)
def test_provider_not_restart_safe_raise_is_not_retryable(monkeypatch):
# A restart_safe=False tool must not run again, so the error must not
# advertise a retry (durable runs would otherwise stay open).
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
seen = []
_stub_litellm(monkeypatch, seen)
runs = []
def send_email(to: str) -> str:
"""Send an email."""
runs.append(to)
raise RuntimeError("SMTP server unreachable")
send_email.restart_safe = False
with pytest.raises(ToolExecutionError) as exc:
asyncio.run(_provider_agent(send_email).achat("email [email protected]"))
assert exc.value.is_retryable is False
assert runs == ["[email protected]"]
assert seen == []
@@ -1,76 +0,0 @@
"""Goal resume across a fresh Agent (simulated new process) — issue #6028.
``_load_goal_state`` used to run before the lazily created session store
existed, so a fresh Agent never saw the persisted state and restarted the goal
at ``turns_used=0``. A crashed run (still ``active``) was also never resumed.
"""
import pytest
import praisonaiagents.goal.loop as goal_loop
from praisonaiagents import Agent
from praisonaiagents.goal.models import GoalState
from praisonaiagents.model_harness import ScriptedModel
SESSION_ID = "goal-resume-6028"
GOAL = "all tests pass"
@pytest.fixture(autouse=True)
def _isolated_home(tmp_path, monkeypatch):
monkeypatch.setenv("PRAISONAI_HOME", str(tmp_path / ".praisonai"))
monkeypatch.setattr(
goal_loop, "judge_goal", lambda state, *a, **k: ("continue", "not yet")
)
def _make_agent():
return Agent(
name="worker",
instructions="Fix the tests.",
llm=ScriptedModel([f"Working, step {i}." for i in range(20)]),
memory={"session_id": SESSION_ID, "history": True},
output="silent",
)
def _persisted(agent):
session = agent._session_store.get_session(SESSION_ID)
return session.metadata.get("goal_state")
def test_paused_goal_resumes_in_fresh_agent():
first = _make_agent()
r = first.run_goal("Fix the failing tests", goal=GOAL, max_turns=2)
assert r.completion_reason == "budget_paused"
assert _persisted(first)["turns_used"] == 2
fresh = _make_agent()
assert fresh._session_store is None # store not created yet
r = fresh.run_goal("Fix the failing tests", goal=GOAL, max_turns=3)
# Resumed at turn 2, so only one more turn fits under max_turns=3.
assert r.iterations == 1
assert _persisted(fresh)["turns_used"] == 3
def test_crashed_active_goal_is_resumed():
seed = _make_agent()
seed._init_session_store()
seed._goal_state = GoalState(goal=GOAL, max_turns=5, turns_used=3)
seed._persist_goal_state() # status "active": process died mid-run
fresh = _make_agent()
state = fresh._resume_or_new_goal_state(GOAL, None, 5, True)
assert state.turns_used == 3
assert state.status == "active"
def test_different_goal_starts_fresh():
seed = _make_agent()
seed._init_session_store()
seed._goal_state = GoalState(goal=GOAL, max_turns=5, turns_used=3)
seed._persist_goal_state()
fresh = _make_agent()
state = fresh._resume_or_new_goal_state("another goal", None, 5, True)
assert state.turns_used == 0
@@ -914,37 +914,6 @@ class TestCommandHooks:
assert not results[0].success
assert "timed out" in results[0].error
@pytest.mark.asyncio
@pytest.mark.skipif(os.name == "nt", reason="POSIX shell command")
async def test_command_hook_cancel_kills_process(self):
"""Cancelling a running command hook must not orphan the child process."""
registry = HookRegistry()
registry.register_command(
event=HookEvent.BEFORE_TOOL,
# ``exec`` so the shell is the sleep itself (dash forks otherwise).
command="exec sleep 10",
timeout=30
)
spawned = []
real_spawn = asyncio.create_subprocess_shell
async def spy(*args, **kwargs):
proc = await real_spawn(*args, **kwargs)
spawned.append(proc)
return proc
runner = HookRunner(registry, cwd="/tmp")
with patch("asyncio.create_subprocess_shell", spy):
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(
runner.execute(HookEvent.BEFORE_TOOL, self._create_sample_input()),
timeout=0.3,
)
assert len(spawned) == 1
await asyncio.wait_for(spawned[0].wait(), timeout=2)
assert spawned[0].returncode is not None
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -109,22 +109,3 @@ def test_tools_streaming_path_maps_max_tokens():
assert "max_tokens" not in sent
assert "temperature" not in sent
assert sent["max_completion_tokens"] == 100
def test_reasoning_model_drops_sampling_params():
# #6128: per-call temperature/top_p must not reach reasoning models.
client = _client()
client.create_completion(
messages=[{"role": "user", "content": "hi"}],
model="o4-mini", temperature=0.2, top_p=0.9, presence_penalty=0.1,
)
sent = _sent(client._sync_client.chat.completions.create)
for key in ("temperature", "top_p", "presence_penalty"):
assert key not in sent
def test_responses_input_omits_temperature_for_reasoning_model():
client = _client()
msgs = [{"role": "user", "content": "hi"}]
assert "temperature" not in client._build_responses_input(msgs, "o4-mini", 0.2)
assert client._build_responses_input(msgs, "gpt-4o-mini", 0.2)["temperature"] == 0.2
@@ -14,10 +14,9 @@ from praisonaiagents.llm.llm import LLM
])
@pytest.mark.parametrize("token,expected", [
(".5", .5), ("1e2", 100), ("0", 0), ("999", 300),
("-.5", None), ("-5", None), ("1e309", None), ("1.2.3", None),
("-.5", 60), ("-5", 60), ("1e309", 60), ("1.2.3", 60),
])
def test_retry_decision_uses_complete_numeric_hint(template, token, expected, monkeypatch):
monkeypatch.setattr("praisonaiagents.llm.error_classifier.random.uniform", lambda low, high: high)
def test_retry_decision_uses_complete_numeric_hint(template, token, expected):
llm = LLM(model="fake")
assert llm._parse_retry_delay(template.format(token)) == expected
decision = llm.resolve_failover_decision(
@@ -25,15 +24,16 @@ def test_retry_decision_uses_complete_numeric_hint(template, token, expected, mo
{"attempt": 1, "max_retries": 3, "side_effecting": False},
)
assert decision.reason == "rate_limit"
# Missing/invalid hints use jitter; an explicit zero stays zero.
assert decision.backoff_ms == int((3 if expected is None else expected) * 1000)
# The existing decision applies its exponential floor to a zero hint.
assert decision.backoff_ms == int((expected or 1) * 1000)
def test_retry_parser_keeps_configured_cap_and_default():
from types import SimpleNamespace
llm = LLM(model="fake", retry_delay=7)
llm = LLM(model="fake")
llm._rate_limiter = SimpleNamespace(max_retry_delay=12)
llm._retry_delay = 7
assert llm._parse_retry_delay('try again in 1e2') == 12
assert llm._parse_retry_delay('no retry hint') == 7
@@ -1,100 +0,0 @@
"""Facts stored via ``agent.store_memory()`` reach the model on every backend (#6040).
The full ``Memory`` class (sqlite/chroma/mem0) returns no system-prompt context
without a query, so recall depends on turn-start prefetch, which now defaults to
"auto" (on for those backends, off for FileMemory).
"""
import asyncio
import pytest
from praisonaiagents import Agent, MemoryConfig
from praisonaiagents.model_harness import ScriptedModel
FACT = "The project codename is BLUEHERON."
QUESTION = "What is the project codename?"
@pytest.fixture(autouse=True)
def _isolated_dirs(tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path / "home"))
monkeypatch.setenv("PRAISONAI_HOME", str(tmp_path / "home" / ".praisonai"))
work = tmp_path / "work"
work.mkdir()
monkeypatch.chdir(work) # the SQLite backend writes under the cwd
def _sent_to_model(model: ScriptedModel, needle: str) -> bool:
request = model.requests[0]
if needle in (request.system_prompt or ""):
return True
return any(needle in str(m.get("content")) for m in request.messages)
def _ask(memory, *, use_async: bool, fact: str = FACT) -> ScriptedModel:
model = ScriptedModel(["(answer)"])
agent = Agent(
name="recall",
instructions="You are a helpful assistant.",
llm=model,
memory=memory,
output="silent",
)
agent.store_memory(fact, memory_type="long_term")
if use_async:
asyncio.run(agent.achat(QUESTION))
else:
agent.chat(QUESTION)
return model
@pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"])
@pytest.mark.parametrize(
"memory",
[
{"user_id": "u1"},
"sqlite",
MemoryConfig(backend="sqlite", user_id="u1"),
MemoryConfig(backend="sqlite", user_id="u1", prefetch=True),
],
ids=["file", "sqlite", "sqlite+user_id", "sqlite+user_id+prefetch"],
)
def test_stored_fact_reaches_model(memory, use_async):
model = _ask(memory, use_async=use_async)
assert _sent_to_model(model, "BLUEHERON")
@pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"])
def test_prefetch_false_still_disables_recall(use_async):
model = _ask(
MemoryConfig(backend="sqlite", user_id="u1", prefetch=False),
use_async=use_async,
)
assert not _sent_to_model(model, "BLUEHERON")
def test_other_user_records_are_not_recalled():
# u2 writes a fact; u1 asks the same question against the same database
# and must not see it, so only the user_id filter keeps them apart.
shared = {"short_db": "shared_short.db", "long_db": "shared_long.db"}
_ask(MemoryConfig(backend="sqlite", user_id="u2", config=shared), use_async=False)
model = _ask(
MemoryConfig(backend="sqlite", user_id="u1", config=shared),
use_async=False,
fact="The project codename is SECRETOWL.",
)
assert _sent_to_model(model, "SECRETOWL")
assert not _sent_to_model(model, "BLUEHERON")
def test_store_long_term_stamps_configured_user_id():
from praisonaiagents.memory.memory import Memory
memory = Memory({"provider": "sqlite", "user_id": "u1"})
assert memory._stamp_user_id(None) == {"user_id": "u1"}
# An explicit metadata user_id wins and the caller's dict is not mutated.
meta = {"user_id": "other"}
assert memory._stamp_user_id(meta) == {"user_id": "other"}
assert meta == {"user_id": "other"}
assert Memory({"provider": "sqlite"})._stamp_user_id({"k": 1}) == {"k": 1}
@@ -26,7 +26,7 @@ def _agent(config: MemoryConfig) -> Agent:
def test_memory_config_prefetch_defaults_and_round_trip():
config = MemoryConfig()
assert config.prefetch is None # auto: decided per backend (#6040)
assert config.prefetch is False
assert config.prefetch_limit == 5
assert config.prefetch_token_budget == 512
@@ -1,173 +0,0 @@
"""
Tests that AFTER_AGENT plugins can block or rewrite the final response (#5061).
"""
import asyncio
import os
import time
from praisonaiagents.plugins.plugin import (
Plugin,
PluginInfo,
PluginHook,
PluginDecision,
GuardrailBlocked,
)
from praisonaiagents.plugins.manager import PluginManager
from praisonaiagents.hooks.registry import HookRegistry
from praisonaiagents.hooks.runner import HookRunner
from praisonaiagents.hooks.types import HookEvent
from praisonaiagents.hooks.events import AfterAgentInput
SECRET = "123-45-6789"
class RaiseBlockPlugin(Plugin):
@property
def info(self):
return PluginInfo(name="raiseblock", hooks=[PluginHook.AFTER_AGENT])
def after_agent(self, response, context):
if SECRET in response:
raise GuardrailBlocked("PII in response")
return response
class DecisionBlockPlugin(Plugin):
@property
def info(self):
return PluginInfo(name="decisionblock", hooks=[PluginHook.AFTER_AGENT])
def after_agent(self, response, context):
if SECRET in response:
return PluginDecision.block("PII in response")
return response
class RedactPlugin(Plugin):
@property
def info(self):
return PluginInfo(name="redact", hooks=[PluginHook.AFTER_AGENT])
def after_agent(self, response, context):
return response.replace(SECRET, "[REDACTED]")
def _registry(plugin):
reg = HookRegistry()
mgr = PluginManager()
mgr.register(plugin)
mgr.set_plugin_options({plugin.info.name: {"allow_conversation": True}})
assert mgr.wire_into_hook_registry(reg) == 1
return reg
def _input(response):
return AfterAgentInput(
session_id="s", cwd=os.getcwd(), event_name=HookEvent.AFTER_AGENT,
timestamp=str(time.time()), agent_name="a", response=response,
)
def _agent(plugin):
from praisonaiagents import Agent
return Agent(name="a", role="r", goal="g", hooks=_registry(plugin))
class TestAfterAgentAdapter:
def test_guardrail_blocked_blocks(self):
runner = HookRunner(registry=_registry(RaiseBlockPlugin()), cwd=os.getcwd())
results = runner.execute_sync(HookEvent.AFTER_AGENT, _input(f"SSN {SECRET}"))
assert runner.is_blocked(results) is True
assert runner.get_blocking_reason(results) == "PII in response"
def test_decision_blocks(self):
runner = HookRunner(registry=_registry(DecisionBlockPlugin()), cwd=os.getcwd())
results = runner.execute_sync(HookEvent.AFTER_AGENT, _input(f"SSN {SECRET}"))
assert runner.is_blocked(results) is True
def test_clean_response_allowed(self):
runner = HookRunner(registry=_registry(RaiseBlockPlugin()), cwd=os.getcwd())
data = _input("all good")
results = runner.execute_sync(HookEvent.AFTER_AGENT, data)
assert runner.is_blocked(results) is False
assert data.response == "all good"
class TestAgentAfterAgentEnforcement:
def test_sync_block_withholds_response(self):
agent = _agent(RaiseBlockPlugin())
out = agent._trigger_after_agent_hook("q", f"SSN {SECRET}", time.time())
assert SECRET not in out
assert "blocked" in out.lower() and "PII in response" in out
def test_async_block_withholds_response(self):
agent = _agent(DecisionBlockPlugin())
out = asyncio.run(agent._atrigger_after_agent_hook("q", f"SSN {SECRET}", time.time()))
assert SECRET not in out
assert "PII in response" in out
def test_sync_rewrite_is_adopted(self):
agent = _agent(RedactPlugin())
out = agent._trigger_after_agent_hook("q", f"SSN {SECRET}", time.time())
assert out == "SSN [REDACTED]"
def test_clean_response_passes_through(self):
agent = _agent(RaiseBlockPlugin())
assert agent._trigger_after_agent_hook("q", "hello", time.time()) == "hello"
def test_block_scrubs_stored_assistant_turn(self):
agent = _agent(RaiseBlockPlugin())
agent.chat_history.append({"role": "assistant", "content": f"SSN {SECRET}"})
out = agent._trigger_after_agent_hook("q", f"SSN {SECRET}", time.time())
assert agent.chat_history[-1]["content"] == out
assert all(SECRET not in str(m.get("content")) for m in agent.chat_history)
def test_block_scrubs_turn_when_newer_turn_interleaved(self):
agent = _agent(RaiseBlockPlugin())
agent.chat_history.append({"role": "assistant", "content": f"SSN {SECRET}"})
agent.chat_history.append({"role": "user", "content": "other"})
agent.chat_history.append({"role": "assistant", "content": "concurrent reply"})
agent._trigger_after_agent_hook("q", f"SSN {SECRET}", time.time())
assert all(SECRET not in str(m.get("content")) for m in agent.chat_history)
assert agent.chat_history[-1]["content"] == "concurrent reply"
def test_block_scrubs_own_turn_not_identical_concurrent_turn(self):
agent = _agent(RaiseBlockPlugin())
own = {"role": "assistant", "content": f"SSN {SECRET}"}
# A concurrent (untracked here) turn appends an identical answer later.
other = {"role": "assistant", "content": f"SSN {SECRET}"}
token = agent._begin_turn_tracking()
try:
agent._append_to_chat_history(own)
agent.chat_history.append(other)
out = agent._trigger_after_agent_hook("q", f"SSN {SECRET}", time.time())
finally:
agent._end_turn_tracking(token)
assert own["content"] == out
assert other["content"] == f"SSN {SECRET}"
class EmailRedactPlugin(Plugin):
@property
def info(self):
return PluginInfo(name="emailredact", hooks=[PluginHook.AFTER_AGENT])
def after_agent(self, response, context):
return response.replace("[email protected]", "[EMAIL]")
def test_stacked_redactors_compose():
reg = HookRegistry()
mgr = PluginManager()
for p in (RedactPlugin(), EmailRedactPlugin()):
mgr.register(p)
mgr.set_plugin_options({
"redact": {"allow_conversation": True},
"emailredact": {"allow_conversation": True},
})
assert mgr.wire_into_hook_registry(reg) == 2
runner = HookRunner(registry=reg, cwd=os.getcwd())
data = _input(f"SSN {SECRET} mail [email protected]")
runner.execute_sync(HookEvent.AFTER_AGENT, data)
assert data.response == "SSN [REDACTED] mail [EMAIL]"
@@ -1,119 +0,0 @@
"""Distinct session ids must never share a session file (Issue #6024)."""
import json
import os
import pytest
from praisonaiagents.session.store import DefaultSessionStore
@pytest.fixture
def store(tmp_path):
return DefaultSessionStore(session_dir=str(tmp_path))
@pytest.mark.parametrize("a,b", [
("alice.smith", "alice_smith"),
("a:b", "a_b"),
("[email protected]", "[email protected]"),
("[email protected]", "u_tag_x_com"),
("a/b", "a b"),
("telegram:acct:default:chat:-1001:", "telegram_acct_default_chat_-1001_"),
])
def test_colliding_ids_get_distinct_paths(store, a, b):
assert store._get_session_path(a) != store._get_session_path(b)
def test_safe_ids_keep_legacy_filename(store):
sid = "user_123-abc" + "x" * 100
assert os.path.basename(store._get_session_path(sid)) == f"{sid}.json"
def test_colliding_users_have_separate_histories(store):
store.add_user_message("[email protected]", "My PIN is 4471.")
assert store.get_chat_history("[email protected]") == []
store.add_user_message("[email protected]", "hello")
assert [m["content"] for m in store.get_chat_history("[email protected]")] == ["My PIN is 4471."]
assert [m["content"] for m in store.get_chat_history("[email protected]")] == ["hello"]
def _write_legacy(store, stem, owner, content):
path = os.path.join(store.session_dir, f"{stem}.json")
with open(path, "w", encoding="utf-8") as f:
json.dump({"session_id": owner, "messages": [
{"role": "user", "content": content, "timestamp": 1.0}]}, f)
return path
def test_legacy_file_migrated_only_for_owner(store):
legacy = _write_legacy(store, "alice_smith", "alice.smith", "secret")
# A colliding unsafe id never adopts it.
assert store.get_chat_history("alice:smith") == []
assert os.path.exists(legacy)
# The real owner migrates it on first read.
assert [m["content"] for m in store.get_chat_history("alice.smith")] == ["secret"]
assert not os.path.exists(legacy)
def test_foreign_file_never_served_and_not_destroyed(store):
# Safe id whose filename holds another id's unmigrated legacy transcript.
_write_legacy(store, "alice_smith", "alice.smith", "secret")
assert store.get_chat_history("alice_smith") == []
store.add_user_message("alice_smith", "mine")
assert [m["content"] for m in store.get_chat_history("alice_smith")] == ["mine"]
# The owner's history was relocated rather than overwritten.
assert [m["content"] for m in store.get_chat_history("alice.smith")] == ["secret"]
def test_failed_foreign_move_aborts_write(store, monkeypatch, tmp_path):
monkeypatch.setattr(store, "_spill_dir", lambda: str(tmp_path / "spill"))
store.add_user_message("alice.smith", "new") # owner path taken
legacy = _write_legacy(store, "alice_smith", "alice.smith", "secret")
monkeypatch.setattr(store, "_quarantine_corrupt", lambda p: None)
assert store.add_user_message("alice_smith", "mine") is False
with open(legacy, encoding="utf-8") as f:
assert json.load(f)["session_id"] == "alice.smith"
def test_foreign_file_set_aside_when_owner_path_taken(store):
store.add_user_message("alice.smith", "new")
legacy = _write_legacy(store, "alice_smith", "alice.smith", "secret")
store.add_user_message("alice_smith", "mine")
assert [m["content"] for m in store.get_chat_history("alice_smith")] == ["mine"]
assert [m["content"] for m in store.get_chat_history("alice.smith")] == ["new"]
aside = [p for p in os.listdir(store.session_dir) if p.startswith("alice_smith.json.corrupt-")]
assert len(aside) == 1 and os.path.exists(legacy)
def test_sqlite_backfill_indexes_hashed_filenames(tmp_path):
from praisonaiagents.session.sqlite_store import SqliteSessionStore
DefaultSessionStore(session_dir=str(tmp_path)).add_user_message(
"[email protected]", "narwhal record")
store = SqliteSessionStore(session_dir=str(tmp_path), db_path=":memory:")
assert [h.session_id for h in store.search("narwhal")] == ["[email protected]"]
def test_sqlite_backfill_indexes_legacy_collision_files(tmp_path):
from praisonaiagents.session.sqlite_store import SqliteSessionStore
legacy = DefaultSessionStore(session_dir=str(tmp_path))
_write_legacy(legacy, "alice_smith", "alice.smith", "walrus record")
store = SqliteSessionStore(session_dir=str(tmp_path), db_path=":memory:")
assert [h.session_id for h in store.search("walrus")] == ["alice.smith"]
def test_spill_recovery_does_not_wait_on_own_lock(store, monkeypatch, tmp_path):
import time
spill = tmp_path / "spill"
spill.mkdir()
monkeypatch.setattr(store, "_spill_dir", lambda: str(spill))
# Legacy name of "alice.smith" belongs to another id, so it is never migrated.
_write_legacy(store, "alice_smith", "alice:smith", "theirs")
(spill / "alice_smith.1.json").write_text(json.dumps({"session_id": "alice.smith", "messages": [
{"role": "user", "content": "spilled", "timestamp": 2.0}]}), encoding="utf-8")
start = time.monotonic()
assert [m["content"] for m in store.get_chat_history("alice.smith")] == ["spilled"]
assert time.monotonic() - start < store.lock_timeout
@@ -332,53 +332,6 @@ class TestArchivedRecall:
hits = reopened.search("zx-9271-alpha")
assert [h.session_id for h in hits] == ["old"]
@pytest.mark.parametrize("store_cls", [DefaultSessionStore, SqliteSessionStore])
def test_compaction_summary_not_scored(self, tmp_dir, store_cls):
# Issue #5051: the synthetic summary paraphrases archived turns that are
# already scored verbatim, so compaction count must not change rank.
from praisonaiagents.session.store import SessionData, SessionMessage
store = store_cls(session_dir=tmp_dir)
turn = ("user", "deploy token zx-9271-alpha")
self._seed_with_archive(store, "plain", archived=[turn], active=[])
compacted = SessionData(session_id="compacted")
compacted.archived_messages.append(SessionMessage(role=turn[0], content=turn[1]))
compacted.messages.append(
SessionMessage(
role="system",
content="[Session Summary]\nuser: deploy token zx-9271-alpha",
metadata={"compaction": True, "compacted_count": 1},
)
)
store._save_session(compacted)
hits = {h.session_id: h for h in store.search("zx-9271-alpha")}
assert hits["plain"].score == hits["compacted"].score
hit = hits["compacted"]
anchor = next(m for m in hit.messages if m["index"] == hit.anchor_index)
assert anchor["archived"]
def test_summary_only_matches_do_not_exhaust_candidates(self, tmp_dir):
# Unscored summaries must not be indexed, or they spend the bounded
# candidate allowance and hide a real match (Issue #5051, Greptile P1).
from praisonaiagents.session.store import SessionData, SessionMessage
store = SqliteSessionStore(session_dir=tmp_dir)
for i in range(6):
data = SessionData(session_id=f"compacted-{i}")
data.archived_messages.append(SessionMessage(role="user", content="hello"))
data.messages.append(
SessionMessage(
role="system",
content="[Session Summary]\n" + "pelican " * 20,
metadata={"compaction": True},
)
)
store._save_session(data)
self._seed_with_archive(store, "real", archived=[], active=[("user", "a pelican flew by")])
assert [h.session_id for h in store.search("pelican", limit=1)] == ["real"]
class TestIndexedSessionRoute:
"""Indexed gateway/agent routing lookups (Issue #2956)."""
@@ -1,36 +0,0 @@
"""Telemetry shutdown must return promptly when the PostHog flush is wedged."""
import threading
import time
from unittest.mock import MagicMock
from praisonaiagents.telemetry.telemetry import MinimalTelemetry
class _FastJoinThread(threading.Thread):
def join(self, timeout=None):
return super().join(timeout=0.2 if timeout else timeout)
def test_shutdown_returns_when_flush_hangs(monkeypatch):
release = threading.Event()
client = MagicMock()
client.flush.side_effect = lambda: release.wait(10)
telemetry = MinimalTelemetry(enabled=False)
telemetry.enabled = True
telemetry._shutdown_lock = threading.Lock()
telemetry._shutdown_complete = False
telemetry._posthog = client
monkeypatch.setattr(telemetry, "flush", lambda: None)
monkeypatch.setattr(threading, "Thread", _FastJoinThread)
start = time.monotonic()
try:
telemetry.shutdown()
finally:
release.set()
assert time.monotonic() - start < 2
# Cleanup that would block on the same wedged transport is skipped.
client.shutdown.assert_not_called()
assert telemetry._posthog is None
@@ -73,25 +73,3 @@ class TestLazyInstantiation:
compaction = [w for w in caught if "context_compaction" in str(w.message)]
assert not compaction
assert ExecutionConfig._context_compaction_internal is True # noqa: SLF001
def test_init_does_not_reimport_feature_configs(monkeypatch):
"""A function-local ``from ..config.feature_configs import X`` probes the
module's lazy ``__getattr__`` for ``__path__`` on every construction (#5835)."""
import praisonaiagents.config.feature_configs as fc
def tool(city: str) -> str:
"""Tool."""
return city
Agent(name="T", model="gpt-4o-mini", tools=[tool], output="silent")
probes = []
original = fc.__getattr__
def spy(name):
probes.append(name)
return original(name)
monkeypatch.setattr(fc, "__getattr__", spy)
Agent(name="T", model="gpt-4o-mini", tools=[tool], output="silent")
assert "__path__" not in probes
@@ -1,151 +0,0 @@
"""Cache-aware and provider-reported cost accounting (issue #5995)."""
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from praisonaiagents.utils.cost_utils import calculate_llm_cost
def test_no_cache_data_keeps_flat_pricing():
# 1M in @ 0.15 + 1M out @ 0.60
assert calculate_llm_cost(1_000_000, 1_000_000, "gpt-4o-mini") == pytest.approx(0.75)
def test_cached_tokens_priced_at_cache_read_rate():
# 1M prompt of which 800k cached at 50%: (200k + 400k) * 2.50/1M
cost = calculate_llm_cost(1_000_000, 0, "gpt-4o", cached_tokens=800_000)
assert cost == pytest.approx(1.5)
def test_cached_tokens_read_from_openai_style_response():
usage = SimpleNamespace(
prompt_tokens=1_000_000,
completion_tokens=0,
prompt_tokens_details=SimpleNamespace(cached_tokens=1_000_000),
)
cost = calculate_llm_cost(1_000_000, 0, "gpt-4o", response=SimpleNamespace(usage=usage))
assert cost == pytest.approx(1.25)
def test_anthropic_cache_read_and_write_from_dict_response():
response = {
"usage": {
"prompt_tokens": 1_000_000,
"cache_read_input_tokens": 500_000,
"cache_creation_input_tokens": 100_000,
}
}
# 400k * 1 + 500k * 0.1 + 100k * 1.25 = 575k tokens @ 3.00/1M
cost = calculate_llm_cost(1_000_000, 0, "claude-3-5-sonnet-20241022", response=response)
assert cost == pytest.approx(1.725)
def test_cached_tokens_clamped_to_prompt_tokens():
assert calculate_llm_cost(100, 0, "gpt-4o", cached_tokens=10_000) == pytest.approx(
calculate_llm_cost(100, 0, "gpt-4o", cached_tokens=100)
)
def test_unknown_model_cache_defaults_to_input_rate():
assert calculate_llm_cost(1_000_000, 0, "some-model", cached_tokens=500_000) == pytest.approx(1.0)
def test_reported_cost_above_estimate_wins():
# Estimate is 12.5 (1M in @ 2.50 + 1M out @ 10.00); provider says more
usage = SimpleNamespace(prompt_tokens=1_000_000, completion_tokens=1_000_000, cost=20.0)
cost = calculate_llm_cost(1_000_000, 1_000_000, "gpt-4o", response=SimpleNamespace(usage=usage))
assert cost == pytest.approx(20.0)
def test_tiny_reported_cost_cannot_undercut_estimate():
# A misreporting endpoint/proxy must not shrink spend below the estimate
usage = SimpleNamespace(prompt_tokens=1_000_000, completion_tokens=1_000_000, cost=1e-6)
response = SimpleNamespace(usage=usage, _hidden_params={"response_cost": 1e-6})
cost = calculate_llm_cost(1_000_000, 1_000_000, "gpt-4o", response=response)
assert cost == pytest.approx(12.5)
def test_tiny_reported_cost_undercut_does_not_disable_budget_guard():
from praisonaiagents import Agent
agent = Agent(name="b", instructions="x", llm="gpt-4o")
response = SimpleNamespace(
usage=SimpleNamespace(prompt_tokens=1_000_000, completion_tokens=0, cost=1e-9)
)
assert agent._calculate_llm_cost(1_000_000, 0, response=response) == pytest.approx(2.5)
def test_tiny_reported_cost_is_not_rounded_to_zero():
# Estimate for 1 token rounds to 0.0; the reported 2e-7 must survive
usage = SimpleNamespace(prompt_tokens=1, completion_tokens=0, cost=2e-7)
cost = calculate_llm_cost(1, 0, "gpt-4o-mini", response=SimpleNamespace(usage=usage))
assert cost == pytest.approx(2e-7)
assert cost > 0
def test_reported_zero_cost_falls_back_to_estimate():
# A 0.0 from an unpriced model/proxy must not disable the budget guard
response = SimpleNamespace(usage=SimpleNamespace(cost=0.0), _hidden_params={"response_cost": 0.0})
assert calculate_llm_cost(1_000_000, 1_000_000, "unknown-model", response=response) == pytest.approx(4.0)
def test_reported_zero_cost_does_not_disable_budget_guard():
from praisonaiagents import Agent
agent = Agent(name="b", instructions="x", llm="custom-proxy-model")
response = SimpleNamespace(
usage=SimpleNamespace(prompt_tokens=1_000_000, completion_tokens=0, cost=0.0)
)
assert agent._calculate_llm_cost(1_000_000, 0, response=response) > 0
def test_mock_response_does_not_leak_into_cost():
assert calculate_llm_cost(1_000_000, 0, "gpt-4o-mini", response=MagicMock()) == pytest.approx(0.15)
def test_agent_accounting_uses_cached_tokens():
from praisonaiagents import Agent
agent = Agent(name="t", instructions="x", llm="gpt-4o")
usage = SimpleNamespace(
prompt_tokens=1_000_000,
completion_tokens=0,
prompt_tokens_details=SimpleNamespace(cached_tokens=1_000_000),
)
response = SimpleNamespace(usage=usage)
assert agent._calculate_llm_cost(1_000_000, 0, response=response) == pytest.approx(1.25)
def test_litellm_usage_carries_cache_counts():
from praisonaiagents.utils.cost_utils import _litellm_usage
usage = _litellm_usage(1_000, 10, 800, 100)
assert usage["prompt_tokens_details"]["cached_tokens"] == 800
assert usage["cache_read_input_tokens"] == 800
assert usage["cache_creation_input_tokens"] == 100
def test_streamed_response_keeps_cache_and_reported_cost():
from praisonaiagents.llm.openai_client import process_stream_chunks
delta = SimpleNamespace(content="hi", tool_calls=None)
content = SimpleNamespace(
id="c", created=0, model="gpt-4o", system_fingerprint=None, usage=None,
choices=[SimpleNamespace(delta=delta, finish_reason="stop")],
)
usage_chunk = SimpleNamespace(
id="c", created=0, model="gpt-4o", system_fingerprint=None, choices=[],
usage=SimpleNamespace(
prompt_tokens=1_000_000, completion_tokens=0,
prompt_tokens_details=SimpleNamespace(cached_tokens=1_000_000),
),
)
combined = process_stream_chunks([content, usage_chunk])
assert combined.usage.prompt_tokens_details.cached_tokens == 1_000_000
assert calculate_llm_cost(1_000_000, 0, "gpt-4o", response=combined) == pytest.approx(1.25)
usage_chunk.usage.cost = 2.0
combined = process_stream_chunks([content, usage_chunk])
assert calculate_llm_cost(1_000_000, 0, "gpt-4o", response=combined) == pytest.approx(2.0)
@@ -1,66 +0,0 @@
"""Handoff context policies must keep tool-call pairs intact and keep the goal."""
from types import SimpleNamespace
from praisonaiagents.agent.handoff import (
ContextPolicy,
Handoff,
HandoffConfig,
_tool_pair_safe_tail,
)
CALL = {"id": "c1", "type": "function", "function": {"name": "search", "arguments": "{}"}}
HISTORY = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "GOAL: book flight"},
{"role": "assistant", "content": "ok, searching"},
{"role": "assistant", "content": None, "tool_calls": [CALL]},
{"role": "tool", "tool_call_id": "c1", "content": "results"},
{"role": "assistant", "content": "found 3 options"},
]
def _prepare(policy, n=10):
target = SimpleNamespace(name="tgt")
handoff = Handoff(agent=target, config=HandoffConfig(context_policy=policy, max_context_messages=n))
source = SimpleNamespace(name="src", chat_history=list(HISTORY))
return handoff._prepare_context(source, {}).messages
def _assert_pairs_valid(messages):
call_ids = {c["id"] for m in messages for c in (m.get("tool_calls") or [])}
response_ids = {m["tool_call_id"] for m in messages if m.get("role") == "tool"}
assert call_ids == response_ids
def test_last_n_drops_orphan_tool_result():
msgs = _prepare(ContextPolicy.LAST_N, n=2)
_assert_pairs_valid(msgs)
assert [m["role"] for m in msgs] == ["system", "assistant"]
def test_last_n_keeps_complete_pair():
msgs = _prepare(ContextPolicy.LAST_N, n=3)
_assert_pairs_valid(msgs)
assert any(m.get("tool_calls") for m in msgs)
def test_summary_keeps_goal_and_valid_pairs():
msgs = _prepare(ContextPolicy.SUMMARY)
_assert_pairs_valid(msgs)
assert msgs[0]["role"] == "system"
assert msgs[1]["content"] == "GOAL: book flight"
assert msgs[-1]["content"] == "found 3 options"
def test_summary_does_not_duplicate_goal_in_tail():
target = SimpleNamespace(name="tgt")
handoff = Handoff(agent=target, config=HandoffConfig(context_policy=ContextPolicy.SUMMARY))
source = SimpleNamespace(name="src", chat_history=[{"role": "user", "content": "hi"}])
assert handoff._prepare_context(source, {}).messages == [{"role": "user", "content": "hi"}]
def test_tail_drops_tool_calls_without_responses():
msgs = HISTORY[1:4] # ends on an unanswered tool_calls message
assert _tool_pair_safe_tail(msgs, 3) == HISTORY[1:3]
assert _tool_pair_safe_tail(msgs, 0) == []
@@ -9,9 +9,7 @@ from praisonaiagents import ImageAgent
("openai/dall-e-2", {}, None),
("openai/gpt-image-1", {}, None),
("openai/gpt-image-1", {"style": "vivid"}, None),
(None, {}, None),
("openai/dall-e-3", {}, None),
("openai/dall-e-3", {"style": "natural"}, "natural"),
("openai/dall-e-3", {}, "natural"),
("openai/dall-e-3", {"style": "vivid"}, "vivid"),
])
def test_style_forwarding(llm, kwargs, expected):
@@ -31,17 +29,3 @@ def test_style_forwarding(llm, kwargs, expected):
assert received["style"] == expected
finally:
agent.close()
@pytest.mark.parametrize("config,expected", [
({}, None),
({"style": "vivid"}, "vivid"),
])
def test_yaml_image_agent_style(config, expected):
from praisonaiagents.workflows.yaml_parser import YAMLWorkflowParser
agent = YAMLWorkflowParser()._create_image_agent("img", "openai/dall-e-3", config)
try:
assert agent.image_config.style == expected
finally:
agent.close()
@@ -1,101 +0,0 @@
"""Regression tests for issue #6134: import side effects, launch() blocking,
and ToolsMCPServer.execute_tool inside a running event loop."""
import asyncio
import subprocess
import sys
import textwrap
def test_import_leaves_host_logging_warnings_and_env_untouched():
code = textwrap.dedent(
"""
import logging, os, warnings
class H(logging.Handler):
def emit(self, r): pass
h = H()
root = logging.getLogger()
root.addHandler(h); root.setLevel(logging.INFO)
env_before = dict(os.environ)
warn_before = warnings.warn
httpx_before = (logging.getLogger("httpx").level, logging.getLogger("httpx").propagate)
import praisonaiagents # noqa: F401
assert root.handlers == [h], root.handlers
assert root.level == logging.INFO
assert warnings.warn is warn_before
assert (logging.getLogger("httpx").level, logging.getLogger("httpx").propagate) == httpx_before
assert dict(os.environ) == env_before, set(os.environ) ^ set(env_before)
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
warnings.warn("pydantic field shadows something", UserWarning)
assert len(w) == 1
print("OK")
"""
)
out = subprocess.run(
[sys.executable, "-c", code], capture_output=True, text=True
)
assert out.returncode == 0, out.stderr
assert out.stdout.strip().endswith("OK")
def test_launch_does_not_read_caller_source():
import inspect
from praisonaiagents.agent import execution_mixin
from praisonaiagents.agents import agents
for mod in (execution_mixin, agents):
src = inspect.getsource(mod)
assert "has_more_launches" not in src
assert "keep_alive_at_exit" in src
def test_execute_tool_async_tool_inside_running_loop():
from praisonaiagents.mcp.mcp_server import ToolsMCPServer
async def async_hi(x: str) -> str:
"""Say hi."""
return f"hi {x}"
server = ToolsMCPServer()
server.register_tool(async_hi)
assert server.execute_tool("async_hi", {"x": "a"}) == "hi a"
async def main():
return server.execute_tool("async_hi", {"x": "b"})
assert asyncio.run(main()) == "hi b"
def test_keep_alive_runs_before_executor_shutdown(tmp_path):
"""Server handlers use run_in_executor; it must still work while the
script-end keep-alive is waiting (i.e. before thread pools shut down)."""
script = tmp_path / "s.py"
script.write_text(textwrap.dedent(
"""
import os, signal, threading, time
from concurrent.futures import ThreadPoolExecutor
from praisonaiagents.agent.server_registry import _server_registry
_server_registry._started[0] = True
_server_registry.keep_alive_at_exit()
def handler():
while not threading._SHUTTING_DOWN:
time.sleep(0.05)
time.sleep(0.2)
print(ThreadPoolExecutor().submit(lambda: "served").result(), flush=True)
os.kill(os.getpid(), signal.SIGINT)
threading.Thread(target=handler, daemon=True).start()
"""
))
out = subprocess.run(
[sys.executable, str(script)], capture_output=True, text=True, timeout=30
)
assert "served" in out.stdout, out.stderr
assert "Servers stopped" in out.stdout
@@ -1,45 +0,0 @@
"""Issue #5741 Gap 3: persisted state writes must not clobber prior content."""
import json
import logging
from praisonaiagents.knowledge.indexing import FileTracker
from praisonaiagents.rag.summarizer import (
HierarchicalSummarizer,
HierarchyResult,
SummaryNode,
)
def test_summarizer_failed_save_keeps_previous_hierarchy(tmp_path, caplog):
s = HierarchicalSummarizer(persist_path=str(tmp_path))
good = HierarchyResult(root_path="/c", total_files=1)
good.nodes["a"] = SummaryNode(path="a", level=1, summary="ok")
s._save_hierarchy("/c", good)
assert s._load_hierarchy("/c").nodes["a"].summary == "ok"
bad = HierarchyResult(root_path="/c", built_at=object()) # not JSON-serialisable
with caplog.at_level(logging.WARNING):
s._save_hierarchy("/c", bad)
loaded = s._load_hierarchy("/c")
assert loaded is not None and loaded.nodes["a"].summary == "ok"
assert "Failed to save hierarchy" in caplog.text
def test_file_tracker_warns_on_corrupt_state(tmp_path, caplog):
state = tmp_path / "state.json"
state.write_text("{truncated", encoding="utf-8")
t = FileTracker(state_file=str(state))
with caplog.at_level(logging.WARNING):
t.load()
assert t._tracked == {}
assert "re-indexing" in caplog.text
def test_file_tracker_roundtrip(tmp_path):
state = tmp_path / "state.json"
t = FileTracker(state_file=str(state))
t._tracked = {"f.py": {"hash": "x"}}
t.save()
assert json.loads(state.read_text(encoding="utf-8")) == {"f.py": {"hash": "x"}}
@@ -1,84 +0,0 @@
"""Regression tests for issue #5920.
* A rejected *input* returns the same fixed blocked string as a rejected
output (never ``None``, never the validator's free-text reason).
* Concurrent ``PolicyConfig.save_learned_rule`` calls keep every rule.
"""
import asyncio
import threading
from praisonaiagents import Agent
from praisonaiagents.policy.config import PolicyConfig
SECRET_REASON = "contains secret: hunter2"
class RejectInput:
def validate_input(self, content, **kwargs):
return False, SECRET_REASON
def validate_output(self, content, **kwargs):
return True, content
def _agent():
return Agent(name="T", instructions="test", llm="gpt-4o-mini", guardrails=RejectInput())
def _assert_blocked(agent, result):
assert isinstance(result, str)
assert result == agent._guardrail_blocked_message()
assert "hunter2" not in result
def test_chat_input_block_returns_fixed_string():
a = _agent()
_assert_blocked(a, a.chat("hi"))
def test_start_input_block_returns_fixed_string():
a = _agent()
_assert_blocked(a, a.start("hi"))
def test_achat_input_block_returns_fixed_string():
a = _agent()
_assert_blocked(a, asyncio.run(a.achat("hi")))
def test_stream_input_block_does_not_leak_reason():
a = _agent()
chunks = list(a.start("hi", stream=True))
_assert_blocked(a, "".join(chunks))
def test_input_block_reason_exposed_via_last_guardrail_error():
a = _agent()
_assert_blocked(a, a.chat("hi"))
assert a.last_guardrail_error == SECRET_REASON
def test_concurrent_learned_rules_are_not_lost(tmp_path):
rules_file = str(tmp_path / "rules" / "default.rules")
threads, per_thread = 8, 25
def worker(i):
cfg = PolicyConfig(rules_file=rules_file)
for j in range(per_thread):
assert cfg.save_learned_rule(f"tool_{i}_{j}")
ts = [threading.Thread(target=worker, args=(i,)) for i in range(threads)]
for t in ts:
t.start()
for t in ts:
t.join()
rules = PolicyConfig(rules_file=rules_file).load_learned_rules()
assert len(rules) == threads * per_thread
def test_learned_rule_is_deduplicated(tmp_path):
cfg = PolicyConfig(rules_file=str(tmp_path / "r.rules"))
assert cfg.save_learned_rule("tool:ls", "allow")
assert cfg.save_learned_rule("tool:ls", "deny")
assert cfg.load_learned_rules() == [{"resource": "tool:ls", "action": "allow"}]
@@ -1,183 +0,0 @@
"""Regression tests for issue #6116 (offline, no LLM calls)."""
import asyncio
import pytest
from praisonaiagents import Agent, AgentTeam, StepResult, Task, Workflow
from praisonaiagents.workflows import loop, parallel
from praisonaiagents.workflows.workflows import WorkflowStepError
async def _fetch(ctx):
await asyncio.sleep(0)
return StepResult(output="real result")
def _use(ctx):
return StepResult(output=f"next saw: {ctx.previous_result}")
def test_async_handler_awaited_in_start():
r = Workflow(steps=[_fetch, _use]).start("x")
assert r["status"] == "completed"
assert r["output"] == "next saw: real result"
def test_async_handler_awaited_in_astart():
r = asyncio.run(Workflow(steps=[_fetch, _use]).astart("x"))
assert r["status"] == "completed"
assert r["output"] == "next saw: real result"
def test_failed_parallel_branch_error_surfaces():
def ok(ctx):
return StepResult(output="a")
def bad(ctx):
raise KeyError("missing")
r = Workflow(steps=[parallel([ok, bad]), ok]).start("x")
assert r["status"] == "failed"
assert "missing" in r["error"]
assert r["output"] is None
def test_failed_run_does_not_expose_previous_output():
def prep(ctx):
return StepResult(output="x")
def boom(ctx):
raise ValueError("bad input")
r = Workflow(steps=[prep, boom]).start("go")
assert r["status"] == "failed"
assert r["output"] is None
assert "bad input" in r["error"]
def test_loop_over_missing_variable_fails_loudly():
def body(ctx):
return StepResult(output="b")
with pytest.raises(WorkflowStepError, match="itmes"):
Workflow(steps=[loop(body, over="itmes")]).start("x")
@pytest.fixture
def agent(monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
return Agent(name="a", instructions="x", llm="gpt-4o-mini")
def _task(name, agent, **kw):
return Task(name=name, description="d", expected_output="o", agent=agent, **kw)
def test_team_keeps_user_declared_routing(agent):
a = _task("A", agent, next_tasks=["C"])
b = _task("B", agent)
c = _task("C", agent)
AgentTeam(agents=[agent], tasks=[a, b, c])
assert a.next_tasks == ["C"]
assert not b.context
def test_team_keeps_async_fan_out_independent(agent):
r1 = _task("r1", agent, async_execution=True)
r2 = _task("r2", agent, async_execution=True)
final = _task("final", agent, context=[r1, r2])
AgentTeam(agents=[agent], tasks=[r1, r2, final])
assert not r2.context
assert [t.name for t in final.context] == ["r1", "r2"]
def test_team_chain_is_idempotent(agent):
t1 = _task("t1", agent)
t2 = _task("t2", agent)
for _ in range(3):
AgentTeam(agents=[agent], tasks=[t1, t2])
assert t1.next_tasks == ["t2"]
assert [t.name for t in t2.context] == ["t1"]
def test_failed_loop_does_not_leak_tool_approvals():
from praisonaiagents.approval import get_approval_registry
def body(ctx):
return StepResult(output="b")
wf = Workflow(steps=[loop(body, over="itmes")])
wf.approve_tools = ["write_file"]
wf.allow_dangerous_tools = True
with pytest.raises(WorkflowStepError):
wf.start("x")
assert not get_approval_registry().is_yaml_approved("write_file")
def test_team_workflow_keeps_navigation_with_declared_context(agent):
a = _task("A", agent)
b = _task("B", agent, context=[a])
AgentTeam(agents=[agent], tasks=[a, b], process="workflow")
assert a.next_tasks == ["B"]
assert b.context == [a]
def test_team_workflow_async_tasks_stay_independent(agent):
from praisonaiagents.process.process import Process
r1 = _task("r1", agent, async_execution=True)
r2 = _task("r2", agent, async_execution=True)
team = AgentTeam(agents=[agent], tasks=[r1, r2], process="workflow")
assert r1.next_tasks == ["r2"] # navigation kept
gen = Process(tasks=team.tasks, agents=[agent]).aworkflow()
asyncio.run(gen.__anext__()) # builds previous_tasks, yields r1
assert r2.previous_tasks == []
assert not team._depends_on_pending(r2, [(r1.id, None)])
def test_team_workflow_user_declared_async_link_still_depends(agent):
from praisonaiagents.process.process import Process
r1 = _task("r1", agent, async_execution=True, next_tasks=["r2"])
r2 = _task("r2", agent, async_execution=True)
team = AgentTeam(agents=[agent], tasks=[r1, r2], process="workflow")
gen = Process(tasks=team.tasks, agents=[agent]).aworkflow()
asyncio.run(gen.__anext__())
assert r2.previous_tasks == ["r1"]
def test_hierarchical_run_does_not_leak_tool_approvals(monkeypatch):
from praisonaiagents.approval import get_approval_registry
def body(ctx):
return StepResult(output="b")
wf = Workflow(steps=[body], process="hierarchical")
wf.approve_tools = ["write_file"]
wf.allow_dangerous_tools = True
seen = {}
def fake_hierarchical(**kwargs):
seen["active"] = get_approval_registry().is_yaml_approved("write_file")
raise RuntimeError("manager failed")
monkeypatch.setattr(wf, "_run_hierarchical", fake_hierarchical)
with pytest.raises(RuntimeError):
wf.start("x")
assert seen["active"] is True
assert not get_approval_registry().is_yaml_approved("write_file")
def test_failed_hierarchical_run_does_not_expose_input_as_output(monkeypatch):
def body(ctx):
return StepResult(output="b")
def raising_step(*args, **kwargs):
raise RuntimeError("step broke")
wf = Workflow(steps=[body], process="hierarchical")
monkeypatch.setattr(wf, "_execute_single_step_internal", raising_step)
r = wf.start("user input")
assert r["status"] == "failed"
assert r["output"] is None
@@ -1,63 +0,0 @@
"""Headerless rate limits use existing jitter unless the caller selected a delay."""
import asyncio
from unittest.mock import AsyncMock
import pytest
from praisonaiagents.llm.llm import LLM
@pytest.mark.parametrize("attempt,expected", [(1, 3), (2, 9), (3, 27), (10, 60)])
def test_explicit_none_and_attempt_cap(monkeypatch, attempt, expected):
"""None selects jitter with the same one-based attempts and 60-second cap."""
llm = LLM(model="openai/gpt-4o-mini", retry_delay=None)
monkeypatch.setattr("praisonaiagents.llm.error_classifier.random.uniform", lambda low, high: high)
decision = llm.resolve_failover_decision(RateLimited(), {"attempt": attempt, "max_retries": 20})
assert decision.backoff_ms == expected * 1000
class RateLimited(Exception):
"""Provider-like exception exposing the authoritative HTTP response."""
status_code = 429
def __init__(self, headers=None):
super().__init__("rate limited")
self.response = type("Response", (), {"headers": headers or {}})()
@pytest.mark.parametrize("is_async", [False, True])
@pytest.mark.parametrize("delay,headers,expected", [
(None, {}, [3, 9, 27]),
(7, {}, [7, 7, 7]),
(0, {}, [0, 0, 0]),
(None, {"retry-after": "2"}, [2, 2, 2]),
(7, {"retry-after": "2"}, [2, 2, 2]),
(None, {"retry-after": "0"}, [0, 0, 0]),
(None, {"retry-after": "99999"}, [300, 300, 300]),
])
def test_actual_retry_loop_delay_selection(monkeypatch, is_async, delay, headers, expected):
"""Drive sync/async retry loops without sleeping or contacting any provider."""
kwargs = {} if delay is None else {"retry_delay": delay}
llm = LLM(model="openai/gpt-4o-mini", **kwargs)
monkeypatch.setattr("praisonaiagents.llm.error_classifier.random.uniform", lambda low, high: high)
sleeps = []
error = RateLimited(headers)
if is_async:
async def sleep(seconds):
sleeps.append(seconds)
monkeypatch.setattr("praisonaiagents.llm.llm.asyncio.sleep", sleep)
fail = AsyncMock(side_effect=error)
with pytest.raises(RateLimited):
asyncio.run(llm._call_with_retry_async(fail))
assert fail.await_count == llm._max_retries + 1
else:
monkeypatch.setattr("praisonaiagents.llm.llm.time.sleep", sleeps.append)
calls = []
def fail():
calls.append(True)
raise error
with pytest.raises(RateLimited):
llm._call_with_retry(fail)
assert len(calls) == llm._max_retries + 1
# Zero-delay retries need not call sleep at all.
assert [value for value in sleeps if value] == [value for value in expected if value]
@@ -7,7 +7,7 @@ Tests:
- Concurrent claims (threads) each get disjoint jobs — no double-fire
- complete() releases the lease
- Expired leases become claimable again (crash recovery)
- One-shot jobs (delete_after_run) stay leased on claim, retired on completion
- One-shot jobs (delete_after_run) are removed on claim
- Lease metadata round-trips through to_dict/from_dict
- ScheduleRunner.claim_due_jobs falls back to get_due_jobs without claim
"""
@@ -78,7 +78,7 @@ class TestClaimDue:
reclaimed = store.claim_due(t1, owner_id="B", lease_seconds=5)
assert len(reclaimed) == 1
def test_one_shot_retired_on_completion(self):
def test_one_shot_removed_on_claim(self):
with tempfile.TemporaryDirectory() as d:
store = FileScheduleStore(store_dir=d)
job = ScheduleJob(
@@ -90,10 +90,6 @@ class TestClaimDue:
store.add(job)
claimed = store.claim_due(time.time(), owner_id="A")
assert len(claimed) == 1
# Kept (leased, retiring) so a crash mid-run cannot lose it.
kept = store.get(job.id)
assert kept is not None and kept._retiring == 1
ScheduleRunner(store).mark_run(claimed[0])
assert store.get(job.id) is None
def test_concurrent_claims_no_double_fire(self):
@@ -339,7 +335,7 @@ class TestConfigYamlClaimDue:
reclaimed = store.claim_due(t1, owner_id="B", lease_seconds=5)
assert len(reclaimed) == 1
def test_one_shot_retired_on_completion(self):
def test_one_shot_removed_on_claim(self):
with tempfile.TemporaryDirectory() as d:
store = self._make_store(d)
job = ScheduleJob(
@@ -351,10 +347,6 @@ class TestConfigYamlClaimDue:
store.add(job)
claimed = store.claim_due(time.time(), owner_id="A")
assert len(claimed) == 1
# Kept (leased, retiring) so a crash mid-run cannot lose it.
kept = store.get(job.id)
assert kept is not None and kept._retiring == 1
ScheduleRunner(store).mark_run(claimed[0])
assert store.get(job.id) is None
def test_concurrent_claims_no_double_fire(self):
@@ -1,183 +0,0 @@
"""
Crash recovery for claimed schedule jobs (issue #6036).
A spent job (one-shot / ``max_runs`` met) is no longer deleted at claim time:
it stays leased and is retired on completion. A lease that expires without
``complete`` is recorded as an ``abandoned`` run; a one-shot is retried once,
a recurring job waits for its next slot. Covers both stores.
"""
import os
import time
from datetime import datetime, timedelta, timezone
import pytest
from praisonaiagents.scheduler.config_store import ConfigYamlScheduleStore
from praisonaiagents.scheduler.models import Schedule, ScheduleJob
from praisonaiagents.scheduler.runner import ScheduleRunner
from praisonaiagents.scheduler.store import FileScheduleStore
@pytest.fixture(params=["file", "yaml"])
def make_store(request, tmp_path):
if request.param == "file":
return lambda: FileScheduleStore(store_dir=str(tmp_path))
path = os.path.join(str(tmp_path), "config.yaml")
return lambda: ConfigYamlScheduleStore(config_path=path)
def _one_shot(name="remind-once"):
past = (datetime.now(timezone.utc) - timedelta(seconds=5)).isoformat()
return ScheduleJob(
name=name,
message="ping",
schedule=Schedule(kind="at", at=past),
delete_after_run=True,
)
def _statuses(store, job_id):
return [r.status for r in store.get_history(job_id=job_id)]
def test_claim_keeps_one_shot_until_completion(make_store):
store = make_store()
job = _one_shot()
store.add(job)
claimed = store.claim_due(time.time(), owner_id="A", lease_seconds=300)
assert [j.id for j in claimed] == [job.id]
kept = make_store().get(job.id)
assert kept is not None and kept._retiring == 1
assert kept._lease_owner == "A"
# A competitor cannot claim it while leased.
assert make_store().claim_due(time.time(), owner_id="B") == []
ScheduleRunner(store).mark_run(claimed[0])
store.complete(job.id, "A")
assert make_store().get(job.id) is None
def test_crashed_one_shot_is_retried_once(make_store):
store = make_store()
job = _one_shot()
store.add(job)
t0 = time.time()
assert store.claim_due(t0, owner_id="A", lease_seconds=1)
del store # crash: no mark_run / complete
restarted = make_store()
retry = restarted.claim_due(t0 + 5, owner_id="B", lease_seconds=1)
assert [j.id for j in retry] == [job.id]
assert _statuses(restarted, job.id) == ["abandoned"]
# Retry also crashes: logged and retired, never offered a third time.
third = make_store()
assert third.claim_due(t0 + 10, owner_id="C", lease_seconds=1) == []
assert third.get(job.id) is None
assert _statuses(make_store(), job.id) == ["abandoned", "abandoned"]
def test_retried_one_shot_completes_once(make_store):
store = make_store()
job = _one_shot()
store.add(job)
t0 = time.time()
store.claim_due(t0, owner_id="A", lease_seconds=1)
restarted = make_store()
retry = restarted.claim_due(t0 + 5, owner_id="B", lease_seconds=300)
ScheduleRunner(restarted).mark_run(retry[0])
restarted.complete(job.id, "B")
final = make_store()
assert final.get(job.id) is None
assert sorted(_statuses(final, job.id)) == ["abandoned", "succeeded"]
def test_crashed_recurring_job_logs_abandoned_and_waits_for_next_slot(make_store):
store = make_store()
job = ScheduleJob(
name="hourly", message="digest",
schedule=Schedule(kind="every", every_seconds=3600),
)
store.add(job)
t0 = time.time()
assert store.claim_due(t0, owner_id="A", lease_seconds=1)
restarted = make_store()
assert restarted.claim_due(t0 + 5, owner_id="B") == []
assert _statuses(restarted, job.id) == ["abandoned"]
assert restarted.get(job.id) is not None
# Next slot fires normally.
assert len(restarted.claim_due(t0 + 3601, owner_id="B")) == 1
def test_failed_one_shot_stays_listed_disabled(make_store):
store = make_store()
job = _one_shot()
store.add(job)
claimed = store.claim_due(time.time(), owner_id="A")
ScheduleRunner(store).mark_run(claimed[0], status="failed", error="provider 503")
store.complete(job.id, "A")
after = make_store()
kept = after.get(job.id)
assert kept is not None and kept.enabled is False
assert _statuses(after, job.id) == ["failed"]
# Disabled: never re-claimed or silently retired.
assert after.claim_due(time.time() + 10, owner_id="B") == []
assert after.get(job.id) is not None
def test_one_shot_in_flight_past_lease_not_refired_by_same_process(make_store):
store = make_store()
job = _one_shot()
store.add(job)
t0 = time.time()
assert store.claim_due(t0, owner_id="A", lease_seconds=1)
# Same ticker polls again while its run is still going.
assert store.claim_due(t0 + 5, owner_id="A", lease_seconds=1) == []
def test_future_one_shot_survives_poll_before_its_time(make_store):
store = make_store()
future = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
job = ScheduleJob(
name="later", message="ping",
schedule=Schedule(kind="at", at=future), delete_after_run=True,
)
store.add(job)
assert store.claim_due(time.time(), owner_id="A") == []
assert make_store().get(job.id) is not None
def test_resumed_failed_one_shot_runs_again(make_store):
store = make_store()
job = _one_shot()
store.add(job)
claimed = store.claim_due(time.time(), owner_id="A")
ScheduleRunner(store).mark_run(claimed[0], status="failed", error="boom")
store.complete(job.id, "A")
resumed = make_store()
kept = resumed.get(job.id)
kept.enabled = True
resumed.update(kept)
retry = resumed.claim_due(time.time() + 10, owner_id="B")
assert [j.id for j in retry] == [job.id]
ScheduleRunner(resumed).mark_run(retry[0])
resumed.complete(job.id, "B")
assert make_store().get(job.id) is None
def test_crashed_spent_job_not_retried_past_until(make_store):
store = make_store()
t0 = time.time()
until = datetime.fromtimestamp(t0 + 3, timezone.utc).isoformat()
job = ScheduleJob(
name="bounded", message="ping",
schedule=Schedule(kind="every", every_seconds=3600),
max_runs=1, until=until,
)
store.add(job)
assert store.claim_due(t0, owner_id="A", lease_seconds=1)
# Crash, then restart after the deadline: logged and retired, not re-run.
restarted = make_store()
assert restarted.claim_due(t0 + 5, owner_id="B") == []
assert restarted.get(job.id) is None
assert _statuses(make_store(), job.id) == ["abandoned"]
@@ -241,9 +241,9 @@ class TestFileScheduleStoreEmits:
class TestOneShotAutoDelete:
"""A spent ``delete_after_run`` job is kept (leased) by ``claim_due`` and
retired by the runner's ``mark_run`` once the run completes — announcing
itself exactly once."""
"""A spent ``delete_after_run`` job is deleted inside ``claim_due``,
bypassing ``remove()`` entirely — it must still announce itself, exactly
once (the runner's later ``remove(job.id)`` finds nothing)."""
def test_claim_due_auto_delete_fires_once(self, tmp_path):
from praisonaiagents.scheduler.store import FileScheduleStore
@@ -258,15 +258,12 @@ class TestOneShotAutoDelete:
with _Recorder(HookEvent.SCHEDULE_REMOVE) as rec:
claimed = store.claim_due(now=time.time() + 5, owner_id="w1")
assert [j.id for j in claimed] == [job.id]
# Not deleted at claim: a crash mid-run must not lose it.
assert rec.fired == []
ScheduleRunner(store).mark_run(claimed[0])
store.complete(job.id, "w1")
# A later poll finds nothing left to retire.
assert store.claim_due(now=time.time() + 10, owner_id="w1") == []
# The runner's post-run cleanup must not double-announce it.
assert store.remove(job.id) is False
assert rec.fired == ["schedule_remove"]
assert rec.payloads[0]["job_name"] == "one-shot"
assert ScheduleRunner is not None # cleanup path lives here
def test_recurring_claim_does_not_fire(self, tmp_path):
from praisonaiagents.scheduler.store import FileScheduleStore
@@ -170,10 +170,8 @@ def test_async_framework_verdict_is_not_visible_to_the_model():
raise RuntimeError("network down")
agent = Agent(name="t", instructions="x", tools=[boom], tool_config=FAST)
# A raised tool exception surfaces as ToolExecutionError (sync parity, #6041).
with pytest.raises(ToolExecutionError) as exc_info:
asyncio.run(agent.execute_tool_async("boom", {}))
assert "_praison_retryable" not in str(exc_info.value)
result = asyncio.run(agent.execute_tool_async("boom", {}))
assert "_praison_retryable" not in str(result)
def test_async_breaker_opens_after_raised_exceptions():
@@ -101,19 +101,15 @@ def test_delegate_task_timeout_exceeded(monkeypatch):
def factory(*, agent_factory=None, **kwargs):
def spawn(task, agent_name=None, **kw):
time.sleep(4)
time.sleep(2)
return {"success": True, "output": "too late", "agent_name": agent_name}
return {"function": spawn}
monkeypatch.setattr(delegation_tools, "create_subagent_tool", factory)
start = time.monotonic()
raw = DelegationTools().delegate_task("Slow task", agent_type="research", timeout=1)
elapsed = time.monotonic() - start
data = json.loads(raw)
assert data["success"] is False
assert "timed out" in data["error"]
# The caller must not wait for the abandoned worker to finish.
assert elapsed < 3
def test_delegate_task_requires_approval_registered():
@@ -1,178 +0,0 @@
"""spawn_subagent(background=True, deliver=...) inside a gateway turn (#6037).
The model cannot supply the origin chat, so it must come from the turn's
session context, and the result must reach the turn's outbound messenger.
"""
import time
from praisonaiagents.background.job_manager import get_job_manager
from praisonaiagents.gateway.protocols import DeliveryResult
from praisonaiagents.session.context import (
clear_outbound_messenger,
clear_session_context,
register_outbound_messenger,
set_session_context,
)
from praisonaiagents.tools.subagent_tool import create_subagent_tool
class _StubAgent:
def chat(self, prompt):
return "long job done"
class _FakeMessenger:
def __init__(self, ok=True):
self.ok = ok
self.sent = []
async def send(self, target, text, *, media=None):
self.sent.append((target, text))
return DeliveryResult(ok=self.ok, target=target)
def list_targets(self):
return []
def _spawn_in_turn(messenger, deliver="origin", **kwargs):
tool = create_subagent_tool(agent_factory=lambda **kw: _StubAgent())
ctx = set_session_context(
platform="telegram", chat_id="100", thread_id="7", user_id="alice"
)
msg = register_outbound_messenger(messenger)
try:
return tool["function"](
task="summarise", background=True, deliver=deliver, **kwargs
)
finally:
clear_outbound_messenger(msg)
clear_session_context(ctx)
def _wait_info(job_id):
jm = get_job_manager()
jm.get_result(job_id, timeout=5)
for _ in range(50):
info = jm.get_job_info(job_id)
if info.delivered:
break
time.sleep(0.02)
return info
def test_origin_filled_from_session_context_and_delivered_once():
messenger = _FakeMessenger()
handle = _spawn_in_turn(messenger)
info = _wait_info(handle["job_id"])
assert info.origin["platform"] == "telegram"
assert info.origin["chat_id"] == "100"
assert info.origin["thread_id"] == "7"
assert info.origin["user_id"] == "alice"
assert info.delivered is True
assert len(messenger.sent) == 1
target, text = messenger.sent[0]
assert target == "origin"
assert "long job done" in text
def test_explicit_origin_arguments_win():
messenger = _FakeMessenger()
handle = _spawn_in_turn(messenger, platform="slack", chat_id="C9")
info = _wait_info(handle["job_id"])
assert info.origin["platform"] == "slack"
assert info.origin["chat_id"] == "C9"
assert info.origin["thread_id"] == ""
assert info.origin["user_id"] == "alice"
# A different chat is not the turn's chat: never send it to chat "100".
assert messenger.sent == []
assert info.delivered is False
def test_explicit_other_thread_is_not_auto_sent():
messenger = _FakeMessenger()
handle = _spawn_in_turn(messenger, thread_id="99")
info = _wait_info(handle["job_id"])
assert info.origin["thread_id"] == "99"
assert messenger.sent == []
assert info.delivered is False
def test_non_origin_target_is_not_auto_sent():
messenger = _FakeMessenger()
handle = _spawn_in_turn(messenger, deliver="telegram:999")
info = _wait_info(handle["job_id"])
assert messenger.sent == []
assert info.delivered is False
def test_send_policy_denial_blocks_auto_send():
from praisonaiagents.gateway.protocols import SendDecision
from praisonaiagents.session.context import (
clear_send_policy,
register_send_policy,
)
class _Deny:
def evaluate(self, target, **kw):
return SendDecision(allow=False, reason="no")
messenger = _FakeMessenger()
token = register_send_policy(_Deny())
try:
handle = _spawn_in_turn(messenger)
finally:
clear_send_policy(token)
info = _wait_info(handle["job_id"])
assert messenger.sent == []
assert info.delivered is False
def test_rejected_send_is_not_marked_delivered():
messenger = _FakeMessenger(ok=False)
handle = _spawn_in_turn(messenger)
jm = get_job_manager()
jm.get_result(handle["job_id"], timeout=5)
time.sleep(0.1)
assert len(messenger.sent) == 1
assert jm.get_job_info(handle["job_id"]).delivered is False
def test_without_gateway_stays_pull_only():
tool = create_subagent_tool(agent_factory=lambda **kw: _StubAgent())
handle = tool["function"](task="t", background=True, deliver="origin")
jm = get_job_manager()
jm.get_result(handle["job_id"], timeout=5)
time.sleep(0.05)
info = jm.get_job_info(handle["job_id"])
assert info.delivered is False
assert info.origin["chat_id"] == ""
def test_restating_own_chat_keeps_turn_thread_and_platform():
messenger = _FakeMessenger()
handle = _spawn_in_turn(messenger, chat_id="100")
info = _wait_info(handle["job_id"])
assert info.origin["platform"] == "telegram"
assert info.origin["thread_id"] == "7"
assert len(messenger.sent) == 1
def test_empty_output_still_notifies_before_marking_delivered():
class _Silent:
def chat(self, prompt):
return ""
tool = create_subagent_tool(agent_factory=lambda **kw: _Silent())
messenger = _FakeMessenger()
ctx = set_session_context(platform="telegram", chat_id="100", user_id="alice")
msg = register_outbound_messenger(messenger)
try:
handle = tool["function"](task="t", background=True, deliver="origin")
finally:
clear_outbound_messenger(msg)
clear_session_context(ctx)
info = _wait_info(handle["job_id"])
assert info.delivered is True
assert len(messenger.sent) == 1
@@ -278,53 +278,6 @@ class TestFlowSteps:
assert "step:page" in ends and "step:queue" not in ends
assert "step:b" in ends and "step:a" not in ends
def test_one_step_object_at_two_positions_keeps_each_node_id(self, recorder):
from praisonaiagents.workflows.workflows import AgentFlow
review = _handler("review")
flow = AgentFlow(steps=[review, _handler("draft"), review])
flow.run("go")
expected = ["step:review", "step:draft", "step:review#2"]
assert [e.node_id for e in _of(recorder, "step_start")] == expected
assert [e.node_id for e in _of(recorder, "step_end")] == expected
nodes = {n["id"] for n in recorder.get_events()[0].data["graph"]["nodes"]}
assert set(expected) <= nodes
def test_one_step_object_at_two_positions_inside_a_loop(self, recorder):
from praisonaiagents.workflows.workflows import AgentFlow, Loop
review = _handler("review")
flow = AgentFlow(steps=[Loop(steps=[review, _handler("draft"), review], over="items")],
variables={"items": ["a", "b"]})
flow.run("go")
leaf = ["step:review", "step:draft", "step:review#2"]
for kind in ("step_start", "step_end"):
ids = [e.node_id for e in _of(recorder, kind) if e.node_id != "step:loop"]
assert ids == leaf * 2 # stable across iterations
def test_one_step_object_in_both_if_branches(self, recorder):
from praisonaiagents.workflows.workflows import AgentFlow, If
review = _handler("review")
flow = AgentFlow(steps=[review, If(condition="{{missing}} == yes", then_steps=[review], else_steps=[review])])
flow.run("go")
ids = [e.node_id for e in _of(recorder, "step_end") if e.node_id != "step:if"]
assert ids == ["step:review", "step:review#3"]
def test_one_pattern_object_at_two_positions_keeps_each_node_id(self, recorder):
from praisonaiagents.workflows.workflows import AgentFlow, If, Loop
branch = If(condition="{{missing}} == yes", then_steps=[_handler("skip")], else_steps=[_handler("review")])
loop = Loop(steps=[branch, branch], over="items")
flow = AgentFlow(steps=[branch, branch, loop], variables={"items": ["a"]})
flow.run("go")
ends = [e.node_id for e in _of(recorder, "step_end")]
nodes = {n["id"] for n in recorder.get_events()[0].data["graph"]["nodes"]}
assert set(ends) <= nodes
assert [i for i in ends if i.startswith("step:if")] == ["step:if", "step:if#2", "step:if#3", "step:if#4"]
assert [i for i in ends if i.startswith("step:review")] == [
"step:review", "step:review#2", "step:review#3", "step:review#4"]
def test_skipped_failed_and_cached_steps(self, recorder):
from praisonaiagents.task.task import Task
from praisonaiagents.workflows.workflows import AgentFlow
@@ -237,7 +237,7 @@ def test_streamed_turn_blocked_by_guardrail_is_failed(sink):
agent = Agent(name="Writer", instructions="x")
with patch.object(agent, "_validate_input_with_guardrail", return_value=(False, None, "no")):
assert "".join(agent._start_stream("hi")) == agent._guardrail_blocked_message()
assert "blocked" in "".join(agent._start_stream("hi"))
assert _of(sink, "agent_end")[0].data == {"status": "failed"}
+1 -1
View File
@@ -3162,7 +3162,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.7.20"
version = "1.7.19"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.1.19"
__version__ = "0.1.18"
@@ -1475,7 +1475,6 @@ def handle_tasks_command(
user_id: str,
args: Optional[str] = None,
runner: Optional[Any] = None,
job_manager: Optional[Any] = None,
) -> str:
"""Handle /tasks to inspect background tasks from inside a chat session.
@@ -1493,9 +1492,6 @@ def handle_tasks_command(
user_id: The requesting user's identifier (owner scope).
args: Optional argument string (an id, or ``cancel <id>``).
runner: Optional ``BackgroundRunner`` (defaults to the shared one).
job_manager: Optional ``BackgroundJobManager`` holding background
subagents (defaults to the shared one). Jobs are owned through
``origin["user_id"]``, captured when they were spawned.
Returns:
A compact, chat-friendly text response.
@@ -1508,28 +1504,6 @@ def handle_tasks_command(
except Exception as e: # noqa: BLE001
return f"❌ Background tasks unavailable: {e}"
if job_manager is None:
try:
from praisonaiagents.background.job_manager import get_job_manager
job_manager = get_job_manager()
except Exception: # noqa: BLE001
job_manager = None
def _mine(info: Any) -> bool:
# Same fail-closed rule as ``_owned``: jobs without an owner are hidden.
owner = (info.origin or {}).get("user_id")
return bool(owner) and str(owner) == str(user_id)
def _owned_job(job_id: str) -> Optional[Any]:
if job_manager is None:
return None
try:
info = job_manager.get_job_info(job_id)
except Exception: # noqa: BLE001
return None
return info if _mine(info) else None
def _owned(task: Dict[str, Any]) -> bool:
# Fail closed: a task is only visible/actionable to the requesting user
# when its owner id matches exactly. Ownerless tasks (e.g. submitted
@@ -1551,11 +1525,7 @@ def handle_tasks_command(
except Exception: # noqa: BLE001
task = None
if task is None or not _owned(task.to_dict()):
if _owned_job(task_id) is None:
return f"❌ Task not found: {task_id}"
if not job_manager.cancel_job(task_id):
return f"❌ Could not cancel task {task_id} (already finished?)."
return f"✅ Cancelled task {task_id}."
return f"❌ Task not found: {task_id}"
try:
# Cancel through the runner so the underlying future is actually
# stopped (not just the record marked cancelled). cancel_task_sync
@@ -1579,21 +1549,7 @@ def handle_tasks_command(
except Exception: # noqa: BLE001
task = None
if task is None or not _owned(task.to_dict()):
info = _owned_job(arg)
if info is None:
return f"❌ Task not found: {arg}"
lines = [
f"🧩 Task {info.job_id} — {info.origin.get('task') or 'subagent'}",
f"Status: {info.status.value}",
]
if info.error:
lines.append(f"Error: {str(info.error)[:200]}")
elif info.result is not None:
result = info.result
if isinstance(result, dict):
result = result.get("output") or result.get("error")
lines.append(f"Result: {str(result)[:200]}")
return "\n".join(lines)
return f"❌ Task not found: {arg}"
t = task.to_dict()
lines = [
f"🧩 Task {t.get('id')} — {t.get('name') or 'unnamed'}",
@@ -1610,19 +1566,8 @@ def handle_tasks_command(
tasks = [t for t in runner.list_tasks() if _owned(t)]
except Exception as e: # noqa: BLE001
return f"❌ Could not list tasks: {e}"
try:
jobs = sorted(
(
j for j in (job_manager.list_jobs().values() if job_manager else ())
if _mine(j)
),
key=lambda j: j.created_at,
reverse=True,
)
except Exception: # noqa: BLE001
jobs = []
if not tasks and not jobs:
if not tasks:
return "💤 No background tasks."
lines = ["🧩 Background tasks:"]
@@ -1632,9 +1577,6 @@ def handle_tasks_command(
f"• {t.get('id')} {t.get('name') or 'unnamed'} — "
f"{t.get('status')} ({progress})"
)
for j in jobs[: max(0, 20 - len(tasks))]:
name = str(j.origin.get("task") or "subagent")[:60]
lines.append(f"• {j.job_id} {name} — {j.status.value}")
lines.append("\nUse /tasks <id> for detail or /tasks cancel <id>.")
return "\n".join(lines)
@@ -928,10 +928,6 @@ class GatewayServerSchema(BaseModel):
# best-effort sends a short plain-text notice on the same channel.
notify_on_undelivered: Optional[bool] = None
undelivered_template: Optional[str] = None
# Notification policy (#6138): ``{defaults: {...}, recipients: {"<platform>:
# <channel_id>": {categories, quiet_hours, timezone, detail}}}`` gating
# categorised proactive sends (scheduled results = ``agent_finished``).
notifications: Optional[Dict[str, Any]] = None
# Additive protocol surfaces (#2715), liveness (#2798), health monitor
api: Optional[Dict[str, Any]] = None
liveness: Optional[Dict[str, Any]] = None
@@ -946,9 +942,6 @@ class GatewayServerSchema(BaseModel):
forensics: Optional[Dict[str, Any]] = None
# Hooks may be nested under ``gateway:`` for grouping
hooks: Optional[List["HookSchema"]] = None
# Outbound lifecycle sinks (#6140): shell commands run on core hook events
# (event JSON on stdin) — the config-only counterpart to inbound ``hooks``.
hooks_outbound: Optional[List["OutboundHookSchema"]] = None
@field_validator("overflow_policy")
@classmethod
@@ -959,19 +952,6 @@ class GatewayServerSchema(BaseModel):
)
return v
@field_validator("notifications")
@classmethod
def validate_notifications(
cls, v: Optional[Dict[str, Any]]
) -> Optional[Dict[str, Any]]:
# Fail at load on an unknown category/detail or malformed quiet_hours
# rather than silently at send time.
if v:
from ._notifications import NotificationGate
NotificationGate(v)
return v
class HookCoalesceSchema(BaseModel):
"""``hooks[].coalesce`` — mirrors core ``CoalesceConfig`` (#5867)."""
@@ -1046,28 +1026,8 @@ class HookSchema(BaseModel):
return v
class OutboundHookSchema(BaseModel):
"""``gateway.hooks_outbound[]`` — a core ``CommandHook`` on lifecycle events (#6140)."""
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
command: str = Field(min_length=1)
events: List[str] = Field(min_length=1)
timeout: float = Field(default=30.0, gt=0)
@field_validator("events")
@classmethod
def validate_events(cls, v: List[str]) -> List[str]:
from praisonaiagents.hooks.types import HookEvent
allowed = {e.value for e in HookEvent}
bad = [e for e in v if e not in allowed]
if bad:
raise ValueError(f"Unknown hook event(s): {', '.join(bad)}")
return v
# Resolve the forward references to ``HookSchema``/``OutboundHookSchema`` in
# ``GatewayServerSchema`` now that both are defined.
# Resolve the forward reference to ``HookSchema`` in
# ``GatewayServerSchema.hooks`` now that ``HookSchema`` is defined.
GatewayServerSchema.model_rebuild()
@@ -645,20 +645,17 @@ def _wire_shell_approval_backend(
or os.environ.get("TELEGRAM_CHAT_ID")
)
if chat_id:
# The channel's own adapter already polls this token, so a
# self-polling TelegramApproval would be a second getUpdates
# consumer (409s, swallowed user messages — Issue #6032). Use the
# durable backend instead; the adapter renders its buttons and
# resolves taps through its own CallbackQueryHandler.
from praisonai_bot.bots._presentation_approval_backend import (
PresentationApprovalBackend,
)
try:
from praisonai_bot.bots import TelegramApproval
agent._approval_backend = PresentationApprovalBackend(
allowed_actors=approvers,
target=str(chat_id),
)
return
agent._approval_backend = TelegramApproval(
token=token,
chat_id=str(chat_id),
allowed_approvers=approvers,
)
return
except ImportError:
logger.warning("TelegramApproval unavailable for allow_shell")
elif channel_type == "discord":
channel_id = (
@@ -1,119 +0,0 @@
"""Notification gate for the proactive delivery path (Issue #6138).
Binds the pure core policy (:func:`praisonaiagents.push.policy.evaluate`) to the
gateway's ``notifications:`` config so :meth:`DeliveryRouter.deliver` can
enforce categories, quiet hours and detail-level redaction for every
categorised proactive send. The YAML block *is* the durable per-recipient store:
.. code-block:: yaml
gateway:
notifications:
defaults: {detail: identified, categories: [approval_requested]}
recipients:
"telegram:12345":
categories: [approval_requested, agent_finished]
quiet_hours: "22:00-07:30"
timezone: "Europe/London"
detail: detailed
Recipients are keyed by the resolved ``platform:channel_id``. A recipient with
no entry falls back to ``defaults`` (or the core ``DEFAULT_PREFERENCE``).
"""
from __future__ import annotations
from datetime import datetime, time, timezone
from typing import Any, Dict, Mapping, Optional
from praisonaiagents.push.policy import (
DEFAULT_PREFERENCE,
DetailLevel,
NotificationCategory,
NotificationDecision,
NotificationPreference,
evaluate,
)
def _parse_time(value: str) -> time:
hour, _, minute = str(value).strip().partition(":")
return time(int(hour), int(minute or 0))
def _parse_timezone(value: Any) -> str:
# The core policy silently falls back to UTC for an unknown zone, so a typo
# would shift quiet hours; reject it at config load instead.
from zoneinfo import ZoneInfo
name = str(value)
try:
ZoneInfo(name)
except Exception as exc:
raise ValueError(f"unknown notification timezone: {name!r}") from exc
return name
def parse_preference(
raw: Mapping[str, Any], base: NotificationPreference = DEFAULT_PREFERENCE
) -> NotificationPreference:
"""Build a :class:`NotificationPreference` from a YAML/dict entry.
Keys absent from ``raw`` inherit from ``base``. ``quiet_hours`` accepts a
``"HH:MM-HH:MM"`` string or a ``[start, end]`` pair. Invalid values raise
``ValueError`` so a typo fails at config load, not silently at send time.
"""
categories = base.categories
if "categories" in raw:
categories = {NotificationCategory(c) for c in (raw["categories"] or [])}
quiet = base.quiet_hours
if "quiet_hours" in raw:
window = raw["quiet_hours"]
if window:
start, end = window.split("-", 1) if isinstance(window, str) else window
quiet = (_parse_time(start), _parse_time(end))
else:
quiet = None
return NotificationPreference(
categories=set(categories),
quiet_hours=quiet,
timezone=(
_parse_timezone(raw["timezone"]) if "timezone" in raw else base.timezone
),
detail=DetailLevel(raw.get("detail", base.detail)),
urgent_overrides_quiet=bool(
raw.get("urgent_overrides_quiet", base.urgent_overrides_quiet)
),
)
class NotificationGate:
"""Resolve a recipient's preference and evaluate one categorised send."""
def __init__(self, config: Mapping[str, Any]):
self._defaults = parse_preference(config.get("defaults") or {})
self._recipients: Dict[str, NotificationPreference] = {
str(key): parse_preference(raw or {}, self._defaults)
for key, raw in (config.get("recipients") or {}).items()
}
def preference_for(self, recipient: str) -> NotificationPreference:
return self._recipients.get(recipient, self._defaults)
def admit(
self,
recipient: str,
category: Any,
content: Optional[str],
*,
identity: Optional[str] = None,
) -> NotificationDecision:
# An aware UTC ``now`` lets the core localise to the recipient's own
# timezone instead of the gateway host's wall clock.
return evaluate(
NotificationCategory(category),
self.preference_for(recipient),
now=datetime.now(timezone.utc),
content=content,
identity=identity,
)
@@ -277,7 +277,14 @@ class PairingCallbackHandler:
elif action == "deny":
# Consume the code to prevent reuse
try:
self.pairing_store.reject(code)
# Try to consume the code without pairing by calling verify_and_pair with dummy values
# This will consume the code and return False since it won't actually pair
self.pairing_store.verify_and_pair(
code=code,
channel_id="__denied__", # Dummy value that won't be stored
channel_type="__denied__",
label="Denied pairing request"
)
except Exception as e:
logger.error(f"Failed to consume denied code: {e}")
@@ -249,47 +249,6 @@ class MessageHookMixin:
is guarded by ``getattr`` checks.
"""
# Platform-native mention markup (e.g. ``"<@{}>"``); ``None`` means the
# platform cannot ping by user id and ``mention_user`` falls back to text.
_mention_template: Optional[str] = None
def mention_user(self, unified_user_id: str) -> str:
"""Render a native mention for a canonical identity (Issue #6119).
Maps ``unified_user_id`` to this platform's user id via the bot's
identity resolver (``links_for``), or accepts the unlinked
``"{platform}:{user_id}"`` form directly. Returns the plain id as
text when the human is not linked here or the platform cannot ping.
"""
template = self._mention_template
if not template or not unified_user_id:
return unified_user_id
platform = getattr(self, "platform", "")
native = None
prefix = f"{platform}:"
if unified_user_id.startswith(prefix):
native = unified_user_id[len(prefix):]
else:
# Bot/BotOS/gateway stamp the resolver on the adapter's session
# manager; direct injection on the adapter still wins.
session = getattr(self, "_session", None) or getattr(
self, "_session_mgr", None
)
resolver = getattr(self, "_identity_resolver", None) or getattr(
session, "_identity_resolver", None
)
if resolver is not None:
try:
links = resolver.links_for(unified_user_id)
except Exception as e: # pragma: no cover — defensive
logger.warning("mention_user: identity lookup failed: %s", e)
links = []
native = next(
(l.platform_user_id for l in links if l.platform == platform),
None,
)
return template.format(native) if native else unified_user_id
def _get_hook_runner(self) -> Any:
"""Resolve the HookRunner from the agent, if available."""
agent = getattr(self, '_agent', None)
@@ -239,13 +239,6 @@ def is_recoverable_error(err: BaseException, platform: str = "") -> bool:
# Platform-specific checks
if platform == "telegram":
try:
from telegram.error import NetworkError
except ImportError:
pass # Telegram remains an optional dependency for other adapters.
else:
if isinstance(err, NetworkError):
return True
for pattern in _TELEGRAM_RECOVERABLE:
if pattern in msg:
return True
@@ -112,7 +112,6 @@ class SessionRunState:
self.start_time: Optional[float] = None
self.last_activity: float = time.time() # Always track activity for cleanup
self.agent: Optional[Any] = None # Live agent handle for STEER mode
self.interrupt_deferred = False # Last busy message was queued, not interrupting
class SessionRunControl:
@@ -211,16 +210,7 @@ class SessionRunControl:
return RunDecision.RUN_NOW
# There's an active run, handle based on busy mode
ctrl = session.interrupt_controller
# Don't supersede a turn mid-subagent or mid-compaction: queue the
# message instead so the in-flight work is not discarded. Once a
# message is queued, keep merging until it runs so the queued
# follow-up is never dropped by a later interrupt.
session.interrupt_deferred = self._busy_mode == BusyMode.INTERRUPT and (
session.pending_message is not None
or bool(getattr(ctrl, "is_protected", lambda: False)())
)
if self._busy_mode == BusyMode.INTERRUPT and not session.interrupt_deferred:
if self._busy_mode == BusyMode.INTERRUPT:
# Cancel current run and start new one
if session.interrupt_controller:
session.interrupt_controller.request("user_new_message")
@@ -285,8 +275,6 @@ class SessionRunControl:
"""
session = self._get_session(user_id)
if decision in (RunDecision.QUEUED, RunDecision.MERGED) and session.interrupt_deferred:
return "⏳ Finishing current work, your message is queued"
if decision == RunDecision.QUEUED:
action = "noted"
elif decision == RunDecision.MERGED:
Loaded 100 of 196 files, more files were not shown because too many files have changed in this diff. Show more