Compare commits

..
Author SHA1 Message Date
praisonai-triage-agent[bot]andMervin Praison ccc4058b17 revert: restore intentional defects in ai_code_editor_fixture
The AI code-editor smoke-test fixture is intentionally broken so live
scenarios (implement celsius_to_fahrenheit / fahrenheit_to_celsius / mode,
fix mean/median empty-list handling) exercise the AI editor's repair
capability. Pre-implementing these functions caused acceptance tests to
pass WITHOUT the editor doing any work, defeating the fixture's purpose.

Also removes the redundant conftest.py sys.path mutation; pyproject.toml
already sets pythonpath = ["src"].

Co-authored-by: Mervin Praison <[email protected]>
2026-08-19 09:20:28 +00:00
124 changed files with 478 additions and 10086 deletions

No files matched your search

+13 -42
View File
@@ -130,7 +130,7 @@ Each layer wraps the one inside it. When an agent misbehaves, the layer tells yo
| **3 · Harness** | Can it act, and be checked? | `tools=`, `MCP()`, `guardrails=`, `approval=`, `hooks=`, `sandbox=` |
| **4 · Loop** | When do we stop? | `execution=ExecutionConfig(...)`, `reflection=`, `autonomy=`, doom-loop detection |
| **5 · Graph** | Who runs when, and who checks whom? | `AgentFlow`, `route()`, `parallel()`, `loop()`, `repeat()` |
| **⬡ Managed** | *Where does it actually run?* | `tools_run_on="docker"` — one shared sandbox for the tools, or `run_on="anthropic"` for the whole agent |
| **⬡ Managed** | *Where does it actually run?* | `run_on="docker"` — one shared remote sandbox, or a fully hosted loop |
### Layer 1 · Prompt — *Did I say it clearly?*
@@ -235,7 +235,7 @@ The harness is commoditising; **where** the agent executes is the next multiplie
pip install praisonai
```
The simplest way in is `tools_run_on=` — one whole team or workflow shares **one** sandbox, so a file written by step 1 is there for step 2. Thinking stays on your machine:
The simplest way in is `run_on=` — one whole team or workflow shares **one** sandbox, so a file written by step 1 is there for step 2:
```python
from praisonaiagents import Agent, AgentFlow
@@ -243,7 +243,7 @@ from praisonaiagents import Agent, AgentFlow
writer = Agent(name="Writer", instructions="You write files.")
reader = Agent(name="Reader", instructions="You read files.")
flow = AgentFlow(tools_run_on="docker", steps=[writer, reader]) # or e2b | modal | daytona | flyio
flow = AgentFlow(run_on="docker", steps=[writer, reader]) # or e2b | modal | daytona | flyio
flow.run("Write 'hello' to /workspace/note.txt, then read it back")
```
@@ -251,7 +251,7 @@ Same thing with no Python at all:
```yaml
name: remote-demo
tools_run_on: docker # every step shares one sandbox
run_on: docker # every step shares one sandbox
agents:
writer: {role: Writer, goal: Write files}
reader: {role: Reader, goal: Read files}
@@ -262,51 +262,22 @@ steps:
action: "Read /workspace/note.txt"
```
For a single agent, two words cover it — and they answer different questions:
For a single agent, pick the axis you need — remote **tools** or a remote **loop**:
```python
from praisonaiagents import Agent
from praisonai import Agent, LocalAgent, LocalAgentConfig, HostedAgent
# A. Only the TOOLS move. Thinking stays on your machine.
agent = Agent(name="builder", instructions="You build things.",
tools_run_on="docker") # docker | e2b | modal | daytona | flyio
# tenki | sandlock | ssh | novita | subprocess
# A. Tools run in a remote sandbox; the agent loop stays local
agent = Agent(name="builder", backend=LocalAgent(
compute="e2b", # or modal | daytona | flyio | docker | tenki
config=LocalAgentConfig(model="gpt-4o-mini", name="RemoteTools"),
))
# B. The WHOLE agent moves — model calls, loop and tools
agent = Agent(name="teacher", instructions="You teach.", run_on="anthropic") # hosted
agent = Agent(name="builder", instructions="You build.", run_on="docker") # self-hosted
# B. The entire agent loop runs in the cloud (needs ANTHROPIC_API_KEY)
agent = Agent(name="teacher", backend=HostedAgent(provider="anthropic"))
agent.start("Write a Python script that prints the first 10 primes, then run it")
```
Ask any object where it runs, and it will tell you:
```python
>>> Agent(name="builder", instructions="x", tools_run_on="docker")
Agent(name='builder', thinks_on='this machine', tools_run_on='a Docker container')
>>> agent.where_does_it_run()
Thinking (the AI model calls) happens on this machine.
Tools run on a Docker container.
Your own tools (check_db) still run on this machine -- only shell, file and
code tools move. They read and write this machine's files.
```
Naming a place that cannot do the job is a typo, not a preference, so it says so:
```python
>>> Agent(name="x", instructions="i", run_on="e2b")
TypeError: Agent(run_on='e2b') is not valid: run_on= places the whole agent
-- model calls, loop and tools -- on a managed runtime, and 'e2b' runs
commands but cannot host an agent loop.
To run only the tools there: Agent(tools_run_on='e2b')
```
To run one block of code somewhere else, name the place on that call:
```python
agent.execute_code_sync("print(6 * 7)", run_in="sandlock") # kernel-enforced
```
See what is running and reclaim strays:
```bash
-2
View File
@@ -30,7 +30,6 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">aclose</a>() -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">add_mcp_server</a>(name: str, mcp: Any) -> Any</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">add_verification</a>(hook: Any) -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">agent_id</a>() -> str</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">analyze_prompt</a>(prompt: str) -> set</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">auto_memory</a>() -> Optional[bool]</code>
@@ -71,7 +70,6 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">redo</a>() -> bool</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">refresh_tools</a>() -> List[Any]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">remove_mcp_server</a>(name: str) -> bool</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">remove_verification</a>(name: str) -> bool</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">retrieval_config</a>() -> Optional[Any]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">retrieve</a>(query: str, **kwargs) -> 'ContextPack'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">rules_manager</a>() -> Optional[Any]</code>
@@ -23,7 +23,6 @@ from praisonaiagents.hooks.verification import (
VerificationResult,
BaseVerificationHook,
CommandVerificationHook,
FileCheckHook,
)
@@ -101,19 +100,7 @@ def main():
print(f" Name: {cmd_hook.name}")
print(f" Command: {cmd_hook.command}")
print(f" Timeout: {cmd_hook.timeout_seconds}s")
# 6. FileCheckHook: declarative, no-shell completion gate
print("\n6. FileCheckHook (declarative completion gate):")
file_hook = FileCheckHook(
name="changelog", path="CHANGELOG.md", non_empty=True
)
file_result = file_hook.run()
print(f" {file_hook.name}: success={file_result.success} — {file_result.output}")
print(
" Gate wiring: when verification_hooks are configured, the autonomous "
"loop cannot finalise with a success outcome while a blocking hook fails."
)
print("\n" + "=" * 60)
print("✓ Verification hooks example completed successfully!")
print("=" * 60)
@@ -2,25 +2,20 @@
#
# praisonai examples/yaml/workflows/remote_sandbox_workflow.yaml
#
# `tools_run_on:` is the only line that differs from a normal local workflow.
# Every step shares the same sandbox and working directory, so the file the
# writer creates is there for the reader. Thinking and orchestration still run
# on your machine, and the sandbox is torn down when the run finishes.
# `run_on:` is the only line that differs from a normal local workflow. Every
# step shares the same sandbox and /workspace, so the file the writer creates
# is there for the reader. Orchestration still runs on your machine, and the
# sandbox is torn down when the run finishes.
#
# Places: docker | e2b | modal | daytona | flyio | tenki | local
# subprocess | sandlock | ssh | novita
# Providers: docker | e2b | modal | daytona | flyio | tenki | local
# - docker needs a local Docker daemon and nothing else
# - e2b / modal / daytona / flyio need that provider's API key
# - sandlock is a kernel-enforced local boundary (Linux 6.12+)
# - subprocess is process separation only, NOT a security boundary
#
# `run_on:` is still accepted here as the old spelling for this key.
#
# See what is still running: praisonai managed ps
# Reclaim strays: praisonai managed stop --all
name: remote-sandbox-demo
tools_run_on: docker
run_on: docker
agents:
writer:
+2 -2
View File
@@ -39,7 +39,7 @@ This report compares **Python SDK feature categories** against **Python document
| ✅ Embeddings | 6 | 23 | 2049 |
| ✅ Evaluation | 1 | 7 | 2905 |
| ✅ Events | 1 | 2 | 749 |
| ✅ Execution | 3 | 4 | 1565 |
| ✅ Execution | 3 | 4 | 1562 |
| ✅ Failover | 2 | 1 | 432 |
| ✅ Files | 2 | 7 | 2275 |
| ✅ Flow | 1 | 3 | 788 |
@@ -68,7 +68,7 @@ This report compares **Python SDK feature categories** against **Python document
| ✅ Reflection | 3 | 3 | 680 |
| ✅ Retrieval | 2 | 5 | 1054 |
| ✅ Routing | 1 | 2 | 430 |
| ✅ Sandbox | 5 | 7 | 3248 |
| ✅ Sandbox | 5 | 7 | 3202 |
| ✅ Security | 1 | 3 | 3332 |
| ✅ Sessions | 4 | 18 | 7158 |
| ✅ Skills | 6 | 15 | 5638 |
@@ -150,8 +150,6 @@ class ServerRegistry:
# Class-level lock for thread-safe singleton creation
_instance_lock = threading.Lock()
@staticmethod
def get_default_instance():
"""Get default global registry for backward compatibility."""
@@ -514,28 +512,6 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
logging.debug(f"Generated tool definition: {tool_def}")
return tool_def
@staticmethod
def _hosted_backend_for(provider: str):
"""Build the managed backend that ``run_on=<provider>`` is shorthand for.
``run_on="anthropic"`` and ``backend=HostedAgent(provider="anthropic")``
place the agent identically; the first is the readable spelling for the
common case, the second is what you reach for when the runtime needs
configuring (model, system prompt, its own tools).
"""
try:
from praisonai.integrations import HostedAgent
except ImportError as exc:
raise ImportError(
f"Agent(run_on={provider!r}) runs the whole agent on a managed "
f"runtime, which ships in the wrapper package.\n"
f" pip install praisonai\n"
f"To keep thinking on this machine and move only the tools, use "
f"tools_run_on= instead -- that needs no extra install for "
f"local places."
) from exc
return HostedAgent(provider=provider)
def __init__(
self,
# Core identity
@@ -592,8 +568,6 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
tool_config: Optional[Union[bool, 'ToolConfig']] = None, # Tool execution configuration (timeout, retry, parallel)
learn: Optional[Union[bool, str, Dict[str, Any], 'LearnConfig']] = None, # Continuous learning (peer to memory)
backend: Optional[Any] = None, # External managed agent backend (e.g., ManagedAgentIntegration)
run_on: Optional[Union['ManagedRuntime', str]] = None, # Run the WHOLE agent on a managed runtime, e.g. "anthropic"
tools_run_on: Optional[Union['ToolPlace', str, Any]] = None, # Run only this agent's TOOLS elsewhere, e.g. "docker"
runtime: Optional[Union[bool, str, Dict[str, Any], 'AgentRuntimeConfig', 'RuntimeConfig']] = None, # Model-scoped runtime configuration with capability validation
interrupt_controller: Optional['InterruptController'] = None, # G2: Cooperative cancellation
tool_search: Optional[Union[bool, str, Dict[str, Any], 'ToolSearchConfig']] = False, # Progressive tool disclosure
@@ -785,34 +759,8 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
verification_hooks = legacy_kwargs.get("verification_hooks", _legacy_defaults["verification_hooks"])
cli_backend = legacy_kwargs.get("cli_backend", _legacy_defaults["cli_backend"])
# ── where does this agent run? ───────────────────────────────────────
# Resolved before anything else is built so a contradiction fails at the
# call site, with the parameter name the user actually wanted, instead
# of surfacing later as tools mysteriously running on the wrong machine.
from .placement import resolve_placement
_placement = resolve_placement(
"Agent", run_on=run_on, tools_run_on=tools_run_on, backend=backend
)
if _placement.whole is not None:
backend = self._hosted_backend_for(_placement.whole)
self.tools_run_on = _placement.tools
self._tools_sandbox = None
# Add check at start if memory is requested.
# Skip the probe for values that resolve to the zero-dependency FileMemory
# backend (True, "file", and file-provider dicts without learn); those never
# touch the heavy memory.memory module, so probing it just wastes an import.
_uses_file_memory = (
memory is True
or memory == "file"
or (
isinstance(memory, dict)
and not memory.get("learn", False)
and memory.get("provider", memory.get("backend", "file")) == "file"
)
)
if memory is not None and not _uses_file_memory:
# Add check at start if memory is requested
if memory is not None:
try:
from ..memory.memory import Memory # noqa: F401
_ = Memory # Silence unused import warning - we just check availability
@@ -1613,12 +1561,6 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
"or pass a validator via guardrails=... / GuardrailConfig(validator=...)."
)
# Apply [defaults].guardrails from config when not explicitly provided,
# mirroring the memory/web/caching/etc. resolution above so a config-wide
# guardrail safety net is actually honoured instead of silently ignored.
if guardrails is None:
guardrails = apply_config_defaults("guardrails", None, GuardrailConfig)
# Fast path: None/False -> no guardrails (skip resolve() call)
if guardrails is None or guardrails is False:
_guardrails_config = None
@@ -2667,16 +2609,6 @@ Your Goal: {self.goal}
)
return mem_inst
def _backstory_without_sandbox_prompt(self):
"""This agent's backstory as it was before any sandbox was attached."""
shared = getattr(self, "_tools_sandbox", None)
if shared is not None:
for ref, _tools, original_backstory in getattr(shared, "_patched", []):
if ref() is self:
return original_backstory
return self.backstory
def clone_for_channel(self) -> "Agent":
"""Return a fully independent copy of this agent for a gateway channel.
@@ -2697,10 +2629,7 @@ Your Goal: {self.goal}
'name': self.name,
'role': self.role,
'goal': self.goal,
# The pre-attach backstory when a sandbox is live: attach() appends
# the capability prompt to backstory, so cloning the current value
# and then attaching again gives the clone two copies of it.
'backstory': self._backstory_without_sandbox_prompt(),
'backstory': self.backstory,
'instructions': self.instructions,
# LLM configuration
@@ -2773,13 +2702,6 @@ Your Goal: {self.goal}
# underscore name (never assigned) made every clone silently drop
# its sandbox and run unisolated.
'sandbox': getattr(self, 'sandbox_config', None),
# Where the tools run must survive cloning for the same reason the
# sandbox must: a gateway-channel clone that quietly drops it runs
# every tool on the host while its repr still claims otherwise.
# The clone gets its own sandbox instance -- placement is a choice,
# not a live connection to share.
'tools_run_on': getattr(self, 'tools_run_on', None),
}
# Handle deprecated parameters for backward compatibility
@@ -4146,18 +4068,6 @@ Summary:"""
prompt = self._goal_continuation_prompt(self._goal_state)
continue
# ─────────────────────────────────────────────────────────────
# VERIFICATION GATE (opt-in): before any success signal below
# can finalise the run, all blocking verification hooks must
# pass. On failure, feed captured stdout/stderr back and keep
# iterating (never-passing checks end at max_iterations).
# No-op when no verification hooks are configured.
# ─────────────────────────────────────────────────────────────
_gate_feedback = self._verification_gate(response_str, iterations)
if _gate_feedback is not None:
prompt = _gate_feedback
continue
# ─────────────────────────────────────────────────────────────
# TOOL-CALL COMPLETION: If the model used tools this turn AND
# produced a substantive response, the inner loop completed
@@ -4611,16 +4521,6 @@ Summary:"""
await asyncio.sleep(0)
continue
# ─────────────────────────────────────────────────────────────
# VERIFICATION GATE (opt-in): blocking hooks must pass before a
# success signal below can finalise the run. No-op when unset.
# ─────────────────────────────────────────────────────────────
_gate_feedback = await self._verification_gate_async(response_str, iterations)
if _gate_feedback is not None:
prompt = _gate_feedback
await asyncio.sleep(0)
continue
# ─────────────────────────────────────────────────────────────
# TOOL-CALL COMPLETION: If the model used tools this turn AND
# produced a substantive response, the inner loop completed
@@ -4959,172 +4859,28 @@ Summary:"""
def _run_verification_hooks(self) -> List[Dict[str, Any]]:
"""Run all registered verification hooks.
Preserves the full result payload (``details``/``error``/
``duration_seconds`` — including ``returncode``/``stdout``/``stderr``
captured by :class:`CommandVerificationHook`) so callers and the goal
judge can gate on executable evidence, not just a boolean.
Returns:
List of verification result dicts.
List of verification results
"""
results: List[Dict[str, Any]] = []
for hook in getattr(self, '_verification_hooks', None) or []:
try:
result = hook.run()
if isinstance(result, dict):
record = dict(result)
record.setdefault("success", False)
elif hasattr(result, "to_dict"):
record = result.to_dict()
else:
record = {
"success": getattr(result, "success", False),
"output": getattr(result, "output", ""),
"details": getattr(result, "details", {}),
"error": getattr(result, "error", None),
"duration_seconds": getattr(result, "duration_seconds", 0.0),
}
record["hook"] = getattr(hook, "name", "unknown")
record["blocking"] = getattr(hook, "blocking", True)
results.append(record)
except Exception as e:
results.append({
"hook": getattr(hook, 'name', 'unknown'),
"blocking": getattr(hook, "blocking", True),
"success": False,
"error": str(e),
})
results = []
if hasattr(self, '_verification_hooks') and self._verification_hooks:
for hook in self._verification_hooks:
try:
result = hook.run()
results.append({
"hook": hook.name,
"success": result.get("success", False) if isinstance(result, dict) else getattr(result, 'success', False),
"output": result.get("output", "") if isinstance(result, dict) else getattr(result, 'output', ""),
})
except Exception as e:
results.append({
"hook": getattr(hook, 'name', 'unknown'),
"success": False,
"error": str(e),
})
return results
def add_verification(self, hook: Any) -> None:
"""Register a verification hook for the current run.
Lets an autonomous agent (or caller) add a task-specific completion
gate discovered mid-run, e.g. the regression test it just wrote.
"""
if not hasattr(self, "_verification_hooks") or self._verification_hooks is None:
self._verification_hooks = []
self._verification_hooks.append(hook)
def remove_verification(self, name: str) -> bool:
"""Remove a verification hook by name. Returns True if one was removed."""
hooks = getattr(self, "_verification_hooks", None) or []
remaining = [h for h in hooks if getattr(h, "name", None) != name]
removed = len(remaining) != len(hooks)
self._verification_hooks = remaining
return removed
def _verification_summary_tail(self, results: List[Dict[str, Any]], limit: int = 800) -> str:
"""Build a compact human-readable summary of gate results for feedback."""
lines = []
for r in results:
status = "PASS" if r.get("success") else "FAIL"
details = r.get("details") or {}
rc = details.get("returncode")
head = f"[{status}] {r.get('hook')}"
if rc is not None:
head += f" (exit={rc})"
lines.append(head)
out = r.get("output") or r.get("error") or ""
if out and not r.get("success"):
lines.append(str(out)[-limit:])
return "\n".join(lines)
def _verification_gate(self, response_str: str, iterations: int) -> Optional[str]:
"""Run verification hooks and gate completion.
Returns ``None`` when no hooks are configured or all blocking hooks
pass (loop may finalise with success). Otherwise returns a feedback
prompt containing the failing hooks' captured output so the loop can
continue and the agent can fix the failure.
"""
if not getattr(self, "_verification_hooks", None):
return None
results = self._run_verification_hooks()
return self._verification_gate_feedback(results)
async def _verification_gate_async(
self, response_str: str, iterations: int
) -> Optional[str]:
"""Async counterpart of :meth:`_verification_gate`.
Hook execution (which may shell out via :class:`CommandVerificationHook`)
is offloaded to a worker thread so a slow or blocking check never stalls
the event loop shared by other concurrent agents.
"""
if not getattr(self, "_verification_hooks", None):
return None
import asyncio as _asyncio
results = await _asyncio.to_thread(self._run_verification_hooks)
return self._verification_gate_feedback(results)
def _verification_gate_feedback(
self, results: List[Dict[str, Any]]
) -> Optional[str]:
"""Shared post-processing for the sync/async gates.
Persists the results, records them durably, and returns a feedback
prompt when a blocking hook failed (else ``None`` to allow completion).
"""
self._last_verification_results = results
self._record_verification_journal(results)
failed = [r for r in results if r.get("blocking", True) and not r.get("success")]
if not failed:
return None
summary = self._verification_summary_tail(failed)
return (
"Completion is blocked: the following verification checks failed. "
"Fix the underlying problem, then continue.\n" + summary
)
def _verification_journal_payloads(
self, results: List[Dict[str, Any]]
) -> List[Dict[str, Any]]:
"""Build durable ``verification``-kind payloads for the run journal."""
import hashlib
payloads: List[Dict[str, Any]] = []
for r in results:
details = r.get("details") or {}
output = r.get("output") or ""
payloads.append({
"name": r.get("hook"),
"success": bool(r.get("success")),
"returncode": details.get("returncode"),
"command": details.get("command"),
"path": details.get("path"),
"duration_seconds": r.get("duration_seconds"),
"output_hash": hashlib.sha256(str(output).encode()).hexdigest(),
})
return payloads
def _record_verification_journal(self, results: List[Dict[str, Any]]) -> None:
"""Best-effort durable record of gate results to the run journal.
Only writes when an opt-in durable run is active (``Agent(...,
durable=True)``): the active :class:`DurableRunContext` appends each
result as a ``verification``-kind :class:`JournalEvent`. When no durable
run is active this is a no-op, so the default (journal-off) path stays
zero-overhead.
"""
get_ctx = getattr(self, "_get_durable_run_context", None)
if not callable(get_ctx):
return
try:
context = get_ctx()
except Exception: # pragma: no cover - defensive
context = None
if context is None:
return
try:
context.record_verification(
self._verification_journal_payloads(results)
)
except Exception: # pragma: no cover - journalling is best-effort
pass
def get_available_tools(self) -> List[Any]:
"""
Get tools available to this agent, filtered by plan_mode if enabled.
@@ -217,13 +217,6 @@ class AutonomyConfig:
f"Invalid autonomy level: {level!r}. "
f"Must be one of {sorted(VALID_AUTONOMY_LEVELS)}"
)
verification_hooks = data.get("verification_hooks")
# Config parity: accept declarative `verification:` entries and map
# `command:`/`file:` shapes onto the two hook classes.
declarative = data.get("verification")
if declarative:
built = _build_verification_hooks(declarative)
verification_hooks = (verification_hooks or []) + built
return cls(
enabled=data.get("enabled", True),
level=level,
@@ -234,7 +227,7 @@ class AutonomyConfig:
observe=data.get("observe", False),
completion_promise=data.get("completion_promise"),
clear_context=data.get("clear_context", False),
verification_hooks=verification_hooks,
verification_hooks=data.get("verification_hooks"),
track_changes=data.get("track_changes"),
sandbox=data.get("sandbox"),
snapshot_dir=data.get("snapshot_dir"),
@@ -244,49 +237,6 @@ class AutonomyConfig:
budget_action=data.get("budget_action", "pause"),
)
def _build_verification_hooks(entries: List[Any]) -> List[Any]:
"""Map declarative ``verification:`` config entries onto hook instances.
Each entry is a dict with either a ``command`` key (→
:class:`CommandVerificationHook`) or a ``file`` key (→
:class:`FileCheckHook`). Unknown shapes are skipped.
Example::
verification:
- {name: tests, command: "python -m pytest -q"}
- {name: changelog, file: CHANGELOG.md, non_empty: true}
"""
from ..hooks.verification import CommandVerificationHook, FileCheckHook
hooks: List[Any] = []
for entry in entries or []:
if not isinstance(entry, dict):
continue
blocking = entry.get("blocking", True)
if "command" in entry:
hooks.append(CommandVerificationHook(
name=entry.get("name", "command"),
command=entry["command"],
cwd=entry.get("cwd"),
timeout=entry.get("timeout", 60.0),
blocking=blocking,
))
elif "file" in entry:
hooks.append(FileCheckHook(
name=entry.get("name", "file"),
path=entry["file"],
exists=entry.get("exists", True),
non_empty=entry.get("non_empty", False),
contains=entry.get("contains"),
json_field=entry.get("json_field"),
equals=entry.get("equals"),
blocking=blocking,
))
return hooks
class AutonomySignal(str, Enum):
"""Signals detected from prompts for autonomy decisions.
@@ -2901,14 +2901,6 @@ Your Goal: {self.goal}"""
'required' forces the LLM to call a tool before responding.
...other args...
"""
# Tools go to their sandbox before the first model call, once per
# agent. Placed on chat/achat/_start_stream rather than run/start
# because AgentTeam and AgentFlow call these three directly -- an
# agent used inside a team would otherwise run its tools locally.
if getattr(self, 'tools_run_on', None) is not None:
from .tools_placement import ensure_tools_placed
ensure_tools_placed(self)
# Slash-command invocation: /skill-name [args] renders the skill
# body before any backend/LLM call.
prompt = self._resolve_skill_invocation(prompt)
@@ -3594,14 +3586,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
attachments: Optional list of image/file paths that are ephemeral
(used for THIS turn only, NEVER stored in history).
"""
# Tools go to their sandbox before the first model call, once per
# agent. Placed on chat/achat/_start_stream rather than run/start
# because AgentTeam and AgentFlow call these three directly -- an
# agent used inside a team would otherwise run its tools locally.
if getattr(self, 'tools_run_on', None) is not None:
from .tools_placement import ensure_tools_placed
ensure_tools_placed(self)
# Slash-command invocation: /skill-name [args] renders the skill body.
prompt = self._resolve_skill_invocation(prompt)
@@ -4455,12 +4439,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# after-context aggregation) are fired once, guarded, inside
# execute_tool_async — no inline duplicate dispatch here.
# Pass the tools list to honor task-scoped tools
result = await self.execute_tool_async(
function_name,
arguments,
tool_call_id=getattr(tool_call, "id", None),
tools_override=tools,
)
result = await self.execute_tool_async(function_name, arguments, tools_override=tools)
results.append(result)
except Exception as e:
@@ -4561,14 +4540,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
def _start_stream(self, prompt: str, **kwargs) -> Generator[str, None, None]:
"""Own the durable context for the complete streaming generator."""
# Tools go to their sandbox before the first model call, once per
# agent. Placed on chat/achat/_start_stream rather than run/start
# because AgentTeam and AgentFlow call these three directly -- an
# agent used inside a team would otherwise run its tools locally.
if getattr(self, 'tools_run_on', None) is not None:
from .tools_placement import ensure_tools_placed
ensure_tools_placed(self)
durable_context = None
durable_token = None
try:
@@ -17,7 +17,6 @@ from ..runtime.journal import (
KIND_MODEL_DECISION,
KIND_TOOL_CALL,
KIND_TOOL_RESULT,
KIND_VERIFICATION,
RunJournal,
)
@@ -278,26 +277,6 @@ class DurableRunContext:
)
self.finalize("failed")
def record_verification(self, payloads: List[Dict[str, Any]]) -> None:
"""Append verification-gate results as durable ``verification`` events.
Each payload gets its own fresh ``seq`` so the events append cleanly
alongside the tool-call cursor without colliding on ``(run_id, seq,
kind)``. Best-effort: journalling never raises into the gate.
"""
if self._closed:
return
with self._lock:
for payload in payloads:
seq = self._next_seq
self._next_seq += 1
try:
self.journal.append(
JournalEvent(self.run_id, seq, KIND_VERIFICATION, payload)
)
except Exception: # pragma: no cover - journalling is best-effort
pass
def wrap_sync(self, execute_tool_fn: Optional[Callable]) -> Optional[Callable]:
if execute_tool_fn is None:
return None
@@ -43,59 +43,24 @@ HERE = "this machine"
# a place that never executes.
_ALIASES = {"native": "sandlock", "local": "subprocess"}
# ...but only for the SANDBOX registry. "local" resolves to two different
# backends depending on which parameter carried it:
# run_in="local" -> SandboxManager collapses it to subprocess:
# scrubbed environment, blocked commands and paths
# tools_run_on="local" -> the wrapper's LocalCompute: a plain shell in the
# current directory, full environment inherited,
# no security policy at all
# Describing the second in the first's words overstates it, which is the exact
# failure this module exists to prevent. One word, two backends -- so the
# phrase has to depend on where the word came from.
_COMPUTE_ONLY_WORDS = {"local": "a plain shell on this machine (no policy applied)"}
# Tiers that separate a process but do NOT contain it. Verified: under the
# subprocess backend, outbound network still works and ~/.ssh is readable even
# though SecurityPolicy declares them blocked.
_NOT_A_BOUNDARY = {"subprocess", "local", "native"}
def say_place(name: Any, *, via: str = "sandbox") -> str:
"""Turn a place name into a phrase a non-developer can read.
``via`` says which registry resolved the name -- ``"compute"`` for
``tools_run_on=``, ``"sandbox"`` for ``run_in=``. It only matters for names
that mean different backends in each (see ``_COMPUTE_ONLY_WORDS``).
"""
def say_place(name: Any) -> str:
"""Turn a provider name into a phrase a non-developer can read."""
if name is None:
return HERE
if name is True:
return _PLACE_WORDS["subprocess"]
if via == "compute" and isinstance(name, str) and name.lower() in _COMPUTE_ONLY_WORDS:
return _COMPUTE_ONLY_WORDS[name.lower()]
if not isinstance(name, str):
# A configured provider instance: use its own declared name if it has one.
name = getattr(name, "provider_name", None) or type(name).__name__
key = str(name).lower()
key = _ALIASES.get(key, key)
known = _PLACE_WORDS.get(key)
if known:
return known
# A place contributed by another package can name itself; the bare name is
# the last resort rather than the only option, because the phrase table is
# a literal that cannot know about packages installed later.
try:
from ..managed._compute_bridge import contributed_display_name
declared = contributed_display_name(key)
if declared:
return declared
except Exception:
pass
return str(name)
return _PLACE_WORDS.get(key, str(name))
def _backend_places(backend: Any) -> Optional[Dict[str, str]]:
@@ -103,21 +68,6 @@ def _backend_places(backend: Any) -> Optional[Dict[str, str]]:
if backend is None:
return None
# Ask the backend what it is, rather than guessing from its class name.
# Name-matching missed any backend that did not happen to contain "Hosted"
# or "Anthropic" -- a self-hosted DockerManagedAgent reported that it was
# thinking on this machine while the whole loop ran in a container.
declared = getattr(backend, "provider_name", None)
if isinstance(declared, str):
try:
from .placement import managed_runtimes
if declared.lower() in managed_runtimes():
where = say_place(declared)
return {"thinks_on": where, "tools_run_on": where}
except Exception:
pass
cls = type(backend).__name__
# Hosted runtimes move the entire turn off this machine.
if "Hosted" in cls or "Anthropic" in cls:
@@ -144,21 +94,11 @@ def describe(obj: Any, *, shared: bool = False) -> Dict[str, str]:
if from_backend:
places.update(from_backend)
where_tools = getattr(obj, "tools_run_on", None)
if where_tools is not None:
where = say_place(where_tools, via="compute")
run_on = getattr(obj, "run_on", None)
if run_on is not None:
where = say_place(run_on)
places["tools_run_on"] = f"{where} (shared)" if shared else where
# run_on= puts the WHOLE agent on a managed runtime, so it moves the
# thinking as well. _backend_places() already reports that (run_on= is
# turned into a backend at construction); this only covers an object
# that carries the name without a built backend.
whole = getattr(obj, "run_on", None)
if whole is not None and not from_backend:
everywhere = say_place(whole)
places["thinks_on"] = everywhere
places["tools_run_on"] = everywhere
sandbox_config = getattr(obj, "sandbox_config", None)
if sandbox_config is not None:
places["code_runs_on"] = say_place(
@@ -169,42 +109,6 @@ def describe(obj: Any, *, shared: bool = False) -> Dict[str, str]:
return places
def _tools_staying_here(obj: Any) -> list:
"""Names of the object's own tools, which do NOT move to the sandbox.
Only four tool names are bridged (``execute_command``, ``read_file``,
``write_file``, ``list_files``); everything else the user passed keeps
running in this process, against this filesystem. The capability prompt
tells the model the sandbox is where things happen, so anything staying
behind has to be said out loud.
Reads the pre-attach tool list when a sandbox is already attached: by then
``agent.tools`` has been rewritten and the originals live in the sandbox's
restore record.
"""
try:
from ..managed.shared_compute import BRIDGED_TOOL_NAMES
except Exception:
BRIDGED_TOOL_NAMES = ("execute_command", "read_file", "write_file", "list_files")
tools = None
shared = getattr(obj, "_tools_sandbox", None)
if shared is not None:
for patched, original_tools, _ in getattr(shared, "_patched", []):
if patched is obj:
tools = original_tools
break
if tools is None:
tools = getattr(obj, "tools", None) or []
names = []
for tool in tools:
name = getattr(tool, "__name__", None) or getattr(tool, "name", None)
if isinstance(name, str) and name not in BRIDGED_TOOL_NAMES:
names.append(name)
return names
def repr_fields(obj: Any, *, shared: bool = False) -> str:
"""Render the places as ``key='value'`` pairs for a ``__repr__``."""
return ", ".join(f"{k}={v!r}" for k, v in describe(obj, shared=shared).items())
@@ -222,20 +126,12 @@ def explain(obj: Any, *, shared: bool = False) -> str:
f"Tools run on {places['tools_run_on']}.",
]
if shared and getattr(obj, "tools_run_on", None) is not None:
if shared and getattr(obj, "run_on", None) is not None:
lines.append(
"Every step shares that same sandbox, so a file written by one step "
"is visible to the next."
)
staying = _tools_staying_here(obj)
if staying and places["tools_run_on"] != places["thinks_on"]:
listed = ", ".join(staying[:6]) + ("..." if len(staying) > 6 else "")
lines.append(
f"Your own tools ({listed}) still run on {HERE} -- only shell, file "
f"and code tools move. They read and write this machine's files."
)
if "code_runs_on" in places:
lines.append(
f"Code you run yourself with execute_code() runs on {places['code_runs_on']}."
@@ -243,7 +139,6 @@ def explain(obj: Any, *, shared: bool = False) -> str:
# Say plainly when the chosen tier is not a security boundary.
raw = [
getattr(obj, "tools_run_on", None),
getattr(obj, "run_on", None),
getattr(getattr(obj, "sandbox_config", None), "sandbox_type", None),
]
@@ -252,8 +147,7 @@ def explain(obj: Any, *, shared: bool = False) -> str:
):
lines.append(
"Note: a separate process is not a security boundary -- it can still "
"reach the network and read your files, and its blocked-command "
"list can be worked around by ordinary shell syntax. Use docker or a cloud "
"reach the network and read your files. Use docker or a cloud "
"provider for untrusted code."
)
@@ -1519,7 +1519,6 @@ Write the complete compiled report:"""
run_id=getattr(self, '_current_run_id', 'unknown'),
session_id=getattr(self, '_session_id', None) or 'default',
tool_name=function_name,
metadata={"tool_call_id": tool_call_id},
),
)
@@ -1,211 +0,0 @@
"""One vocabulary for "where does this run?", shared by Agent, AgentFlow and AgentTeam.
Two questions get asked about placement and they have different answers:
``run_on=``
Where the **whole agent** runs -- the model calls, the loop, the tools, all
of it on someone else's machine. Only a *managed runtime* can answer this,
because running the loop remotely means something has to host the loop.
``tools_run_on=``
Where **the tools** run. Thinking stays on this machine; only the shell,
the file writes and the code execution move. Any sandbox or compute place
can answer this, because all it has to do is run a command.
Keeping the two words apart is the entire point. The old surface used one word
(``run_on=``) on ``AgentFlow``/``AgentTeam`` for the tools-only meaning, another
(``compute=``) on ``LocalAgent`` for exactly the same meaning, and a third
(``backend=``) for the whole-agent meaning -- so the same concept had two names
and one name had two concepts.
Because the two questions have disjoint answer sets, a wrong pairing is a typo
worth catching rather than a preference worth honouring. ``run_on="docker"``
cannot mean anything: Docker does not host agent loops. It gets a ``TypeError``
naming the parameter the user actually wanted, not a silent fallback.
"""
from __future__ import annotations
from typing import Any, Literal, NamedTuple, Optional, Union
# Runtimes that can host an entire agent loop. Resolved from the wrapper's
# entry-point registry when it is installed so third-party backends work with
# `run_on=` too; the literal is the floor, not the ceiling.
_FALLBACK_MANAGED = ("anthropic",)
# Static spellings so editors can autocomplete and type-checkers can reject a
# typo at the call site rather than at the first model turn. The runtime
# functions below stay the authority for validation -- these exist for tooling,
# which cannot call a function to learn a set.
# Keep in sync with _compute_bridge._PROVIDERS | _sandbox_adapter.SANDBOX_ONLY.
ManagedRuntime = Literal["anthropic"]
ToolPlace = Literal[
"local", "subprocess", "sandlock", "docker", "e2b",
"modal", "daytona", "flyio", "tenki", "novita", "ssh",
]
def managed_runtimes() -> tuple:
"""Names that ``run_on=`` accepts -- places that host a whole agent."""
try:
from praisonai.integrations.backend_registry import get_backend_registry
names = tuple(get_backend_registry().list_all_names())
if names:
return tuple(sorted(set(names) | set(_FALLBACK_MANAGED)))
except Exception:
pass
return _FALLBACK_MANAGED
def tool_places() -> list:
"""Names that ``tools_run_on=`` accepts -- places that can run a command.
Includes the resolver's aliases. Validation runs before resolution, so
leaving them out rejected spellings the resolver handles happily:
``tools_run_on="native"`` raised "not a known place" while
``resolve_compute("native")`` returned a working sandlock adapter.
"""
try:
from ..managed._compute_bridge import _ALIASES, available_providers
return sorted(set(available_providers()) | set(_ALIASES))
except Exception:
return ["local", "docker"]
class Placement(NamedTuple):
"""The resolved answer to "where does this run?"."""
whole: Optional[Any] = None # managed runtime name/object, or None
tools: Optional[Any] = None # tools place name/object, or None
def _name_of(value: Any) -> Optional[str]:
"""The comparable name of a placement value, or None if it is an object.
Objects (a built provider, a live ``SSHSandbox``) carry their own config and
are never second-guessed -- only bare strings get routed by name.
"""
return value.lower() if isinstance(value, str) else None
def _quote_list(names) -> str:
return ", ".join(repr(n) for n in names)
def resolve_placement(
owner: str,
*,
run_on: Any = None,
tools_run_on: Any = None,
backend: Any = None,
supports_run_on: bool = True,
) -> Placement:
"""Validate placement arguments and return where things should run.
``owner`` is the class name, used only to make errors read naturally.
``supports_run_on`` is False for AgentFlow/AgentTeam, which can place tools
but cannot (yet) hand a whole multi-agent run to a managed runtime.
Raises:
TypeError: on any combination that names two different places for the
same thing, or a place that cannot do the job asked of it.
"""
managed = managed_runtimes()
places = tool_places()
# ── a place that cannot do the job asked of it ───────────────────────────
run_on_name = _name_of(run_on)
if run_on_name is not None and run_on_name not in managed:
if run_on_name in places:
raise TypeError(
f"{owner}(run_on={run_on!r}) is not valid: run_on= places the "
f"whole agent -- model calls, loop and tools -- on a managed "
f"runtime, and {run_on_name!r} runs commands but cannot host an "
f"agent loop.\n"
f" To run only the tools there: {owner}(tools_run_on={run_on!r})\n"
f" Runtimes that host a whole agent: {_quote_list(managed)}"
)
raise TypeError(
f"{owner}(run_on={run_on!r}) is not a known managed runtime.\n"
f" Runtimes that host a whole agent: {_quote_list(managed)}\n"
f" Places that can run tools: {_quote_list(places)} "
f"(pass those as tools_run_on=)"
)
tools_name = _name_of(tools_run_on)
if tools_name is not None and tools_name in managed and tools_name not in places:
raise TypeError(
f"{owner}(tools_run_on={tools_run_on!r}) is not valid: {tools_name!r} "
f"hosts an entire agent, not individual tools.\n"
f" Did you mean: {owner}(run_on={tools_run_on!r})"
)
# A typo here used to survive construction and only surface on the first
# model turn -- after the prompt was built and, for a real run, after the
# API spend. run_on= validates at the call site and YAML validates at load
# time; this is the same standard for the third spelling.
if tools_name is not None and tools_name not in places:
raise TypeError(
f"{owner}(tools_run_on={tools_run_on!r}) is not a known place.\n"
f" Places that can run tools: {_quote_list(places)}\n"
f" Runtimes that host a whole agent: {_quote_list(managed)} "
f"(pass those as run_on=)"
)
# Names that cannot work as a bare string, because the connection details
# live in the object. Catch it now rather than at first use.
if tools_name is not None:
try:
from ..managed._sandbox_adapter import NEEDS_INSTANCE
except Exception:
NEEDS_INSTANCE = {}
if tools_name in NEEDS_INSTANCE:
raise TypeError(
f"{owner}(tools_run_on={tools_run_on!r}) needs more than a name: "
f"{NEEDS_INSTANCE[tools_name]}"
)
# ── two names for the same thing ─────────────────────────────────────────
if run_on is not None and backend is not None:
raise TypeError(
f"{owner}(run_on=..., backend=...) sets the agent's runtime twice. "
f"run_on={run_on!r} is the short form of backend=HostedAgent("
f"provider={run_on!r}); pass one or the other.\n"
f" Use backend= only when you need to configure the runtime "
f"(model, system prompt, tools)."
)
if run_on is not None and tools_run_on is not None:
raise TypeError(
f"{owner}(run_on={run_on!r}, tools_run_on={tools_run_on!r}) points "
f"the tools at two machines. run_on= already runs everything -- "
f"including tools -- on {run_on!r}.\n"
f" Whole agent remote: {owner}(run_on={run_on!r})\n"
f" Only tools remote: {owner}(tools_run_on={tools_run_on!r})"
)
if backend is not None and tools_run_on is not None:
raise TypeError(
f"{owner}(backend=..., tools_run_on={tools_run_on!r}) points the "
f"tools at two machines: backend= runs the whole agent (tools "
f"included) on its managed runtime, so the tools cannot also run on "
f"{tools_run_on!r}.\n"
f" Drop tools_run_on= to keep the hosted runtime, or drop backend= "
f"to keep local thinking with remote tools."
)
# ── a class that cannot answer this question ─────────────────────────────
if run_on is not None and not supports_run_on:
raise TypeError(
f"{owner}(run_on={run_on!r}) is not supported: run_on= hands one "
f"agent's whole loop to a managed runtime, and a {owner} "
f"orchestrates several agents locally.\n"
f" To run every step's tools in one shared sandbox:\n"
f" {owner}(tools_run_on={run_on!r})\n"
f" To host a single agent's loop, put run_on= on that Agent."
)
return Placement(whole=run_on, tools=tools_run_on)
@@ -52,61 +52,19 @@ class SandboxMixin:
return self._sandbox_manager
def _manager_for(self, run_in: Optional[Union[bool, str, 'SandboxConfig']]):
"""Return the sandbox manager for a ``run_in=`` argument.
``run_in=`` names where a single ``execute_code()`` call runs, so the
place lives on the call that uses it rather than on the constructor.
``Agent(sandbox=...)`` still sets the default for every call.
Managers are cached per place: a fresh ``SandboxManager`` per call would
start and tear down a container each time, which for Docker is seconds
of latency and for any backend means the previous call's files are gone.
"""
if run_in is None:
if not self.has_sandbox:
raise RuntimeError(
"No sandbox configured. Either name a place on the call --\n"
" agent.execute_code_sync(code, run_in='docker')\n"
"or set a default for every call with Agent(sandbox='docker')."
)
return self.get_sandbox_manager()
from ..sandbox import SandboxConfig, SandboxManager
if run_in is True:
config = SandboxConfig.subprocess()
elif isinstance(run_in, str):
config = SandboxConfig(sandbox_type=run_in)
else:
config = run_in
key = getattr(config, "sandbox_type", None) or repr(config)
cache = getattr(self, "_sandbox_managers", None)
if cache is None:
cache = self._sandbox_managers = {}
if key not in cache:
cache[key] = SandboxManager(config)
return cache[key]
async def execute_code(
self,
code: str,
language: str = "python",
check_security: bool = True,
run_in: Optional[Union[bool, str, 'SandboxConfig']] = None,
**kwargs
) -> 'SandboxResult':
"""Execute code safely in a sandbox.
"""Execute code safely in configured sandbox.
Args:
code: Code to execute
language: Programming language (python, bash, etc.)
check_security: Whether to run security pre-checks
run_in: Where to run this call -- "docker", "sandlock",
"subprocess", a SandboxConfig, or True for the default local
sandbox. Overrides Agent(sandbox=...) for this call only.
**kwargs: Additional arguments passed to sandbox.execute()
Returns:
@@ -115,7 +73,10 @@ class SandboxMixin:
Raises:
RuntimeError: If no sandbox is configured
"""
manager = self._manager_for(run_in)
if not self.has_sandbox:
raise RuntimeError(
"No sandbox configured. Set sandbox=True or provide SandboxConfig to Agent()"
)
# Security pre-check if enabled
security_warnings = None
@@ -127,6 +88,7 @@ class SandboxMixin:
if self.verbose:
logger.warning(f"Security warnings for code execution:\n{warning_text}")
manager = self.get_sandbox_manager()
result = await manager.run_code(code, language=language, **kwargs)
# Attach warnings to the result rather than forwarding a `metadata`
@@ -147,7 +109,6 @@ class SandboxMixin:
code: str,
language: str = "python",
check_security: bool = True,
run_in: Optional[Union[bool, str, 'SandboxConfig']] = None,
**kwargs
) -> 'SandboxResult':
"""Synchronous wrapper for execute_code.
@@ -162,15 +123,12 @@ class SandboxMixin:
SandboxResult with execution details
"""
from ..approval.utils import run_coroutine_safely
return run_coroutine_safely(
self.execute_code(code, language, check_security, run_in=run_in, **kwargs)
)
return run_coroutine_safely(self.execute_code(code, language, check_security, **kwargs))
async def run_shell_command(
self,
command: Union[str, list],
check_security: bool = True,
run_in: Optional[Union[bool, str, 'SandboxConfig']] = None,
**kwargs
) -> 'SandboxResult':
"""Run a shell command in the sandbox.
@@ -183,7 +141,8 @@ class SandboxMixin:
Returns:
SandboxResult with execution details
"""
manager = self._manager_for(run_in)
if not self.has_sandbox:
raise RuntimeError("No sandbox configured")
# Convert command to string for security checking
if isinstance(command, list):
@@ -200,7 +159,7 @@ class SandboxMixin:
if self.verbose:
logger.warning(f"Security warnings for command:\n{warning_text}")
async with manager as sandbox:
async with self.get_sandbox_manager() as sandbox:
return await sandbox.run_command(command, **kwargs)
def get_sandbox_status(self) -> Dict[str, Any]:
@@ -489,7 +489,6 @@ class ToolExecutionMixin:
run_id=getattr(self, '_current_run_id', 'unknown'),
session_id=getattr(self, '_session_id', None) or 'default',
tool_name=function_name,
metadata={"tool_call_id": tool_call_id},
),
)
@@ -1,125 +0,0 @@
"""Give a single Agent's tools a sandbox, without touching its thinking.
``AgentFlow``/``AgentTeam`` already share one sandbox across a run and tear it
down when the run ends. A lone ``Agent(tools_run_on=...)`` has no "run" to hang
that lifetime on -- it is just an object someone calls ``chat()`` on, possibly
many times -- so the sandbox is created on first use and lives as long as the
agent does. That is deliberate: a file written in turn 1 is still there in turn
2, which is what anyone who asked for a working directory expects.
Attachment is idempotent and happens at the top of ``chat``/``achat``/
``_start_stream``, and in ``AgentTeam`` before the task's tool list is
snapshotted. Doing it once, rather than wrapping each call in a context
manager, sidesteps two real hazards in this codebase:
* ``_start_stream`` is a **generator**; a context manager around the call would
exit before the caller consumed a single token, tearing down the sandbox while
the model was still using it.
* ``_start_stream`` falls back to ``self.chat``, so a wrapper would **re-enter**
itself and provision a second sandbox.
Lifetime is tied to the agent by ``weakref.finalize`` rather than a process-exit
hook over a weak set. The weak set leaked: an agent and its ``SharedCompute``
reference each other, so a dropped agent was reclaimed by the cycle collector,
vanished from the set, and its sandbox was never shut down. Gateways clone an
agent **per request**, so that was one orphaned container per request.
"""
from __future__ import annotations
import logging
import threading
import weakref
from typing import Any
logger = logging.getLogger(__name__)
# One lock per agent, so two unrelated agents never wait on each other and no
# lock is ever held while another agent's provider is being imported. Held only
# by ensure_tools_placed/close_tools_sandbox for that one agent.
_AGENT_LOCKS: "weakref.WeakKeyDictionary" = weakref.WeakKeyDictionary()
_LOCKS_GUARD = threading.Lock()
def _lock_for(agent: Any) -> threading.Lock:
"""The placement lock for ``agent``, created on demand.
``_LOCKS_GUARD`` covers only this dictionary lookup -- microseconds -- never
provider construction or attachment. An earlier version held one global lock
across ``SharedCompute(...)``, which imports the provider SDK: unrelated
agents serialised behind each other, and a slow import could stall
interpreter exit.
"""
with _LOCKS_GUARD:
lock = _AGENT_LOCKS.get(agent)
if lock is None:
lock = threading.Lock()
try:
_AGENT_LOCKS[agent] = lock
except TypeError: # pragma: no cover - unhashable/non-weakrefable
pass
return lock
def _shutdown_orphan(shared: Any, name: str) -> None:
"""Tear down a sandbox whose agent is gone.
Registered with ``weakref.finalize``, so it runs when the agent is collected
*and* at interpreter exit. It must not close over the agent -- that would
keep it alive and defeat the whole point.
"""
try:
shared.shutdown()
logger.debug("[tools_placement] released the sandbox belonging to %s", name)
except Exception: # pragma: no cover - teardown must stay quiet
logger.debug("[tools_placement] could not release the sandbox for %s", name)
def ensure_tools_placed(agent: Any) -> None:
"""Attach the sandbox tools to ``agent`` if it asked for them. Idempotent.
Called at the top of every entry point, so it must be cheap when there is
nothing to do -- which is the overwhelmingly common case.
"""
place = getattr(agent, "tools_run_on", None)
if place is None or getattr(agent, "_tools_sandbox", None) is not None:
return
from ..managed.shared_compute import SharedCompute
with _lock_for(agent):
# Re-check under the lock: two threads calling chat() at once must not
# each provision a sandbox and leak one.
if getattr(agent, "_tools_sandbox", None) is not None:
return
shared = SharedCompute(place)
# override_declared: this sandbox exists *because* the agent named a
# place, so the skip-if-declared rule must not skip the agent itself.
shared.attach([agent], override_declared=True)
agent._tools_sandbox = shared
agent._tools_sandbox_finalizer = weakref.finalize(
agent, _shutdown_orphan, shared, str(getattr(agent, "name", agent))
)
logger.info("[tools_placement] %r tools now run on %s", getattr(agent, "name", agent), place)
def close_tools_sandbox(agent: Any) -> None:
"""Tear down the agent's sandbox and put its original tools back."""
with _lock_for(agent):
# Everything happens under the lock. Clearing the marker first and
# shutting down afterwards left a window where a concurrent chat()
# re-attached and the in-flight shutdown then restored over it, leaving
# the agent marked as sandboxed while holding no sandbox tools -- a
# state ensure_tools_placed() could never repair, because its idempotent
# early return sees the marker and does nothing.
shared = getattr(agent, "_tools_sandbox", None)
if shared is None:
return
finalizer = getattr(agent, "_tools_sandbox_finalizer", None)
if finalizer is not None:
finalizer.detach() # we are shutting down explicitly
agent._tools_sandbox_finalizer = None
shared.shutdown()
agent._tools_sandbox = None
@@ -2,8 +2,6 @@ import os
import time
import json
import logging
import contextlib
import contextvars
import threading
from praisonaiagents._logging import get_logger
from typing import Any, Dict, Optional, List, Tuple, Callable
@@ -377,18 +375,6 @@ def _build_execution_context(agents_instance, task_id, skip_memory_init=False):
if llm and hasattr(llm, 'set_current_agent'):
llm.set_current_agent(executor_agent.display_name)
# Place the agent's tools BEFORE snapshotting them. This snapshot is passed
# to chat() as an explicit `tools=`, which wins over `agent.tools` -- so if
# the sandbox attaches later (inside chat()), the model is handed the
# pre-attach list and every tool runs on the host while a sandbox sits
# provisioned and unused. Silent, and worse than it sounds: in
# start_for_each() row 1 got local tools and row 2 got the bridged set,
# because row 1's chat() mutated agent.tools in time for row 2's snapshot.
if executor_agent is not None and getattr(executor_agent, "tools_run_on", None) is not None:
from ..agent.tools_placement import ensure_tools_placed
ensure_tools_placed(executor_agent)
# Ensure tools are available from both task and agent (create copy to avoid mutation)
tools = list(task.tools or [])
if executor_agent and executor_agent.tools:
@@ -676,12 +662,9 @@ class AgentTeam(SpawnAnnounceProtocol):
learn: Optional[Any] = None, # Union[bool, LearnConfig] - continuous learning
# Union[str, ComputeProviderProtocol] - where every agent's shell/file
# tools run: "docker", "e2b", "modal", "daytona", "flyio", "tenki",
# "local", "subprocess", "sandlock", "ssh", "novita". The team shares
# ONE sandbox and one working directory, so files written by one agent
# are visible to the others. Orchestration and thinking stay local.
# "local". The team shares ONE sandbox and /workspace, so files written
# by one agent are visible to the others. Orchestration stays local.
# Pass a configured provider instance to customise resources.
tools_run_on: Optional[Any] = None,
# Accepted only to fail with a useful message -- see resolve_placement.
run_on: Optional[Any] = None,
):
"""
@@ -927,19 +910,7 @@ class AgentTeam(SpawnAnnounceProtocol):
self.stream = _stream
self.name = name
# Remote execution: one shared sandbox for every agent on this team.
from ..agent.placement import resolve_placement
resolve_placement(
"AgentTeam", run_on=run_on, tools_run_on=tools_run_on,
supports_run_on=False,
)
self.tools_run_on = tools_run_on
# Kept readable rather than removed. `team.run_on` used to exist, and
# code doing getattr(team, "run_on", None) for introspection or
# serialisation would otherwise start raising AttributeError. None is
# the honest value: a team orchestrates locally, so nothing about it
# runs wholly on a managed runtime.
self.run_on = None
self.run_on = run_on
# Callbacks for workflow execution
self.on_task_start = _on_task_start
@@ -1573,62 +1544,6 @@ class AgentTeam(SpawnAnnounceProtocol):
except StopAsyncIteration:
pass
# ── shared sandbox lifetime ──────────────────────────────────────────────
# A ContextVar, not an instance attribute: the previous version set
# `self.run_on = None` for the duration of the run, so a second caller
# inspecting the team mid-run saw "no sandbox" and any concurrent start()
# executed every tool on the host. Depth lives with the caller, not on the
# shared object, so the team's declared placement is always readable.
#
# The depth is keyed PER TEAM, not shared class-wide. A single integer here
# meant any team started while another team's run was on the stack saw
# depth==1 and skipped its own sandbox entirely -- so a nested team ran on
# the host, and a team sharing an agent with the outer run executed on the
# OUTER team's sandbox.
#
# ContextVar, not threading.local: two coroutines that `astart()` the SAME
# team under one asyncio.gather run on one thread, so thread-local state is
# shared between them -- the second saw the first's depth==1, skipped its
# own sandbox, and ran against a sandbox the first could tear down mid-flight.
# A ContextVar is copied per task at gather/create_task time, so each run
# gets its own depth map; it is also per-thread, so sync callers stay
# isolated too. The value is an immutable frozen mapping replaced via .set()
# (never mutated in place), because a copied context shares the *reference*
# to a mutable dict and in-place mutation would leak straight back across
# the tasks the copy was meant to separate.
_tools_scope_depths: "contextvars.ContextVar" = contextvars.ContextVar(
"praisonai_tools_scope_depths", default=()
)
def _needs_tools_scope(self) -> bool:
"""True when this call should provision the team's shared sandbox."""
if getattr(self, "tools_run_on", None) is None:
return False
return dict(self._tools_scope_depths.get()).get(id(self), 0) == 0
@contextlib.contextmanager
def _shared_tools_scope(self):
"""Hold one sandbox for everything nested inside.
Re-entrant by design: ``start_for_each`` opens the scope once for the
whole batch, so the ``start()`` call it makes per input row finds the
scope already open and reuses that sandbox instead of provisioning and
destroying one per row.
"""
from ..managed.shared_compute import SharedCompute
key = id(self)
depths = dict(self._tools_scope_depths.get())
depths[key] = depths.get(key, 0) + 1
token = self._tools_scope_depths.set(tuple(sorted(depths.items())))
try:
with SharedCompute(self.tools_run_on) as shared:
shared.attach(list(self.agents or []))
yield shared
finally:
self._tools_scope_depths.reset(token)
async def astart(self, content=None, return_dict=False, **kwargs):
"""Async version of start method.
@@ -1637,12 +1552,6 @@ class AgentTeam(SpawnAnnounceProtocol):
return_dict: If True, returns the full results dictionary instead of only the final response
**kwargs: Additional arguments
"""
# Same shared sandbox as start(). Without this, an async team with
# tools_run_on= ran every tool on the host and said nothing.
if self._needs_tools_scope():
with self._shared_tools_scope():
return await self.astart(content=content, return_dict=return_dict, **kwargs)
# Track execution via telemetry
if hasattr(self, '_telemetry') and self._telemetry:
self._telemetry.track_agent_execution(self.name, success=True, async_mode=True)
@@ -1903,11 +1812,19 @@ class AgentTeam(SpawnAnnounceProtocol):
```
"""
# Remote execution: provision ONE sandbox shared by every agent on the
# team, and tear it down even if execution raises.
if self._needs_tools_scope():
with self._shared_tools_scope():
return self.start(content=content, return_dict=return_dict,
output=output, **kwargs)
# team, and tear it down even if execution raises. Re-enters start()
# with run_on cleared so the wrapper applies exactly once.
if getattr(self, "run_on", None) is not None:
from ..managed.shared_compute import SharedCompute
run_on, self.run_on = self.run_on, None
try:
with SharedCompute(run_on) as shared:
shared.attach(list(self.agents or []))
return self.start(content=content, return_dict=return_dict,
output=output, **kwargs)
finally:
self.run_on = run_on
# Track execution via telemetry
if hasattr(self, '_telemetry') and self._telemetry:
@@ -2223,15 +2140,6 @@ class AgentTeam(SpawnAnnounceProtocol):
if on_error not in ("continue", "fail_fast"):
raise ValueError("on_error must be 'continue' or 'fail_fast'")
# One sandbox for the whole batch. Without this the per-row start()
# call provisions and destroys its own, so a 50-row batch paid for 50
# sandboxes and no row could see what an earlier row wrote.
if self._needs_tools_scope():
with self._shared_tools_scope():
return self.start_for_each(
inputs, on_error=on_error, output=output, **kwargs
)
batch_id = f"batch_{uuid.uuid4().hex[:12]}"
saved_variables = self.variables
task_snapshot = self._snapshot_task_state()
@@ -2268,13 +2176,6 @@ class AgentTeam(SpawnAnnounceProtocol):
if on_error not in ("continue", "fail_fast"):
raise ValueError("on_error must be 'continue' or 'fail_fast'")
# One sandbox for the whole batch -- see start_for_each.
if self._needs_tools_scope():
with self._shared_tools_scope():
return await self.astart_for_each(
inputs, on_error=on_error, **kwargs
)
batch_id = f"batch_{uuid.uuid4().hex[:12]}"
saved_variables = self.variables
task_snapshot = self._snapshot_task_state()
@@ -3244,14 +3145,9 @@ class AgentTeam(SpawnAnnounceProtocol):
console.print(f"[dim]Progress: [{'█' * 30}] 100%[/dim]")
console.print(f"[green]Completed {completed_count}/{len(self.tasks)} tasks![/green]\n")
# Keep plan-step tasks/results under _plan_tasks for introspection,
# then restore the user's original task set as the canonical self.tasks.
self._plan_tasks = self.tasks
self.tasks = original_tasks
with self._task_id_lock:
self.task_id_counter = (
max(original_tasks.keys()) + 1 if original_tasks else 0
)
# Restore original tasks reference for result retrieval
self._plan_tasks = self.tasks.copy()
# Keep plan tasks for results but note original tasks are preserved in _plan_tasks
# Resource Lifecycle Management
def close(self) -> None:
@@ -37,8 +37,6 @@ __all__ = [
"ToolResultPrunerProtocol",
"MessageFormatterProtocol",
"SummaryBuilderProtocol",
# Default structured summary builder
"DefaultSummaryBuilder",
# Read-only recap
"build_recap",
"MemoryFlushResult",
@@ -77,10 +75,6 @@ def __getattr__(name: str):
from .protocols import SummaryBuilderProtocol
return SummaryBuilderProtocol
if name == "DefaultSummaryBuilder":
from .summary_builder import DefaultSummaryBuilder
return DefaultSummaryBuilder
if name == "build_recap":
from .recap import build_recap
return build_recap
@@ -122,18 +122,7 @@ class ContextCompactor:
# Protocol implementations (defaults to None - no heavy implementations in core)
self.tool_pruner = tool_pruner
self.message_formatter = message_formatter
# Default the structured summary builder when the config asks for the
# phase-aware template but no builder was injected, so
# `_build_structured_summary` renders SUMMARY_TEMPLATE instead of the
# "Summary of N messages" stub. The builder is deterministic and
# dependency-free, so this stays offline-safe and zero-overhead.
if summary_builder is None and getattr(self.config, "structured_template", False):
from .summary_builder import DefaultSummaryBuilder
summary_builder = DefaultSummaryBuilder(
llm_summarize_fn=self.llm_summarize_fn
)
self.message_formatter = message_formatter
self.summary_builder = summary_builder
# Anti-thrashing state tracking
@@ -1,172 +0,0 @@
"""Default structured summary builder for context compaction.
This is the concrete :class:`~praisonaiagents.compaction.protocols.
SummaryBuilderProtocol` implementation that renders the declared
:data:`~praisonaiagents.compaction.config.SUMMARY_TEMPLATE` — the phase-aware
sections (``## Active Task`` / ``## Completed Actions`` / ``## In Progress`` /
``## Pending Questions`` / ``## Relevant Files / Paths`` / ``## Remaining
Work``) — so a compacted context keeps its structure instead of degrading to a
one-line ``"Summary of N messages"`` count.
Design: **deterministic, dependency-free, offline-safe.** It categorises the
transcript into the template sections from the messages themselves (roles,
tool calls, question marks, file-path tokens) rather than making an LLM call, so
it never blocks the hot compaction path, never adds a runtime dependency, and
never raises into the loop. File paths and identifiers are preserved verbatim.
When an optional ``llm_summarize_fn`` is supplied the builder still renders the
same template shape; on any failure it falls back to the deterministic render,
honouring the issue's "fall back to the plain summary on failure" rule.
"""
from __future__ import annotations
import re
from typing import Any, Dict, List, Optional
from .config import SUMMARY_TEMPLATE
# Match file/path-like tokens so they can be surfaced verbatim in the
# "Relevant Files / Paths" section (absolute/relative paths and dotted module
# names with a recognisable extension).
_PATH_RE = re.compile(
r"(?:/[^\s'\"`]+|(?:[\w.-]+/)+[\w.-]+|[\w./-]+\.[A-Za-z0-9]{1,6})"
)
_MAX_ITEMS = 12
_MAX_LINE = 200
def _text(content: Any) -> str:
"""Best-effort flatten of a message ``content`` field into a string."""
if isinstance(content, str):
return content
if isinstance(content, list):
parts: List[str] = []
for part in content:
if isinstance(part, dict):
parts.append(str(part.get("text", "")))
else:
parts.append(str(part))
return " ".join(p for p in parts if p)
return ""
def _clip(text: str, limit: int = _MAX_LINE) -> str:
text = text.strip().replace("\n", " ")
return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
class DefaultSummaryBuilder:
"""Deterministic :class:`SummaryBuilderProtocol` renderer for the template.
Args:
llm_summarize_fn: Optional callable ``(prompt: str) -> str`` used to
distil section bodies. Any failure falls back to the deterministic
render, so callers get a valid structured summary regardless.
"""
def __init__(self, llm_summarize_fn: Optional[Any] = None) -> None:
self._llm_summarize_fn = llm_summarize_fn
# -- protocol ----------------------------------------------------------
def build_structured_summary(self, messages: List[Dict[str, Any]]) -> str:
"""Render :data:`SUMMARY_TEMPLATE` from ``messages`` deterministically.
Never raises: on any failure returns the plain one-line summary so the
compaction loop keeps a valid summary string (issue fallback rule).
"""
try:
return self._render(messages)
except Exception: # noqa: BLE001 - summarisation must never crash the loop
return f"Summary of {len(messages)} messages"
def merge_summaries(self, previous: str, current: str) -> str:
"""Merge a prior structured summary with the current one.
Keeps both under a single "previous / latest" framing so an iterative
re-compaction never discards the earlier phase structure.
"""
previous = (previous or "").strip()
current = (current or "").strip()
if not previous:
return current
if not current:
return previous
return f"{previous}\n\n[Updated with newer activity]\n{current}"
# -- rendering ---------------------------------------------------------
def _render(self, messages: List[Dict[str, Any]]) -> str:
completed: List[str] = []
in_progress: List[str] = []
pending: List[str] = []
files: List[str] = []
active_task = ""
for msg in messages:
role = msg.get("role", "")
body = _text(msg.get("content"))
# First substantive user turn frames the active task.
if role == "user" and body.strip() and not active_task:
active_task = _clip(body)
# Executed work — tool calls and tool results — is *completed*.
# An assistant turn that merely proposes or *asks* about work has
# not been executed, so it must not be recorded as completed (it is
# captured under "Pending Questions" / "In Progress" instead);
# only a substantive, non-questioning assistant turn is completed.
tool_calls = msg.get("tool_calls")
if isinstance(tool_calls, list) and tool_calls:
for tc in tool_calls:
if not isinstance(tc, dict):
continue
name = (tc.get("function", {}) or {}).get("name", "tool")
completed.append(f"Called `{name}`")
elif role == "assistant" and body.strip() and "?" not in body:
completed.append(_clip(body))
elif role == "tool" and body.strip():
completed.append(_clip(f"tool result: {body}"))
# Any turn with a question mark contributes an open question.
if body and "?" in body:
for sentence in body.split("?"):
sentence = sentence.strip()
if sentence:
pending.append(_clip(sentence + "?"))
# Harvest file/path tokens verbatim from every turn.
if body:
for match in _PATH_RE.findall(body):
if match not in files:
files.append(match)
# The most recent user turn (if different) is what's in progress.
for msg in reversed(messages):
if msg.get("role") == "user":
latest = _clip(_text(msg.get("content")))
if latest and latest != active_task:
in_progress.append(latest)
break
return SUMMARY_TEMPLATE.format(
active_task=active_task or "(not specified)",
completed=self._bullets(completed),
in_progress=self._bullets(in_progress),
pending=self._bullets(pending),
files=self._bullets(files),
remaining=self._bullets(pending or in_progress),
)
@staticmethod
def _bullets(items: List[str]) -> str:
if not items:
return "(none)"
# De-duplicate while preserving order, then cap.
seen: List[str] = []
for item in items:
if item not in seen:
seen.append(item)
return "\n".join(f"- {item}" for item in seen[:_MAX_ITEMS])
@@ -847,6 +847,12 @@ class ExecutionConfig:
code_execution: bool = False
code_mode: str = "safe" # "safe" or "unsafe"
# NOT IMPLEMENTED - no execution path reads this field, so setting it
# provides NO isolation. Do not rely on it for safety. Use
# Agent(sandbox=...) for local isolation, or AgentFlow(run_on="docker")
# to run a whole workflow in a remote sandbox.
code_sandbox_mode: str = "sandbox" # "sandbox" or "direct" (inert)
# Code-execution-with-tools (code mode): when True, model-generated code may
# call the agent's registered tools directly via injected proxies, enabling
# multi-step tool pipelines in a single turn (intermediate results stay out
@@ -989,6 +995,7 @@ class ExecutionConfig:
"retry_jitter": self.retry_jitter,
"code_execution": self.code_execution,
"code_mode": self.code_mode,
"code_sandbox_mode": self.code_sandbox_mode,
"code_tools": self.code_tools,
"code_tools_allow": self.code_tools_allow,
"context_compaction": (
@@ -1040,6 +1047,7 @@ class ExecutionConfig:
retry_jitter=data.get("retry_jitter", 0.1),
code_execution=data.get("code_execution", False),
code_mode=data.get("code_mode", "safe"),
code_sandbox_mode=data.get("code_sandbox_mode", "sandbox"),
code_tools=data.get("code_tools", False),
code_tools_allow=data.get("code_tools_allow", None),
context_compaction=context_compaction,
@@ -49,6 +49,7 @@ VALID_DEFAULTS_KEYS = {
"model": (str,),
"small_model": (str,),
"base_url": (str,),
"api_key": (str,),
# Feature flags
"allow_delegation": (bool,),
"allow_code_execution": (bool,),
@@ -65,7 +66,9 @@ VALID_DEFAULTS_KEYS = {
"caching": (dict, bool),
"autonomy": (dict, bool),
"skills": (dict, list),
"retry": (dict, bool),
"context": (dict, bool),
"hooks": (dict, list),
"templates": (dict,),
}
VALID_ROOT_KEYS = {"plugins", "defaults"}
@@ -155,14 +158,12 @@ class DefaultsConfig:
knowledge: Optional[Dict[str, Any]] = None
planning: Optional[Dict[str, Any]] = None
reflection: Optional[Dict[str, Any]] = None
guardrails: Optional[Dict[str, Any]] = None
web: Optional[Dict[str, Any]] = None
output: Optional[Dict[str, Any]] = None
execution: Optional[Dict[str, Any]] = None
caching: Optional[Dict[str, Any]] = None
autonomy: Optional[Dict[str, Any]] = None
skills: Optional[Dict[str, Any]] = None
retry: Optional[Dict[str, Any]] = None
def to_dict(self) -> Dict[str, Any]:
return {
@@ -176,14 +177,12 @@ class DefaultsConfig:
"knowledge": self.knowledge,
"planning": self.planning,
"reflection": self.reflection,
"guardrails": self.guardrails,
"web": self.web,
"output": self.output,
"execution": self.execution,
"caching": self.caching,
"autonomy": self.autonomy,
"skills": self.skills,
"retry": self.retry,
}
@@ -353,14 +352,12 @@ def _dict_to_defaults_config(data: Dict[str, Any]) -> DefaultsConfig:
knowledge=data.get("knowledge"),
planning=data.get("planning"),
reflection=data.get("reflection"),
guardrails=data.get("guardrails"),
web=data.get("web"),
output=data.get("output"),
execution=data.get("execution"),
caching=data.get("caching"),
autonomy=data.get("autonomy"),
skills=data.get("skills"),
retry=data.get("retry"),
)
@@ -709,8 +706,8 @@ def _defaults_has_any_values() -> bool:
getattr(defaults, key, None) is not None
for key in (
"model", "small_model", "base_url", "memory", "knowledge",
"planning", "reflection", "guardrails", "web", "output",
"execution", "caching", "autonomy", "skills", "retry",
"planning", "reflection", "web", "output", "execution",
"caching", "autonomy",
)
)
return _defaults_has_values
@@ -242,51 +242,41 @@ class ContextComposer:
full_history: List[Dict[str, Any]],
tool_call_ids: set
) -> List[Dict[str, Any]]:
"""Ensure tool_call/tool_result pairs are complete.
Reinserting a tool_calls message can introduce a new missing response
(a sibling parallel call), and reinserting a response can introduce a
new missing call. Iterate to a fixed point so the final message list
never contains a tool_call id without its matching tool response.
"""
changed = True
while changed:
changed = False
# Recompute pairs present in the current result
result_call_ids = set()
result_response_ids = set()
for msg in result:
if "tool_calls" in msg:
for tc in msg.get("tool_calls", []):
if isinstance(tc, dict) and "id" in tc:
result_call_ids.add(tc["id"])
if msg.get("tool_call_id"):
result_response_ids.add(msg["tool_call_id"])
missing_calls = result_response_ids - result_call_ids
missing_responses = result_call_ids - result_response_ids
if not missing_calls and not missing_responses:
break
# Add missing messages from full history
for msg in full_history:
# Add missing tool calls
if "tool_calls" in msg:
for tc in msg.get("tool_calls", []):
if isinstance(tc, dict) and tc.get("id") in missing_calls:
if msg not in result:
result.insert(0, msg)
changed = True
break
# Add missing tool responses
if msg.get("tool_call_id") in missing_responses:
if msg not in result:
result.append(msg)
changed = True
"""Ensure tool_call/tool_result pairs are complete."""
# Find tool calls in result
result_call_ids = set()
result_response_ids = set()
for msg in result:
if "tool_calls" in msg:
for tc in msg.get("tool_calls", []):
if isinstance(tc, dict) and "id" in tc:
result_call_ids.add(tc["id"])
if msg.get("tool_call_id"):
result_response_ids.add(msg["tool_call_id"])
# Find missing pairs
missing_calls = result_response_ids - result_call_ids
missing_responses = result_call_ids - result_response_ids
if not missing_calls and not missing_responses:
return result
# Add missing messages from full history
for msg in full_history:
# Add missing tool calls
if "tool_calls" in msg:
for tc in msg.get("tool_calls", []):
if isinstance(tc, dict) and tc.get("id") in missing_calls:
if msg not in result:
result.insert(0, msg)
break
# Add missing tool responses
if msg.get("tool_call_id") in missing_responses:
if msg not in result:
result.append(msg)
return result
def get_ledger(self) -> ContextLedger:
@@ -47,11 +47,7 @@ def _tail(text: str, limit: int = 4000) -> str:
return text[-limit:]
def _build_goal_judge_prompt(
state: "GoalState",
transcript_tail: str,
verification_block: Optional[str] = None,
) -> str:
def _build_goal_judge_prompt(state: "GoalState", transcript_tail: str) -> str:
"""Build a strict, criteria-anchored, JSON-only judge prompt."""
lines = [
"You are an independent completion judge for an autonomous agent.",
@@ -76,13 +72,6 @@ def _build_goal_judge_prompt(
lines.append(
"If ANY constraint is violated, you MUST answer 'continue'."
)
if verification_block:
lines += [
"",
"EXECUTABLE VERIFICATION RESULTS (authoritative — command exit "
"codes and file checks; prefer this over prose claims):",
verification_block,
]
lines += [
"",
"AGENT OUTPUT (most recent):",
@@ -176,7 +165,6 @@ def judge_goal(
transcript_tail: str,
*,
judge_model: Optional[str] = None,
verification_block: Optional[str] = None,
) -> Tuple[str, str]:
"""Judge whether ``state.goal`` is met given the latest agent output.
@@ -192,7 +180,7 @@ def judge_goal(
"""
from ..eval.judge import Judge
prompt = _build_goal_judge_prompt(state, transcript_tail, verification_block)
prompt = _build_goal_judge_prompt(state, transcript_tail)
try:
judge = Judge(model=judge_model or _default_judge_model(), temperature=0.0)
litellm = judge._get_litellm()
@@ -128,44 +128,11 @@ class GoalLoopMixin:
if state is None or state.status != "active":
return None
# Executable verification: run configured hooks and gate on the result.
# A failing BLOCKING hook short-circuits to "continue" WITHOUT spending
# a judge call. Passing/non-blocking results become authoritative
# evidence in the judge prompt.
verification_block: Optional[str] = None
if getattr(self, "_verification_hooks", None):
results = self._run_verification_hooks()
self._last_verification_results = results
self._record_verification_journal(results)
verification_block = self._verification_summary_tail(results)
blocking_failed = [
r for r in results
if r.get("blocking", True) and not r.get("success")
]
if blocking_failed:
state.turns_used += 1
state.last_verdict = "continue"
# Carry the failing hooks' captured stdout/stderr into the
# continuation prompt so the next turn has the diagnostics
# needed to repair the failure (not just a bare label).
failure_detail = self._verification_summary_tail(blocking_failed)
reason = "verification gate failed"
if failure_detail:
reason = f"{reason}:\n{failure_detail}"
state.last_reason = reason
if state.turns_used >= state.max_turns:
state.status = "paused"
self._persist_goal_state()
return "budget_paused", reason
self._persist_goal_state()
return "continue", reason
state.turns_used += 1
verdict, reason = judge_goal(
state,
_tail(response),
judge_model=getattr(self, "_goal_judge_model", None),
verification_block=verification_block,
)
state.last_verdict, state.last_reason = verdict, reason
@@ -104,9 +104,6 @@ __all__ = [
# Verification hooks (protocols)
"VerificationHook",
"VerificationResult",
"BaseVerificationHook",
"CommandVerificationHook",
"FileCheckHook",
# Simplified API (beginner-friendly)
"add_hook",
"remove_hook",
@@ -181,9 +178,6 @@ _LAZY_GROUPS = {
'verification': {
'VerificationHook': ('praisonaiagents.hooks.verification', 'VerificationHook'),
'VerificationResult': ('praisonaiagents.hooks.verification', 'VerificationResult'),
'BaseVerificationHook': ('praisonaiagents.hooks.verification', 'BaseVerificationHook'),
'CommandVerificationHook': ('praisonaiagents.hooks.verification', 'CommandVerificationHook'),
'FileCheckHook': ('praisonaiagents.hooks.verification', 'FileCheckHook'),
},
# Simplified API (beginner-friendly aliases)
'simplified_api': {
@@ -184,19 +184,7 @@ class HookRunner:
self._execute_single(hook, event, input_data)
for hook in hooks
]
results = await asyncio.gather(*tasks)
# Parallel hooks cannot mutate input_data (only sequential hooks apply
# modified_input). Warn if a parallel hook returns modifications that
# will be silently discarded, so hook authors mark it sequential=True.
for hook, result in zip(hooks, results):
if result.success and result.output and result.output.modified_input:
logger.warning(
"Parallel hook '%s' returned modified_input which is "
"discarded; register it with sequential=True to apply it.",
hook.name,
)
return results
return await asyncio.gather(*tasks)
async def _execute_sequential(
self,
@@ -114,26 +114,17 @@ class BaseVerificationHook:
name: str = "base"
timeout_seconds: float = 60.0
blocking: bool = True
def __init__(
self,
name: Optional[str] = None,
timeout: float = 60.0,
blocking: bool = True,
):
def __init__(self, name: Optional[str] = None, timeout: float = 60.0):
"""Initialize the hook.
Args:
name: Override the hook name
timeout: Timeout in seconds for execution
blocking: When True (default), a failure of this hook blocks the
autonomous loop from finalising with a success outcome.
"""
if name:
self.name = name
self.timeout_seconds = timeout
self.blocking = blocking
def run(self, context: Optional[Dict[str, Any]] = None) -> VerificationResult:
"""Run the verification hook.
@@ -186,25 +177,19 @@ class CommandVerificationHook(BaseVerificationHook):
def __init__(
self,
name: str,
command,
command: list,
cwd: Optional[str] = None,
timeout: float = 60.0,
blocking: bool = True,
):
"""Initialize command hook.
Args:
name: Hook name
command: Command to run as a list (e.g., ["pytest", "-v"]) or a
string (e.g., "python -m pytest -q"), which is shell-split.
command: Command to run as list (e.g., ["pytest", "-v"])
cwd: Working directory for command
timeout: Timeout in seconds
blocking: When True (default), a failure blocks completion.
"""
super().__init__(name=name, timeout=timeout, blocking=blocking)
if isinstance(command, str):
import shlex
command = shlex.split(command)
super().__init__(name=name, timeout=timeout)
self.command = command
self.cwd = cwd
@@ -250,143 +235,3 @@ class CommandVerificationHook(BaseVerificationHook):
output=str(e),
error=str(e),
)
def _get_dotted(data: Any, path: str) -> Any:
"""Access a dotted path (e.g. ``"a.b.c"``) in nested dicts/lists.
Returns ``None`` if any segment is missing. List indices are supported
via integer-looking segments.
"""
value = data
for part in path.split("."):
if isinstance(value, dict):
value = value.get(part)
elif isinstance(value, (list, tuple)):
try:
value = value[int(part)]
except (ValueError, IndexError):
return None
else:
return None
return value
class FileCheckHook(BaseVerificationHook):
"""Declarative file-assertion verification hook (no shell involved).
Checks portable filesystem facts:
- ``exists`` : the file exists (default True).
- ``non_empty`` : the file has non-zero size.
- ``contains`` : the file text contains a substring.
- ``json_field``/``equals`` : the JSON value at a dotted path equals a value.
Example:
hook = FileCheckHook(name="changelog", path="CHANGELOG.md", non_empty=True)
hook = FileCheckHook(name="cfg", path="out.json",
json_field="status.code", equals=0)
"""
def __init__(
self,
name: str,
path: str,
exists: bool = True,
non_empty: bool = False,
contains: Optional[str] = None,
json_field: Optional[str] = None,
equals: Any = None,
blocking: bool = True,
):
super().__init__(name=name, blocking=blocking)
self.path = path
self.exists = exists
self.non_empty = non_empty
self.contains = contains
self.json_field = json_field
self.equals = equals
def _execute(self, context: Optional[Dict[str, Any]] = None) -> VerificationResult:
import os
details: Dict[str, Any] = {"path": self.path}
file_exists = os.path.isfile(self.path)
details["exists"] = file_exists
if self.exists and not file_exists:
return VerificationResult(
success=False,
output=f"File not found: {self.path}",
error="not_found",
details=details,
)
if not self.exists:
success = not file_exists
return VerificationResult(
success=success,
output=(
f"File exists but was expected absent: {self.path}"
if not success else f"File absent as expected: {self.path}"
),
details=details,
)
if self.non_empty:
size = os.path.getsize(self.path)
details["size"] = size
if size == 0:
return VerificationResult(
success=False,
output=f"File is empty: {self.path}",
error="empty",
details=details,
)
if self.contains is not None or self.json_field is not None:
try:
with open(self.path, "r", encoding="utf-8") as fh:
text = fh.read()
except Exception as e:
return VerificationResult(
success=False, output=str(e), error=str(e), details=details
)
if self.contains is not None and self.contains not in text:
return VerificationResult(
success=False,
output=f"File {self.path} does not contain {self.contains!r}",
error="missing_substring",
details=details,
)
if self.json_field is not None:
import json as _json
try:
data = _json.loads(text)
except Exception as e:
return VerificationResult(
success=False,
output=f"File {self.path} is not valid JSON: {e}",
error="invalid_json",
details=details,
)
actual = _get_dotted(data, self.json_field)
details["json_field"] = self.json_field
details["actual"] = actual
if actual != self.equals:
return VerificationResult(
success=False,
output=(
f"{self.path}:{self.json_field} = {actual!r}, "
f"expected {self.equals!r}"
),
error="mismatch",
details=details,
)
return VerificationResult(
success=True,
output=f"File check passed: {self.path}",
details=details,
)
@@ -413,11 +413,7 @@ class Knowledge:
def history(self, memory_id):
"""Get the history of changes for a memory."""
if hasattr(self.memory, "history"):
return self.memory.history(memory_id)
raise NotImplementedError(
f"{type(self.memory).__name__} does not support history()"
)
return self.memory.history(memory_id)
def delete(self, memory_id):
"""Delete a memory."""
@@ -425,21 +421,11 @@ class Knowledge:
def delete_all(self, user_id=None, agent_id=None, run_id=None):
"""Delete all memories."""
if hasattr(self.memory, "delete_all"):
self.memory.delete_all(user_id=user_id, agent_id=agent_id, run_id=run_id)
else:
raise NotImplementedError(
f"{type(self.memory).__name__} does not support delete_all()"
)
self.memory.delete_all(user_id=user_id, agent_id=agent_id, run_id=run_id)
def reset(self):
"""Reset all memories."""
if hasattr(self.memory, "reset"):
self.memory.reset()
else:
logger.warning(
f"{type(self.memory).__name__} does not support reset(); no-op"
)
self.memory.reset()
def normalize_content(self, content):
"""Normalize content for consistent storage."""
@@ -32,42 +32,9 @@ _EXTRA_HINTS = {
}
# Places that live only in praisonai-sandbox. They speak SandboxProtocol
# (one object IS one environment) rather than ComputeProviderProtocol (one
# object hands out many), so an adapter supplies the missing half.
from ._sandbox_adapter import NEEDS_INSTANCE, SANDBOX_ONLY # noqa: E402
# Spellings users already have in their code, mapped to the canonical name.
_ALIASES = {"native": "sandlock"}
def _entry_point_providers() -> dict:
"""Places contributed by other installed packages.
The sandbox and managed-backend registries already discover plugins this
way (``praisonai.sandbox``, ``praisonai.managed_backends``); the compute
registry was the one hardcoded dict, so adding a place meant editing core.
A provider can now ship in its own distribution:
[project.entry-points."praisonai.compute"]
runpod = "praisonai_compute_runpod:RunpodCompute"
"""
found = {}
try:
from importlib.metadata import entry_points
for ep in entry_points(group="praisonai.compute"):
found[ep.name.lower()] = ep
except Exception: # pragma: no cover - discovery must never break resolution
pass
return found
def available_providers() -> list:
"""Every place ``tools_run_on=`` / ``run_in=`` accepts, from all registries."""
return sorted(
set(_PROVIDERS) | set(SANDBOX_ONLY) | set(_entry_point_providers())
)
"""Return the compute provider names this bridge can resolve."""
return sorted(_PROVIDERS)
def compute_package_available() -> bool:
@@ -91,44 +58,15 @@ def resolve_compute(compute: Optional[Any]) -> Optional[Any]:
if not isinstance(compute, str):
if hasattr(compute, "provision") and hasattr(compute, "execute"):
return compute
# A SandboxProtocol instance -- e.g. SSHSandbox(host=...) -- is adapted,
# which is the only way to pass connection details.
if hasattr(compute, "run_command") and hasattr(compute, "start"):
from ._sandbox_adapter import wrap_sandbox_instance
return wrap_sandbox_instance(compute)
raise TypeError(
f"compute= expects a provider name or a ComputeProviderProtocol "
f"instance, got {type(compute).__name__}"
)
name = compute.lower().strip()
name = _ALIASES.get(name, name)
# A sandbox-only place: adapt it to the compute-provider shape.
if name in SANDBOX_ONLY:
if name in NEEDS_INSTANCE:
raise ValueError(
f"{name!r} needs connection details a bare name cannot carry. "
f"Instead, {NEEDS_INSTANCE[name]}"
)
from ._sandbox_adapter import SandboxComputeAdapter
return SandboxComputeAdapter(name)
external = _entry_point_providers()
if name in external and name not in _PROVIDERS:
# Let a contributed place describe itself. Without this the repr falls
# back to the bare name, because the phrase table is a literal that
# cannot know about packages installed later.
_remember_display_name(name, external[name])
# A place contributed by another package. Loaded on demand, so an
# installed-but-broken plugin cannot break anything that never names it.
return external[name].load()()
if name not in _PROVIDERS:
raise ValueError(
f"Unknown place {name!r}. "
f"Unknown compute provider {name!r}. "
f"Available: {', '.join(available_providers())}"
)
@@ -140,23 +78,3 @@ def resolve_compute(compute: Optional[Any]) -> Optional[Any]:
raise ImportError(f"compute={name!r} requires {module_name}. {hint}") from exc
return getattr(module, attr)()
#: Display phrases contributed by external providers, filled in on resolve.
_DISPLAY_NAMES: dict = {}
def _remember_display_name(name: str, entry_point) -> None:
"""Record a contributed provider's own phrase, if it declares one."""
try:
cls = entry_point.load()
phrase = getattr(cls, "display_name", None)
if isinstance(phrase, str) and phrase:
_DISPLAY_NAMES[name] = phrase
except Exception: # pragma: no cover - naming must never break resolution
pass
def contributed_display_name(name: str):
"""The phrase a contributed place declared for itself, if any."""
return _DISPLAY_NAMES.get((name or "").lower())
@@ -1,204 +0,0 @@
"""Expose a single sandbox environment as a one-instance compute provider.
There are two execution stacks in this project and they are *not* duplicates:
* ``SandboxProtocol`` (praisonai-sandbox) -- one object **is** one environment:
``start / stop / execute / run_command / reset``.
* ``ComputeProviderProtocol`` -- one object **hands out** many environments:
``provision / execute(instance_id, ...) / shutdown / list_instances``.
``tools_run_on=`` speaks the second protocol, so four backends that only exist
as sandboxes -- ``subprocess``, ``sandlock``, ``ssh``, ``novita`` -- were
unreachable through it. This adapter is the missing half: it holds exactly one
environment and presents the manager-shaped API over it.
Deliberate limitation: ``list_instances()`` can only report what *this process*
provisioned, so adapter-backed places never appear in ``praisonai managed ps``.
A single environment has no cross-process registry to consult, and pretending
otherwise would be worse than saying so.
"""
from __future__ import annotations
import logging
import uuid
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
# Backends that exist only as SandboxProtocol implementations.
SANDBOX_ONLY = ("subprocess", "sandlock", "ssh", "novita")
# Names that need connection details no bare string can carry.
NEEDS_INSTANCE = {
"ssh": (
"run on a remote machine over SSH by passing the object, so it can "
"carry the host: SSHSandbox(host='...', user='...')"
),
}
class SandboxComputeAdapter:
"""One ``SandboxProtocol`` environment, wearing the compute-provider shape."""
def __init__(self, sandbox_type: str, config: Optional[Any] = None,
sandbox: Optional[Any] = None):
self._type = sandbox_type
self._config = config
self._sandbox = sandbox # pre-built instance, if supplied
self._owns_sandbox = sandbox is None
self._instance_id: Optional[str] = None
# ── identity ─────────────────────────────────────────────────────────────
@property
def provider_name(self) -> str:
return self._type
@property
def is_available(self) -> bool:
if self._sandbox is not None:
return bool(getattr(self._sandbox, "is_available", True))
try:
from ..sandbox._sandbox_bridge import resolve_sandbox_class
cls = resolve_sandbox_class(self._type)
probe = cls() if self._type not in NEEDS_INSTANCE else None
return bool(getattr(probe, "is_available", False)) if probe else False
except Exception:
return False
# ── lifecycle ────────────────────────────────────────────────────────────
async def provision(self, config: Any) -> Any:
from .protocols import InstanceInfo, InstanceStatus
if self._sandbox is None:
from ..sandbox import SandboxConfig
from ..sandbox.manager import SandboxManager
cfg = self._config
if cfg is None:
# "/workspace" is the convention for *container* sandboxes and
# does not exist on a Mac or a bare Linux box -- creating it
# fails with "Read-only file system". Local backends pick their
# own private temp dir, so only pass a working_dir through when
# the caller actually chose one.
wanted_dir = getattr(config, "working_dir", None)
local_place = self._type in ("subprocess", "sandlock")
if local_place and wanted_dir in (None, "", "/workspace"):
wanted_dir = None
cfg = SandboxConfig(
sandbox_type=self._type,
env=dict(getattr(config, "env", None) or {}),
**({"working_dir": wanted_dir} if wanted_dir else {}),
)
# This sandbox exists to back the model's execute_command tool,
# which IS a shell. The default policy sets allow_subprocess=
# False, which makes `sh -c` -- and therefore every pipe,
# redirect and && the model writes -- fail as "disabled by
# security policy". Blocking the shell inside a shell tool is a
# contradiction, not a protection; blocked_commands and
# blocked_paths still apply.
if getattr(cfg, "security_policy", None) is not None:
cfg.security_policy.allow_subprocess = True
if getattr(config, "image", None):
cfg.image = config.image
self._manager = SandboxManager(cfg)
self._sandbox = await self._manager.__aenter__()
else:
self._manager = None
starter = getattr(self._sandbox, "start", None)
if starter is not None:
await starter()
self._instance_id = f"{self._type}_{uuid.uuid4().hex[:12]}"
logger.info("[sandbox_adapter] provisioned %s as %s", self._type, self._instance_id)
return InstanceInfo(
instance_id=self._instance_id,
status=InstanceStatus.RUNNING,
endpoint=f"{self._type}://local",
provider=self._type,
metadata={"adapter": True, "discoverable": False},
)
async def execute(self, instance_id: str, command: str,
timeout: Optional[int] = 300) -> Dict[str, Any]:
if self._sandbox is None:
raise RuntimeError(
f"{self._type} sandbox is not running; provision() was not called"
)
# The bridged execute_command tool hands us a shell command string, and
# models write pipes, redirects and && constantly. Without shell=True
# `echo x > f` is argv-split and "> f" arrives as a literal argument.
try:
result = await self._sandbox.run_command(command, shell=True)
except TypeError:
# Backends predating the shell= parameter.
result = await self._sandbox.run_command(command)
# A policy denial arrives as result.error with exit_code=None. Dropping
# it would turn "blocked by security policy" into a silent success --
# the caller would see exit 0 and empty output.
error = getattr(result, "error", None)
exit_code = getattr(result, "exit_code", None)
stderr = getattr(result, "stderr", "") or ""
if error:
stderr = f"{stderr}\n{error}".strip() if stderr else str(error)
if exit_code in (None, 0):
exit_code = 1
return {
"stdout": getattr(result, "stdout", "") or "",
"stderr": stderr,
"exit_code": exit_code or 0,
}
async def shutdown(self, instance_id: str) -> None:
try:
if getattr(self, "_manager", None) is not None:
await self._manager.__aexit__(None, None, None)
elif self._sandbox is not None:
stopper = getattr(self._sandbox, "stop", None)
if stopper is not None:
await stopper()
finally:
if self._owns_sandbox:
self._sandbox = None
self._manager = None
self._instance_id = None
async def get_status(self, instance_id: str) -> Any:
from .protocols import InstanceInfo, InstanceStatus
running = self._sandbox is not None and instance_id == self._instance_id
return InstanceInfo(
instance_id=instance_id,
status=InstanceStatus.RUNNING if running else InstanceStatus.STOPPED,
provider=self._type,
)
async def list_instances(self) -> List[Any]:
"""Only what this process provisioned -- see the module docstring."""
from .protocols import InstanceInfo, InstanceStatus
if self._instance_id is None:
return []
return [InstanceInfo(
instance_id=self._instance_id,
status=InstanceStatus.RUNNING,
provider=self._type,
metadata={"adapter": True, "discoverable": False},
)]
# ── file transfer ────────────────────────────────────────────────────────
async def upload_file(self, instance_id: str, local_path: str, remote_path: str) -> None:
with open(local_path, "r") as handle:
await self._sandbox.write_file(remote_path, handle.read())
async def download_file(self, instance_id: str, remote_path: str, local_path: str) -> None:
content = await self._sandbox.read_file(remote_path)
with open(local_path, "w") as handle:
handle.write(content if isinstance(content, str) else str(content))
def wrap_sandbox_instance(sandbox: Any) -> SandboxComputeAdapter:
"""Adapt an already-constructed sandbox (e.g. ``SSHSandbox(host=...)``)."""
name = getattr(sandbox, "sandbox_type", None) or type(sandbox).__name__.lower()
return SandboxComputeAdapter(str(name), sandbox=sandbox)
@@ -17,7 +17,6 @@ import logging
import shlex
import threading
import uuid
import weakref
from typing import Any, Dict, List, Optional
from ._compute_bridge import resolve_compute
@@ -119,13 +118,8 @@ class SharedCompute:
def shutdown(self) -> None:
"""Tear down the instance and restore any agents we patched."""
for ref, original_tools, original_backstory in self._patched:
agent = ref()
if agent is None:
continue # already collected; nothing left to restore
for agent, original_tools, original_backstory in self._patched:
try:
if getattr(agent, "_tools_sandbox", None) is self:
agent._tools_sandbox = None
agent.tools = original_tools
agent.backstory = original_backstory
cache = getattr(agent, "_system_prompt_cache", None)
@@ -193,14 +187,9 @@ class SharedCompute:
# heredoc early (which would truncate the file and run the remainder
# as shell commands). Guard against the astronomically-unlikely case
# where the token still appears in the body.
# Uppercase hex on purpose. uuid4().hex is lowercase, and the
# security policy substring-matches blocked_commands against the
# whole command -- "dd" is one of them, and appears in 9.6% of
# random lowercase hex strings. One write in ten failed with
# "Blocked command pattern: dd" for no reason the caller could see.
delimiter = f"__PRAISON_EOF_{uuid.uuid4().hex.upper()}__"
delimiter = f"__PRAISON_EOF_{uuid.uuid4().hex}__"
while delimiter in content:
delimiter = f"__PRAISON_EOF_{uuid.uuid4().hex.upper()}__"
delimiter = f"__PRAISON_EOF_{uuid.uuid4().hex}__"
heredoc = (
f"mkdir -p $(dirname {quoted}) && "
f"cat > {quoted} <<'{delimiter}'\n{content}\n{delimiter}"
@@ -214,25 +203,13 @@ class SharedCompute:
"""List files in a directory on the shared remote sandbox."""
return self._format(self.run_command(f"ls -la {shlex.quote(path)}"))
bridged = [execute_command, read_file, write_file, list_files]
# Tag them so Agent.clone_for_channel() drops them: each closes over
# THIS sandbox, so a clone inheriting them would talk to the original
# agent's instance and then attach a second sandbox of its own --
# duplicating the capability prompt and splitting its work across two
# machines. The clone regenerates its own on first use.
for tool in bridged:
tool._praison_sandbox_tool = True
return bridged
return [execute_command, read_file, write_file, list_files]
def attach(self, agents: List[Any], *, override_declared: bool = False) -> None:
def attach(self, agents: List[Any]) -> None:
"""Give each agent the shared tools and tell it they exist.
Agents that already carry their own managed ``backend`` are skipped --
they have deliberately been pointed at their own runtime.
``override_declared`` is set only by the agent's *own* placement, which
is this sandbox's whole reason for existing. A run-level sandbox leaves
it False so an agent that named its own place keeps it.
"""
tools = self.build_tools()
by_name = {t.__name__: t for t in tools}
@@ -240,31 +217,6 @@ class SharedCompute:
for agent in agents:
if agent is None or getattr(agent, "backend", None) is not None:
continue
# An agent that named its own place, or already has a sandbox, must
# not be attached again. A second attach appends CAPABILITY_PROMPT
# twice and, if the two teardowns do not happen in LIFO order,
# permanently restores the wrong tools: the agent is left holding
# four tools bound to a shut-down instance.
#
# `tools_run_on` is checked as well as `_tools_sandbox` because of
# ordering: a flow attaches at the start of the run, before the
# agent's first chat() has created its own sandbox. Checking only
# the live sandbox would attach here and let the agent attach again
# on its first turn. An explicit per-agent choice outranks the run's
# default, so the flow leaves it alone.
declared_own = (
not override_declared
and getattr(agent, "tools_run_on", None) is not None
)
if getattr(agent, "_tools_sandbox", None) is not None or declared_own:
logger.debug(
"[shared_compute] %r already runs its tools on %s; leaving it alone",
getattr(agent, "name", agent),
getattr(agent, "tools_run_on", "its own sandbox"),
)
continue
if any(agent is ref() for ref, _, _ in self._patched):
continue
original_tools = list(getattr(agent, "tools", None) or [])
# Agent.__init__ copies `instructions` into role/goal/backstory and
@@ -296,18 +248,4 @@ class SharedCompute:
logger.warning("[shared_compute] could not attach tools to %r: %s", agent, exc)
continue
# Mark where this agent's tools now run, so a nested attach (or a
# later ensure_tools_placed) can see it is already bound.
try:
agent._tools_sandbox = self
except Exception: # pragma: no cover - exotic agent objects
pass
# Weakly: an agent points at its SharedCompute and the SharedCompute
# pointed back, so a dropped agent was reclaimed as a cycle and its
# sandbox was never shut down. Holding the agent weakly lets
# weakref.finalize see the agent die and release the sandbox.
try:
ref = weakref.ref(agent)
except TypeError: # pragma: no cover - non-weakrefable agent
ref = lambda _agent=agent: _agent
self._patched.append((ref, original_tools, original_backstory))
self._patched.append((agent, original_tools, original_backstory))
@@ -51,12 +51,6 @@ _LAZY_IMPORTS = {
"get_redirect_url": _t("praisonaiagents.mcp.mcp_oauth_callback", "get_redirect_url"),
"OAUTH_CALLBACK_PORT": _t("praisonaiagents.mcp.mcp_oauth_callback", "OAUTH_CALLBACK_PORT"),
"OAUTH_CALLBACK_PATH": _t("praisonaiagents.mcp.mcp_oauth_callback", "OAUTH_CALLBACK_PATH"),
# Zero-config OAuth: discovery, dynamic registration, orchestration.
"discover_oauth_metadata": _t("praisonaiagents.mcp.oauth_discovery", "discover_oauth_metadata"),
"parse_www_authenticate": _t("praisonaiagents.mcp.oauth_discovery", "parse_www_authenticate"),
"register_client": _t("praisonaiagents.mcp.oauth_registration", "register_client"),
"MCPOAuthProvider": _t("praisonaiagents.mcp.oauth_provider", "MCPOAuthProvider"),
"InteractiveAuthRequired": _t("praisonaiagents.mcp.oauth_provider", "InteractiveAuthRequired"),
# Protocols (lazy loaded)
"MCPClientProtocol": _t("praisonaiagents.mcp.protocols", "MCPClientProtocol"),
# Loader (lazy loaded)
@@ -106,10 +100,4 @@ __all__ = [
"get_redirect_url",
"OAUTH_CALLBACK_PORT",
"OAUTH_CALLBACK_PATH",
# Zero-config OAuth (lazy loaded)
"discover_oauth_metadata",
"parse_www_authenticate",
"register_client",
"MCPOAuthProvider",
"InteractiveAuthRequired",
]
@@ -1,231 +0,0 @@
"""
MCP OAuth Metadata Discovery.
Implements the discovery half of zero-config OAuth for remote MCP servers:
- Parse a ``WWW-Authenticate: Bearer resource_metadata="..."`` challenge from a
``401`` response (RFC 9728 / MCP authorization spec).
- Fetch protected-resource metadata (``/.well-known/oauth-protected-resource``).
- Fetch authorization-server metadata
(``/.well-known/oauth-authorization-server`` with an OpenID configuration
fallback), yielding ``authorization_endpoint``, ``token_endpoint`` and
``registration_endpoint``.
Network access is HTTPS-only (except loopback) to preserve the SSRF-safe
posture used elsewhere in the MCP transports. ``requests`` is imported lazily so
this module stays additive and does not affect import time.
"""
import re
from typing import Any, Dict, List, Optional
from urllib.parse import urljoin, urlparse
from praisonaiagents._logging import get_logger
logger = get_logger("mcp-oauth-discovery")
_LOCALHOST_HOSTS = {"localhost", "127.0.0.1", "::1"}
def _require_https(url: str) -> None:
"""Reject non-HTTPS metadata URLs (loopback HTTP is allowed for testing)."""
parsed = urlparse(url)
if parsed.scheme == "https":
return
if parsed.scheme == "http" and (parsed.hostname or "") in _LOCALHOST_HOSTS:
return
raise ValueError(
f"Refusing to fetch OAuth metadata over insecure transport: {url!r}"
)
def parse_www_authenticate(header: Optional[str]) -> Dict[str, str]:
"""
Parse a ``WWW-Authenticate`` challenge header into its parameters.
Only ``Bearer`` challenges are relevant here. Returns a dict of the
``key="value"`` auth-params (e.g. ``resource_metadata``, ``scope``,
``error``). Returns an empty dict when the header is missing or not a
Bearer challenge.
"""
if not header:
return {}
# Strip the leading scheme (e.g. "Bearer ") if present.
stripped = header.strip()
if stripped.lower().startswith("bearer"):
stripped = stripped[len("bearer"):].lstrip()
params: Dict[str, str] = {}
for match in re.finditer(r'(\w+)\s*=\s*"([^"]*)"', stripped):
params[match.group(1).lower()] = match.group(2)
return params
def _get_json(url: str, timeout: float = 10.0) -> Dict[str, Any]:
"""Fetch and parse a JSON metadata document over HTTPS.
Redirects are followed but every hop — including the final destination — is
re-validated with :func:`_require_https` so a redirect cannot escape the
HTTPS/loopback guard to an internal or plaintext endpoint (SSRF-safe).
"""
_require_https(url)
import requests # lazy import — optional dependency
resp = requests.get(
url,
headers={"Accept": "application/json"},
timeout=timeout,
allow_redirects=True,
)
# Re-validate every redirect hop and the final URL: allow_redirects only
# checks the initial URL, so a 30x to http:// or an internal host would
# otherwise bypass the guard.
for previous in getattr(resp, "history", None) or []:
_require_https(previous.url)
_require_https(resp.url)
resp.raise_for_status()
return resp.json()
def _wellknown_candidates(server_url: str) -> List[str]:
"""Build ``.well-known`` metadata URL candidates for a server URL."""
parsed = urlparse(server_url)
origin = f"{parsed.scheme}://{parsed.netloc}"
return [
urljoin(origin + "/", ".well-known/oauth-protected-resource"),
urljoin(origin + "/", ".well-known/oauth-authorization-server"),
urljoin(origin + "/", ".well-known/openid-configuration"),
]
def discover_protected_resource(
server_url: str,
resource_metadata_url: Optional[str] = None,
timeout: float = 10.0,
) -> Optional[Dict[str, Any]]:
"""
Discover protected-resource metadata for an MCP server.
Args:
server_url: The MCP server URL that returned a 401.
resource_metadata_url: Optional explicit URL from the
``WWW-Authenticate: resource_metadata="..."`` challenge.
timeout: Per-request timeout in seconds.
Returns:
The protected-resource metadata document, or None if unavailable.
"""
url = resource_metadata_url or _wellknown_candidates(server_url)[0]
try:
return _get_json(url, timeout=timeout)
except Exception as exc: # noqa: BLE001 - discovery is best-effort
logger.debug(f"Protected-resource metadata unavailable at {url}: {exc}")
return None
def discover_authorization_server(
server_url: str,
authorization_servers: Optional[List[str]] = None,
timeout: float = 10.0,
) -> Optional[Dict[str, Any]]:
"""
Discover authorization-server metadata.
Tries, in order: any ``authorization_servers`` advertised by the
protected-resource metadata, then the server-origin
``/.well-known/oauth-authorization-server`` and OpenID-configuration
fallbacks.
Returns the first metadata document that exposes at least an
``authorization_endpoint`` and ``token_endpoint``, or None.
"""
candidates: List[str] = []
for issuer in authorization_servers or []:
issuer = issuer.rstrip("/")
candidates.append(issuer + "/.well-known/oauth-authorization-server")
candidates.append(issuer + "/.well-known/openid-configuration")
# Fall back to the resource server's own origin.
for candidate in _wellknown_candidates(server_url)[1:]:
candidates.append(candidate)
for url in candidates:
try:
metadata = _get_json(url, timeout=timeout)
except Exception as exc: # noqa: BLE001 - best-effort discovery
logger.debug(f"Auth-server metadata unavailable at {url}: {exc}")
continue
if metadata.get("authorization_endpoint") and metadata.get("token_endpoint"):
return metadata
return None
def discover_oauth_metadata(
server_url: str,
www_authenticate: Optional[str] = None,
timeout: float = 10.0,
) -> Optional[Dict[str, Any]]:
"""
Full metadata discovery for an OAuth-protected MCP server.
Combines the ``WWW-Authenticate`` challenge (if any), protected-resource
metadata, and authorization-server metadata into a single normalised dict:
{
"authorization_endpoint": str,
"token_endpoint": str,
"registration_endpoint": str | None,
"scopes_supported": list[str] | None,
"resource": str | None,
}
Returns None if a usable authorization/token endpoint pair cannot be found.
"""
challenge = parse_www_authenticate(www_authenticate)
resource_meta = discover_protected_resource(
server_url,
resource_metadata_url=challenge.get("resource_metadata"),
timeout=timeout,
)
authorization_servers = None
resource_id = None
if resource_meta:
authorization_servers = resource_meta.get("authorization_servers")
resource_id = resource_meta.get("resource")
auth_meta = discover_authorization_server(
server_url,
authorization_servers=authorization_servers,
timeout=timeout,
)
if not auth_meta:
return None
# Enforce the HTTPS/loopback guard on the advertised endpoints too: an
# HTTPS metadata document must not be able to smuggle a plaintext
# authorization/token/registration endpoint that would later receive
# authorization codes, PKCE verifiers, refresh tokens, or client secrets
# over an insecure transport.
authorization_endpoint = auth_meta["authorization_endpoint"]
token_endpoint = auth_meta["token_endpoint"]
registration_endpoint = auth_meta.get("registration_endpoint")
_require_https(authorization_endpoint)
_require_https(token_endpoint)
if registration_endpoint:
_require_https(registration_endpoint)
return {
"authorization_endpoint": authorization_endpoint,
"token_endpoint": token_endpoint,
"registration_endpoint": registration_endpoint,
"scopes_supported": (
auth_meta.get("scopes_supported")
or (resource_meta or {}).get("scopes_supported")
),
"resource": resource_id,
}
@@ -1,340 +0,0 @@
"""
MCP OAuth Provider — zero-config orchestration.
Composes the OAuth building blocks into a single ``ensure_authenticated`` entry
point that transports invoke on a ``401``:
discovery -> dynamic client registration (if no client stored)
-> PKCE authorization-code flow (browser + loopback callback)
-> token persistence
-> transparent refresh on expiry
All heavy work (network I/O, browser, loopback HTTP server) is lazily imported
and only runs when a server actually challenges for OAuth, so servers without
auth — or with pre-supplied client info/tokens — behave exactly as before.
"""
import time
import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Thread
from typing import Any, Dict, Optional
from urllib.parse import urlencode, urlparse, parse_qs
from praisonaiagents._logging import get_logger
from .mcp_oauth_callback import (
OAuthCallbackHandler,
OAUTH_CALLBACK_PORT,
OAUTH_CALLBACK_PATH,
generate_state,
generate_code_verifier,
generate_code_challenge,
get_redirect_url,
)
logger = get_logger("mcp-oauth-provider")
class InteractiveAuthRequired(RuntimeError):
"""Raised when interactive OAuth is needed but not possible (headless/CI)."""
def _make_callback_server(handler: OAuthCallbackHandler, port: int) -> HTTPServer:
"""Build a minimal loopback HTTP server that feeds codes to ``handler``."""
class _Handler(BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802 - required name
parsed = urlparse(self.path)
if parsed.path != OAUTH_CALLBACK_PATH:
self.send_response(404)
self.end_headers()
return
params = parse_qs(parsed.query)
state = (params.get("state") or [""])[0]
code = (params.get("code") or [""])[0]
if state and code:
handler.receive_callback(state, code)
body = b"Authentication complete. You can close this window."
else:
body = b"Authentication failed: missing code or state."
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(body)
def log_message(self, *args, **kwargs): # silence default logging
pass
return HTTPServer(("127.0.0.1", port), _Handler)
class MCPOAuthProvider:
"""
Orchestrates zero-config OAuth for a single remote MCP server.
Args:
mcp_name: Stable identifier used as the storage key.
server_url: The MCP server URL.
storage: Optional pre-built ``MCPAuthStorage`` (created lazily otherwise).
open_browser: Whether to open a browser for interactive auth. Set False
for headless/CI to get a single actionable ``InteractiveAuthRequired``.
"""
def __init__(
self,
mcp_name: str,
server_url: str,
storage: Optional[Any] = None,
open_browser: bool = True,
):
self.mcp_name = mcp_name
self.server_url = server_url
self.open_browser = open_browser
if storage is None:
from .mcp_auth_storage import MCPAuthStorage
storage = MCPAuthStorage()
self.storage = storage
def get_valid_token(self) -> Optional[str]:
"""Return a non-expired access token if one is stored, else None."""
entry = self.storage.get_for_url(self.mcp_name, self.server_url)
if not entry or not entry.get("tokens"):
return None
if self.storage.is_token_expired(self.mcp_name):
refreshed = self._try_refresh(entry)
if not refreshed:
return None
entry = self.storage.get_for_url(self.mcp_name, self.server_url) or {}
tokens = entry.get("tokens") or {}
return tokens.get("access_token")
def ensure_authenticated(
self,
www_authenticate: Optional[str] = None,
timeout: float = 10.0,
) -> str:
"""
Ensure a valid access token exists, running the full flow if needed.
Returns:
A valid bearer access token.
Raises:
InteractiveAuthRequired: If interactive auth is needed but disabled.
RuntimeError: If discovery fails or the flow cannot complete.
"""
token = self.get_valid_token()
if token:
return token
from .oauth_discovery import discover_oauth_metadata
metadata = discover_oauth_metadata(
self.server_url,
www_authenticate=www_authenticate,
timeout=timeout,
)
if not metadata:
raise RuntimeError(
f"Could not discover OAuth metadata for {self.server_url}. "
"The server may not advertise standard authorization-server "
"metadata; supply client credentials manually."
)
client_info = self._ensure_client(metadata, timeout=timeout)
scope = None
scopes = metadata.get("scopes_supported")
if scopes:
scope = " ".join(scopes)
return self._run_pkce_flow(metadata, client_info, scope, timeout=timeout)
def _ensure_client(
self,
metadata: Dict[str, Any],
timeout: float,
) -> Dict[str, Any]:
"""Return stored client info or perform dynamic client registration."""
entry = self.storage.get_for_url(self.mcp_name, self.server_url)
if entry and entry.get("client_info", {}).get("client_id"):
return entry["client_info"]
registration_endpoint = metadata.get("registration_endpoint")
if not registration_endpoint:
raise RuntimeError(
f"No client is registered for {self.server_url} and the "
"authorization server does not support dynamic client "
"registration (no registration_endpoint)."
)
from .oauth_registration import register_client
scope = None
scopes = metadata.get("scopes_supported")
if scopes:
scope = " ".join(scopes)
client_info = register_client(
registration_endpoint,
redirect_uris=[get_redirect_url()],
scope=scope,
timeout=timeout,
)
self.storage.set_client_info(
self.mcp_name, client_info, server_url=self.server_url
)
return client_info
def _run_pkce_flow(
self,
metadata: Dict[str, Any],
client_info: Dict[str, Any],
scope: Optional[str],
timeout: float,
) -> str:
"""Run the interactive PKCE authorization-code flow and store tokens."""
if not self.open_browser:
raise InteractiveAuthRequired(
f"Interactive OAuth is required to authenticate with "
f"{self.server_url}. Run this from an interactive session, or "
f"pre-supply tokens, to complete authentication."
)
callback = OAuthCallbackHandler()
state = generate_state()
code_verifier = generate_code_verifier()
code_challenge = generate_code_challenge(code_verifier)
params = {
"response_type": "code",
"client_id": client_info["client_id"],
"redirect_uri": get_redirect_url(),
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
}
if scope:
params["scope"] = scope
if metadata.get("resource"):
params["resource"] = metadata["resource"]
auth_url = f"{metadata['authorization_endpoint']}?{urlencode(params)}"
server = _make_callback_server(callback, OAUTH_CALLBACK_PORT)
thread = Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
logger.debug(f"Opening browser for OAuth authorization: {auth_url}")
webbrowser.open(auth_url)
code = callback.wait_for_callback(state, timeout=300.0)
finally:
server.shutdown()
tokens = self._exchange_code(
metadata, client_info, code, code_verifier, timeout=timeout
)
self.storage.set_tokens(
self.mcp_name, tokens, server_url=self.server_url
)
return tokens["access_token"]
def _exchange_code(
self,
metadata: Dict[str, Any],
client_info: Dict[str, Any],
code: str,
code_verifier: str,
timeout: float,
) -> Dict[str, Any]:
"""Exchange an authorization code for tokens at the token endpoint."""
import requests # lazy import — optional dependency
data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": get_redirect_url(),
"client_id": client_info["client_id"],
"code_verifier": code_verifier,
}
if client_info.get("client_secret"):
data["client_secret"] = client_info["client_secret"]
if metadata.get("resource"):
data["resource"] = metadata["resource"]
resp = requests.post(
metadata["token_endpoint"],
data=data,
headers={"Accept": "application/json"},
timeout=timeout,
)
resp.raise_for_status()
return self._normalize_tokens(
resp.json(), token_endpoint=metadata["token_endpoint"]
)
def _try_refresh(self, entry: Dict[str, Any]) -> bool:
"""Attempt a refresh-token grant; return True on success."""
tokens = entry.get("tokens") or {}
refresh_token = tokens.get("refresh_token")
token_endpoint = tokens.get("token_endpoint")
client_info = entry.get("client_info") or {}
client_id = client_info.get("client_id")
if not (refresh_token and token_endpoint and client_id):
return False
try:
from .oauth_discovery import _require_https
# Defense in depth: never POST a refresh token / client secret to a
# persisted endpoint that isn't HTTPS (or loopback).
_require_https(token_endpoint)
import requests # lazy import — optional dependency
data = {
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": client_id,
}
if client_info.get("client_secret"):
data["client_secret"] = client_info["client_secret"]
resp = requests.post(
token_endpoint,
data=data,
headers={"Accept": "application/json"},
timeout=10.0,
)
resp.raise_for_status()
new_tokens = self._normalize_tokens(
resp.json(), token_endpoint=token_endpoint
)
# Preserve refresh_token if the server didn't return a new one.
if not new_tokens.get("refresh_token"):
new_tokens["refresh_token"] = refresh_token
self.storage.set_tokens(
self.mcp_name, new_tokens, server_url=self.server_url
)
return True
except Exception as exc: # noqa: BLE001 - refresh is best-effort
logger.debug(f"Token refresh failed for {self.mcp_name}: {exc}")
return False
@staticmethod
def _normalize_tokens(
raw: Dict[str, Any],
token_endpoint: Optional[str] = None,
) -> Dict[str, Any]:
"""Normalise a token response, computing an absolute ``expires_at``."""
tokens = dict(raw)
expires_in = tokens.get("expires_in")
if expires_in and not tokens.get("expires_at"):
try:
tokens["expires_at"] = time.time() + float(expires_in)
except (TypeError, ValueError):
pass
if token_endpoint:
tokens["token_endpoint"] = token_endpoint
return tokens
@@ -1,91 +0,0 @@
"""
MCP OAuth Dynamic Client Registration (RFC 7591).
Performs an anonymous dynamic client registration against an authorization
server's ``registration_endpoint`` so that connecting to a hosted MCP server by
URL requires no pre-provisioned ``client_id``. The registered client info is
returned for persistence via :meth:`MCPAuthStorage.set_client_info`.
``requests`` is imported lazily to keep this module additive.
"""
from typing import Any, Dict, List, Optional
from urllib.parse import urlparse
from praisonaiagents._logging import get_logger
logger = get_logger("mcp-oauth-registration")
_LOCALHOST_HOSTS = {"localhost", "127.0.0.1", "::1"}
def _require_https(url: str) -> None:
"""Reject non-HTTPS registration URLs (loopback HTTP allowed for testing)."""
parsed = urlparse(url)
if parsed.scheme == "https":
return
if parsed.scheme == "http" and (parsed.hostname or "") in _LOCALHOST_HOSTS:
return
raise ValueError(
f"Refusing to register client over insecure transport: {url!r}"
)
def register_client(
registration_endpoint: str,
redirect_uris: List[str],
client_name: str = "PraisonAI Agent",
scope: Optional[str] = None,
timeout: float = 10.0,
) -> Dict[str, Any]:
"""
Register a public OAuth client via RFC 7591 Dynamic Client Registration.
Args:
registration_endpoint: The ``registration_endpoint`` from auth-server
metadata.
redirect_uris: Allowed redirect URIs (the loopback callback URL).
client_name: Human-readable client name.
scope: Optional space-delimited scope string to request.
timeout: Request timeout in seconds.
Returns:
The registration response containing at least ``client_id`` (and
``client_secret`` for confidential clients).
Raises:
ValueError: If the endpoint is insecure or the response lacks a
``client_id``.
"""
_require_https(registration_endpoint)
import requests # lazy import — optional dependency
payload: Dict[str, Any] = {
"client_name": client_name,
"redirect_uris": redirect_uris,
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
}
if scope:
payload["scope"] = scope
resp = requests.post(
registration_endpoint,
json=payload,
headers={"Content-Type": "application/json", "Accept": "application/json"},
timeout=timeout,
)
resp.raise_for_status()
client_info = resp.json()
if not client_info.get("client_id"):
raise ValueError(
"Dynamic client registration response missing 'client_id'"
)
logger.debug(
f"Registered OAuth client '{client_info.get('client_id')}' "
f"at {registration_endpoint}"
)
return client_info
@@ -371,39 +371,6 @@ class ChromaMemoryAdapter:
sanitized[k] = str(v)
return sanitized
def _reset_collection(self) -> None:
"""Delete and recreate this adapter's collection, wiping stored memory.
Short-term and long-term share one collection in this adapter, so both
resets route here. Scoped to this adapter's own collection to avoid
wiping other collections in the shared persistent store.
"""
try:
self.client.delete_collection(name=self.collection_name)
except Exception:
pass
self.collection = self.client.create_collection(
name=self.collection_name,
metadata={"hnsw:space": "cosine"}
)
def reset_short_term(self) -> None:
"""Clear this adapter's memory.
NOTE: this adapter stores short-term and long-term in a single shared
collection (``store_short_term``/``search_short_term`` delegate to their
long-term counterparts and no ``memory_type`` tag is recorded), so
per-tier scoping is not possible here — resetting short-term also clears
long-term. Use a tier-aware backend (e.g. the Dakera adapter) if you need
to erase a single tier.
"""
self._reset_collection()
def reset_long_term(self) -> None:
"""Clear this adapter's memory (shared with short-term; see
``reset_short_term`` for the shared-collection caveat)."""
self._reset_collection()
def close(self):
"""Clean up ChromaDB resources."""
# ChromaDB PersistentClient has no explicit close; release references.
@@ -814,11 +814,6 @@ class Memory(SearchMixin, MemoryCoreMixin):
if getattr(self, "memory_adapter", None):
if hasattr(self.memory_adapter, "reset_short_term"):
self.memory_adapter.reset_short_term()
else:
logger.warning(
f"{type(self.memory_adapter).__name__} does not support "
"reset_short_term(); short-term memory was NOT cleared"
)
return
conn = self._get_stm_conn()
with self._write_lock: # Serialize write operations
@@ -1204,11 +1199,6 @@ class Memory(SearchMixin, MemoryCoreMixin):
if getattr(self, "memory_adapter", None):
if hasattr(self.memory_adapter, "reset_long_term"):
self.memory_adapter.reset_long_term()
else:
logger.warning(
f"{type(self.memory_adapter).__name__} does not support "
"reset_long_term(); long-term memory was NOT cleared"
)
return
conn = self._get_ltm_conn()
with self._write_lock: # Serialize write operations
@@ -74,10 +74,6 @@ class Process:
self._state_lock = asyncio.Lock()
return self._state_lock
def _task_by_name(self, name):
"""Return the task whose name matches, or None. First-match, mirrors prior inline lookup."""
return next((t for t in self.tasks.values() if t.name == name), None)
def _create_llm_instance(self):
"""Create and return a configured LLM instance for manager tasks."""
from ..llm import LLM
@@ -119,7 +115,7 @@ class Process:
if current_task.previous_tasks:
# For validation tasks, typically validate the most recent previous task
prev_task_name = current_task.previous_tasks[-1]
validated_task = self._task_by_name(prev_task_name)
validated_task = next((t for t in self.tasks.values() if t.name == prev_task_name), None)
elif current_task.context:
# If no previous_tasks, check context for the validated task
for ctx_task in reversed(current_task.context):
@@ -352,43 +348,6 @@ class Process:
task_id=loop_task.id
)
def _build_routing_context(self, current_task: Task) -> Dict[str, Any]:
"""Build a variable context for evaluating a task's `when` condition.
Combines the task's structured result (json_dict / pydantic fields) with
the raw output so expressions like ``{{score}} > 80`` can resolve, and
exposes the raw text as ``previous_output``.
"""
context: Dict[str, Any] = {}
result = getattr(current_task, "result", None)
if result is not None:
try:
context.update(result.to_dict())
except Exception:
pass
context["previous_output"] = getattr(result, "raw", "")
return context
def _has_when_routing(self, task: Task) -> bool:
"""True if the task uses the unified when/then_task/else_task routing."""
return task.when is not None or task.then_task is not None or task.else_task is not None
def _resolve_when_routing(self, current_task: Task) -> Optional[Task]:
"""Resolve the next Task via the unified `when`/`then_task`/`else_task`
routing on the current task. Returns None if no such routing applies."""
if current_task.when is None and current_task.then_task is None and current_task.else_task is None:
return None
next_name = current_task.get_next_task(self._build_routing_context(current_task))
if not next_name:
return None
next_task = next((t for t in self.tasks.values() if t.name == next_name), None)
if next_task:
next_task.status = "not started"
logging.debug(f"Routing to {next_task.name} based on when-condition: {current_task.when}")
else:
logging.warning(f"when-routing target '{next_name}' not found among tasks")
return next_task
def _build_task_context(self, current_task: Task) -> str:
"""Build context for a task based on its retain_full_context setting"""
# Check if we have validation feedback to include
@@ -418,7 +377,7 @@ class Process:
if current_task.retain_full_context:
# Original behavior: include all previous tasks
for prev_name in current_task.previous_tasks:
prev_task = self._task_by_name(prev_name)
prev_task = next((t for t in self.tasks.values() if t.name == prev_name), None)
if prev_task and prev_task.result:
context += f"\n{prev_name}: {prev_task.result.raw}"
@@ -432,7 +391,7 @@ class Process:
if current_task.previous_tasks:
# Get the most recent previous task (last in the list)
prev_name = current_task.previous_tasks[-1]
prev_task = self._task_by_name(prev_name)
prev_task = next((t for t in self.tasks.values() if t.name == prev_name), None)
if prev_task and prev_task.result:
context += f"\n{prev_name}: {prev_task.result.raw}"
@@ -640,7 +599,7 @@ class Process:
for task in self.tasks.values():
if task.next_tasks:
for next_task_name in task.next_tasks:
next_task = self._task_by_name(next_task_name)
next_task = next((t for t in self.tasks.values() if t.name == next_task_name), None)
if next_task:
next_task.previous_tasks.append(task.name)
logging.debug(f"Added {task.name} as previous task for {next_task_name}")
@@ -811,7 +770,7 @@ Subtask: {st.name}
target_tasks = current_task.condition.get(decision_str, []) if decision_str else []
task_value = target_tasks[0] if isinstance(target_tasks, list) else target_tasks
next_task = self._task_by_name(task_value)
next_task = next((t for t in self.tasks.values() if t.name == task_value), None)
if next_task:
next_task.status = "not started" # Reset status to allow execution
logging.debug(f"Routing to {next_task.name} based on decision: {decision_str}")
@@ -840,7 +799,7 @@ Subtask: {st.name}
logging.debug(f"No input file, marking {current_task.name} as completed")
if current_task.next_tasks:
next_task_name = current_task.next_tasks[0]
next_task = self._task_by_name(next_task_name)
next_task = next((t for t in self.tasks.values() if t.name == next_task_name), None)
current_task = next_task
else:
current_task = None
@@ -852,8 +811,8 @@ Subtask: {st.name}
yield task_id
visited_tasks.add(task_id)
# Only end workflow if no next_tasks AND no conditions AND no when-routing
if not current_task.next_tasks and not current_task.condition and not self._has_when_routing(current_task) and not any(
# Only end workflow if no next_tasks AND no conditions
if not current_task.next_tasks and not current_task.condition and not any(
t.task_type == "loop" and current_task.name.startswith(t.name + "_")
for t in self.tasks.values()
):
@@ -911,7 +870,7 @@ Subtask: {st.name}
else:
# Find the target task by name
task_value = target_tasks[0] if isinstance(target_tasks, list) else target_tasks
next_task = self._task_by_name(task_value)
next_task = next((t for t in self.tasks.values() if t.name == task_value), None)
if next_task:
next_task.status = "not started" # Reset status to allow execution
@@ -940,26 +899,10 @@ Subtask: {st.name}
# Don't mark workflow as finished when following condition path
await self._set_workflow_finished(False)
# Unified condition routing (when/then_task/else_task) takes priority
# over next_tasks when configured on the current task.
when_routing_exhausted = False
if not next_task and current_task:
if self._has_when_routing(current_task):
when_task = self._resolve_when_routing(current_task)
if when_task:
next_task = when_task
await self._set_workflow_finished(False)
elif not current_task.next_tasks:
# when-routing is authoritative: it resolved to no target
# (e.g. the taken branch has no then/else_task) and there
# is no next_tasks fallback, so this path ends cleanly
# instead of picking an unrelated not-started task.
when_routing_exhausted = True
# If no condition-based routing, use next_tasks
if not next_task and current_task and current_task.next_tasks:
next_task_name = current_task.next_tasks[0]
next_task = self._task_by_name(next_task_name)
next_task = next((t for t in self.tasks.values() if t.name == next_task_name), None)
if next_task:
# Reset the next task to allow re-execution
next_task.status = "not started"
@@ -972,7 +915,7 @@ Subtask: {st.name}
logging.debug(f"Following next_tasks to {next_task.name}")
current_task = next_task
if not current_task and not when_routing_exhausted:
if not current_task:
current_task = self._find_next_not_started_task() # General fallback if no next task in workflow
@@ -1223,7 +1166,7 @@ Provide a JSON with the structure:
for task in self.tasks.values():
if task.next_tasks:
for next_task_name in task.next_tasks:
next_task = self._task_by_name(next_task_name)
next_task = next((t for t in self.tasks.values() if t.name == next_task_name), None)
if next_task:
next_task.previous_tasks.append(task.name)
@@ -1418,7 +1361,7 @@ Subtask: {st.name}
target_tasks = current_task.condition.get(decision_str, []) if decision_str else []
task_value = target_tasks[0] if isinstance(target_tasks, list) else target_tasks
next_task = self._task_by_name(task_value)
next_task = next((t for t in self.tasks.values() if t.name == task_value), None)
if next_task:
next_task.status = "not started" # Reset status to allow execution
logging.debug(f"Routing to {next_task.name} based on decision: {decision_str}")
@@ -1447,7 +1390,7 @@ Subtask: {st.name}
logging.debug(f"No input file, marking {current_task.name} as completed")
if current_task.next_tasks:
next_task_name = current_task.next_tasks[0]
next_task = self._task_by_name(next_task_name)
next_task = next((t for t in self.tasks.values() if t.name == next_task_name), None)
current_task = next_task
else:
current_task = None
@@ -1459,8 +1402,8 @@ Subtask: {st.name}
yield task_id
visited_tasks.add(task_id)
# Only end workflow if no next_tasks AND no conditions AND no when-routing
if not current_task.next_tasks and not current_task.condition and not self._has_when_routing(current_task) and not any(
# Only end workflow if no next_tasks AND no conditions
if not current_task.next_tasks and not current_task.condition and not any(
t.task_type == "loop" and current_task.name.startswith(t.name + "_")
for t in self.tasks.values()
):
@@ -1516,7 +1459,7 @@ Subtask: {st.name}
else:
# Find the target task by name
task_value = target_tasks[0] if isinstance(target_tasks, list) else target_tasks
next_task = self._task_by_name(task_value)
next_task = next((t for t in self.tasks.values() if t.name == task_value), None)
if next_task:
next_task.status = "not started" # Reset status to allow execution
@@ -1545,26 +1488,10 @@ Subtask: {st.name}
# Don't mark workflow as finished when following condition path
self._set_workflow_finished_sync(False)
# Unified condition routing (when/then_task/else_task) takes priority
# over next_tasks when configured on the current task.
when_routing_exhausted = False
if not next_task and current_task:
if self._has_when_routing(current_task):
when_task = self._resolve_when_routing(current_task)
if when_task:
next_task = when_task
self._set_workflow_finished_sync(False)
elif not current_task.next_tasks:
# when-routing is authoritative: it resolved to no target
# (e.g. the taken branch has no then/else_task) and there
# is no next_tasks fallback, so this path ends cleanly
# instead of picking an unrelated not-started task.
when_routing_exhausted = True
# If no condition-based routing, use next_tasks
if not next_task and current_task and current_task.next_tasks:
next_task_name = current_task.next_tasks[0]
next_task = self._task_by_name(next_task_name)
next_task = next((t for t in self.tasks.values() if t.name == next_task_name), None)
if next_task:
# Reset the next task to allow re-execution
next_task.status = "not started"
@@ -1577,7 +1504,7 @@ Subtask: {st.name}
logging.debug(f"Following next_tasks to {next_task.name}")
current_task = next_task
if not current_task and not when_routing_exhausted:
if not current_task:
current_task = self._find_next_not_started_task() # General fallback if no next task in workflow
@@ -78,9 +78,6 @@ KIND_TOOL_RESULT = "tool_result"
KIND_APPROVAL = "approval"
#: The loop iteration index, journalled so the cursor is restored exactly.
KIND_ITERATION = "iteration"
#: A verification/completion-gate check result: ``{"name", "success",
#: "returncode", "command"/"path", "duration_seconds", "output_hash"}``.
KIND_VERIFICATION = "verification"
VALID_KINDS = frozenset(
{
@@ -89,7 +86,6 @@ VALID_KINDS = frozenset(
KIND_TOOL_RESULT,
KIND_APPROVAL,
KIND_ITERATION,
KIND_VERIFICATION,
}
)
@@ -79,43 +79,10 @@ class SandboxManager:
f"Unknown sandbox type: {sandbox_type!r}. {e}"
) from e
# Not every backend takes a SandboxConfig: SSHSandbox and ModalSandbox
# carry their own connection details instead, so passing config= to them
# raised "unexpected keyword argument 'config'" before the sandbox was
# ever built. Ask the constructor what it accepts.
import inspect
params = inspect.signature(sandbox_cls.__init__).parameters
takes = lambda name: name in params or any(
p.kind is p.VAR_KEYWORD for p in params.values()
)
kwargs: Dict[str, Any] = {}
if takes("config"):
kwargs["config"] = self.config
if sandbox_type == "docker" and takes("image"):
kwargs: Dict[str, Any] = {"config": self.config}
if sandbox_type == "docker":
kwargs["image"] = self.config.image
# A required argument we cannot supply from a config object -- SSH needs
# a host. Say so instead of raising TypeError from deep inside.
missing = [
name for name, prm in params.items()
if name != "self"
and prm.default is prm.empty
and prm.kind not in (prm.VAR_POSITIONAL, prm.VAR_KEYWORD)
and name not in kwargs
]
if missing:
raise TypeError(
f"The {sandbox_type!r} sandbox needs {', '.join(missing)}, which a "
f"SandboxConfig cannot carry. Build it directly and pass the "
f"object:\n"
f" from praisonai_sandbox.{sandbox_type} import "
f"{sandbox_cls.__name__}\n"
f" sandbox = {sandbox_cls.__name__}("
f"{'=..., '.join(missing)}=...)"
)
sandbox = sandbox_cls(**kwargs)
is_available = getattr(sandbox, "is_available", True)
@@ -7,7 +7,6 @@ other config.yaml content.
"""
import contextlib
import copy
from praisonaiagents._logging import get_logger
import os
import threading
@@ -22,11 +21,6 @@ logger = get_logger(__name__)
_HISTORY_FILE = "run_history.yaml"
_LOCK_FILE = "config.schedules.lock"
_MAX_HISTORY = 200
# Hard cap on a single job's persisted per-run scratchpad. A watermark /
# cursor / last-seen hash is tiny; this only exists so a runaway ``set_state``
# cannot grow config.yaml unbounded. Oversized writes are rejected (prior
# state kept) rather than silently truncated.
_MAX_STATE_BYTES = 16 * 1024
class ConfigYamlScheduleStore:
"""CRUD store backed by ``config.yaml``.
@@ -47,9 +41,6 @@ class ConfigYamlScheduleStore:
self._max_history = max_history
self._lock = threading.RLock()
self._jobs: Dict[str, ScheduleJob] = {}
# Per-job durable scratchpad (cursors / watermarks / last-seen hash),
# persisted under the ``job_state`` key alongside ``schedules``.
self._job_state: Dict[str, Dict] = {}
self._history: List[RunRecord] = []
# In-memory record of leases we currently hold, so ``complete`` can
# release them. Cross-process leases live in the persisted job
@@ -135,8 +126,6 @@ class ConfigYamlScheduleStore:
self._reload_locked()
if job_id in self._jobs:
del self._jobs[job_id]
# Drop any per-job scratchpad so a deleted job leaves no state.
self._job_state.pop(job_id, None)
self._save()
return True
return False
@@ -156,7 +145,6 @@ class ConfigYamlScheduleStore:
if principal is not None and job.principal != principal:
continue
del self._jobs[jid]
self._job_state.pop(jid, None)
self._save()
return True
return False
@@ -211,7 +199,6 @@ class ConfigYamlScheduleStore:
if job.delete_after_run:
# One-shot: remove now so no competitor re-claims it.
del self._jobs[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
@@ -296,7 +283,6 @@ class ConfigYamlScheduleStore:
a job already claimed/leased by another process is observed.
"""
self._jobs = {}
self._job_state = {}
self._load()
# ── execution history ─────────────────────────────────────────────
@@ -341,59 +327,6 @@ class ConfigYamlScheduleStore:
records = [r for r in records if r.job_id == job_id]
return records[:limit]
# ── per-job state (JobStateStoreProtocol) ─────────────────────────
def get_state(self, job_id: str) -> Dict:
"""Return the persisted scratchpad for ``job_id`` (``{}`` if none).
A deep copy is returned so a caller mutating the result — including a
nested list or dict — cannot silently alter the in-memory store without
going through :meth:`set_state` (which enforces the size cap).
"""
with self._lock:
state = self._job_state.get(job_id)
return copy.deepcopy(state) if isinstance(state, dict) else {}
def set_state(self, job_id: str, state: Dict) -> None:
"""Persist ``state`` for ``job_id`` under the ``job_state`` key.
Size-capped: a serialised state larger than ``_MAX_STATE_BYTES`` is
rejected (prior state kept) so a runaway write cannot grow config.yaml
unbounded. An empty/falsy ``state`` clears the entry.
"""
if not state:
self.clear_state(job_id)
return
if not isinstance(state, dict):
logger.warning(
"Ignoring non-dict state for job '%s' (%s)", job_id, type(state).__name__,
)
return
try:
import yaml
size = len(yaml.safe_dump(state).encode("utf-8"))
except Exception as e: # pragma: no cover - defensive
logger.warning("Could not size state for job '%s': %s", job_id, e)
return
if size > _MAX_STATE_BYTES:
logger.warning(
"Rejecting oversized state for job '%s' (%d > %d bytes); keeping prior",
job_id, size, _MAX_STATE_BYTES,
)
return
with self._lock, self._file_lock():
self._reload_locked()
self._job_state[job_id] = copy.deepcopy(state)
self._save()
def clear_state(self, job_id: str) -> None:
"""Drop any persisted scratchpad for ``job_id`` (no-op if absent)."""
with self._lock, self._file_lock():
self._reload_locked()
if job_id in self._job_state:
del self._job_state[job_id]
self._save()
# ── persistence ──────────────────────────────────────────────────
def _load(self) -> None:
@@ -420,11 +353,6 @@ class ConfigYamlScheduleStore:
job_data.setdefault("id", job_id)
job = ScheduleJob.from_dict(job_data)
self._jobs[job.id] = job
job_state = data.get("job_state", {})
if isinstance(job_state, dict):
for job_id, state in job_state.items():
if isinstance(state, dict):
self._job_state[str(job_id)] = state
except ValueError:
raise
except Exception as e:
@@ -453,15 +381,6 @@ class ConfigYamlScheduleStore:
schedules[job.id] = job.to_dict()
data["schedules"] = schedules
# Persist per-job scratchpad alongside; drop the key entirely when
# empty so config.yaml stays clean for the stateless common case.
if self._job_state:
data["job_state"] = {
jid: state for jid, state in self._job_state.items()
}
else:
data.pop("job_state", None)
# Atomic write
os.makedirs(os.path.dirname(self._path), exist_ok=True)
tmp = self._path + ".tmp"
@@ -170,12 +170,6 @@ def __getattr__(name: str):
_module_cache[name] = workspace_id
return workspace_id
# Cross-context continuation prompt assembler (session handoff)
if name == "build_handoff_prompt":
from .handoff import build_handoff_prompt
_module_cache[name] = build_handoff_prompt
return build_handoff_prompt
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -207,5 +201,4 @@ __all__ = [
"get_session_context",
"clear_session_context",
"workspace_id",
"build_handoff_prompt",
]
@@ -1,164 +0,0 @@
"""Continuation-prompt assembler for cross-context resume ("session handoff").
When a long run loses its context — compaction, a fresh session, a new machine —
the next context should be handed a *generated continuation prompt built from
durable state*, not left to reconstruct history by scrolling. Every durable
ingredient already exists (session recap, goal state, workflow checkpoints); this
module is the missing assembler.
:func:`build_handoff_prompt` performs **deterministic assembly with no LLM call**:
it collects a persisted recap/summary, the persisted :class:`GoalState`, and (when
supplied) a workflow checkpoint, and renders a self-contained continuation prompt.
Sections whose store has no data are omitted, and the output is hard-capped with
tail-biasing so it can never overflow a delivery channel or terminal.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
HANDOFF_MAX_CHARS = 4000
_PREAMBLE = (
"You are resuming an interrupted run. Recover from durable state; do not "
"trust memory of a previous conversation."
)
def _goal_sections(goal_state: Optional[Dict[str, Any]]) -> List[str]:
"""Render GOAL / DEFINITION OF DONE / CONSTRAINTS from persisted GoalState."""
if not isinstance(goal_state, dict):
return []
lines: List[str] = []
goal = (goal_state.get("goal") or "").strip()
if goal:
lines.append(f"GOAL: {goal}")
criteria = goal_state.get("criteria")
if isinstance(criteria, dict):
outcome = (criteria.get("outcome") or "").strip()
verification = (criteria.get("verification") or "").strip()
dod = outcome
if verification:
dod = f"{dod} (verify: {verification})" if dod else f"verify: {verification}"
if dod:
lines.append(f"DEFINITION OF DONE: {dod}")
constraints = criteria.get("constraints")
if isinstance(constraints, list) and constraints:
lines.append(
"CONSTRAINTS (never violate): "
+ "; ".join(str(c) for c in constraints)
)
reason = (goal_state.get("last_reason") or "").strip()
if reason:
lines.append(f"STATUS: {reason}")
return lines
def _progress_section(checkpoint: Optional[Dict[str, Any]]) -> List[str]:
"""Render PROGRESS from a workflow checkpoint dict, if any."""
if not isinstance(checkpoint, dict):
return []
completed = checkpoint.get("completed_steps")
total = checkpoint.get("total_steps") or checkpoint.get("step_count")
last = checkpoint.get("last_step") or checkpoint.get("current_step")
if completed is None and last is None:
return []
if total:
head = f"PROGRESS: {completed or 0} of {total} workflow steps done"
else:
head = f"PROGRESS: {completed or 0} workflow steps done"
if last:
head += f"; last completed: {last}"
return [head]
def _recent_actions(recap_text: str) -> List[str]:
"""Derive a RECENT ACTIONS block from a recap/summary string, if present."""
recap_text = (recap_text or "").strip()
if not recap_text:
return []
return ["RECENT ACTIONS (recap-derived):", recap_text]
_ELLIPSIS = "\n…\n"
def _cap(text: str, tail: str, max_chars: int) -> str:
"""Cap the prompt to ``max_chars`` while preserving the ``NEXT:`` tail.
The closing ``NEXT:`` guidance is the most actionable line for a resuming
context, so a naive head-truncation would silently drop it. Instead we keep
the head (preamble/goal/progress) and the ``tail`` verbatim, and trim from
the middle recap block. Only if the tail alone already exceeds the budget do
we fall back to a plain truncation.
"""
if max_chars <= 0 or len(text) <= max_chars:
return text
tail = tail.strip()
# If even the tail cannot fit, fall back to a plain head-truncation.
if not tail or len(tail) + len(_ELLIPSIS) >= max_chars:
return text[: max_chars - 1].rstrip() + "…"
# Preserve the tail verbatim; spend the remaining budget on the head, then
# bridge the trimmed middle with an ellipsis marker.
head_budget = max_chars - len(tail) - len(_ELLIPSIS)
head = text[:head_budget].rstrip()
return f"{head}{_ELLIPSIS}{tail}"
def build_handoff_prompt(
*,
recap: str = "",
goal_state: Optional[Dict[str, Any]] = None,
workflow_checkpoint: Optional[Dict[str, Any]] = None,
recent_actions: Optional[List[str]] = None,
key_files: Optional[List[str]] = None,
max_chars: int = HANDOFF_MAX_CHARS,
) -> str:
"""Assemble a self-contained continuation prompt from durable state.
Deterministic: makes **no LLM call**. Sections whose source has no data are
omitted. The result is capped to ``max_chars``.
Args:
recap: A persisted compaction summary / recap string (may be empty).
goal_state: A persisted ``GoalState.to_dict()`` mapping, if any.
workflow_checkpoint: A workflow checkpoint dict, if any.
recent_actions: Explicit recent-action lines (e.g. journal-derived).
When omitted, a recap-derived block is used instead.
key_files: File paths to surface under KEY FILES.
max_chars: Upper bound on the rendered prompt. ``0`` disables the cap.
Returns:
The continuation prompt string.
"""
sections: List[str] = [_PREAMBLE, ""]
goal_lines = _goal_sections(goal_state)
if goal_lines:
sections.extend(goal_lines)
progress_lines = _progress_section(workflow_checkpoint)
if progress_lines:
sections.extend(progress_lines)
if recent_actions:
sections.append("RECENT ACTIONS:")
sections.extend(f" - {line}" for line in recent_actions)
else:
sections.extend(_recent_actions(recap))
if key_files:
seen: List[str] = []
for path in key_files:
if path and path not in seen:
seen.append(path)
if seen:
sections.append("KEY FILES: " + ", ".join(seen))
next_tail = (
"NEXT: continue toward the remaining work. First verify the current "
"state on disk before acting."
)
sections.append(next_tail)
return _cap("\n".join(sections).strip(), next_tail, max_chars)
@@ -281,20 +281,9 @@ class Task:
self.on_error = on_error
else:
self.on_error = on_error
# Workflow-engine routing surfaces (read by WorkflowManager executor).
# Default to None so the executor's getattr() reads have real attributes.
self.branch_condition = None
self.next_steps = None
# Handle routing parameter - use routing if provided, else fall back to condition
if routing is not None:
# TaskRoutingConfig consolidates branch/next_steps for markdown
# workflows. Unpack it onto the attributes the executor reads
# instead of clobbering self.condition (which must stay a dict/str).
if hasattr(routing, 'branches') or hasattr(routing, 'next_steps'):
self.branch_condition = getattr(routing, 'branches', None)
self.next_steps = getattr(routing, 'next_steps', None)
else:
self.condition = routing
self.condition = routing
self.routing = self.condition # Alias for backward compat
self.output_config = output_config
# ============================================================
@@ -206,7 +206,6 @@ def enable_pii_redaction() -> str:
event=HookEvent.BEFORE_LLM,
func=_pii_before_llm_hook,
name="praisonaiagents.pii_redactor",
sequential=True,
)
return _PII_HOOK_ID
@@ -632,17 +632,10 @@ class AgentFlow:
# REMOTE EXECUTION
# ============================================================
# Union[str, ComputeProviderProtocol] - where each step's shell/file tools
# run: "docker", "e2b", "modal", "daytona", "flyio", "tenki", "local",
# "subprocess", "sandlock", "ssh", "novita".
# Every step shares ONE sandbox and one working directory, so a file written
# by one step is visible to later steps. Orchestration -- routing, parallel,
# repeat -- stays local, and so does the thinking. Pass a configured
# provider instance instead of a name to customise resources.
tools_run_on: Optional[Union['ToolPlace', str, Any]] = None
# Accepted only to fail with a useful message: on an Agent, run_on= moves
# the whole agent to a managed runtime, so honouring it here as a synonym
# for tools_run_on= would make one word mean two things.
# run: "docker", "e2b", "modal", "daytona", "flyio", "tenki", "local".
# Every step shares ONE sandbox and /workspace, so a file written by one
# step is visible to later steps. Orchestration stays local. Pass a
# configured provider instance instead of a name to customise resources.
run_on: Optional[Any] = None
# ============================================================
@@ -699,14 +692,6 @@ class AgentFlow:
def __post_init__(self):
"""Resolve consolidated params to internal values."""
from ..agent.placement import resolve_placement
resolve_placement(
"AgentFlow",
run_on=self.run_on,
tools_run_on=self.tools_run_on,
supports_run_on=False,
)
from .workflow_configs import (
resolve_output_config, resolve_planning_config,
resolve_memory_config, resolve_hooks_config,
@@ -1130,7 +1115,7 @@ class AgentFlow:
"separate Workflow instance per concurrent run."
)
try:
if self.tools_run_on is None:
if self.run_on is None:
return self._run_impl(input, llm, verbose, stream)
# One sandbox for the whole run; torn down even if a step raises.
with self._shared_compute() as shared:
@@ -1167,10 +1152,10 @@ class AgentFlow:
return explain(self, shared=True)
def _shared_compute(self):
"""Build the SharedCompute for this run (see ``tools_run_on=``)."""
"""Build the SharedCompute for this run (see the ``run_on=`` field)."""
from ..managed.shared_compute import SharedCompute
return SharedCompute(self.tools_run_on)
return SharedCompute(self.run_on)
def _collect_agents(self) -> List[Any]:
"""Walk steps (including nested route/parallel/loop/repeat/if) for Agents.
@@ -3488,9 +3473,7 @@ class WorkflowManager:
from ..paths import DEFAULT_DIR_NAME as _DIR_NAME
WORKFLOWS_DIR = f"{_DIR_NAME}/workflows"
# Only markdown is parsed by this manager; YAML workflows are handled by
# YAMLWorkflowParser. A discovered YAML file fails loudly in _load_workflow.
SUPPORTED_EXTENSIONS = [".md"]
SUPPORTED_EXTENSIONS = [".md", ".yaml", ".yml"]
def __init__(
self,
@@ -3802,17 +3785,6 @@ class WorkflowManager:
def _load_workflow(self, file_path: Path) -> Optional[Workflow]:
"""Load a workflow from a file."""
# The markdown parser below cannot read YAML workflows; loading a
# ``.yaml``/``.yml`` here would silently produce zero steps. Fail loudly
# so the mismatch is visible instead of running an empty workflow.
if file_path.suffix.lower() in (".yaml", ".yml"):
logger.error(
"Workflow file '%s' is YAML, but WorkflowManager only parses "
"markdown workflows. Use YAMLWorkflowParser / 'praisonai workflow "
"run' for YAML workflows.",
file_path,
)
return None
try:
content = file_path.read_text(encoding="utf-8")
frontmatter, body = self._parse_frontmatter(content)
@@ -4121,8 +4093,7 @@ class WorkflowManager:
stream: bool = False,
verbose: int = 0,
checkpoint: Optional[str] = None,
resume: Optional[str] = None,
rebase_checkpoint: bool = False
resume: Optional[str] = None
) -> Dict[str, Any]:
"""
Execute a workflow with context passing between steps.
@@ -4141,9 +4112,6 @@ class WorkflowManager:
verbose: Verbosity level
checkpoint: Save checkpoint after each step with this name
resume: Resume from checkpoint with this name
rebase_checkpoint: Force resume at the same step index even when the
workflow definition changed since the checkpoint (skips the
fingerprint check with a warning)
Returns:
Execution results with step outputs and status
@@ -4154,7 +4122,6 @@ class WorkflowManager:
default_llm=default_llm,
planning=planning,
resume=resume,
rebase_checkpoint=rebase_checkpoint,
)
if "error" in loop:
return loop["error"]
@@ -4198,19 +4165,12 @@ class WorkflowManager:
default_llm: Optional[str],
planning: bool,
resume: Optional[str],
rebase_checkpoint: bool = False,
) -> Dict[str, Any]:
"""Resolve the workflow and build the initial loop state.
Shared by the sync ``execute`` and async ``aexecute`` drivers so the
setup (framework validation, variable merge, checkpoint resume) lives
in a single place. Returns ``{"error": <result dict>}`` on failure.
On resume the stored ``definition_fingerprint`` is compared against the
current workflow. A mismatch means the file was edited between save and
resume, so the numeric step index no longer points at the same step;
this raises unless ``rebase_checkpoint`` is set to deliberately continue
at the same index against the edited definition.
"""
workflow = self.get_workflow(workflow_name)
if not workflow:
@@ -4240,61 +4200,14 @@ class WorkflowManager:
results: List[Dict[str, Any]] = []
start_step = 0
resumed_from_step = None
fingerprint = self._definition_fingerprint(workflow)
# Resume from checkpoint if specified
if resume:
checkpoint_data = self._load_checkpoint(resume)
if not checkpoint_data:
# Fail closed: an explicit resume onto an absent/misspelled/
# deleted checkpoint must not silently re-run from step 1 and
# repeat already-completed side effects.
return {
"error": {
"success": False,
"error": (
f"checkpoint '{resume}' not found; refusing to resume. "
"Run `workflow checkpoints` to list saved checkpoints, "
"or re-run without --resume to start fresh."
),
"results": [],
}
}
if checkpoint_data:
saved_fingerprint = checkpoint_data.get("definition_fingerprint")
if (
saved_fingerprint
and saved_fingerprint != fingerprint
and not rebase_checkpoint
):
return {
"error": {
"success": False,
"error": (
"workflow definition changed since the checkpoint "
f"(fingerprint mismatch: checkpoint={saved_fingerprint}, "
f"current={fingerprint}). Re-run without --resume to start "
"fresh, or pass --rebase-checkpoint to deliberately continue "
"at the same step index against the edited definition."
),
"results": [],
}
}
if (
saved_fingerprint
and saved_fingerprint != fingerprint
and rebase_checkpoint
):
self._log(
"Warning: workflow definition changed since the checkpoint "
f"(checkpoint={saved_fingerprint}, current={fingerprint}); "
"continuing at the same step index because --rebase-checkpoint was set."
)
results = checkpoint_data.get("results", [])
all_variables = checkpoint_data.get("variables", all_variables)
start_step = checkpoint_data.get("completed_steps", 0)
resumed_from_step = start_step
self._log(f"Resuming workflow from step {start_step + 1}")
return {
@@ -4306,23 +4219,17 @@ class WorkflowManager:
"all_variables": all_variables,
"success": True,
"start_step": start_step,
"resumed_from_step": resumed_from_step,
"definition_fingerprint": fingerprint,
},
}
def _workflow_loop_result(self, workflow: Workflow, state: Dict[str, Any]) -> Dict[str, Any]:
"""Assemble the return dict shared by ``execute`` and ``aexecute``."""
result = {
return {
"success": state["success"],
"workflow": workflow.name,
"results": state["results"],
"variables": state["all_variables"],
}
# Surface resume provenance so a resumed run is visible after the fact.
if state.get("resumed_from_step") is not None:
result["resumed_from_step"] = state["resumed_from_step"]
return result
def _run_workflow_loop(
self,
@@ -4358,9 +4265,8 @@ class WorkflowManager:
step = workflow.steps[current_step_idx]
i = current_step_idx
# Check condition (only string conditions are evaluable here; a
# dict condition is a routing table handled by branch logic below)
if step.condition and isinstance(step.condition, str):
# Check condition
if step.condition:
condition = self._substitute_variables(step.condition, all_variables)
# Simple condition evaluation (could be enhanced)
if condition.lower() in ("false", "no", "skip", "0"):
@@ -4423,8 +4329,7 @@ class WorkflowManager:
workflow_name=workflow_name,
completed_steps=i + 1,
results=results,
variables=all_variables,
definition_fingerprint=state.get("definition_fingerprint")
variables=all_variables
)
current_step_idx += 1
@@ -4466,8 +4371,7 @@ class WorkflowManager:
workflow_name=workflow_name,
completed_steps=i + 1,
results=results,
variables=all_variables,
definition_fingerprint=state.get("definition_fingerprint")
variables=all_variables
)
# Handle failure
@@ -4484,17 +4388,6 @@ class WorkflowManager:
# Use getattr with defaults for workflow-specific attributes that may not exist on Task
branch_condition = getattr(step, 'branch_condition', None)
next_steps = getattr(step, 'next_steps', None)
# Unified condition syntax: Task(when=..., then_task=..., else_task=...)
# redirects execution by step name based on the evaluated condition.
if getattr(step, 'when', None) is not None:
when_context = dict(all_variables)
when_context['previous_output'] = step_result.get("output")
target = step.get_next_task(when_context)
if target and target in step_lookup:
current_step_idx, _ = step_lookup[target]
continue
if branch_condition and step_result["output"]:
# Evaluate branch condition based on output
output_lower = str(step_result["output"]).lower()
@@ -4677,8 +4570,7 @@ class WorkflowManager:
stream: bool = False,
verbose: int = 0,
checkpoint: Optional[str] = None,
resume: Optional[str] = None,
rebase_checkpoint: bool = False
resume: Optional[str] = None
) -> Dict[str, Any]:
"""
Async version of execute() for workflow execution.
@@ -4697,9 +4589,6 @@ class WorkflowManager:
verbose: Verbosity level
checkpoint: Save checkpoint after each step with this name
resume: Resume from checkpoint with this name
rebase_checkpoint: Force resume at the same step index even when the
workflow definition changed since the checkpoint (skips the
fingerprint check with a warning)
Returns:
Execution results with step outputs and status
@@ -4710,7 +4599,6 @@ class WorkflowManager:
default_llm=default_llm,
planning=planning,
resume=resume,
rebase_checkpoint=rebase_checkpoint,
)
if "error" in loop:
return loop["error"]
@@ -5058,40 +4946,6 @@ class WorkflowManager:
checkpoints_dir = self.workspace_path / DEFAULT_DIR_NAME / "checkpoints"
checkpoints_dir.mkdir(parents=True, exist_ok=True)
return checkpoints_dir
def _definition_fingerprint(self, workflow: Workflow) -> str:
"""Stable content hash of a workflow's step definition.
Normalised like ``managed/protocols.py`` hashes environments: it hashes
the ordered list of each step's identity (name / action / agent / full
config), not the raw file bytes. Whitespace/comment-only edits therefore
keep the same fingerprint, while adding, removing, reordering, or
changing a step's action or agent configuration produces a new one. Used
to refuse a silent resume onto a stale step index after the workflow file
was edited.
Config *values* (not just keys) are hashed so that semantic edits — e.g.
changing an agent's instructions, model, tools, condition, or routing —
also invalidate the checkpoint rather than silently combining a stale
step position with an edited definition.
"""
import hashlib
import json
def _step_identity(step: Any) -> Dict[str, Any]:
agent = getattr(step, "agent", None)
agent_name = getattr(agent, "name", None) if agent is not None else None
config = getattr(step, "agent_config", None) or {}
return {
"name": getattr(step, "name", None),
"action": getattr(step, "action", None),
"agent": agent_name,
"config": config,
}
payload = [_step_identity(s) for s in (workflow.steps or [])]
blob = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)
return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:12]
def _save_checkpoint(
self,
@@ -5099,8 +4953,7 @@ class WorkflowManager:
workflow_name: str,
completed_steps: int,
results: List[Dict[str, Any]],
variables: Dict[str, Any],
definition_fingerprint: Optional[str] = None
variables: Dict[str, Any]
) -> str:
"""
Save workflow checkpoint for later resumption.
@@ -5111,8 +4964,6 @@ class WorkflowManager:
completed_steps: Number of completed steps
results: Results from completed steps
variables: Current variable state
definition_fingerprint: Stable hash of the workflow definition,
compared on resume to refuse a stale step index after edits
Returns:
Path to checkpoint file
@@ -5128,7 +4979,6 @@ class WorkflowManager:
"completed_steps": completed_steps,
"results": results,
"variables": variables,
"definition_fingerprint": definition_fingerprint,
"saved_at": time.time(),
"saved_at_iso": datetime.now().isoformat()
}
@@ -5180,7 +5030,6 @@ class WorkflowManager:
"name": data.get("name", checkpoint_file.stem),
"workflow": data.get("workflow_name", ""),
"completed_steps": data.get("completed_steps", 0),
"definition_fingerprint": data.get("definition_fingerprint"),
"saved_at": data.get("saved_at_iso", "")
})
except Exception:
@@ -348,30 +348,23 @@ class YAMLWorkflowParser:
# Parse history flag for execution tracing (robustness feature)
history_enabled = data.get('history', False)
# Remote execution: `tools_run_on: docker` gives every step's shell and
# file tools one shared sandbox. Validated here so a typo fails at load
# time with the list of valid names, rather than at first tool call.
#
# `run_on:` is accepted silently as the old spelling. Unlike the Python
# kwarg -- which now raises, because on an Agent run_on= means something
# different -- a YAML file is data someone already wrote and shipped,
# and there is no Agent-level run_on: key in YAML for it to be confused
# with. Breaking those files would buy nothing.
tools_run_on = data.get('tools_run_on', data.get('run_on'))
if tools_run_on is not None:
if not isinstance(tools_run_on, str):
# Remote execution: `run_on: docker` gives every step's shell/file tools
# one shared sandbox. Validated here so a typo fails at load time with
# the list of valid names, rather than at first tool call.
run_on = data.get('run_on')
if run_on is not None:
if not isinstance(run_on, str):
raise ValueError(
f"'tools_run_on' must be a provider name string, "
f"got {type(tools_run_on).__name__}"
f"'run_on' must be a provider name string, got {type(run_on).__name__}"
)
from ..managed._compute_bridge import available_providers
if tools_run_on.lower().strip() not in available_providers():
if run_on.lower().strip() not in available_providers():
raise ValueError(
f"Unknown tools_run_on place {tools_run_on!r}. "
f"Unknown run_on provider {run_on!r}. "
f"Available: {', '.join(available_providers())}"
)
tools_run_on = tools_run_on.lower().strip()
run_on = run_on.lower().strip()
workflow = Workflow(
name=name,
@@ -385,7 +378,7 @@ class YAMLWorkflowParser:
memory=memory_value, # Pass memory config to Workflow
context=context_value, # Pass context management config to Workflow
history=history_enabled, # Enable execution history tracking (robustness)
tools_run_on=tools_run_on, # One shared sandbox for every step (None = local)
run_on=run_on, # Shared remote sandbox for every step (None = local)
)
# Store additional attributes for feature parity with agents.yaml
-13
View File
@@ -21,18 +21,6 @@ dependencies = [
]
[project.optional-dependencies]
# Isolated code execution backends (docker, sandlock, ssh, novita, e2b, modal,
# daytona) used by Agent(tools_run_on=...) and execute_code(run_in=...).
#
# praisonai-sandbox depends on praisonaiagents, so this extra closes a cycle.
# That is deliberate and safe here: it is an *optional* edge, so nothing
# installing plain praisonaiagents ever walks it, and pip resolves the pair
# without pinning either to the other's version. Do not promote it to a
# hard dependency, which would make the cycle mandatory.
sandbox = [
"praisonai-sandbox>=0.0.13",
]
mcp = [
"mcp>=1.20.0",
"fastapi>=0.115.0",
@@ -127,7 +115,6 @@ a2ui = [
# Declarative agent UI (Google A2UI SDK — lazy loaded)
all = [
"praisonaiagents[memory]",
"praisonaiagents[sandbox]",
"praisonaiagents[knowledge]",
"praisonaiagents[graph]",
"praisonaiagents[llm]",
@@ -640,26 +640,11 @@ class TestComputeProviderWiring:
assert agent.compute_provider.provider_name == "flyio"
def test_resolve_unknown_raises(self):
"""An unknown place still raises -- and now names the valid ones.
This class used to carry its own hardcoded provider list, which is why
LocalAgent(tools_run_on="sandlock") failed for a place the rest of the
library resolves. It delegates to the merged resolver now, so the
message changed from "Unknown compute provider" to "Unknown place",
listing every place both registries offer.
"""
import pytest
from praisonai.integrations.managed_local import LocalManagedAgent
with pytest.raises(ValueError, match="Unknown place"):
with pytest.raises(ValueError, match="Unknown compute provider"):
LocalManagedAgent(provider="local", compute="unknown_provider")
def test_resolve_accepts_sandbox_only_places(self):
"""The whole point of the merged registry: one vocabulary everywhere."""
from praisonai.integrations.managed_local import LocalManagedAgent
for place in ("sandlock", "subprocess", "novita"):
agent = LocalManagedAgent(provider="local", tools_run_on=place)
assert agent.compute_provider is not None, place
def test_resolve_instance(self):
from praisonai.integrations.managed_local import LocalManagedAgent
from praisonai.integrations.compute.local import LocalCompute
@@ -33,7 +33,7 @@ def _build_workflow(monkeypatch):
wf = Workflow(
name="temp-agent-run-on",
steps=[Task(name="s1", action="write a file to /workspace")],
tools_run_on="docker",
run_on="docker",
)
monkeypatch.setattr(wf, "_shared_compute", lambda: shared)
return wf, shared
@@ -241,5 +241,5 @@ def test_flow_without_run_on_does_not_provision():
from praisonaiagents import AgentFlow
flow = AgentFlow(steps=[FakeAgent("A")])
assert flow.tools_run_on is None
assert flow.run_on is None
assert flow._collect_agents()[0].tools == []
@@ -25,22 +25,22 @@ def _parse(extra="", name="wf"):
def test_run_on_is_parsed():
assert _parse("run_on: docker\n").tools_run_on == "docker"
assert _parse("run_on: docker\n").run_on == "docker"
def test_run_on_defaults_to_none():
"""Omitting run_on must leave the workflow running locally, as before."""
assert _parse().tools_run_on is None
assert _parse().run_on is None
def test_run_on_is_case_and_space_insensitive():
assert _parse("run_on: DOCKER\n").tools_run_on == "docker"
assert _parse('run_on: " docker "\n').tools_run_on == "docker"
assert _parse("run_on: DOCKER\n").run_on == "docker"
assert _parse('run_on: " docker "\n').run_on == "docker"
@pytest.mark.parametrize("provider", ["docker", "e2b", "modal", "daytona", "flyio", "local"])
def test_all_known_providers_accepted(provider):
assert _parse(f"run_on: {provider}\n").tools_run_on == provider
assert _parse(f"run_on: {provider}\n").run_on == provider
def test_unknown_provider_fails_at_load_time():
@@ -59,5 +59,5 @@ def test_non_string_run_on_rejected():
def test_run_on_reaches_the_workflow_object():
"""The parsed value must land on the field AgentFlow.run() actually reads."""
wf = _parse("run_on: docker\n")
assert "tools_run_on" in getattr(type(wf), "__dataclass_fields__", {})
assert wf.tools_run_on == "docker"
assert "run_on" in getattr(type(wf), "__dataclass_fields__", {})
assert wf.run_on == "docker"
@@ -1,303 +0,0 @@
"""
Tests for workflow checkpoint resume + definition fingerprinting.
Covers the correctness hazard where resuming against an edited workflow file
silently lands on a stale step index: a stable definition fingerprint is stored
in the checkpoint and compared on resume.
"""
import os
import sys
import tempfile
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from praisonaiagents.workflows import WorkflowManager
WORKFLOW_3_STEPS = """---
name: demo
description: Three step demo
---
## Step 1: One
First step.
```action
Do step one
```
## Step 2: Two
Second step.
```action
Do step two
```
## Step 3: Three
Third step.
```action
Do step three
```
"""
# Same three steps, only whitespace/comment differences -> same fingerprint.
WORKFLOW_3_STEPS_WHITESPACE = """---
name: demo
description: Three step demo (reworded description, extra blank lines)
---
## Step 1: One
First step.
```action
Do step one
```
## Step 2: Two
Second step.
```action
Do step two
```
## Step 3: Three
Third step.
```action
Do step three
```
"""
# A structurally different workflow (a step added) -> different fingerprint.
WORKFLOW_4_STEPS = WORKFLOW_3_STEPS + """
## Step 4: Four
Fourth step.
```action
Do step four
```
"""
def _write_workflow(workspace: str, body: str) -> None:
wf_dir = os.path.join(workspace, ".praisonai", "workflows")
os.makedirs(wf_dir, exist_ok=True)
with open(os.path.join(wf_dir, "demo.md"), "w") as f:
f.write(body)
def _make_executor(marker_dir: str, crash_on: str = None):
"""Executor that records which steps ran and can simulate a crash.
A crash raises ``KeyboardInterrupt`` (a ``BaseException``, not an
``Exception``) so it escapes the per-step error handling exactly like a real
interrupt/container recycle would — the failing step's checkpoint is never
written, so a subsequent resume re-runs that step from the beginning.
"""
def executor(prompt: str) -> str:
# The prompt contains the action text; map back to a step marker.
for step_no, needle in (("1", "step one"), ("2", "step two"),
("3", "step three"), ("4", "step four")):
if needle in prompt.lower():
if crash_on == step_no:
raise KeyboardInterrupt(f"crash on step {step_no}")
open(os.path.join(marker_dir, f"step{step_no}"), "w").close()
return f"ran step {step_no}"
return "ran"
return executor
def _ran(marker_dir: str, step_no: str) -> bool:
return os.path.exists(os.path.join(marker_dir, f"step{step_no}"))
class TestResumeSkipsCompletedSteps:
def test_resume_runs_only_remaining_steps(self):
with tempfile.TemporaryDirectory() as workspace:
_write_workflow(workspace, WORKFLOW_3_STEPS)
marker_dir = os.path.join(workspace, "markers")
os.makedirs(marker_dir)
manager = WorkflowManager(workspace_path=workspace)
# First run: crash on step 3 (steps 1-2 complete + checkpointed).
with pytest.raises(KeyboardInterrupt):
manager.execute(
"demo",
executor=_make_executor(marker_dir, crash_on="3"),
checkpoint="demo",
)
assert _ran(marker_dir, "1")
assert _ran(marker_dir, "2")
assert not _ran(marker_dir, "3")
# Fresh markers so re-execution of 1-2 would be detectable.
for f in os.listdir(marker_dir):
os.remove(os.path.join(marker_dir, f))
# Resume: only step 3 should execute.
result = manager.execute(
"demo",
executor=_make_executor(marker_dir),
checkpoint="demo",
resume="demo",
)
assert result["success"] is True
assert not _ran(marker_dir, "1")
assert not _ran(marker_dir, "2")
assert _ran(marker_dir, "3")
assert result.get("resumed_from_step") == 2
class TestDefinitionFingerprint:
def test_whitespace_edit_does_not_change_fingerprint(self):
with tempfile.TemporaryDirectory() as ws1, tempfile.TemporaryDirectory() as ws2:
_write_workflow(ws1, WORKFLOW_3_STEPS)
_write_workflow(ws2, WORKFLOW_3_STEPS_WHITESPACE)
m1 = WorkflowManager(workspace_path=ws1)
m2 = WorkflowManager(workspace_path=ws2)
fp1 = m1._definition_fingerprint(m1.get_workflow("demo"))
fp2 = m2._definition_fingerprint(m2.get_workflow("demo"))
assert fp1 == fp2
def test_structural_edit_changes_fingerprint(self):
with tempfile.TemporaryDirectory() as ws1, tempfile.TemporaryDirectory() as ws2:
_write_workflow(ws1, WORKFLOW_3_STEPS)
_write_workflow(ws2, WORKFLOW_4_STEPS)
m1 = WorkflowManager(workspace_path=ws1)
m2 = WorkflowManager(workspace_path=ws2)
fp1 = m1._definition_fingerprint(m1.get_workflow("demo"))
fp2 = m2._definition_fingerprint(m2.get_workflow("demo"))
assert fp1 != fp2
def test_resume_after_edit_refuses_with_mismatch(self):
with tempfile.TemporaryDirectory() as workspace:
_write_workflow(workspace, WORKFLOW_3_STEPS)
marker_dir = os.path.join(workspace, "markers")
os.makedirs(marker_dir)
manager = WorkflowManager(workspace_path=workspace)
with pytest.raises(KeyboardInterrupt):
manager.execute(
"demo",
executor=_make_executor(marker_dir, crash_on="3"),
checkpoint="demo",
)
# Edit the workflow (add a step) then reload.
_write_workflow(workspace, WORKFLOW_4_STEPS)
manager.reload()
result = manager.execute(
"demo",
executor=_make_executor(marker_dir),
checkpoint="demo",
resume="demo",
)
assert result["success"] is False
assert "fingerprint mismatch" in result["error"]
assert "--rebase-checkpoint" in result["error"]
def test_rebase_checkpoint_forces_resume_after_edit(self):
with tempfile.TemporaryDirectory() as workspace:
_write_workflow(workspace, WORKFLOW_3_STEPS)
marker_dir = os.path.join(workspace, "markers")
os.makedirs(marker_dir)
manager = WorkflowManager(workspace_path=workspace)
with pytest.raises(KeyboardInterrupt):
manager.execute(
"demo",
executor=_make_executor(marker_dir, crash_on="3"),
checkpoint="demo",
)
_write_workflow(workspace, WORKFLOW_4_STEPS)
manager.reload()
for f in os.listdir(marker_dir):
os.remove(os.path.join(marker_dir, f))
result = manager.execute(
"demo",
executor=_make_executor(marker_dir),
checkpoint="demo",
resume="demo",
rebase_checkpoint=True,
)
assert result["success"] is True
# Continues at the same index (step 3) against the edited definition.
assert not _ran(marker_dir, "1")
assert not _ran(marker_dir, "2")
assert _ran(marker_dir, "3")
class TestResumeFailsClosedOnMissingCheckpoint:
def test_resume_missing_checkpoint_does_not_run(self):
with tempfile.TemporaryDirectory() as workspace:
_write_workflow(workspace, WORKFLOW_3_STEPS)
marker_dir = os.path.join(workspace, "markers")
os.makedirs(marker_dir)
manager = WorkflowManager(workspace_path=workspace)
# No checkpoint was ever saved; an explicit resume must fail closed
# rather than silently re-running every step from the beginning.
result = manager.execute(
"demo",
executor=_make_executor(marker_dir),
resume="does-not-exist",
)
assert result["success"] is False
assert "not found" in result["error"]
assert not _ran(marker_dir, "1")
assert not _ran(marker_dir, "2")
assert not _ran(marker_dir, "3")
class TestConfigValueEditChangesFingerprint:
def test_agent_config_value_change_changes_fingerprint(self):
with tempfile.TemporaryDirectory() as workspace:
_write_workflow(workspace, WORKFLOW_3_STEPS)
manager = WorkflowManager(workspace_path=workspace)
workflow = manager.get_workflow("demo")
fp_before = manager._definition_fingerprint(workflow)
# Same config keys, different value: a semantic edit that must
# invalidate the checkpoint (keys-only hashing would miss this).
workflow.steps[0].agent_config = {"instructions": "old"}
fp_key = manager._definition_fingerprint(workflow)
workflow.steps[0].agent_config = {"instructions": "new"}
fp_value = manager._definition_fingerprint(workflow)
assert fp_before != fp_key
assert fp_key != fp_value
class TestCheckpointListingAndDelete:
def test_list_and_delete_checkpoints(self):
with tempfile.TemporaryDirectory() as workspace:
_write_workflow(workspace, WORKFLOW_3_STEPS)
marker_dir = os.path.join(workspace, "markers")
os.makedirs(marker_dir)
manager = WorkflowManager(workspace_path=workspace)
manager.execute(
"demo",
executor=_make_executor(marker_dir),
checkpoint="demo",
)
checkpoints = manager.list_checkpoints()
assert any(cp["name"] == "demo" for cp in checkpoints)
cp = next(cp for cp in checkpoints if cp["name"] == "demo")
assert cp["definition_fingerprint"]
assert manager.delete_checkpoint("demo") is True
assert not any(c["name"] == "demo" for c in manager.list_checkpoints())
@@ -1,137 +0,0 @@
"""
Tests for workflow step routing surfaces (issue #4052).
Covers:
- Markdown workflow branch routing actually taking a branch (matched + unmatched).
- Task(routing=TaskRoutingConfig(...)) mapping onto executor-read attributes
without clobbering ``condition``.
- Task(when=..., then_task=..., else_task=...) redirecting execution in the
WorkflowManager step loop.
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from praisonaiagents import Workflow, Task
from praisonaiagents.workflows import WorkflowManager
from praisonaiagents.workflows.workflow_configs import TaskRoutingConfig
def _run(manager, workflow, outputs):
"""Execute a registered workflow with a scripted per-step executor.
``outputs`` maps step name -> output string the stub executor returns,
recording the visited order so tests can assert non-linear execution.
"""
manager._workflows[workflow.name] = workflow
manager._loaded = True
visited = []
def executor(prompt, step=None): # noqa: ARG001
# The manager calls the executor per step; capture order via on_step.
return ""
order = []
def on_step(step, idx):
order.append(step.name)
# Map outputs by step name using a closure over the running index.
def scripted_executor(prompt):
# Determine current step from the last on_step callback.
name = order[-1]
return outputs.get(name, "")
result = manager.execute(
workflow.name,
executor=scripted_executor,
on_step=on_step,
)
return order, result
class TestRoutingConfigOnTask:
def test_routing_config_maps_to_branch_attributes(self):
t = Task(
name="decision",
description="d",
routing=TaskRoutingConfig(
branches={"success": ["step2"]},
next_steps=["step3"],
),
)
assert t.branch_condition == {"success": ["step2"]}
assert t.next_steps == ["step3"]
# condition must stay a dict/str, never a dataclass instance.
assert isinstance(t.condition, (dict, str))
def test_plain_task_has_routing_attributes(self):
t = Task(name="s", description="d")
assert t.branch_condition is None
assert t.next_steps is None
class TestMarkdownBranchRouting:
def _branching_workflow(self):
# step1 branches to 'success_step' when output contains 'success'.
step1 = Task(
name="step1",
description="classify",
routing=TaskRoutingConfig(branches={"success": ["success_step"]}),
)
fail_step = Task(name="fail_step", description="handle failure")
success_step = Task(name="success_step", description="handle success")
return Workflow(
name="branchflow",
description="branch test",
steps=[step1, fail_step, success_step],
)
def test_matched_branch_is_taken(self):
manager = WorkflowManager()
wf = self._branching_workflow()
order, result = _run(manager, wf, {"step1": "success"})
# Non-linear: step1 -> success_step, skipping fail_step.
assert order == ["step1", "success_step"]
assert "fail_step" not in order
def test_unmatched_branch_falls_through_linearly(self):
manager = WorkflowManager()
wf = self._branching_workflow()
order, result = _run(manager, wf, {"step1": "nope"})
# No branch matched -> linear order.
assert order == ["step1", "fail_step", "success_step"]
class TestWhenRouting:
def test_when_true_routes_to_then_task(self):
manager = WorkflowManager()
step1 = Task(
name="gate",
description="gate",
when="{{previous_output}} contains yes",
then_task="then_step",
else_task="else_step",
)
else_step = Task(name="else_step", description="else")
then_step = Task(name="then_step", description="then")
wf = Workflow(name="whenflow", description="", steps=[step1, else_step, then_step])
order, _ = _run(manager, wf, {"gate": "yes please"})
assert order == ["gate", "then_step"]
def test_when_false_routes_to_else_task(self):
manager = WorkflowManager()
step1 = Task(
name="gate",
description="gate",
when="{{previous_output}} contains yes",
then_task="then_step",
else_task="else_step",
)
else_step = Task(name="else_step", description="else")
then_step = Task(name="then_step", description="then")
wf = Workflow(name="whenflow2", description="", steps=[step1, else_step, then_step])
order, _ = _run(manager, wf, {"gate": "no thanks"})
assert order == ["gate", "else_step", "then_step"]
@@ -104,7 +104,6 @@ async def test_achat_completion_resolves_basetool_without_dunder_name():
assert not hasattr(browser_tool, "__name__")
tool_call = SimpleNamespace(
id="legacy-tool-call-001",
function=SimpleNamespace(name="browserbase", arguments="{}")
)
response = SimpleNamespace(
@@ -118,7 +117,6 @@ async def test_achat_completion_resolves_basetool_without_dunder_name():
mock_exec.assert_awaited_once()
assert mock_exec.await_args[0][0] == "browserbase"
assert mock_exec.await_args.kwargs["tool_call_id"] == "legacy-tool-call-001"
assert results is not None
@@ -1,108 +0,0 @@
"""Tests for the default structured summary builder.
Un-stubs the documented ``structured_template=True`` compaction feature: the
builder must render ``SUMMARY_TEMPLATE``'s section headers, preserve file paths
verbatim, and never raise (falling back to a plain summary on failure).
"""
from praisonaiagents.compaction import (
ContextCompactor,
CompactionConfig,
DefaultSummaryBuilder,
)
_HEADERS = [
"## Active Task",
"## Completed Actions",
"## In Progress",
"## Pending Questions",
"## Relevant Files / Paths",
"## Remaining Work",
]
def _conversation():
return [
{"role": "system", "content": "You are a helpful agent."},
{"role": "user", "content": "Refactor src/app/main.py to add logging."},
{
"role": "assistant",
"content": "",
"tool_calls": [
{"function": {"name": "read_file", "arguments": "{}"}}
],
},
{"role": "tool", "content": "contents of src/app/main.py"},
{"role": "assistant", "content": "Should I also update tests/test_main.py?"},
{"role": "user", "content": "Yes, update tests/test_main.py too."},
]
def test_output_contains_all_template_headers():
builder = DefaultSummaryBuilder()
out = builder.build_structured_summary(_conversation())
for header in _HEADERS:
assert header in out
def test_preserves_file_paths_verbatim():
builder = DefaultSummaryBuilder()
out = builder.build_structured_summary(_conversation())
assert "src/app/main.py" in out
assert "tests/test_main.py" in out
def test_captures_active_task_and_tool_call():
builder = DefaultSummaryBuilder()
out = builder.build_structured_summary(_conversation())
assert "Refactor src/app/main.py" in out
assert "read_file" in out
def test_assistant_question_is_pending_not_completed():
# A proposing/asking assistant turn is unexecuted work: it belongs under
# "Pending Questions", never "Completed Actions" (else a resumed agent would
# skip work that was only discussed).
builder = DefaultSummaryBuilder()
out = builder.build_structured_summary(_conversation())
completed = out.split("## Completed Actions", 1)[1].split("##", 1)[0]
pending = out.split("## Pending Questions", 1)[1].split("##", 1)[0]
assert "Should I also update tests/test_main.py?" in pending
assert "Should I also update tests/test_main.py?" not in completed
# Executed work (the tool call) is still recorded as completed.
assert "read_file" in completed
def test_never_raises_falls_back_to_plain_summary():
builder = DefaultSummaryBuilder()
# Malformed messages (None content, non-dict entries) must not raise.
bad = [None, {"role": "user", "content": None}, 42] # type: ignore
out = builder.build_structured_summary(bad) # type: ignore[arg-type]
assert isinstance(out, str) and out
def test_merge_summaries_keeps_both():
builder = DefaultSummaryBuilder()
merged = builder.merge_summaries("PREV", "CUR")
assert "PREV" in merged and "CUR" in merged
assert builder.merge_summaries("", "CUR") == "CUR"
assert builder.merge_summaries("PREV", "") == "PREV"
def test_compactor_defaults_builder_when_structured_template():
compactor = ContextCompactor(config=CompactionConfig(structured_template=True))
assert compactor.summary_builder is not None
out = compactor._build_structured_summary(_conversation())
assert "## Active Task" in out
# No longer the one-line stub.
assert not out.startswith("Summary of ")
def test_compactor_no_builder_when_disabled():
compactor = ContextCompactor(
config=CompactionConfig(structured_template=False)
)
assert compactor.summary_builder is None
out = compactor._build_structured_summary(_conversation())
assert out.startswith("Summary of ")
@@ -1,68 +0,0 @@
"""Tests for tool-call identity exposed through middleware context."""
from __future__ import annotations
import pytest
from praisonaiagents import Agent
from praisonaiagents.hooks import wrap_tool_call
def test_sync_tool_middleware_receives_original_tool_call_id():
observed_ids: list[str | None] = []
handler_calls: list[str] = []
@wrap_tool_call
def capture_identity(request, call_next):
observed_ids.append(request.context.metadata.get("tool_call_id"))
return call_next(request)
def inert_tool(value: str) -> str:
handler_calls.append(value)
return "completed"
agent = Agent(
name="sync-middleware-identity",
instructions="Exercise one inert test tool.",
tools=[inert_tool],
hooks=[capture_identity],
approval=True,
)
result = agent.execute_tool(
"inert_tool", {"value": "synthetic-value"}, "sync-tool-call-001"
)
assert result == "completed"
assert handler_calls == ["synthetic-value"]
assert observed_ids == ["sync-tool-call-001"]
@pytest.mark.asyncio
async def test_async_tool_middleware_receives_original_tool_call_id():
observed_ids: list[str | None] = []
handler_calls: list[str] = []
@wrap_tool_call
def capture_identity(request, call_next):
observed_ids.append(request.context.metadata.get("tool_call_id"))
return call_next(request)
async def inert_tool(value: str) -> str:
handler_calls.append(value)
return "completed"
agent = Agent(
name="async-middleware-identity",
instructions="Exercise one inert test tool.",
tools=[inert_tool],
hooks=[capture_identity],
approval=True,
)
result = await agent.execute_tool_async(
"inert_tool", {"value": "synthetic-value"}, "async-tool-call-001"
)
assert result == "completed"
assert handler_calls == ["synthetic-value"]
assert observed_ids == ["async-tool-call-001"]
@@ -1,351 +0,0 @@
"""
Unit tests for executable completion gates (issue #4053).
Covers:
- FileCheckHook matrix (exists / non_empty / json_field equals / contains).
- CommandVerificationHook string-command parsing.
- The verification gate blocks completion until a blocking hook passes; a
never-passing check ends at the budget cap (max_iterations), never success.
- The gate releases once a hook flips to passing (temp-file flag).
- add_verification mid-run is enforced on the same run.
- The goal loop short-circuits to 'continue' without a judge call when a
blocking hook fails, and injects executable evidence into the judge prompt.
"""
import json
import os
from unittest.mock import patch
from praisonaiagents.hooks.verification import (
CommandVerificationHook,
FileCheckHook,
)
from praisonaiagents.goal.judge import _build_goal_judge_prompt
from praisonaiagents.goal.models import GoalState
def _make_agent(verification_hooks=None):
from praisonaiagents import Agent
return Agent(
instructions="Test agent",
autonomy=True,
output="silent",
verification_hooks=verification_hooks,
)
# =============================================================================
# FileCheckHook matrix
# =============================================================================
class TestFileCheckHook:
def test_exists_pass(self, tmp_path):
p = tmp_path / "f.txt"
p.write_text("x")
r = FileCheckHook(name="f", path=str(p)).run()
assert r.success is True
def test_exists_fail(self, tmp_path):
r = FileCheckHook(name="f", path=str(tmp_path / "missing.txt")).run()
assert r.success is False
assert "not found" in r.output.lower()
def test_absent_pass(self, tmp_path):
r = FileCheckHook(name="f", path=str(tmp_path / "missing.txt"),
exists=False).run()
assert r.success is True
def test_non_empty_fail(self, tmp_path):
p = tmp_path / "empty.txt"
p.write_text("")
r = FileCheckHook(name="f", path=str(p), non_empty=True).run()
assert r.success is False
assert "empty" in r.output.lower()
def test_non_empty_pass(self, tmp_path):
p = tmp_path / "full.txt"
p.write_text("data")
r = FileCheckHook(name="f", path=str(p), non_empty=True).run()
assert r.success is True
def test_contains_pass_and_fail(self, tmp_path):
p = tmp_path / "c.txt"
p.write_text("hello world")
assert FileCheckHook(name="c", path=str(p), contains="world").run().success
r = FileCheckHook(name="c", path=str(p), contains="absent").run()
assert r.success is False
def test_json_field_equals(self, tmp_path):
p = tmp_path / "d.json"
p.write_text(json.dumps({"status": {"code": 0}}))
ok = FileCheckHook(name="j", path=str(p),
json_field="status.code", equals=0).run()
assert ok.success is True
bad = FileCheckHook(name="j", path=str(p),
json_field="status.code", equals=1).run()
assert bad.success is False
assert "expected" in bad.output.lower()
def test_json_invalid(self, tmp_path):
p = tmp_path / "bad.json"
p.write_text("not json")
r = FileCheckHook(name="j", path=str(p),
json_field="a", equals=1).run()
assert r.success is False
assert "json" in r.output.lower()
class TestCommandHookParsing:
def test_string_command_is_split(self):
hook = CommandVerificationHook(name="t", command="python -c pass")
assert hook.command == ["python", "-c", "pass"]
def test_list_command_preserved(self):
hook = CommandVerificationHook(name="t", command=["echo", "hi"])
assert hook.command == ["echo", "hi"]
# =============================================================================
# _run_verification_hooks preserves the full payload
# =============================================================================
class TestRunnerFidelity:
def test_details_survive_rewrap(self, tmp_path):
p = tmp_path / "x.txt"
p.write_text("hi")
agent = _make_agent([FileCheckHook(name="f", path=str(p))])
results = agent._run_verification_hooks()
assert len(results) == 1
rec = results[0]
assert rec["hook"] == "f"
assert rec["success"] is True
assert "details" in rec
assert "duration_seconds" in rec
assert rec["blocking"] is True
# =============================================================================
# Gate blocks / releases completion in the autonomous loop
# =============================================================================
class TestGateBlocksCompletion:
def test_failing_hook_blocks_success_until_budget(self, tmp_path):
# A file that never exists → blocking hook never passes.
hook = FileCheckHook(name="need", path=str(tmp_path / "never.txt"))
agent = _make_agent([hook])
counter = {"n": 0}
def fake_chat(prompt):
counter["n"] += 1
return f"used a tool and did work step {counter['n']} " + "x" * 200
with patch.object(agent, "chat", side_effect=fake_chat):
result = agent.run_autonomous("do it", max_iterations=3)
assert result.success is False
assert result.completion_reason == "max_iterations"
# last feedback prompt carries the failing hook's name
assert any(
r.get("hook") == "need" and not r.get("success")
for r in getattr(agent, "_last_verification_results", [])
)
def test_gate_releases_when_hook_flips(self, tmp_path):
flag = tmp_path / "done.flag"
hook = FileCheckHook(name="flag", path=str(flag))
agent = _make_agent([hook])
counter = {"n": 0}
def fake_chat(prompt):
counter["n"] += 1
# a tool was "used" this turn so the tool_completion signal fires
agent._autonomy_turn_tool_count = 1
# create the flag on the 2nd turn so the gate releases afterwards
if counter["n"] == 2:
flag.write_text("ok")
return f"progress {counter['n']} " + "y" * 200
with patch.object(agent, "chat", side_effect=fake_chat), \
patch.object(agent, "get_recommended_stage", return_value="autonomous"):
result = agent.run_autonomous("do it", max_iterations=10)
assert result.success is True
# released only after the flag appeared (2+ iterations)
assert result.iterations >= 2
def test_add_verification_midrun_is_enforced(self, tmp_path):
agent = _make_agent() # no hooks initially
missing = tmp_path / "missing.txt"
counter = {"n": 0}
def fake_chat(prompt):
counter["n"] += 1
if counter["n"] == 1:
# discover a completion condition mid-run
agent.add_verification(FileCheckHook(name="late", path=str(missing)))
return f"work {counter['n']} " + "z" * 200
with patch.object(agent, "chat", side_effect=fake_chat):
result = agent.run_autonomous("do it", max_iterations=3)
# Now blocked by the late-added hook → cannot finish successfully.
assert result.success is False
assert result.completion_reason == "max_iterations"
def test_no_hooks_unchanged(self):
agent = _make_agent()
with patch.object(agent, "chat",
side_effect=lambda p: "used tool " + "q" * 200):
result = agent.run_autonomous("do it", max_iterations=5)
# Behaviour is unchanged for agents without verification hooks: the
# existing heuristics still terminate successfully (not blocked).
assert result.success is True
assert result.completion_reason != "max_iterations"
# =============================================================================
# Goal loop: executable evidence + short-circuit
# =============================================================================
class TestGoalLoopEvidence:
def test_failing_blocking_hook_short_circuits_without_judge(self, tmp_path):
hook = FileCheckHook(name="need", path=str(tmp_path / "never.txt"))
agent = _make_agent([hook])
judge_calls = {"n": 0}
def fake_judge(*a, **k):
judge_calls["n"] += 1
return ("done", "met")
counter = {"n": 0}
with patch.object(agent, "chat",
side_effect=lambda p: f"step {counter.__setitem__('n', counter['n'] + 1) or counter['n']}"), \
patch("praisonaiagents.goal.loop.judge_goal", side_effect=fake_judge):
result = agent.run_goal("task", goal="g", max_turns=2)
# Blocking hook fails every turn → judge never consulted, ends paused.
assert judge_calls["n"] == 0
assert result.completion_reason == "budget_paused"
def test_passing_hook_evidence_reaches_judge(self, tmp_path):
p = tmp_path / "ok.txt"
p.write_text("data")
hook = FileCheckHook(name="ok", path=str(p))
agent = _make_agent([hook])
seen = {}
def fake_judge(state, tail, **k):
seen["block"] = k.get("verification_block")
return ("done", "met")
with patch.object(agent, "chat", side_effect=lambda pr: "did it"), \
patch("praisonaiagents.goal.loop.judge_goal", side_effect=fake_judge):
result = agent.run_goal("task", goal="g", max_turns=5)
assert result.completion_reason == "goal_met"
assert seen.get("block") is not None
assert "ok" in seen["block"]
def test_judge_prompt_includes_verification_block(self):
state = GoalState(goal="G")
prompt = _build_goal_judge_prompt(
state, "output", verification_block="[PASS] tests (exit=0)"
)
assert "[PASS] tests (exit=0)" in prompt
def test_goal_failure_carries_diagnostics_into_continuation(self, tmp_path):
# A failing blocking command hook must surface its captured stderr in
# the next continuation prompt (not just a bare label).
hook = CommandVerificationHook(
name="fails",
command="python -c \"import sys; sys.stderr.write('boom-detail'); sys.exit(1)\"",
)
agent = _make_agent([hook])
prompts = []
def fake_chat(pr):
prompts.append(pr)
return "trying"
with patch.object(agent, "chat", side_effect=fake_chat), \
patch("praisonaiagents.goal.loop.judge_goal",
side_effect=lambda *a, **k: ("done", "met")):
result = agent.run_goal("task", goal="g", max_turns=2)
assert result.completion_reason == "budget_paused"
# The continuation prompt after the first failing turn embeds the
# hook's captured stderr so the agent can repair the failure.
assert any("boom-detail" in p for p in prompts)
# =============================================================================
# Async gate: hook execution must not block the event loop
# =============================================================================
class TestAsyncGate:
def test_async_gate_offloads_to_thread(self, tmp_path):
import asyncio
import threading
loop_thread = {"id": None}
hook_thread = {"id": None}
class _ThreadProbeHook:
name = "probe"
blocking = True
def run(self, context=None):
from praisonaiagents.hooks.verification import VerificationResult
hook_thread["id"] = threading.get_ident()
return VerificationResult(success=True, output="ok")
agent = _make_agent([_ThreadProbeHook()])
async def _drive():
loop_thread["id"] = threading.get_ident()
return await agent._verification_gate_async("resp", 1)
feedback = asyncio.run(_drive())
# Passing hook → gate releases (None) and ran off the loop thread.
assert feedback is None
assert hook_thread["id"] is not None
assert hook_thread["id"] != loop_thread["id"]
# =============================================================================
# Durable journal: verification results are persisted as journal events
# =============================================================================
class TestDurableJournal:
def test_gate_records_verification_events(self, tmp_path):
from praisonaiagents.runtime.journal import RunJournal
from praisonaiagents.agent.durable import DurableRunContext
journal = RunJournal(":memory:")
journal.open_run("r1", agent="a", task="t")
ctx = DurableRunContext(journal, "r1", replaying=False)
p = tmp_path / "ok.txt"
p.write_text("data")
agent = _make_agent([FileCheckHook(name="ok", path=str(p))])
with patch.object(agent, "_get_durable_run_context", return_value=ctx):
feedback = agent._verification_gate("resp", 1)
assert feedback is None
events = [e for e in journal.events("r1") if e.kind == "verification"]
assert len(events) == 1
assert events[0].payload["name"] == "ok"
assert events[0].payload["success"] is True
def test_no_durable_run_is_noop(self, tmp_path):
p = tmp_path / "ok.txt"
p.write_text("data")
agent = _make_agent([FileCheckHook(name="ok", path=str(p))])
# No durable context attached → journal write is a silent no-op.
assert agent._verification_gate("resp", 1) is None
@@ -1,211 +0,0 @@
"""
Unit tests for zero-config MCP OAuth discovery, registration, and provider.
These cover the pure/parsing logic and the storage-driven provider paths that
do not require live network access or a browser.
"""
import pytest
class TestParseWWWAuthenticate:
def test_none_returns_empty(self):
from praisonaiagents.mcp.oauth_discovery import parse_www_authenticate
assert parse_www_authenticate(None) == {}
def test_parses_resource_metadata(self):
from praisonaiagents.mcp.oauth_discovery import parse_www_authenticate
header = 'Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource", error="invalid_token"'
params = parse_www_authenticate(header)
assert params["resource_metadata"] == "https://api.example.com/.well-known/oauth-protected-resource"
assert params["error"] == "invalid_token"
def test_non_bearer_still_parses_params(self):
from praisonaiagents.mcp.oauth_discovery import parse_www_authenticate
params = parse_www_authenticate('scope="read write"')
assert params["scope"] == "read write"
class TestHttpsGuard:
def test_rejects_plain_http_remote(self):
from praisonaiagents.mcp.oauth_discovery import _require_https
with pytest.raises(ValueError):
_require_https("http://evil.example.com/.well-known/x")
def test_allows_https(self):
from praisonaiagents.mcp.oauth_discovery import _require_https
_require_https("https://api.example.com/.well-known/x")
def test_allows_loopback_http(self):
from praisonaiagents.mcp.oauth_discovery import _require_https
_require_https("http://127.0.0.1:8080/.well-known/x")
class TestWellKnownCandidates:
def test_builds_origin_candidates(self):
from praisonaiagents.mcp.oauth_discovery import _wellknown_candidates
candidates = _wellknown_candidates("https://api.example.com/mcp/v1")
assert "https://api.example.com/.well-known/oauth-protected-resource" in candidates
assert "https://api.example.com/.well-known/oauth-authorization-server" in candidates
assert "https://api.example.com/.well-known/openid-configuration" in candidates
class TestEndpointHttpsValidation:
def test_rejects_plaintext_advertised_endpoints(self, monkeypatch):
from praisonaiagents.mcp import oauth_discovery
monkeypatch.setattr(
oauth_discovery, "discover_protected_resource",
lambda *a, **k: None,
)
monkeypatch.setattr(
oauth_discovery, "discover_authorization_server",
lambda *a, **k: {
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "http://auth.example.com/token",
},
)
with pytest.raises(ValueError):
oauth_discovery.discover_oauth_metadata("https://api.example.com/mcp")
def test_accepts_all_https_endpoints(self, monkeypatch):
from praisonaiagents.mcp import oauth_discovery
monkeypatch.setattr(
oauth_discovery, "discover_protected_resource",
lambda *a, **k: None,
)
monkeypatch.setattr(
oauth_discovery, "discover_authorization_server",
lambda *a, **k: {
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "https://auth.example.com/token",
"registration_endpoint": "https://auth.example.com/register",
},
)
meta = oauth_discovery.discover_oauth_metadata("https://api.example.com/mcp")
assert meta["token_endpoint"] == "https://auth.example.com/token"
class TestRedirectRevalidation:
def test_rejects_redirect_to_plaintext(self, monkeypatch):
from praisonaiagents.mcp import oauth_discovery
class _Hop:
url = "http://internal.local/.well-known/x"
class _Resp:
url = "http://internal.local/.well-known/x"
history = [_Hop()]
def raise_for_status(self):
pass
def json(self):
return {}
class _Requests:
@staticmethod
def get(*a, **k):
return _Resp()
monkeypatch.setitem(
__import__("sys").modules, "requests", _Requests()
)
with pytest.raises(ValueError):
oauth_discovery._get_json("https://api.example.com/.well-known/x")
class TestRegistrationGuard:
def test_rejects_insecure_registration(self):
from praisonaiagents.mcp.oauth_registration import register_client
with pytest.raises(ValueError):
register_client(
"http://evil.example.com/register",
redirect_uris=["http://127.0.0.1:19876/mcp/oauth/callback"],
)
class TestProviderTokenReuse:
@pytest.fixture
def storage(self, tmp_path):
from praisonaiagents.mcp.mcp_auth_storage import MCPAuthStorage
return MCPAuthStorage(filepath=str(tmp_path / "mcp-auth.json"))
def test_returns_valid_stored_token(self, storage):
import time
from praisonaiagents.mcp.oauth_provider import MCPOAuthProvider
url = "https://api.example.com/mcp"
storage.set_tokens(
"example",
{"access_token": "tok123", "expires_at": time.time() + 3600},
server_url=url,
)
provider = MCPOAuthProvider("example", url, storage=storage)
assert provider.get_valid_token() == "tok123"
assert provider.ensure_authenticated() == "tok123"
def test_expired_token_without_refresh_returns_none(self, storage):
import time
from praisonaiagents.mcp.oauth_provider import MCPOAuthProvider
url = "https://api.example.com/mcp"
storage.set_tokens(
"example",
{"access_token": "old", "expires_at": time.time() - 10},
server_url=url,
)
provider = MCPOAuthProvider("example", url, storage=storage)
assert provider.get_valid_token() is None
def test_headless_raises_interactive_required(self, storage, monkeypatch):
from praisonaiagents.mcp import oauth_provider
from praisonaiagents.mcp.oauth_provider import (
MCPOAuthProvider,
InteractiveAuthRequired,
)
url = "https://api.example.com/mcp"
def fake_discover(server_url, www_authenticate=None, timeout=10.0):
return {
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "https://auth.example.com/token",
"registration_endpoint": "https://auth.example.com/register",
"scopes_supported": ["read"],
"resource": None,
}
def fake_register(endpoint, redirect_uris, scope=None, timeout=10.0):
return {"client_id": "dyn-client-123"}
monkeypatch.setattr(
oauth_provider, "discover_oauth_metadata", fake_discover, raising=False
)
monkeypatch.setattr(
"praisonaiagents.mcp.oauth_discovery.discover_oauth_metadata",
fake_discover,
)
monkeypatch.setattr(
"praisonaiagents.mcp.oauth_registration.register_client",
fake_register,
)
provider = MCPOAuthProvider(
"example", url, storage=storage, open_browser=False
)
with pytest.raises(InteractiveAuthRequired):
provider.ensure_authenticated()
entry = storage.get_for_url("example", url)
assert entry["client_info"]["client_id"] == "dyn-client-123"
class TestLazyExports:
def test_exports_available(self):
import praisonaiagents.mcp as mcp
assert mcp.discover_oauth_metadata is not None
assert mcp.parse_www_authenticate is not None
assert mcp.register_client is not None
assert mcp.MCPOAuthProvider is not None
assert mcp.InteractiveAuthRequired is not None
@@ -218,46 +218,3 @@ class TestSandboxManager:
await manager._create_sandbox()
mock_resolve.assert_called_once_with("sandlock")
class TestConstructorIntrospection:
"""SandboxManager passed config= to every backend, including the two that
do not accept it -- so ssh and modal raised "unexpected keyword argument
'config'" before the sandbox was ever built."""
@pytest.mark.asyncio
async def test_a_backend_that_takes_no_config_is_not_handed_one(self):
import inspect
from praisonaiagents.sandbox._sandbox_bridge import resolve_sandbox_class
for name in ("ssh", "modal"):
params = inspect.signature(resolve_sandbox_class(name).__init__).parameters
assert "config" not in params, (
f"{name} grew a config= parameter; this guard can be simplified"
)
@pytest.mark.asyncio
async def test_a_missing_required_argument_says_what_to_build(self):
"""SSH needs a host, which a SandboxConfig cannot carry. Say that,
rather than raising TypeError from deep inside the constructor."""
from praisonaiagents.sandbox import SandboxConfig, SandboxManager
with pytest.raises(TypeError) as exc:
await SandboxManager(SandboxConfig(sandbox_type="ssh"))._create_sandbox()
message = str(exc.value)
assert "host" in message
assert "SSHSandbox" in message
assert "unexpected keyword" not in message, "the raw TypeError leaked through"
@pytest.mark.asyncio
async def test_a_backend_that_takes_config_still_receives_it(self):
from praisonaiagents.sandbox import SandboxConfig, SandboxManager
config = SandboxConfig(sandbox_type="subprocess")
sandbox = await SandboxManager(config)._create_sandbox()
try:
assert sandbox.config is config
finally:
await sandbox.stop()
@@ -134,14 +134,9 @@ def test_inert_fields_are_documented_as_inert():
from praisonaiagents.config import feature_configs
assert "NOT IMPLEMENTED" in (autonomy.AutonomyConfig.__doc__ or "")
# code_sandbox_mode is gone rather than labelled. It defaulted to
# "sandbox" while no execution path read it, so its own default told
# every reader they had isolation they did not have. An inert field can
# be documented; a field whose default misstates a security property
# cannot. Real isolation is execute_code(run_in=...) or tools_run_on=.
src = inspect.getsource(feature_configs.ExecutionConfig)
assert "code_sandbox_mode" not in src
idx = src.find("code_sandbox_mode")
assert "NOT IMPLEMENTED" in src[max(0, idx - 400):idx]
# ── real agentic run: sandbox= adds no model-visible execution tools ─────────
@@ -1,92 +0,0 @@
"""Tests for the cross-context continuation-prompt assembler (session handoff).
The handoff builder converts already-persisted durable state (recap + goal state
+ workflow checkpoint) into a self-contained continuation prompt. It must be
deterministic (no LLM call), omit empty sections, and stay within its char cap.
"""
from praisonaiagents.session import build_handoff_prompt
def _goal_state():
return {
"goal": "Ship the logging refactor",
"criteria": {
"outcome": "All modules use structured logging",
"verification": "pytest passes and logs are JSON",
"constraints": ["do not change public API"],
},
"last_reason": "tests still failing",
}
def _checkpoint():
return {"completed_steps": 3, "total_steps": 5, "last_step": "wire logger"}
def test_goal_and_progress_sections_render():
out = build_handoff_prompt(
recap="Recap — where we were: ...",
goal_state=_goal_state(),
workflow_checkpoint=_checkpoint(),
)
assert "GOAL: Ship the logging refactor" in out
assert "DEFINITION OF DONE:" in out
assert "CONSTRAINTS (never violate): do not change public API" in out
assert "PROGRESS: 3 of 5 workflow steps done" in out
assert "last completed: wire logger" in out
assert out.startswith("You are resuming an interrupted run")
assert "NEXT:" in out
def test_recap_only_when_no_goal_or_checkpoint():
out = build_handoff_prompt(recap="Recap block here")
assert "GOAL:" not in out
assert "PROGRESS:" not in out
assert "Recap block here" in out
assert "NEXT:" in out
def test_empty_state_still_valid_prompt():
out = build_handoff_prompt()
assert out.startswith("You are resuming an interrupted run")
assert "NEXT:" in out
assert "GOAL:" not in out
def test_explicit_recent_actions_and_key_files():
out = build_handoff_prompt(
goal_state=_goal_state(),
recent_actions=["read_file main.py (t0)", "run tests (t1)"],
key_files=["src/main.py", "src/main.py", "tests/test_main.py"],
)
assert "RECENT ACTIONS:" in out
assert "- read_file main.py (t0)" in out
# Key files de-duplicated, order preserved.
assert "KEY FILES: src/main.py, tests/test_main.py" in out
def test_char_cap_enforced():
big = "x" * 10000
out = build_handoff_prompt(recap=big, max_chars=500)
assert len(out) <= 500
def test_char_cap_preserves_next_tail():
# An oversized recap must not truncate away the actionable NEXT: guidance;
# the cap trims the middle recap block and keeps the tail verbatim.
big = "recap " * 5000
out = build_handoff_prompt(
recap=big, goal_state=_goal_state(), max_chars=800
)
assert len(out) <= 800
assert out.startswith("You are resuming an interrupted run")
assert "GOAL: Ship the logging refactor" in out
assert "NEXT: continue toward the remaining work" in out
assert out.rstrip().endswith("before acting.")
def test_determinism_no_llm_and_stable():
# Two identical calls produce identical output (pure assembly, no LLM).
args = dict(recap="same", goal_state=_goal_state())
assert build_handoff_prompt(**args) == build_handoff_prompt(**args)
@@ -158,17 +158,10 @@ def test_execution_config_round_trip_preserves_retry_and_strategy():
assert restored.compaction_strategy == ConfigCompactionStrategy.SUMMARIZE
def test_execution_config_from_dict_ignores_the_removed_sandbox_mode():
"""A config persisted before code_sandbox_mode was removed must still load.
The field is gone because it read as a security control while no execution
path consulted it. Dropping the key silently is right here: it never did
anything, so ignoring it changes no behaviour -- but a stored dict
containing it must not fail to load.
"""
restored = ExecutionConfig.from_dict({"code_sandbox_mode": "direct"})
assert not hasattr(restored, "code_sandbox_mode")
assert "code_sandbox_mode" not in restored.to_dict()
def test_execution_config_from_dict_defaults_sandbox_mode():
"""Omitted code_sandbox_mode should fall back to the dataclass default 'sandbox'."""
restored = ExecutionConfig.from_dict({})
assert restored.code_sandbox_mode == "sandbox"
def test_mutable_singleton_fix():
@@ -26,7 +26,7 @@ def test_agent_repr_states_where_it_runs():
def test_flow_repr_states_shared_sandbox():
flow = AgentFlow(tools_run_on="docker", steps=[_agent("W"), _agent("R")])
flow = AgentFlow(run_on="docker", steps=[_agent("W"), _agent("R")])
text = repr(flow)
assert "steps=2" in text
assert "Docker container" in text
@@ -35,7 +35,7 @@ def test_flow_repr_states_shared_sandbox():
def test_team_repr_states_shared_sandbox():
a = _agent("W")
team = AgentTeam(agents=[a], tasks=[Task(description="d", agent=a)], tools_run_on="e2b")
team = AgentTeam(agents=[a], tasks=[Task(description="d", agent=a)], run_on="e2b")
assert "E2B" in repr(team)
assert "shared" in repr(team)
@@ -59,7 +59,7 @@ def test_explain_is_plain_for_a_local_agent():
def test_explain_mentions_shared_state_for_a_workflow():
flow = AgentFlow(tools_run_on="docker", steps=[_agent()])
flow = AgentFlow(run_on="docker", steps=[_agent()])
text = flow.where_does_it_run()
assert "shares that same sandbox" in text
assert "visible to the next" in text
@@ -75,7 +75,7 @@ def test_explain_warns_that_a_subprocess_is_not_a_boundary():
def test_explain_does_not_warn_for_a_real_boundary():
flow = AgentFlow(tools_run_on="docker", steps=[_agent()])
flow = AgentFlow(run_on="docker", steps=[_agent()])
assert "not a security boundary" not in flow.where_does_it_run()
@@ -114,32 +114,15 @@ def test_sandbox_aliases_report_the_real_backend(alias, expected):
# ── vocabulary gate: never reuse a word that is public API elsewhere ─────────
#: Words the repr uses that are ALSO parameters, on purpose: you set the place
#: with the same word the repr reports it with. That mirroring is the feature.
_DELIBERATE_MIRRORS = {"tools_run_on", "run_on"}
def test_no_field_name_collides_with_existing_public_api():
"""`loop` was rejected as a field name because it is already
praisonaiagents.workflows.loop(), asyncio's event loop, and agent-loop
jargon -- one word, three meanings. This test stops that class of collision.
It deliberately exempts the placement words. `tools_run_on` appearing both
as a kwarg and as a repr field is the opposite failure mode: one word, one
meaning, in both directions --
>>> Agent(name='w', instructions='x', tools_run_on='docker')
Agent(name='w', thinks_on='this machine', tools_run_on='a Docker container')
A user reads the repr and knows exactly what to type. Renaming one side to
satisfy a blanket no-overlap rule would break that, so the rule is narrowed
to what it was actually protecting: words that mean something *else*.
"""
jargon. This test stops anyone reintroducing that class of collision."""
import praisonaiagents.workflows as wf
agent_kwargs = set(inspect.signature(Agent.__init__).parameters)
workflow_api = {n for n in dir(wf) if not n.startswith("_")}
taken = (agent_kwargs | workflow_api) - _DELIBERATE_MIRRORS
taken = agent_kwargs | workflow_api
for field in describe(_agent()):
assert field not in taken, (
@@ -148,19 +131,11 @@ def test_no_field_name_collides_with_existing_public_api():
)
def test_mirrored_words_mean_the_same_thing_on_both_sides():
"""Guards the exemption above: a mirrored word must describe what the
parameter of that name actually does, or the exemption is being abused."""
agent = Agent(name="w", instructions="x", tools_run_on="docker")
assert describe(agent)["tools_run_on"] == say_place("docker")
assert "Docker" in repr(agent)
def test_explanation_avoids_jargon():
"""The sentences are for someone who does not know the vocabulary."""
text = " ".join([
_agent().where_does_it_run(),
AgentFlow(tools_run_on="docker", steps=[_agent()]).where_does_it_run(),
AgentFlow(run_on="docker", steps=[_agent()]).where_does_it_run(),
]).lower()
for jargon in ("harness", "provision", "orchestration", "topology", "runtime"):
assert jargon not in text, f"{jargon!r} is jargon; say it plainly"
@@ -170,7 +145,7 @@ def test_explanation_avoids_jargon():
def test_repl_answers_the_question_without_docs():
"""One line per topology, each stating what runs where."""
local = repr(_agent())
remote = repr(AgentFlow(tools_run_on="docker", steps=[_agent()]))
remote = repr(AgentFlow(run_on="docker", steps=[_agent()]))
assert local != remote
for text in (local, remote):
assert "thinks_on=" in text and "tools_run_on=" in text
@@ -1,656 +0,0 @@
"""Where things run, and what happens when you ask for two places at once.
The vocabulary under test:
run_on= the WHOLE agent moves to a managed runtime
tools_run_on= only the tools move; thinking stays here
run_in= one execute_code() call, this call only
The rule these tests exist to protect: **one word means one thing**. A place
that cannot do the job asked of it is a typo, not a preference, so it raises
with the parameter name the user actually wanted.
"""
import asyncio
import gc
import itertools
import pytest
from praisonaiagents import Agent, AgentFlow, AgentTeam, Task
from praisonaiagents.agent.execution_location import describe
from praisonaiagents.agent.placement import (
managed_runtimes,
resolve_placement,
tool_places,
)
def _agent(name="A", **kw):
return Agent(name=name, instructions="x", **kw)
# ── the two registries stay disjoint in meaning ──────────────────────────────
def test_a_name_in_both_sets_is_valid_for_both_parameters():
"""The two sets may overlap, and where they do, both spellings must work.
`docker` can host a whole agent (a container running the loop) AND run
tools for a local loop. That is not one word with two meanings -- it is one
place with two scopes, and the scope is carried by the parameter name:
run_on="docker" the whole agent runs in a container
tools_run_on="docker" only the tools do; thinking stays here
What must stay true is that a name valid for only ONE of them is refused by
the other, with a message naming the right parameter.
"""
for name in set(managed_runtimes()) & set(tool_places()):
assert _agent(run_on=name) is not None, f"run_on={name!r} must work"
assert _agent(tools_run_on=name) is not None, f"tools_run_on={name!r} must work"
def test_the_two_scopes_of_one_place_are_reported_differently():
"""Overlap is only safe if the object still says which scope you chose."""
whole = describe(_agent(run_on="docker"))
tools_only = describe(_agent(tools_run_on="docker"))
assert whole["thinks_on"] == whole["tools_run_on"], "run_on moves the thinking too"
assert tools_only["thinks_on"] != tools_only["tools_run_on"], (
"tools_run_on must leave the thinking here"
)
assert whole != tools_only
def test_the_sandbox_only_backends_are_reachable():
"""sandlock is the only real local boundary and was unreachable before."""
for place in ("sandlock", "subprocess", "ssh", "novita"):
assert place in tool_places(), f"{place} must be selectable"
# ── asking a place to do a job it cannot do ──────────────────────────────────
def test_run_on_a_tools_only_place_names_the_right_parameter():
"""`sandlock` runs commands but cannot host a loop, so run_on= must refuse
it and point at the parameter that does work."""
with pytest.raises(TypeError) as exc:
_agent(run_on="sandlock")
message = str(exc.value)
assert "tools_run_on='sandlock'" in message, "must name the parameter they wanted"
assert "cannot host an agent loop" in message
def test_tools_run_on_a_managed_runtime_names_the_right_parameter():
with pytest.raises(TypeError) as exc:
_agent(tools_run_on="anthropic")
assert "run_on='anthropic'" in str(exc.value)
def test_an_unknown_place_lists_both_registries():
with pytest.raises(TypeError) as exc:
_agent(run_on="dokcer")
message = str(exc.value)
assert "anthropic" in message and "docker" in message
# ── two names for one thing ──────────────────────────────────────────────────
@pytest.mark.parametrize("kwargs", [
{"run_on": "anthropic", "tools_run_on": "docker"},
{"run_on": "anthropic", "backend": object()},
{"backend": object(), "tools_run_on": "docker"},
])
def test_two_places_for_the_same_work_is_a_type_error(kwargs):
"""Never "last one wins" -- that ships tools to a machine nobody chose."""
with pytest.raises(TypeError):
resolve_placement("Agent", **kwargs)
# ── a class that cannot answer the question ──────────────────────────────────
@pytest.mark.parametrize("build", [
lambda: AgentFlow(run_on="docker", steps=[_agent()]),
lambda: AgentTeam(agents=[_agent()], tasks=[], run_on="docker"),
])
def test_run_on_is_refused_on_orchestrators(build):
"""`run_on=` on a Flow/Team used to mean tools-only. It now means the whole
agent, so honouring the old meaning would give one word two meanings across
adjacent classes. Refuse, and say what to type instead."""
with pytest.raises(TypeError) as exc:
build()
assert "tools_run_on='docker'" in str(exc.value)
# ── the happy paths ──────────────────────────────────────────────────────────
def test_tools_run_on_is_recorded_without_provisioning_anything():
"""Nothing is created until a tool is actually called: an agent that never
touches its sandbox must cost nothing."""
agent = _agent(tools_run_on="subprocess")
assert agent.tools_run_on == "subprocess"
assert agent._tools_sandbox is None
def test_a_plain_agent_is_untouched():
agent = _agent()
assert agent.tools_run_on is None
assert agent._tools_sandbox is None
def _team(**kw):
worker = _agent("W")
return AgentTeam(agents=[worker], tasks=[Task(description="d", agent=worker)], **kw)
@pytest.mark.parametrize("build,expected", [
(lambda: AgentFlow(tools_run_on="docker", steps=[_agent()]), "Docker"),
(lambda: _team(tools_run_on="e2b"), "E2B"),
])
def test_orchestrators_take_tools_run_on(build, expected):
obj = build()
assert expected in repr(obj)
assert "shared" in repr(obj), "the shared sandbox is the whole point"
# ── the object says where it runs ────────────────────────────────────────────
def test_repr_uses_the_same_word_you_typed():
agent = _agent(tools_run_on="docker")
assert "tools_run_on='a Docker container'" in repr(agent)
def test_explain_names_the_tools_that_do_not_move():
"""The capability prompt tells the model the sandbox is where things
happen. Any tool staying behind has to be said out loud, or the model is
being lied to about its own reach."""
def check_db(query: str) -> str:
"""Query the production database."""
return "ok"
text = _agent(tools=[check_db], tools_run_on="docker").where_does_it_run()
assert "check_db" in text
assert "still run on this machine" in text
def test_explain_says_nothing_about_local_tools_when_nothing_moves():
def check_db(query: str) -> str:
"""Query the production database."""
return "ok"
assert "still run on" not in _agent(tools=[check_db]).where_does_it_run()
# ── run_in=: the place lives on the call that uses it ────────────────────────
def test_run_in_needs_no_constructor_argument():
result = _agent().execute_code_sync("print(6 * 7)", run_in="subprocess")
assert result.stdout.strip() == "42"
def test_run_in_reuses_one_sandbox_across_calls():
"""A fresh manager per call would restart a container every time and lose
the previous call's files."""
agent = _agent()
agent.execute_code_sync("print(1)", run_in="subprocess")
agent.execute_code_sync("print(2)", run_in="subprocess")
assert list(agent._sandbox_managers) == ["subprocess"]
def test_no_place_at_all_says_how_to_name_one():
with pytest.raises(RuntimeError) as exc:
_agent().execute_code_sync("print(1)")
assert "run_in='docker'" in str(exc.value)
def test_constructor_sandbox_still_sets_the_default():
"""`sandbox=` stays: clone_for_channel() passes it, and it is a legitimate
per-agent default. `run_in=` overrides it per call."""
agent = _agent(sandbox=True)
assert agent.execute_code_sync("print('default')").stdout.strip() == "default"
# ── the shared sandbox contract ──────────────────────────────────────────────
# The point of a shared sandbox is that step 2 can read what step 1 wrote. These
# drive the tools directly rather than through a model, so they assert the
# contract itself and need no API key.
def test_every_agent_in_a_flow_shares_one_sandbox():
from praisonaiagents.managed.shared_compute import SharedCompute
writer, reader = _agent("W"), _agent("R")
with SharedCompute("subprocess") as shared:
shared.attach([writer, reader])
w = {t.__name__: t for t in writer.tools}
r = {t.__name__: t for t in reader.tools}
w["write_file"]("handoff.txt", "written by the first agent")
assert r["read_file"]("handoff.txt").strip() == "written by the first agent"
# ...and it really is one instance, not two that happen to agree.
assert w["execute_command"]("pwd") == r["execute_command"]("pwd")
# teardown restores both agents
assert writer.tools == [] and reader.tools == []
def test_an_agent_with_its_own_sandbox_is_left_alone_by_a_flow():
"""Attaching twice appended the capability prompt twice and, on non-LIFO
teardown, left the agent holding tools bound to a dead instance."""
from praisonaiagents.agent.tools_placement import (
close_tools_sandbox,
ensure_tools_placed,
)
from praisonaiagents.managed.shared_compute import SharedCompute
own = _agent("own", tools_run_on="subprocess")
ensure_tools_placed(own)
inner = own._tools_sandbox
try:
with SharedCompute("subprocess") as flow_sandbox:
flow_sandbox.attach([own])
assert own._tools_sandbox is inner, "the agent's own choice wins"
assert own.backstory.count("REMOTE Linux sandbox") == 1
finally:
close_tools_sandbox(own)
assert own.tools == []
def test_a_batch_provisions_one_sandbox_not_one_per_row():
"""start_for_each() calls start() per input; each used to provision and
destroy its own sandbox, so a 50-row batch paid for 50 of them."""
team = _team(tools_run_on="subprocess")
assert team._needs_tools_scope()
with team._shared_tools_scope():
# inside the batch scope, a nested start() must not open a second one
assert not team._needs_tools_scope()
assert team._needs_tools_scope(), "scope must reopen for the next batch"
# ── run_on= moves the thinking too ───────────────────────────────────────────
def test_a_hosted_agent_says_its_thinking_moved():
"""describe() used to report thinks_on='this machine' for a hosted agent --
the diagnostic lying about the one thing it exists to answer."""
from praisonaiagents.agent.execution_location import describe
pytest.importorskip("praisonai.integrations")
agent = _agent("teacher", run_on="anthropic")
places = describe(agent)
assert places["thinks_on"] == places["tools_run_on"]
assert "Anthropic" in places["thinks_on"]
def test_run_on_builds_the_same_thing_backend_would():
pytest.importorskip("praisonai.integrations")
from praisonai.integrations import HostedAgent
assert isinstance(_agent("t", run_on="anthropic").backend, HostedAgent)
def test_a_flow_does_not_clobber_an_agent_that_named_its_own_place():
"""Ordering matters: a flow attaches at the start of the run, before the
agent's first turn. Checking only for a *live* sandbox would let the flow
attach first and the agent attach again on its first chat() -- two sandboxes
and two copies of the capability prompt on one agent.
"""
from praisonaiagents.agent.tools_placement import (
close_tools_sandbox,
ensure_tools_placed,
)
from praisonaiagents.managed.shared_compute import SharedCompute
own = _agent("own", tools_run_on="subprocess")
plain = _agent("plain")
try:
with SharedCompute("subprocess") as flow:
flow.attach([own, plain])
assert own._tools_sandbox is None, "the flow must skip a declared place"
assert plain._tools_sandbox is flow, "the flow still places plain agents"
ensure_tools_placed(own) # the agent's first turn
assert own._tools_sandbox not in (None, flow) # its own, not the flow's
assert own.backstory.count("REMOTE Linux sandbox") == 1
assert plain.backstory.count("REMOTE Linux sandbox") == 1
assert [t.__name__ for t in own.tools] == [
"execute_command", "read_file", "write_file", "list_files",
]
finally:
close_tools_sandbox(own)
assert own.tools == [] and plain.tools == []
# ── regressions found by adversarial validation ──────────────────────────────
def test_a_placed_agent_inside_a_team_gets_its_sandbox_tools():
"""The sandbox was provisioned and the model never saw it.
AgentTeam snapshots agent.tools and passes it to chat() as an explicit
`tools=`, which wins over agent.tools. Placing after that snapshot meant
every tool ran on the host while a real sandbox sat unused.
"""
from praisonaiagents.agent.agent import Agent as _A
def mytool(x: str) -> str:
"""A local tool."""
return x
seen = []
original = _A.chat
_A.chat = lambda self, prompt, *a, **k: (
seen.append(sorted(getattr(t, "__name__", str(t)) for t in (k.get("tools") or []))),
"done",
)[1]
try:
agent = _agent("TA", tools=[mytool], tools_run_on="subprocess")
team = AgentTeam(
agents=[agent],
tasks=[Task(name="t", description="d", expected_output="o", agent=agent)],
)
team.start()
finally:
_A.chat = original
assert seen, "chat() was never reached"
assert "mytool" in seen[0], "the agent's own tool must survive"
for bridged in ("execute_command", "read_file", "write_file", "list_files"):
assert bridged in seen[0], f"{bridged} never reached the model"
def test_one_teams_run_does_not_suppress_another_teams_sandbox():
"""The scope depth was a single class-level integer, so any team started
while another team's run was on the stack saw depth==1 and silently skipped
its own sandbox."""
outer = _team(tools_run_on="subprocess")
inner = _team(tools_run_on="subprocess")
assert inner._needs_tools_scope()
with outer._shared_tools_scope():
assert not outer._needs_tools_scope(), "outer must not re-enter its own scope"
assert inner._needs_tools_scope(), "a different team still needs its own sandbox"
assert outer._needs_tools_scope()
def test_two_concurrent_runs_of_one_team_each_get_their_own_scope():
"""Two coroutines that run the SAME team under one asyncio.gather share the
event-loop thread. Thread-local scope state made the second see the first's
depth==1 and skip provisioning -- so it ran against a sandbox the first
could tear down mid-flight. A ContextVar is copied per task, so each run's
depth is its own.
"""
team = _team(tools_run_on="subprocess")
provisioned = []
async def one_run(hold):
# Mirror astart()'s guard without a live model: only the scope logic.
if team._needs_tools_scope():
depths = dict(team._tools_scope_depths.get())
depths[id(team)] = depths.get(id(team), 0) + 1
token = team._tools_scope_depths.set(tuple(sorted(depths.items())))
try:
provisioned.append(id(asyncio.current_task()))
await asyncio.sleep(hold)
# Nested check inside our own scope must reuse, never re-provision.
assert not team._needs_tools_scope()
finally:
team._tools_scope_depths.reset(token)
return "own"
await asyncio.sleep(hold)
return "borrowed"
async def drive():
# Stagger so the first run is mid-flight when the second starts.
return await asyncio.gather(one_run(0.05), one_run(0.0))
results = asyncio.run(drive())
assert results == ["own", "own"], results
assert len(set(provisioned)) == 2, "each concurrent run must own its scope"
# The map is clean once both runs finish (no leaked depth for this team).
assert dict(team._tools_scope_depths.get()).get(id(team), 0) == 0
def test_a_dropped_agent_releases_its_sandbox():
"""Gateways clone an agent per request. An agent and its SharedCompute
referenced each other, so a dropped agent was collected as a cycle and its
sandbox was never shut down -- one orphaned container per request."""
import gc
from praisonaiagents.agent.tools_placement import ensure_tools_placed
agent = _agent("throwaway", tools_run_on="subprocess")
ensure_tools_placed(agent)
shared = agent._tools_sandbox
{t.__name__: t for t in agent.tools}["execute_command"]("true") # force provisioning
assert shared.instance_id is not None
del agent
gc.collect()
assert shared.instance_id is None, "the sandbox outlived the agent that owned it"
@pytest.mark.parametrize("bad", ["dokcer", "sandbox", "ec2"])
def test_an_unknown_tools_place_fails_at_construction(bad):
"""It used to construct fine and only fail on the first model turn -- after
the prompt was built and, in a real run, after the API spend."""
with pytest.raises(TypeError) as exc:
_agent(tools_run_on=bad)
assert "known place" in str(exc.value)
def test_ssh_says_it_needs_more_than_a_name():
with pytest.raises(TypeError) as exc:
_agent(tools_run_on="ssh")
assert "SSHSandbox" in str(exc.value)
def test_writing_a_file_that_mentions_a_blocked_pattern_still_works():
"""The policy substring-scanned the heredoc BODY, which is file content --
so saving a tutorial containing "rm -rf" was rejected as running it."""
from praisonaiagents.managed.shared_compute import SharedCompute
with SharedCompute("subprocess") as shared:
tools = {t.__name__: t for t in shared.build_tools()}
for body in ("never run rm -rf / at home", "/etc/passwd lists users", "dd if=/dev/zero"):
assert tools["write_file"]("/tmp/praison_policy_test.txt", body).startswith("Wrote")
# ...and the boundary still holds for actual commands
assert "blocked" in tools["execute_command"]("cat /etc/passwd").lower()
assert "blocked" in tools["execute_command"]("rm -rf /").lower()
def test_the_write_delimiter_cannot_trip_a_blocked_pattern():
"""uuid4().hex is lowercase and contains "dd" in ~9.6% of cases; "dd" is a
blocked command, so one write in ten failed for no visible reason."""
from praisonaiagents.managed.shared_compute import SharedCompute
with SharedCompute("subprocess") as shared:
write = {t.__name__: t for t in shared.build_tools()}["write_file"]
for i in range(40):
assert write("/tmp/praison_delim_test.txt", f"payload {i}").startswith("Wrote")
# ── the paths that actually move work ────────────────────────────────────────
# Mutation testing showed the earlier tests covered argument validation and repr
# strings well, and every path that actually moves work to a sandbox not at all:
# deleting the ensure_tools_placed() call sites, or reverting the AgentTeam
# provisioning fixes, left the whole suite green. These bite instead.
def _counting_provider(monkeypatch):
"""Install a fake place and return the list of provisioned instance ids."""
import praisonaiagents.managed.shared_compute as sc
from praisonaiagents.managed.protocols import InstanceInfo, InstanceStatus
provisioned, shut = [], []
class Counting:
provider_name = "counting"
def __init__(self, ident):
self.ident = ident
async def provision(self, config):
provisioned.append(self.ident)
return InstanceInfo(instance_id=self.ident, status=InstanceStatus.RUNNING,
provider="counting")
async def execute(self, instance_id, command, timeout=None):
return {"stdout": "", "stderr": "", "exit_code": 0}
async def shutdown(self, instance_id):
shut.append(self.ident)
seq = itertools.count(1)
# "subprocess" is a real place, so construction-time validation passes;
# only the resolver underneath is swapped for the counting fake.
monkeypatch.setattr(sc, "resolve_compute", lambda place: Counting(f"inst-{next(seq)}"))
# Run pending finalizers before the counting window opens. Agents from
# earlier tests release their sandbox when collected, and that collection
# can otherwise land mid-measurement and make an exact count flaky.
gc.collect()
provisioned.clear()
shut.clear()
return provisioned, shut
@pytest.mark.parametrize("drive", [
pytest.param(lambda a: a.chat("hi"), id="chat"),
pytest.param(lambda a: asyncio.run(a.achat("hi")), id="achat"),
pytest.param(lambda a: next(iter(a._start_stream("hi")), None), id="_start_stream"),
])
def test_every_entry_point_places_tools_before_the_first_turn(drive):
"""Deleting the ensure_tools_placed() call sites left the suite green.
Whatever happens downstream -- no API key, a stubbed model, an outright
error -- the hook runs at the top, so the sandbox must exist afterwards.
"""
from praisonaiagents.agent.tools_placement import close_tools_sandbox
agent = _agent("driver", tools_run_on="subprocess")
try:
try:
drive(agent)
except Exception:
pass # the turn itself may fail; placement must not
assert agent._tools_sandbox is not None, "this entry point never placed the tools"
assert "execute_command" in [t.__name__ for t in agent.tools]
finally:
close_tools_sandbox(agent)
def test_a_team_run_provisions_exactly_one_sandbox(monkeypatch):
"""Reverting the AgentTeam.start() provisioning changed nothing in the
suite, because the old test only poked the private _needs_tools_scope()."""
provisioned, shut = _counting_provider(monkeypatch)
team = _team(tools_run_on="subprocess")
team.start()
assert len(provisioned) == 1, f"expected one sandbox, got {provisioned}"
assert shut == provisioned, "the sandbox must be torn down with the run"
def test_an_async_team_run_provisions_its_sandbox(monkeypatch):
"""astart() used to provision nothing at all -- every tool ran on the host
and nothing said so."""
provisioned, _ = _counting_provider(monkeypatch)
team = _team(tools_run_on="subprocess")
asyncio.run(team.astart())
assert len(provisioned) == 1, f"astart() provisioned {provisioned}"
def test_a_batch_shares_one_sandbox_across_every_row(monkeypatch):
"""start_for_each() provisioned one per row: a 50-row batch paid for 50,
and no row could see what an earlier row wrote."""
provisioned, _ = _counting_provider(monkeypatch)
team = _team(tools_run_on="subprocess")
team.start_for_each([{"i": 1}, {"i": 2}, {"i": 3}])
assert len(provisioned) == 1, f"expected one sandbox for the batch, got {provisioned}"
def test_a_clone_does_not_inherit_the_source_agents_sandbox():
"""The clone got tools bound to the ORIGINAL's instance and then attached
its own, ending up with two copies of the capability prompt and its work
split across two machines."""
from praisonaiagents.agent.tools_placement import close_tools_sandbox, ensure_tools_placed
source = _agent("source", tools_run_on="subprocess")
ensure_tools_placed(source)
clone = source.clone_for_channel()
try:
assert (clone.backstory or "").count("REMOTE Linux sandbox") == 0
assert not [t for t in (clone.tools or []) if t.__name__ == "execute_command"]
ensure_tools_placed(clone)
assert (clone.backstory or "").count("REMOTE Linux sandbox") == 1
assert clone._tools_sandbox is not source._tools_sandbox
finally:
close_tools_sandbox(source)
close_tools_sandbox(clone)
# ── one word, two backends ───────────────────────────────────────────────────
def test_local_is_described_by_the_backend_it_actually_gets():
""""local" resolves to two different things depending on the parameter:
run_in="local" is collapsed to the subprocess backend (scrubbed environment,
blocked commands), while tools_run_on="local" is the wrapper's LocalCompute
-- a plain shell with the full environment and no policy at all. Describing
the weaker one in the stronger one's words overstates it."""
from praisonaiagents.agent.execution_location import say_place
assert say_place("local") != say_place("local", via="compute")
assert "no policy" in say_place("local", via="compute")
assert say_place("local") == say_place("subprocess"), (
"run_in='local' really is the subprocess backend"
)
def test_a_local_place_still_warns_that_it_is_not_a_boundary():
text = _agent(tools_run_on="local").where_does_it_run()
assert "not a security boundary" in text
@pytest.mark.parametrize("alias,resolves_to", [("native", "sandlock")])
def test_resolver_aliases_are_accepted_at_construction(alias, resolves_to):
"""Validation ran before alias resolution, so a spelling the resolver
handles happily was rejected as "not a known place"."""
from praisonaiagents.agent.execution_location import say_place
agent = _agent(tools_run_on=alias)
assert say_place(resolves_to) in repr(agent)
# ── extending the registry from outside ──────────────────────────────────────
def test_a_place_can_be_contributed_by_another_package(monkeypatch):
"""Adding a compute provider used to mean editing core in several files.
The sandbox and managed-backend registries already discovered plugins by
entry point; the compute registry was the one hardcoded dict. A provider
can now ship in its own distribution and be selected by name.
"""
import praisonaiagents.managed._compute_bridge as bridge
class Contributed:
provider_name = "contributed"
class FakeEntryPoint:
name = "contributed"
def load(self):
return Contributed
monkeypatch.setattr(bridge, "_entry_point_providers",
lambda: {"contributed": FakeEntryPoint()})
assert "contributed" in bridge.available_providers()
assert isinstance(bridge.resolve_compute("contributed"), Contributed)
def test_a_broken_plugin_cannot_break_places_that_do_not_name_it(monkeypatch):
"""Providers load on demand, so an installed-but-broken plugin only fails
for the caller that actually asks for it."""
import praisonaiagents.managed._compute_bridge as bridge
class Exploding:
name = "exploding"
def load(self):
raise ImportError("this plugin is broken")
monkeypatch.setattr(bridge, "_entry_point_providers",
lambda: {"exploding": Exploding()})
assert bridge.resolve_compute("subprocess") is not None # unaffected
with pytest.raises(ImportError):
bridge.resolve_compute("exploding")
@@ -1,225 +0,0 @@
"""Every provider, checked for the things that do not need a credential.
Most of what breaks an integration is checkable for free: the class resolves,
it satisfies the protocol, its methods are actually coroutines, it reports
availability without raising, both placement parameters accept or refuse it
correctly, and — the one that actually bites people — the failure when a
credential is missing names the key or the install command instead of throwing
an AttributeError from four frames down.
Modal was the cautionary tale: its `run_in=` path was broken in five separate
ways and nothing noticed, because nothing ever ran it. These tests are the
cheap half of not repeating that. The expensive half is a CI job with real
credentials.
"""
import inspect
import pytest
from praisonaiagents import Agent
from praisonaiagents.agent.placement import managed_runtimes, tool_places
from praisonaiagents.managed._compute_bridge import resolve_compute
#: Places resolvable without any vendor account.
LOCAL_PLACES = ("subprocess", "local", "docker")
#: Every place except the two that cannot be resolved from a bare name:
#: `ssh` needs a host object, `native` is an alias checked separately.
ALL_PLACES = [p for p in tool_places() if p not in ("ssh", "native")]
#: Words that make a credential failure actionable.
_ACTIONABLE = (
"key", "token", "auth", "credential", "not available",
"install", "config", "login", "unauthor", "api", "host", "daemon",
)
@pytest.fixture(scope="module", params=ALL_PLACES)
def place(request):
return request.param
def _agent(**kw):
return Agent(name="p", instructions="x", **kw)
# ── the registry ─────────────────────────────────────────────────────────────
def test_every_place_resolves_to_a_provider(place):
assert resolve_compute(place) is not None
def test_every_provider_satisfies_the_protocol(place):
"""Everything downstream assumes these three exist."""
provider = resolve_compute(place)
for method in ("provision", "execute", "shutdown"):
assert hasattr(provider, method), f"{place} has no {method}()"
def test_every_protocol_method_is_awaitable(place):
"""A sync method here does not raise -- it returns a coroutine nobody
awaits, so the call silently does nothing."""
provider = resolve_compute(place)
for method in ("provision", "execute", "shutdown"):
fn = getattr(provider, method)
assert inspect.iscoroutinefunction(fn), f"{place}.{method}() is not async"
def test_availability_can_be_answered_without_raising(place):
"""`is_available` is consulted before anything is provisioned, so it has to
survive a machine with no credentials and no daemon."""
provider = resolve_compute(place)
value = getattr(provider, "is_available", None)
value = value() if callable(value) else value
assert isinstance(value, bool) or value is None
# ── the parameters ───────────────────────────────────────────────────────────
def test_tools_run_on_accepts_every_place(place):
agent = _agent(tools_run_on=place)
assert agent.tools_run_on == place
def test_run_on_accepts_exactly_the_places_that_host_a_loop(place):
"""A place that only runs commands must be refused by run_on=, and a place
that can host a loop must be accepted. Getting this backwards either hides
a capability or silently runs the agent somewhere unintended."""
hosts_a_loop = place in managed_runtimes()
if hosts_a_loop:
assert _agent(run_on=place).backend is not None
else:
with pytest.raises(TypeError) as exc:
_agent(run_on=place)
assert "tools_run_on" in str(exc.value), "must name the parameter that works"
def test_the_repr_names_the_place_it_was_given(place):
"""The repr must describe the place that was actually chosen.
A place with no phrase falls back to its own name, which is fine. What
would not be fine is the repr describing a different place -- so the value
has to match what say_place() resolves for this name, not merely be
non-empty. (An earlier version of this test banned the substring "this
machine", which wrongly failed `sandlock`: a locked-down process really is
on this machine.)
"""
from praisonaiagents.agent.execution_location import say_place
text = repr(_agent(tools_run_on=place))
assert "tools_run_on=" in text
assert say_place(place, via="compute") in text, (
f"repr describes something other than {place!r}: {text}"
)
# ── failure quality, which is most of the user experience ────────────────────
def test_a_missing_credential_fails_with_something_actionable(place):
"""The failure has to name the key, the install command, or the daemon --
not surface as an AttributeError from inside a vendor SDK."""
import asyncio
from praisonaiagents.managed.protocols import ComputeConfig
provider = resolve_compute(place)
available = getattr(provider, "is_available", True)
available = available() if callable(available) else available
if available:
pytest.skip(f"{place} is usable on this machine, so it cannot fail here")
async def attempt():
info = await provider.provision(ComputeConfig())
await provider.shutdown(getattr(info, "instance_id", info))
with pytest.raises(Exception) as exc:
asyncio.run(attempt())
message = f"{type(exc.value).__name__}: {exc.value}".lower()
assert any(word in message for word in _ACTIONABLE), (
f"{place} failed unhelpfully: {message[:160]}"
)
# ── the places that deliberately refuse a bare name ──────────────────────────
def test_ssh_explains_that_a_name_is_not_enough():
with pytest.raises(TypeError) as exc:
_agent(tools_run_on="ssh")
message = str(exc.value)
assert "SSHSandbox" in message and "host" in message
def test_native_is_accepted_as_an_alias_for_sandlock():
from praisonaiagents.agent.execution_location import say_place
assert say_place("sandlock") in repr(_agent(tools_run_on="native"))
# ── live, where the machine allows it ────────────────────────────────────────
@pytest.mark.parametrize("place", LOCAL_PLACES)
def test_a_locally_available_place_actually_runs_a_command(place):
"""Skips loudly rather than passing quietly when the backend is absent."""
import asyncio
from praisonaiagents.managed.protocols import ComputeConfig
provider = resolve_compute(place)
available = getattr(provider, "is_available", True)
available = available() if callable(available) else available
if not available:
pytest.skip(f"{place} is not available on this machine")
async def run():
info = await provider.provision(ComputeConfig())
instance = getattr(info, "instance_id", info)
try:
return await provider.execute(instance, "echo matrix-ok")
finally:
await provider.shutdown(instance)
result = asyncio.run(run())
assert "matrix-ok" in (result.get("stdout") or ""), result
# ── reclaiming what we provisioned ───────────────────────────────────────────
def test_managed_ps_polls_places_from_the_registry_not_a_literal():
"""`_PS_PROVIDERS` was a hardcoded tuple, so a place contributed by another
package could be provisioned and then never listed or stopped -- and an
unreclaimable cloud sandbox is a bill nobody sees."""
pytest.importorskip("praisonai.cli.commands.managed")
from praisonai.cli.commands.managed import _ps_providers
polled = set(_ps_providers())
assert "docker" in polled
# Local places have nothing to reclaim and must not be polled.
assert not polled & {"local", "subprocess", "sandlock", "ssh"}
# Everything polled must be a real place.
assert polled <= set(tool_places())
def test_a_contributed_place_would_be_polled(monkeypatch):
pytest.importorskip("praisonai.cli.commands.managed")
import praisonaiagents.managed._compute_bridge as bridge
from praisonai.cli.commands.managed import _ps_providers
monkeypatch.setattr(bridge, "available_providers",
lambda: ["docker", "contributed-cloud"])
assert "contributed-cloud" in _ps_providers()
def test_a_contributed_place_can_name_itself(monkeypatch):
"""The phrase table is a literal, so it cannot know about a package
installed later. Rather than always printing a bare name, a contributed
provider may declare `display_name` and have it used."""
import praisonaiagents.managed._compute_bridge as bridge
from praisonaiagents.agent.execution_location import say_place
monkeypatch.setitem(bridge._DISPLAY_NAMES, "contributed", "a Contributed cloud sandbox")
assert say_place("contributed") == "a Contributed cloud sandbox"
def test_an_unknown_place_still_falls_back_to_its_name():
"""Declaring a phrase is optional; the fallback must stay graceful."""
from praisonaiagents.agent.execution_location import say_place
assert say_place("some-new-cloud") == "some-new-cloud"
@@ -1,223 +0,0 @@
"""`run_on="docker"` — the whole agent in a container you own.
The distinction under test is what crosses the boundary:
tools_run_on="docker" your process runs the loop; only tools go inside
run_on="docker" the loop itself runs inside; the model call is
made from the container
Both name the same place. The parameter carries the scope, which is why one
word can serve both without ambiguity.
Everything that needs a live daemon is marked, so it is skipped loudly rather
than passing quietly when Docker is not running.
"""
import shutil
import subprocess
import pytest
from praisonaiagents import Agent
from praisonaiagents.agent.execution_location import describe
from praisonaiagents.agent.placement import managed_runtimes, tool_places
def _docker_running() -> bool:
if not shutil.which("docker"):
return False
try:
return subprocess.run(
["docker", "version", "--format", "{{.Server.Version}}"],
capture_output=True, timeout=25,
).returncode == 0
except Exception:
return False
needs_docker = pytest.mark.skipif(
not _docker_running(), reason="needs a running Docker daemon"
)
def _agent(**kw):
return Agent(name="A", instructions="You are helpful.", **kw)
# ── registration ─────────────────────────────────────────────────────────────
def test_docker_is_a_managed_runtime():
"""run_on= resolves against the managed-backend registry, so registering
the backend is all that is needed for the parameter to accept it."""
assert "docker" in managed_runtimes()
def test_docker_is_also_a_tool_place():
assert "docker" in tool_places()
def test_run_on_docker_builds_the_self_hosted_backend():
backend = _agent(run_on="docker").backend
assert type(backend).__name__ == "DockerManagedAgent"
assert backend.provider_name == "docker"
# ── the two scopes stay distinguishable ──────────────────────────────────────
def test_run_on_moves_the_thinking_too():
places = describe(_agent(run_on="docker"))
assert places["thinks_on"] == places["tools_run_on"] == "a Docker container"
def test_tools_run_on_leaves_the_thinking_here():
places = describe(_agent(tools_run_on="docker"))
assert places["thinks_on"] == "this machine"
assert places["tools_run_on"] == "a Docker container"
def test_the_two_read_differently_in_the_repr():
assert repr(_agent(run_on="docker")) != repr(_agent(tools_run_on="docker"))
def test_naming_both_is_still_a_conflict():
"""They are alternatives even when they name the same place: run_on already
runs the tools there, so tools_run_on= has nothing left to say."""
with pytest.raises(TypeError):
_agent(run_on="docker", tools_run_on="docker")
# ── config marshalling (no daemon needed) ────────────────────────────────────
def test_the_agent_is_rebuilt_from_serialisable_config_only():
"""A Python callable cannot be reconstructed in the container, so it is not
sent. The limitation is real; the test pins it so it stays deliberate."""
from praisonai.integrations.docker_managed_agent import _as_dict
def local_tool(x: str) -> str:
return x
out = _as_dict({"instructions": "be brief", "llm": "gpt-4o-mini",
"tools": [local_tool], "verbose": True})
assert out["instructions"] == "be brief"
assert out["llm"] == "gpt-4o-mini"
assert "tools" not in out, "callables must not be smuggled into the payload"
def test_hosted_config_spellings_are_translated():
"""HostedAgentConfig says system/model where Agent says instructions/llm."""
from praisonai.integrations.docker_managed_agent import _as_dict
out = _as_dict({"system": "you are terse", "model": "gpt-4o-mini"})
assert out["instructions"] == "you are terse"
assert out["llm"] == "gpt-4o-mini"
assert "system" not in out and "model" not in out
def test_an_empty_config_still_produces_a_valid_agent():
from praisonai.integrations.docker_managed_agent import _as_dict
assert _as_dict(None)["instructions"]
def test_the_result_is_read_from_a_marked_line():
"""pip warnings and telemetry share stdout with the answer, so the result
is marked rather than assumed to be the last line."""
from praisonai.integrations.docker_managed_agent import _parse
noisy = (
"WARNING: Running pip as root\n"
"some telemetry line\n"
'__PRAISON_RESULT__{"ok": true, "result": "42"}\n'
)
assert _parse(noisy) == {"ok": True, "result": "42"}
assert _parse("no marker here") is None
# ── failure is reported, not swallowed ───────────────────────────────────────
def test_a_missing_daemon_says_what_to_do():
from praisonai.integrations.docker_managed_agent import DockerManagedAgent
backend = DockerManagedAgent()
if backend.is_available:
pytest.skip("Docker is running, so this path cannot be exercised")
with pytest.raises(RuntimeError) as exc:
backend._ensure_container()
message = str(exc.value)
assert "Docker daemon" in message
assert "tools_run_on='docker'" in message, "offer the alternative that needs no daemon"
# ── the real thing ───────────────────────────────────────────────────────────
@needs_docker
def test_the_whole_agent_really_runs_in_a_container():
"""Proves the loop moved: the agent reports the container's platform, not
the host's. Needs a model key, so it asserts placement rather than output."""
from praisonai.integrations.docker_managed_agent import DockerManagedAgent
backend = DockerManagedAgent(config={"instructions": "be brief"})
try:
backend._ensure_container()
assert backend._container, "no container was started"
probe = subprocess.run(
["docker", "exec", backend._container, "python", "-c",
"import platform, praisonaiagents; print(platform.system())"],
capture_output=True, text=True, timeout=300,
)
assert probe.returncode == 0, probe.stderr[-400:]
assert probe.stdout.strip() == "Linux", "the loop is not in a Linux container"
finally:
backend.shutdown()
@needs_docker
def test_the_container_is_removed_afterwards():
from praisonai.integrations.docker_managed_agent import DockerManagedAgent
backend = DockerManagedAgent()
backend._ensure_container()
name = backend._container
backend.shutdown()
remaining = subprocess.run(
["docker", "ps", "-aq", "--filter", f"name={name}"],
capture_output=True, text=True, timeout=120,
).stdout.strip()
assert remaining == "", f"container {name} outlived the backend"
# ── run_on= across every place that can host a loop ──────────────────────────
def test_run_on_covers_the_cloud_places_too():
"""run_on= accepted two names while tools_run_on= accepted twelve. That gap
was an implementation detail -- no backend had been written for the rest --
not a fact about the places. One generic backend closed it."""
from praisonaiagents.agent.placement import managed_runtimes
runtimes = set(managed_runtimes())
for place in ("anthropic", "docker", "modal", "e2b", "daytona", "flyio", "tenki", "novita"):
assert place in runtimes, f"run_on={place!r} should host a whole agent"
@pytest.mark.parametrize("place", ["modal", "e2b", "daytona"])
def test_a_cloud_place_builds_its_own_backend_class(place):
"""HostedAgent's factory calls issubclass() on whatever the registry
returns, so a loader must hand back a class, not a factory function."""
backend = _agent(run_on=place).backend
assert backend.provider_name == place
assert type(backend).__name__.lower().startswith(place[:4].lower())
@pytest.mark.parametrize("place", ["local", "subprocess", "sandlock", "ssh"])
def test_places_that_cannot_host_a_loop_are_still_refused(place):
"""`local` would run the agent in your own shell, which is what you get by
passing nothing; `ssh` needs an object; the local sandboxes isolate tools
rather than host a runtime."""
with pytest.raises(TypeError):
_agent(run_on=place)
def test_the_remote_agent_is_rebuilt_without_callables():
from praisonai.integrations.compute_managed_agent import _as_dict
def local_tool(x: str) -> str:
return x
out = _as_dict({"instructions": "be brief", "tools": [local_tool]})
assert "tools" not in out, "callables cannot be rebuilt remotely"
assert out["instructions"] == "be brief"
@@ -1,126 +0,0 @@
"""The adapter that makes sandbox-only backends reachable as places.
``tools_run_on=`` speaks ``ComputeProviderProtocol`` (one object hands out many
environments). ``subprocess``, ``sandlock``, ``ssh`` and ``novita`` only ever
spoke ``SandboxProtocol`` (one object *is* one environment), so they could not
be selected at all. ``SandboxComputeAdapter`` supplies the missing half.
These tests exist because mutation testing found the adapter had none: dropping
``result.error`` -- which turns "blocked by security policy" into exit 0 with
empty output -- left the whole suite green.
"""
import asyncio
import pytest
from praisonaiagents.managed._compute_bridge import available_providers, resolve_compute
from praisonaiagents.managed._sandbox_adapter import (
NEEDS_INSTANCE,
SANDBOX_ONLY,
SandboxComputeAdapter,
)
from praisonaiagents.managed.protocols import ComputeConfig
def _run(coro):
return asyncio.run(coro)
async def _with_instance(name, fn):
provider = resolve_compute(name)
info = await provider.provision(ComputeConfig())
try:
return await fn(provider, info.instance_id)
finally:
await provider.shutdown(info.instance_id)
# ── reachability: the point of the adapter ───────────────────────────────────
def test_sandbox_only_backends_are_selectable_as_places():
for name in SANDBOX_ONLY:
assert name in available_providers()
def test_the_registry_is_the_union_of_both_stacks():
places = available_providers()
assert "docker" in places, "compute registry"
assert "sandlock" in places, "sandbox registry"
assert places == sorted(set(places)), "no duplicates, stable order"
def test_a_sandbox_only_name_resolves_to_the_adapter():
assert isinstance(resolve_compute("subprocess"), SandboxComputeAdapter)
def test_a_name_that_needs_connection_details_says_so():
"""`ssh` cannot work as a bare string -- the host lives in the object."""
with pytest.raises((TypeError, ValueError)) as exc:
resolve_compute("ssh")
assert NEEDS_INSTANCE["ssh"].split(",")[0][:20] in str(exc.value) or "SSHSandbox" in str(exc.value)
# ── the failure mode that survived mutation ──────────────────────────────────
def test_a_policy_denial_is_reported_as_a_failure():
"""Dropping result.error turned a refusal into exit 0 with empty output --
the caller could not tell "blocked" from "succeeded and printed nothing"."""
async def check(provider, instance_id):
return await provider.execute(instance_id, "cat /etc/passwd")
result = _run(_with_instance("subprocess", check))
assert result["exit_code"] != 0, "a blocked command must not report success"
assert "blocked" in result["stderr"].lower()
assert "root:" not in result["stdout"], "the file must not have been read"
def test_a_successful_command_reports_success():
async def check(provider, instance_id):
return await provider.execute(instance_id, "echo hello")
result = _run(_with_instance("subprocess", check))
assert result["exit_code"] == 0
assert result["stdout"].strip() == "hello"
# ── shell semantics: the bridged tool IS a shell ─────────────────────────────
@pytest.mark.parametrize("command,expected", [
("echo a | tr a-z A-Z", "A"),
("echo x > f.txt && cat f.txt", "x"),
("echo $((6*7))", "42"),
])
def test_pipes_redirects_and_expansion_work(command, expected):
"""Without shell semantics the model's `echo x > f` is argv-split and
"> f" arrives as a literal argument."""
async def check(provider, instance_id):
return await provider.execute(instance_id, command)
assert _run(_with_instance("subprocess", check))["stdout"].strip() == expected
def test_state_persists_across_calls():
"""One environment held open, not one per call: a manager opened and closed
per command would lose the previous command's files."""
async def check(provider, instance_id):
await provider.execute(instance_id, "echo persisted > note.txt")
first = await provider.execute(instance_id, "pwd")
read = await provider.execute(instance_id, "cat note.txt")
second = await provider.execute(instance_id, "pwd")
return first, read, second
first, read, second = _run(_with_instance("subprocess", check))
assert read["stdout"].strip() == "persisted"
assert first["stdout"] == second["stdout"], "each call landed in a different place"
def test_it_reports_that_its_instances_are_not_discoverable():
"""A single environment has no cross-process registry, so it never shows up
in `praisonai managed ps`. Saying so beats pretending otherwise."""
async def check(provider, instance_id):
return await provider.list_instances()
listed = _run(_with_instance("subprocess", check))
assert listed and listed[0].metadata.get("discoverable") is False
@@ -1,135 +0,0 @@
"""
Unit tests for the per-job durable scratchpad (JobStateStoreProtocol) on
``ConfigYamlScheduleStore``.
Covers:
- get_state returns {} when absent; set/get round-trip and persist across
fresh store instances (config.yaml)
- clear_state and empty-state-clears semantics
- state is dropped when a job is removed (remove / remove_by_name)
- oversized state is rejected (prior kept)
- the store satisfies the core JobStateStoreProtocol
"""
import os
import tempfile
from praisonaiagents.scheduler.models import Schedule, ScheduleJob
from praisonaiagents.scheduler.config_store import ConfigYamlScheduleStore
from praisonaiagents.scheduler.protocols import JobStateStoreProtocol
def _cfg(d):
return os.path.join(d, "config.yaml")
def _make_job(name="job", every=1):
return ScheduleJob(
name=name,
schedule=Schedule(kind="every", every_seconds=every),
message="hello",
)
class TestJobState:
def test_absent_returns_empty(self):
with tempfile.TemporaryDirectory() as d:
store = ConfigYamlScheduleStore(config_path=_cfg(d))
assert store.get_state("nope") == {}
def test_set_get_round_trip(self):
with tempfile.TemporaryDirectory() as d:
store = ConfigYamlScheduleStore(config_path=_cfg(d))
store.set_state("j1", {"last_seen_pr": 4821})
assert store.get_state("j1") == {"last_seen_pr": 4821}
def test_persists_across_instances(self):
with tempfile.TemporaryDirectory() as d:
path = _cfg(d)
store = ConfigYamlScheduleStore(config_path=path)
store.set_state("j1", {"cursor": "abc"})
fresh = ConfigYamlScheduleStore(config_path=path)
assert fresh.get_state("j1") == {"cursor": "abc"}
def test_get_returns_copy(self):
with tempfile.TemporaryDirectory() as d:
store = ConfigYamlScheduleStore(config_path=_cfg(d))
store.set_state("j1", {"n": 1})
got = store.get_state("j1")
got["n"] = 999
assert store.get_state("j1") == {"n": 1}
def test_get_returns_deep_copy(self):
# A mutated nested value in a read result must not leak into the store
# (bypassing set_state's size cap). Guards the copy-on-read promise.
with tempfile.TemporaryDirectory() as d:
store = ConfigYamlScheduleStore(config_path=_cfg(d))
store.set_state("j1", {"seen": [1, 2], "meta": {"k": "v"}})
got = store.get_state("j1")
got["seen"].append(3)
got["meta"]["k"] = "mutated"
assert store.get_state("j1") == {"seen": [1, 2], "meta": {"k": "v"}}
def test_set_snapshots_deep_copy(self):
# Mutating the caller's dict after set_state must not alter stored state.
with tempfile.TemporaryDirectory() as d:
store = ConfigYamlScheduleStore(config_path=_cfg(d))
src = {"seen": [1, 2]}
store.set_state("j1", src)
src["seen"].append(99)
assert store.get_state("j1") == {"seen": [1, 2]}
def test_clear_state(self):
with tempfile.TemporaryDirectory() as d:
store = ConfigYamlScheduleStore(config_path=_cfg(d))
store.set_state("j1", {"n": 1})
store.clear_state("j1")
assert store.get_state("j1") == {}
def test_empty_state_clears(self):
with tempfile.TemporaryDirectory() as d:
store = ConfigYamlScheduleStore(config_path=_cfg(d))
store.set_state("j1", {"n": 1})
store.set_state("j1", {})
assert store.get_state("j1") == {}
def test_state_dropped_on_remove(self):
with tempfile.TemporaryDirectory() as d:
store = ConfigYamlScheduleStore(config_path=_cfg(d))
job = _make_job()
store.add(job)
store.set_state(job.id, {"n": 1})
assert store.remove(job.id) is True
assert store.get_state(job.id) == {}
def test_state_dropped_on_remove_by_name(self):
with tempfile.TemporaryDirectory() as d:
store = ConfigYamlScheduleStore(config_path=_cfg(d))
job = _make_job(name="daily")
store.add(job)
store.set_state(job.id, {"n": 1})
assert store.remove_by_name("daily") is True
assert store.get_state(job.id) == {}
def test_oversized_rejected_keeps_prior(self):
with tempfile.TemporaryDirectory() as d:
store = ConfigYamlScheduleStore(config_path=_cfg(d))
store.set_state("j1", {"ok": 1})
store.set_state("j1", {"big": "x" * (32 * 1024)})
# oversized write rejected → prior state retained
assert store.get_state("j1") == {"ok": 1}
def test_job_state_key_absent_when_empty(self):
with tempfile.TemporaryDirectory() as d:
path = _cfg(d)
store = ConfigYamlScheduleStore(config_path=path)
store.add(_make_job())
import yaml
with open(path) as f:
data = yaml.safe_load(f) or {}
assert "job_state" not in data
def test_satisfies_protocol(self):
with tempfile.TemporaryDirectory() as d:
store = ConfigYamlScheduleStore(config_path=_cfg(d))
assert isinstance(store, JobStateStoreProtocol)
@@ -121,13 +121,9 @@ class DurableAdapterMixin:
async def drain_outbox(self) -> tuple[int, int]:
"""Drain pending messages from the outbox.
Called on adapter startup and, per Issue #4043, whenever the channel
supervisor observes the channel recover from a transient outage — so
replies the outbox held through the outage are re-attempted promptly
instead of waiting for the next inbound ``chat()`` turn or a restart.
Idempotent and bounded by the outbox's own attempt-and-age policy.
Should be called on adapter startup.
Returns:
Tuple of (succeeded, failed) counts
"""
@@ -139,14 +139,6 @@ def gateway_start(
help="Fail fast if any tool named in the config cannot be resolved. "
"Use --no-strict-tools to skip unresolved tools and start anyway (#3553)",
),
verify_turn: Optional[bool] = typer.Option(
None,
"--verify-turn/--no-verify-turn",
help="Run one real agent turn before serving so a missing/invalid model "
"credential fails at startup, not on the first user message. Defaults to "
"the config's gateway.preflight.verify_turn (on). Use --no-verify-turn to "
"skip in constrained/offline environments (#4042)",
),
openai_api: bool = typer.Option(
False,
"--openai-api",
@@ -306,35 +298,6 @@ def gateway_start(
if config and os.path.exists(config):
_preflight_tools(config, strict_tools=strict_tools)
# Turn pre-flight: verify one real agent turn so a missing/invalid model
# credential fails HERE (at start), not silently on the first user message
# (#4042). The channel + tool probes above prove wiring, never a model
# round-trip. Default on via config (gateway.preflight.verify_turn); the
# --verify-turn/--no-verify-turn flag overrides for constrained/offline use.
#
# This is governed by its own toggle, NOT the channel `preflight` flag: an
# operator running `--no-preflight --verify-turn` still wants the model
# round-trip, so skipping the channel probes must not silently suppress the
# requested turn check.
if config and os.path.exists(config):
import asyncio
enabled, prompt = _resolve_verify_turn(config)
if verify_turn is not None:
enabled = verify_turn
channels = _load_channels(config)
if enabled and channels:
ok, detail = asyncio.run(_verify_turn_preflight(config, prompt=prompt))
if ok:
print(f"Turn pre-flight OK — agent replied to '{prompt}'.")
else:
print(
f"\nTurn pre-flight failed — the agent did not produce a reply: "
f"{detail}\nCheck the model/provider credentials for this agent, "
"or pass --no-verify-turn to skip this check."
)
raise typer.Exit(1)
handler = GatewayHandler()
# Pass True only when the flag is set so an unset flag does not override a
# YAML ``gateway.api.*`` value (None = "fall back to config"). The same
@@ -636,10 +599,8 @@ from praisonai_bot.gateway.preflight import ( # noqa: E402 — re-exported for
probe_results_to_dict as _probe_results_to_dict,
resolve_env_token as _resolve_env_token,
resolve_platform_dlq_path as _resolve_platform_dlq_path,
resolve_verify_turn as _resolve_verify_turn,
run_shell_readiness_check as _run_shell_readiness_check,
run_turn_test as _run_gateway_turn_test,
verify_turn_preflight as _verify_turn_preflight,
)
@@ -1807,63 +1768,6 @@ sessions_app = typer.Typer(
app.add_typer(sessions_app, name="sessions")
diagnostics_app = typer.Typer(
help="Produce a portable, pre-sanitised support bundle for the gateway",
no_args_is_help=True,
)
app.add_typer(diagnostics_app, name="diagnostics")
@diagnostics_app.command("export")
def gateway_diagnostics_export(
config: str = typer.Option(
"gateway.yaml", "--config", "-c", help="Path to gateway.yaml"
),
output_dir: Optional[str] = typer.Option(
None, "--output-dir",
help="Directory to write the bundle (default: ~/.praisonai/diagnostics)",
),
log_lines: int = typer.Option(
200, "--log-lines", help="Number of recent (redacted) log lines to include"
),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Write a single, pre-sanitised diagnostics archive for a bug report.
Consolidates a human-readable summary, machine-readable diagnostics, the
sanitised config *shape* (keys/modes only — no secrets), redacted recent
log summaries, best-effort health/status, and the latest forensics snapshot
into one ``.zip``. It runs even when the gateway is unhealthy (falling back
to local logs) and never includes chat text, prompts, tool outputs,
credentials, or raw tokens — safe to attach to an issue.
Examples:
praisonai gateway diagnostics export
praisonai gateway diagnostics export --config gateway.yaml
praisonai gateway diagnostics export --json
"""
import json
import os
from praisonai_bot.gateway.diagnostics import build_diagnostics_bundle
config = _resolve_doctor_config(config)
config_path = config if config and os.path.exists(config) else None
result = build_diagnostics_bundle(
config_path, output_dir=output_dir, log_lines=log_lines
)
if json_output:
print(json.dumps({"path": result["path"], "manifest": result["manifest"]}, indent=2))
else:
print(f"Wrote diagnostics bundle: {result['path']}")
print(
"Pre-sanitised (config shape only, secrets redacted, no chat/prompt "
"data) — safe to attach to a bug report."
)
@sessions_app.command("list")
def gateway_sessions_list(
platform: Optional[str] = typer.Option(None, "--platform", help="Filter by platform (e.g. slack)"),
@@ -2001,7 +1905,6 @@ Manage the gateway server: praisonai gateway <command>
[green]doctor[/green] Validate channel credentials (pre-flight check)
[green]test[/green] One-shot readiness (probes + shell + optional turn)
[green]channels[/green] List channels from gateway.yaml (use --probe to check creds)
[green]diagnostics[/green] Export a pre-sanitised support bundle (diagnostics export)
[green]send[/green] Send a test message to a channel
[green]hooks[/green] Manage inbound trigger hooks (add | list | remove)
[green]install[/green] Install as OS daemon service
@@ -621,13 +621,6 @@ class OnboardWizard:
f.write(yaml_content)
console.print(f" [green]✓[/green] Written to [cyan]{self.config_path}[/cyan]")
# Step 5c: Verify a real agent turn (#4042). The channel probe above
# proves the token works; it never proves the agent's model credential
# is present/valid. Run one bounded turn now so a missing/invalid model
# key surfaces at setup, not on the first user message. Skipped when no
# channel has a token, and opt-out via PRAISONAI_NO_VERIFY_TURN=1.
self._verify_turn(console.print)
# Step 6: Optional daemon install
# Guard: refuse to install a daemon that has no tokens — it would loop
# in a crash-restart cycle. List the channels missing tokens so the
@@ -980,40 +973,6 @@ class OnboardWizard:
bot = Bot(channel["platform"], token=channel["token"])
return await bot.probe()
def _verify_turn(self, print_fn) -> None:
"""Prove one real agent turn against the written config (#4042).
Reuses the gateway's ``verify_turn_preflight`` so onboarding and
``gateway start`` share one code path. Best-effort: a failure warns with
a clear next step rather than aborting the wizard, since the config is
already written and the operator may simply need to add a model key.
"""
if os.environ.get("PRAISONAI_NO_VERIFY_TURN", "").strip().lower() in (
"1", "true", "yes", "on",
):
return
if not any(ch.get("token") for ch in self.channels.values()):
return
print_fn("\n[bold]Step 5c: Verify the agent replies[/bold]\n")
print_fn(" Running one test turn ('ping')...", )
try:
from praisonai_bot.gateway.preflight import verify_turn_preflight
ok, detail = asyncio.run(
verify_turn_preflight(self.config_path, prompt="ping")
)
except Exception as exc: # never let verification break onboarding
ok, detail = False, str(exc)[:200]
if ok:
print_fn(" [green]✓[/green] Agent replied — model credentials work")
else:
print_fn(
f" [yellow]⚠[/yellow] Agent did not reply: {detail}\n"
" [dim]Set the model/provider API key (e.g. OPENAI_API_KEY) in "
"~/.praisonai/.env, then re-run [cyan]praisonai onboard[/cyan] or "
"[cyan]praisonai gateway test --turn ping[/cyan].[/dim]"
)
async def _probe(self, platform: str):
"""Run a probe for a platform (legacy method for backward compatibility)."""
from praisonai_bot.bots import Bot
@@ -1,258 +0,0 @@
"""On-demand, pre-sanitised diagnostics bundle for the gateway (Issue #4044).
When a gateway/bot misbehaves an operator otherwise has to hand-assemble
evidence — tail logs, redact secrets, capture health/status, copy the config —
before they can debug it or open a useful report. This module consolidates the
existing building blocks (crash forensics, ``health()``, secret-ref redaction)
into ONE portable archive an operator (or the bot itself) can produce in a
single step, so nobody accidentally pastes a raw token into an issue.
It is typer-free so it stays importable/testable without the CLI, and every
section is best-effort: a section that cannot be gathered is recorded as an
error rather than aborting the whole bundle. It runs even when the gateway is
unhealthy (falling back to local daemon logs) and it NEVER includes chat text,
prompts, tool outputs, credentials, or raw tokens — the config is reduced to
its key/mode shape and every text section is passed through the shared
``redact_secrets`` pass before it is written.
"""
from __future__ import annotations
import datetime as _dt
import io
import json
import os
import zipfile
from typing import Any, Dict, List, Optional, Sequence
DEFAULT_SECTIONS = ("summary", "health", "config_shape", "logs", "forensics")
# Config keys whose *values* are secrets/credentials and must never be copied
# into the sanitised shape even in redacted form — we keep only their presence.
_SECRET_KEYS = frozenset(
{
"token",
"app_token",
"verify_token",
"auth_token",
"api_key",
"apikey",
"secret",
"password",
"passwd",
"client_secret",
"signing_secret",
"webhook_secret",
"access_token",
"refresh_token",
"bearer",
}
)
def _now_stamp() -> str:
return _dt.datetime.now().strftime("%Y%m%d-%H%M%S")
def _unique_bundle_path(out_dir: str) -> str:
"""A collision-free archive path in ``out_dir``.
``_now_stamp()`` is second-resolution, so two exports into the same
directory within one second would otherwise resolve to the same path and
the second truncating write would silently destroy the first bundle
(Greptile P1). Keep the human-friendly timestamped name but append a ``-N``
suffix when that exact name already exists so every export is preserved.
"""
stamp = _now_stamp()
candidate = os.path.join(out_dir, f"praisonai-diagnostics-{stamp}.zip")
counter = 1
while os.path.exists(candidate):
candidate = os.path.join(
out_dir, f"praisonai-diagnostics-{stamp}-{counter}.zip"
)
counter += 1
return candidate
def _redact(text: str) -> str:
"""Pass text through the shared secret-redaction registry (best-effort)."""
try:
from praisonaiagents.secrets import redact_secrets
return redact_secrets(text)
except Exception: # pragma: no cover — never let redaction failure block
return text
def _sanitise_config_shape(value: Any) -> Any:
"""Reduce a loaded config to keys/modes only — no secret values.
A key whose name marks it as a credential is replaced with a
``"<set>"``/``"<empty>"`` presence marker; every other scalar is kept only
as its type name (never its value) so the shape is shareable without leaking
endpoints, ids, or free text that might contain secrets.
"""
if isinstance(value, dict):
shape: Dict[str, Any] = {}
for key, val in value.items():
lname = str(key).lower()
if lname in _SECRET_KEYS:
shape[key] = "<set>" if val not in (None, "") else "<empty>"
else:
shape[key] = _sanitise_config_shape(val)
return shape
if isinstance(value, list):
return [_sanitise_config_shape(v) for v in value]
if isinstance(value, bool):
return value
if value is None:
return None
# Scalars (str/int/float): keep the type, not the value.
return f"<{type(value).__name__}>"
def _collect_config_shape(config_path: Optional[str]) -> Dict[str, Any]:
if not config_path:
return {"error": "no config path resolved"}
if not os.path.exists(config_path):
return {"error": f"config not found: {config_path}"}
try:
import yaml
with open(config_path) as fh:
cfg = yaml.safe_load(fh) or {}
except Exception as exc: # pragma: no cover — defensive
return {"error": f"could not read config: {exc}"}
return {"path": os.path.basename(config_path), "shape": _sanitise_config_shape(cfg)}
def _collect_forensics() -> Dict[str, Any]:
"""The latest fast forensic snapshot (never chat/prompt data)."""
try:
from praisonai_bot.gateway.forensics import ShutdownForensics
return ShutdownForensics(enabled=False).snapshot()
except Exception as exc: # pragma: no cover — defensive
return {"error": f"forensics unavailable: {exc}"}
def _collect_logs(lines: int = 200) -> str:
"""Redacted tail of the daemon service logs (local fallback)."""
try:
from praisonai_bot.daemon import _detect_platform
plat = _detect_platform()
if plat == "systemd":
from praisonai_bot.daemon.systemd import get_logs
elif plat == "launchd":
from praisonai_bot.daemon.launchd import get_logs
elif plat == "windows":
from praisonai_bot.daemon.windows import get_logs
else:
return f"(no log reader for platform: {plat})"
raw = get_logs(lines=lines) or ""
except Exception as exc: # pragma: no cover — defensive
return f"(logs unavailable: {exc})"
return _redact(raw)
def _collect_health(config_path: Optional[str]) -> Dict[str, Any]:
"""Best-effort gateway health/status; degrade gracefully when unreachable.
Uses the running gateway's REST ``/health`` endpoint when reachable so the
bundle reflects live status, but the whole export must still succeed when
the gateway is down (which is exactly when an operator wants a bundle), so
an unreachable gateway is recorded as a note rather than raising.
"""
try:
from praisonai_bot.gateway.preflight import check_gateway_running
ok, message = check_gateway_running(config_path) if config_path else (False, "no config")
return {"reachable": bool(ok), "detail": _redact(str(message))}
except Exception as exc:
return {"reachable": False, "detail": f"health probe unavailable: {exc}"}
def build_diagnostics_bundle(
config_path: Optional[str],
*,
output_dir: Optional[str] = None,
include: Sequence[str] = DEFAULT_SECTIONS,
log_lines: int = 200,
) -> Dict[str, Any]:
"""Build a portable, pre-sanitised diagnostics archive.
Returns a dict with the resolved ``path`` of the written ``.zip`` and the
in-memory ``manifest`` (also written into the archive). Every section is
best-effort and redacted; nothing here contains chat text, prompts, tool
outputs, or raw credentials.
"""
include = tuple(include)
manifest: Dict[str, Any] = {
"tool": "praisonai gateway diagnostics export",
"issue": 4044,
"generated_at": _dt.datetime.now(_dt.timezone.utc).isoformat(),
"sections": list(include),
"redacted": True,
}
sections: Dict[str, Any] = {}
if "config_shape" in include:
sections["config_shape"] = _collect_config_shape(config_path)
if "health" in include:
sections["health"] = _collect_health(config_path)
if "forensics" in include:
sections["forensics"] = _collect_forensics()
if "logs" in include:
sections["logs"] = _collect_logs(lines=log_lines)
summary_lines: List[str] = [
"PraisonAI Gateway Diagnostics Bundle",
"=" * 38,
f"generated_at : {manifest['generated_at']}",
f"config : {sections.get('config_shape', {}).get('path', 'n/a') if isinstance(sections.get('config_shape'), dict) else 'n/a'}",
]
health = sections.get("health")
if isinstance(health, dict):
summary_lines.append(
f"gateway : {'reachable' if health.get('reachable') else 'unreachable (local fallback)'}"
)
forensics = sections.get("forensics")
if isinstance(forensics, dict) and "error" not in forensics:
summary_lines.append(
f"supervised : {forensics.get('supervised', 'unknown')}"
)
summary_lines.append("")
summary_lines.append(
"This bundle is pre-sanitised: config is reduced to key/mode shape and "
"all text is secret-redacted. It contains no chat text, prompts, tool "
"outputs, or raw tokens. Safe to attach to a bug report."
)
summary_text = _redact("\n".join(summary_lines))
out_dir = output_dir or os.path.join(
os.path.expanduser(os.environ.get("PRAISONAI_HOME", "~/.praisonai")),
"diagnostics",
)
os.makedirs(out_dir, exist_ok=True)
bundle_path = _unique_bundle_path(out_dir)
manifest["files"] = []
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
if "summary" in include:
zf.writestr("summary.txt", summary_text)
manifest["files"].append("summary.txt")
for name, payload in sections.items():
if name == "logs":
zf.writestr("logs.txt", payload if isinstance(payload, str) else str(payload))
manifest["files"].append("logs.txt")
else:
zf.writestr(f"{name}.json", json.dumps(payload, indent=2, default=str))
manifest["files"].append(f"{name}.json")
zf.writestr("manifest.json", json.dumps(manifest, indent=2, default=str))
with open(bundle_path, "wb") as fh:
fh.write(buffer.getvalue())
return {"path": bundle_path, "manifest": manifest}
@@ -233,16 +233,11 @@ async def run_turn_test(
config_path: str,
channel_name: str,
prompt: str,
run_timeout: Optional[float] = None,
) -> Tuple[bool, str]:
"""Simulate one inbound agent turn offline via ``BotSessionManager.chat``.
Does **not** exercise Slack Bolt/socket handlers or @mention routing — only
the BotSessionManager path after manual shell setup.
``run_timeout`` bounds the agent round-trip (seconds). When ``None`` the
session default applies; preflight callers pass a short bound so a stalled
provider fails fast instead of hanging on the 5-minute interactive default.
"""
import yaml
from praisonaiagents import Agent
@@ -269,32 +264,18 @@ async def run_turn_test(
return False, f"No agent configured for channel '{channel_name}'"
acfg = agents_cfg[agent_id] or {}
# Pass through any per-agent provider overrides so the preflight exercises
# the SAME provider the live gateway will use — otherwise a valid custom
# base_url/api_key config tests the default provider and gives a misleading
# verdict (false abort, or invalid runtime creds passing).
agent_kwargs: Dict[str, Any] = {
"name": acfg.get("name", agent_id),
"instructions": acfg.get("instructions", ""),
"llm": acfg.get("model") or acfg.get("llm", "gpt-4o-mini"),
}
base_url = acfg.get("base_url") or acfg.get("api_base")
if base_url:
agent_kwargs["base_url"] = base_url
if acfg.get("api_key"):
agent_kwargs["api_key"] = acfg["api_key"]
agent = Agent(**agent_kwargs)
agent = Agent(
name=acfg.get("name", agent_id),
instructions=acfg.get("instructions", ""),
llm=acfg.get("model") or acfg.get("llm", "gpt-4o-mini"),
)
bot_config = BotConfig()
agent = apply_bot_smart_defaults(agent, bot_config)
platform = str(ch_cfg.get("platform") or channel_name).lower()
if ch_cfg.get("allow_shell"):
agent = enable_shell_tools(agent, bot_config, ch_cfg, channel_type=platform)
mgr = (
BotSessionManager(platform=platform, run_timeout=run_timeout)
if run_timeout is not None
else BotSessionManager(platform=platform)
)
mgr = BotSessionManager(platform=platform)
try:
result = await mgr.chat(
agent,
@@ -311,71 +292,6 @@ async def run_turn_test(
return True, text[:500]
def resolve_verify_turn(config_path: str) -> Tuple[bool, str]:
"""Resolve the ``gateway.preflight.verify_turn`` toggle and its prompt.
Returns ``(enabled, prompt)``. Defaults to ``(True, "ping")`` so the happy
path proves a real agent reply at startup; operators can opt out for
constrained/offline environments via ``verify_turn: false``.
"""
import yaml
try:
with open(config_path) as f:
cfg = yaml.safe_load(f) or {}
except OSError:
return True, "ping"
pre = ((cfg.get("gateway") or {}).get("preflight") or {})
enabled = pre.get("verify_turn", True)
prompt = str(pre.get("verify_turn_prompt") or "ping")
return bool(enabled), prompt
VERIFY_TURN_TIMEOUT_DEFAULT = 60.0
async def verify_turn_preflight(
config_path: str,
channel_name: Optional[str] = None,
prompt: str = "ping",
run_timeout: Optional[float] = None,
) -> Tuple[bool, str]:
"""Run one bounded agent turn to prove the resolved agent actually replies.
Reuses :func:`run_turn_test` against the first configured channel (or
``channel_name`` when given). A missing/invalid model credential is thus
surfaced at setup/startup, not on the first user message.
The round-trip is bounded (default 60s, overridable via
``gateway.preflight.verify_turn_timeout``) so a stalled provider fails fast
instead of blocking startup/onboarding on the 5-minute interactive default.
"""
channels = load_channels_mapping(config_path)
if not channels:
return True, "No channels configured — skipping turn verification"
target = channel_name or next(iter(channels.keys()))
if run_timeout is None:
run_timeout = _resolve_verify_turn_timeout(config_path)
return await run_turn_test(config_path, target, prompt, run_timeout=run_timeout)
def _resolve_verify_turn_timeout(config_path: str) -> float:
"""Resolve ``gateway.preflight.verify_turn_timeout`` (seconds)."""
import yaml
try:
with open(config_path) as f:
cfg = yaml.safe_load(f) or {}
except OSError:
return VERIFY_TURN_TIMEOUT_DEFAULT
pre = ((cfg.get("gateway") or {}).get("preflight") or {})
try:
raw = pre.get("verify_turn_timeout")
return float(raw) if raw is not None else VERIFY_TURN_TIMEOUT_DEFAULT
except (TypeError, ValueError):
return VERIFY_TURN_TIMEOUT_DEFAULT
def check_gateway_running(config_path: str, timeout: float = 5.0) -> Tuple[bool, str]:
"""Probe the gateway REST ``/info`` endpoint (advisory)."""
import urllib.error
@@ -232,24 +232,13 @@ class ChannelSupervisor:
abort_signal.clear()
status.state = ChannelState.RUNNING
recovering = monitor.attempt > 0
logger.info(f"Starting channel '{name}'..." +
(f" (attempt {monitor.attempt + 1})" if recovering else ""))
(f" (attempt {monitor.attempt + 1})" if monitor.attempt > 0 else ""))
# Start the bot in a task so it can be cancelled
bot_task = asyncio.create_task(start_fn(name, bot))
abort_task = asyncio.create_task(abort_signal.wait())
# Issue #4043: the channel is coming back after a transient
# outage. Re-attempt anything the durable outbox held through the
# outage now, instead of leaving it undelivered until the next
# inbound chat() turn or a process restart. Scheduled as a
# background task (not awaited here) so it runs once the adapter
# has reconnected without blocking the supervision wait — bounded
# and best-effort, see _on_channel_recovered.
if recovering:
asyncio.create_task(self._on_channel_recovered(name, bot))
# Wait for either the bot to exit or abort signal
done, pending = await asyncio.wait(
[bot_task, abort_task],
@@ -412,48 +401,6 @@ class ChannelSupervisor:
f"(continuing with restart): {exc}"
)
async def _on_channel_recovered(self, name: str, bot: Any) -> None:
"""Re-drain the durable outbox after a channel recovers (Issue #4043).
The durable outbox deliberately *holds* deliverable replies through a
transient channel outage (attempt-and-age dead-letter policy) rather than
dropping them, but nothing re-attempted them once the channel came back —
they sat undelivered until the next inbound ``chat()`` turn or a process
restart. This closes that loop: when the supervisor observes a channel
transition from unhealthy back to connected, it triggers a bounded
re-drain of that platform's outbox so held replies go out promptly.
Duck-typed and best-effort: bots/adapters without a ``drain_outbox()``
hook are a silent no-op, and any error is swallowed so a failed re-drain
can never wedge supervision — the outbox's own attempt-and-age policy
still governs those messages.
The hook is resolved on the supervised object *itself* first — in the
gateway the durable platform adapter (``DurableAdapterMixin``) is passed
directly to ``run()``, so ``drain_outbox`` lives on ``bot`` — and only
then on a nested ``bot.adapter`` for wrapper bots that compose an
adapter. Checking both keeps built-in channels covered without assuming
a particular object shape.
"""
drain = getattr(bot, "drain_outbox", None)
if not callable(drain):
adapter = getattr(bot, "adapter", None)
drain = getattr(adapter, "drain_outbox", None)
if not callable(drain):
return
try:
succeeded, failed = await drain()
if succeeded or failed:
logger.info(
f"Channel '{name}' recovered: re-drained outbox "
f"({succeeded} sent, {failed} failed)"
)
except Exception as exc:
logger.warning(
f"Channel '{name}' outbox re-drain on recovery failed "
f"(will retry on next drain): {exc}"
)
async def _get_channel_health(self, name: str, bot: Any) -> Any:
"""Get health status for a channel.
@@ -16,19 +16,10 @@ Design notes:
platform with no token simply has no standalone sender available.
- Fully additive: this is only consulted as a fallback when the executor has no
live ``delivery_handler``; the live-adapter path is unchanged.
- Bounded retry: an ephemeral tick (cron / CI / scale-to-zero) has no persistent
process to drain a durable outbox, so instead of a fire-and-forget send each
HTTP call is retried in-process with exponential backoff, honouring a
server-mandated ``Retry-After`` on a 429. Only transient failures (5xx / 429 /
network) are retried; a permanent failure (bad token, 4xx) raises immediately
so the executor records ``delivery_error`` without wasting the tick. Retries
reuse the same battle-tested ``bots._resilience`` primitives interactive
replies use, so behaviour stays consistent across paths.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
@@ -104,27 +95,6 @@ def _resolve_chat_id(
return _home_channel_from_registry(platform)
class _HttpSendError(RuntimeError):
"""HTTP send failure that carries the status code.
Exposing ``status``/``headers`` lets the shared ``bots._resilience``
classifiers recognise a transient 5xx/429 (and honour a ``Retry-After``)
even when the response body has no recognisable text, so the retry decision
matches the interactive path exactly. The raw ``Retry-After`` header is
preserved verbatim (rather than pre-parsed to a float) so ``server_retry_after``
honours both integer-seconds *and* HTTP-date forms via its own parser — a
plain ``float(raw)`` here would silently discard an HTTP-date delay.
"""
def __init__(self, status: int, body: str = "", retry_after: Optional[str] = None):
super().__init__(f"HTTP {status}: {body}")
self.status = status
# Mirror the response-header shape ``server_retry_after`` reads from, so
# it can parse an integer-seconds *or* HTTP-date ``Retry-After`` itself.
if retry_after is not None:
self.headers = {"Retry-After": retry_after}
def _post_json(url: str, payload: dict, *, headers: Optional[dict] = None) -> None:
"""POST ``payload`` as JSON to ``url``, raising on a non-2xx response.
@@ -141,65 +111,17 @@ def _post_json(url: str, payload: dict, *, headers: Optional[dict] = None) -> No
status = getattr(resp, "status", 200)
if status >= 300:
body = resp.read().decode("utf-8", "replace")[:500]
raise _HttpSendError(status, body)
raise RuntimeError(f"HTTP {status}: {body}")
except urllib.error.HTTPError as e: # 4xx/5xx
body = e.read().decode("utf-8", "replace")[:500] if e.fp else ""
# Preserve the raw ``Retry-After`` verbatim so ``server_retry_after``
# honours both integer-seconds and HTTP-date forms; parsing to a float
# here would silently drop an HTTP-date delay (429 under throttling).
raw = None
try:
raw = e.headers.get("Retry-After") if e.headers else None
except Exception: # pragma: no cover - defensive: headers always dict-like
raw = None
retry_after = str(raw) if raw is not None else None
raise _HttpSendError(e.code, body, retry_after) from e
_MAX_ATTEMPTS = 4
raise RuntimeError(f"HTTP {e.code}: {body}") from e
async def _run_sync(fn: Callable[[], None]) -> None:
"""Run a blocking send ``fn`` off the event loop, retrying transient failures.
"""Run a blocking send ``fn`` off the event loop."""
import asyncio
A standalone tick has no persistent process to drain a durable outbox, so
durability here is a bounded in-process retry: ``fn`` is attempted up to
:data:`_MAX_ATTEMPTS` times with exponential backoff. A server-mandated
``Retry-After`` (e.g. a Telegram/HTTP 429) is honoured over the computed
backoff. Only errors classified transient by the shared resilience helper
are retried; a permanent failure (bad token, 4xx) raises on the first
attempt so the executor records ``delivery_error`` promptly rather than
burning the whole tick on a doomed send.
"""
try:
from ..bots._resilience import (
BackoffPolicy,
compute_backoff,
is_recoverable_error,
server_retry_after,
)
except Exception: # pragma: no cover - defensive: helper ships in-package
await asyncio.to_thread(fn)
return
policy = BackoffPolicy(initial_ms=1000, max_ms=20000, factor=2.0, jitter=0.2)
attempt = 0
while True:
attempt += 1
try:
await asyncio.to_thread(fn)
return
except Exception as e: # noqa: BLE001 — classify then re-raise/retry
if attempt >= _MAX_ATTEMPTS or not is_recoverable_error(e):
raise
wait = server_retry_after(e)
if wait is None:
wait = compute_backoff(policy, attempt)
logger.warning(
"Standalone delivery attempt %d/%d failed (%s); retrying in %.1fs",
attempt, _MAX_ATTEMPTS, e, wait,
)
await asyncio.sleep(wait)
await asyncio.to_thread(fn)
def _chunk(text: str, max_length: int) -> list:
@@ -227,8 +149,6 @@ def _chunk(text: str, max_length: int) -> list:
_TELEGRAM_LIMIT = 4096
_SLACK_LIMIT = 39000
_DISCORD_LIMIT = 2000
_WHATSAPP_LIMIT = 4096
_SIGNAL_LIMIT = 2000
async def _telegram_send(target: "DeliveryTarget", text: str) -> None:
@@ -310,67 +230,12 @@ async def _discord_send(target: "DeliveryTarget", text: str) -> None:
await _run_sync(lambda p=payload: _post_json(url, p, headers=headers))
async def _whatsapp_send(target: "DeliveryTarget", text: str) -> None:
# WhatsApp Cloud API: same token/phone-number-id env the live adapter uses
# (see bots/whatsapp.py). The chat id is the recipient phone number, resolved
# via the standard target/env/registry order like every other platform.
token = _env("WHATSAPP_ACCESS_TOKEN")
if not token:
raise RuntimeError("WHATSAPP_ACCESS_TOKEN not set for standalone delivery")
phone_number_id = _env("WHATSAPP_PHONE_NUMBER_ID")
if not phone_number_id:
raise RuntimeError("WHATSAPP_PHONE_NUMBER_ID not set for standalone delivery")
to = _resolve_chat_id(target, "whatsapp", "WHATSAPP_HOME_CHANNEL")
if not to:
raise RuntimeError("no recipient for whatsapp standalone delivery")
url = f"https://graph.facebook.com/v20.0/{phone_number_id}/messages"
headers = {"Authorization": f"Bearer {token}"}
def _send(part: str) -> None:
payload = {
"messaging_product": "whatsapp",
"to": to,
"type": "text",
"text": {"body": part},
}
# NOTE: a scheduler ``thread_id`` is a generic conversation identifier,
# NOT a WhatsApp message id. WhatsApp's ``context.message_id`` requires a
# real inbound message id to quote-reply; feeding it a generic thread id
# yields an invalid id the Cloud API rejects (a permanent 4xx). The live
# adapter (bots/whatsapp.py) only sets ``context`` from an explicit
# ``reply_to`` message id, never from ``thread_id`` — so we mirror that
# and simply omit the context here.
_post_json(url, payload, headers=headers)
for part in _chunk(text, _WHATSAPP_LIMIT):
await _run_sync(lambda p=part: _send(p))
async def _signal_send(target: "DeliveryTarget", text: str) -> None:
# Signal via a signal-cli-rest-api bridge (see bots/signal.py). The bridge
# url and sender account come from the same env the live adapter uses; the
# target channel id is the recipient number/group.
account = _env("SIGNAL_ACCOUNT")
if not account:
raise RuntimeError("SIGNAL_ACCOUNT not set for standalone delivery")
bridge = _env("SIGNAL_BRIDGE_URL") or "http://localhost:8080"
recipient = _resolve_chat_id(target, "signal", "SIGNAL_HOME_CHANNEL")
if not recipient:
raise RuntimeError("no recipient for signal standalone delivery")
url = f"{bridge.rstrip('/')}/v2/send"
for part in _chunk(text, _SIGNAL_LIMIT):
payload = {"number": account, "message": part, "recipients": [recipient]}
await _run_sync(lambda p=payload: _post_json(url, p))
# Platform → standalone sender. Keyed by the same lowercase platform names the
# adapter registry uses so a ``deliver: telegram`` target resolves uniformly.
_STANDALONE_SENDERS: dict = {
"telegram": _telegram_send,
"slack": _slack_send,
"discord": _discord_send,
"whatsapp": _whatsapp_send,
"signal": _signal_send,
}
@@ -344,23 +344,6 @@ class ScheduledAgentExecutor:
Split out from :meth:`_dispatch` so the run-scoped model pin can be
restored in a single ``finally`` around every exit path below.
"""
# Cross-run memory: load the job's persisted scratchpad (cursor /
# watermark / last-seen hash) so both the pre-run gate and the model
# turn can do *incremental* work — "only what's new since last run".
# ``prior`` is ``{}`` when the store has no per-job state support or the
# job has none, preserving today's stateless behaviour exactly.
prior_state = self._get_job_state(job)
# The unmodified baseline used when persisting after the run so a
# merge that only re-adds an already-present gate watermark is still
# detected as a change and written (``prior_state`` below gets the gate
# watermark folded in for the notepad, so it can't serve as the base).
original_state = dict(prior_state)
# Accumulates state to persist after the run — a monitor gate's
# watermark and/or state the agent emits. Kept separate from
# ``prior_state`` so we only ever *write* when something actually
# changed (an empty accumulator → no write, today's behaviour).
pending_updates: Dict[str, Any] = {}
# Pre-run condition gate (cost/efficiency): a cheap, deterministic
# check that decides whether the (expensive) model turn is warranted.
# When it reports "nothing to do" the tick is recorded as ``skipped`` —
@@ -368,20 +351,12 @@ class ScheduledAgentExecutor:
# any context it produced is appended to the message before the turn.
gate = self._resolve_condition(job)
if gate is not None:
# Only pass ``state=`` to a gate that opts into it — a legacy
# stateless gate (``should_run(self, job)``) would raise TypeError.
pass_state = self._gate_accepts_state(gate)
try:
# Run off the event loop: the default gate shells out via
# subprocess.run(), which would otherwise block every other
# tick / delivery / health-check coroutine for the gate's
# timeout. Mirrors the agent.chat() offload below.
if pass_state:
decision = await asyncio.to_thread(
gate.should_run, job, state=prior_state,
)
else:
decision = await asyncio.to_thread(gate.should_run, job)
decision = await asyncio.to_thread(gate.should_run, job)
except Exception as e: # pragma: no cover - defensive
logger.warning(
"Pre-run gate raised for job '%s': %s; running anyway",
@@ -391,12 +366,6 @@ class ScheduledAgentExecutor:
if decision is not None and not getattr(decision, "run", True):
reason = getattr(decision, "reason", None) or "pre-run gate: nothing to do"
logger.info("Job '%s' skipped by pre-run gate: %s", job.id, reason)
# A monitor gate may carry a watermark forward even on a
# suppressed tick (e.g. ``no_change`` recording the last-seen
# hash). Persist it so the *next* tick sees the advance.
self._persist_job_state(
job, prior_state, getattr(decision, "state_updates", None),
)
duration = time.time() - started
self._runner.mark_run(
job, status="skipped", error=reason, duration=duration,
@@ -408,18 +377,6 @@ class ScheduledAgentExecutor:
context = getattr(decision, "context", None)
if context:
message = f"{message}\n\n{context}"
# Fold any gate watermark into the state visible to this run
# (so the notepad reflects the latest cursor) AND queue it for
# persistence after the run — a go-path gate watermark must land
# too, not only a skip-path one.
updates = getattr(decision, "state_updates", None)
if isinstance(updates, dict) and updates:
prior_state = {**prior_state, **updates}
pending_updates.update(updates)
# Give the model turn memory of the last run by injecting a compact,
# clearly-delimited notepad. No-op when there is no prior state.
message = self._with_notepad(message, prior_state)
# Run-scoped policy: scan the *untrusted* portion of the run before it
# reaches the model — the user message plus any runtime-loaded skill or
@@ -518,16 +475,6 @@ class ScheduledAgentExecutor:
)
await asyncio.to_thread(self._audit_output, job, job_result)
# Persist cross-run memory: any gate watermark queued above plus any
# state the agent emitted (a ``state`` / ``state_updates`` mapping on a
# rich chat result) are merged onto the original baseline and written,
# keyed by job id and size-bounded by the store. No-op for stores
# without ``set_state`` or when nothing changed — today's behaviour.
agent_updates = self._agent_state_updates(result)
if isinstance(agent_updates, dict):
pending_updates.update(agent_updates)
self._persist_job_state(job, original_state, pending_updates)
# Honour the core intentional-silence contract: a run whose whole
# output is an exact silence marker (NO_REPLY / [SILENT] / SILENT) means
# "nothing worth sending — stay quiet". The run is still recorded as
@@ -802,125 +749,6 @@ class ScheduledAgentExecutor:
from .condition_gate import ShellConditionGate
return ShellConditionGate()
@staticmethod
def _gate_accepts_state(gate: Any) -> bool:
"""Whether ``gate.should_run`` opts into a keyword-only ``state=``.
A legacy stateless gate (``should_run(self, job)``) must never be
passed ``state=`` — it would raise ``TypeError`` and break every tick.
We detect the capability by signature (an explicit ``state`` parameter
or ``**kwargs``) so stateless gates keep working unchanged.
"""
should_run = getattr(gate, "should_run", None)
if should_run is None:
return False
try:
params = inspect.signature(should_run).parameters
except (TypeError, ValueError): # pragma: no cover - C-callable
return False
if "state" in params:
return True
return any(
p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()
)
# ── cross-run state helpers (JobStateStoreProtocol) ───────────────
def _state_store(self) -> Optional[Any]:
"""Return the backing store if it supports per-job state, else ``None``.
Reuses the runner's existing schedule store when it implements the core
:class:`JobStateStoreProtocol` (``get_state`` / ``set_state``). Detected
with ``hasattr`` so a store without state support (or an older runner)
transparently yields ``None`` — the stateless behaviour of today.
"""
store = getattr(self._runner, "_store", None)
if store is None:
return None
if hasattr(store, "get_state") and hasattr(store, "set_state"):
return store
return None
def _get_job_state(self, job: "ScheduleJob") -> Dict[str, Any]:
"""Load the job's persisted scratchpad (``{}`` when unsupported/empty)."""
store = self._state_store()
if store is None:
return {}
try:
state = store.get_state(job.id)
except Exception as e: # pragma: no cover - defensive
logger.warning("Could not read state for job '%s': %s", job.id, e)
return {}
return dict(state) if isinstance(state, dict) else {}
def _persist_job_state(
self,
job: "ScheduleJob",
prior: Dict[str, Any],
updates: Optional[Dict[str, Any]],
) -> None:
"""Merge ``updates`` onto ``prior`` and persist, if anything changed.
``updates is None`` means "write nothing" (an error/no-op probe leaves
prior state untouched); an empty dict likewise changes nothing. No-op
when the store has no state support.
One-shot jobs (``delete_after_run``) are already removed from the store
by ``claim_due`` before the run reaches here, and their state is popped
with them. Persisting now would recreate an orphan ``job_state`` entry
that nothing ever cleans up (the schedule is gone, so ``remove`` is a
no-op) — and which would be injected if the same explicit id is reused.
A single-run job has no "next run" to read a watermark, so skip it.
"""
if getattr(job, "delete_after_run", False):
return
if not isinstance(updates, dict) or not updates:
return
store = self._state_store()
if store is None:
return
merged = {**prior, **updates}
if merged == prior:
return
try:
store.set_state(job.id, merged)
except Exception as e: # pragma: no cover - defensive
logger.warning("Could not persist state for job '%s': %s", job.id, e)
@staticmethod
def _agent_state_updates(result: Any) -> Optional[Dict[str, Any]]:
"""Extract a state mapping the agent emitted on a rich chat result.
Most agents return a plain string (no state) — this returns ``None``
then, so nothing is written. A richer result object MAY expose a
``state_updates`` or ``state`` mapping to carry a watermark forward.
"""
for attr in ("state_updates", "state"):
value = getattr(result, attr, None)
if isinstance(value, dict) and value:
return value
return None
@staticmethod
def _with_notepad(message: str, state: Dict[str, Any]) -> str:
"""Prepend a compact, delimited "state since last run" notepad.
Returns ``message`` unchanged when there is no prior state so a
stateless job's prompt is byte-for-byte what it was before. The block
is rendered as sorted ``key=value`` lines so the agent can pick up a
cursor / watermark and do incremental work.
"""
if not state:
return message
try:
lines = "\n".join(
f"{k}={state[k]}" for k in sorted(state, key=str)
)
except Exception: # pragma: no cover - defensive
lines = str(state)
notepad = f"Notepad (state since last run):\n{lines}"
return f"{notepad}\n\n{message}"
# ── model pin / drift helpers ────────────────────────────────────
@staticmethod
@@ -1,231 +0,0 @@
"""Tests for recovery-triggered outbox re-drain (Issue #4043).
When a channel recovers from a transient outage, the supervisor must re-attempt
anything the durable outbox held through the outage — instead of leaving it
undelivered until the next inbound ``chat()`` turn or a process restart.
"""
from __future__ import annotations
import asyncio
import pytest
from praisonai_bot.bots._resilience import BackoffPolicy
from praisonai_bot.gateway.supervisor import ChannelState, ChannelSupervisor
def _fast_supervisor() -> ChannelSupervisor:
# Near-zero backoff so the recoverable-retry path is exercised quickly.
return ChannelSupervisor(policy=BackoffPolicy(initial_ms=1, max_ms=1, jitter=0.0))
def test_recovery_triggers_outbox_drain():
"""A transient outage then reconnect -> adapter.drain_outbox() is fired."""
drained = asyncio.Event()
class _Adapter:
def __init__(self):
self.drain_calls = 0
async def drain_outbox(self):
self.drain_calls += 1
drained.set()
return (1, 0)
class _Bot:
platform = "slack"
def __init__(self):
self.adapter = _Adapter()
bot = _Bot()
calls = {"n": 0}
running = asyncio.Event()
async def start_fn(name, b):
calls["n"] += 1
if calls["n"] == 1:
# First boot: transient (recoverable) outage.
raise ConnectionError("connection reset by peer")
# Reconnected: hold the channel open.
running.set()
await asyncio.Event().wait()
sup = _fast_supervisor()
async def scenario():
task = asyncio.create_task(sup.run("slack", bot, start_fn))
# Recovery drain must fire once the channel comes back after the outage.
await asyncio.wait_for(drained.wait(), timeout=2.0)
await asyncio.wait_for(running.wait(), timeout=2.0)
assert bot.adapter.drain_calls == 1
assert sup.get_status("slack").state == ChannelState.RUNNING
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
asyncio.run(asyncio.wait_for(scenario(), timeout=5.0))
def test_recovery_drains_hook_on_bot_itself():
"""Production shape: the durable adapter is supervised directly, so
drain_outbox() lives on the supervised object itself (not bot.adapter)."""
drained = asyncio.Event()
class _DurableAdapter:
platform = "slack"
def __init__(self):
self.drain_calls = 0
async def drain_outbox(self):
self.drain_calls += 1
drained.set()
return (2, 0)
bot = _DurableAdapter()
calls = {"n": 0}
running = asyncio.Event()
async def start_fn(name, b):
calls["n"] += 1
if calls["n"] == 1:
raise ConnectionError("connection reset by peer")
running.set()
await asyncio.Event().wait()
sup = _fast_supervisor()
async def scenario():
task = asyncio.create_task(sup.run("slack", bot, start_fn))
await asyncio.wait_for(drained.wait(), timeout=2.0)
await asyncio.wait_for(running.wait(), timeout=2.0)
assert bot.drain_calls == 1
assert sup.get_status("slack").state == ChannelState.RUNNING
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
asyncio.run(asyncio.wait_for(scenario(), timeout=5.0))
def test_clean_first_start_does_not_drain():
"""A channel that starts cleanly (no prior outage) must NOT re-drain."""
class _Adapter:
def __init__(self):
self.drain_calls = 0
async def drain_outbox(self):
self.drain_calls += 1
return (0, 0)
class _Bot:
platform = "slack"
def __init__(self):
self.adapter = _Adapter()
bot = _Bot()
running = asyncio.Event()
async def start_fn(name, b):
running.set()
await asyncio.Event().wait()
sup = _fast_supervisor()
async def scenario():
task = asyncio.create_task(sup.run("slack", bot, start_fn))
await asyncio.wait_for(running.wait(), timeout=2.0)
# Give any stray scheduled drain a chance to run before asserting.
await asyncio.sleep(0.05)
assert bot.adapter.drain_calls == 0
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
asyncio.run(asyncio.wait_for(scenario(), timeout=5.0))
def test_recovery_drain_without_hook_is_noop():
"""A bot/adapter with no drain_outbox() must recover without error."""
class _Bot:
platform = "slack"
adapter = object() # no drain_outbox
calls = {"n": 0}
running = asyncio.Event()
async def start_fn(name, b):
calls["n"] += 1
if calls["n"] == 1:
raise ConnectionError("connection reset by peer")
running.set()
await asyncio.Event().wait()
sup = _fast_supervisor()
async def scenario():
task = asyncio.create_task(sup.run("slack", _Bot(), start_fn))
await asyncio.wait_for(running.wait(), timeout=2.0)
assert sup.get_status("slack").state == ChannelState.RUNNING
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
asyncio.run(asyncio.wait_for(scenario(), timeout=5.0))
def test_recovery_drain_error_does_not_wedge_supervision():
"""A failing re-drain is swallowed; the channel still runs."""
class _Adapter:
async def drain_outbox(self):
raise RuntimeError("outbox is temporarily unavailable")
class _Bot:
platform = "slack"
adapter = _Adapter()
calls = {"n": 0}
running = asyncio.Event()
async def start_fn(name, b):
calls["n"] += 1
if calls["n"] == 1:
raise ConnectionError("connection reset by peer")
running.set()
await asyncio.Event().wait()
sup = _fast_supervisor()
async def scenario():
task = asyncio.create_task(sup.run("slack", _Bot(), start_fn))
await asyncio.wait_for(running.wait(), timeout=2.0)
await asyncio.sleep(0.05)
assert sup.get_status("slack").state == ChannelState.RUNNING
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
asyncio.run(asyncio.wait_for(scenario(), timeout=5.0))
@@ -1,111 +0,0 @@
"""Tests for the on-demand diagnostics bundle (Issue #4044).
Pins the two properties that make the bundle safe to attach to a bug report:
1. it produces one portable archive with the expected sanitised sections, and
2. it never leaks secret values — config credentials collapse to presence
markers and registered secrets are redacted out of every text section.
"""
from __future__ import annotations
import json
import zipfile
import pytest
from praisonai_bot.gateway.diagnostics import (
_sanitise_config_shape,
build_diagnostics_bundle,
)
def test_config_shape_drops_secret_values():
cfg = {
"channels": {
"telegram": {
"platform": "telegram",
"token": "123:SUPERSECRETTOKEN",
"chat_id": 42,
}
},
"gateway": {"auth_token": "", "host": "127.0.0.1"},
}
shape = _sanitise_config_shape(cfg)
tg = shape["channels"]["telegram"]
assert tg["token"] == "<set>"
assert tg["platform"] == "<str>"
assert tg["chat_id"] == "<int>"
assert shape["gateway"]["auth_token"] == "<empty>"
# No raw secret value survives anywhere in the shape.
assert "SUPERSECRETTOKEN" not in json.dumps(shape)
def test_build_bundle_writes_sanitised_archive(tmp_path):
config = tmp_path / "gateway.yaml"
config.write_text(
"channels:\n"
" telegram:\n"
" platform: telegram\n"
" token: 999:LEAKYTOKEN\n"
)
out_dir = tmp_path / "diag"
result = build_diagnostics_bundle(str(config), output_dir=str(out_dir))
bundle = result["path"]
assert bundle.endswith(".zip")
with zipfile.ZipFile(bundle) as zf:
names = set(zf.namelist())
assert {"summary.txt", "manifest.json", "config_shape.json"} <= names
blob = b"".join(zf.read(n) for n in names)
# The raw token from the config must never appear in the archive.
assert b"LEAKYTOKEN" not in blob
def test_build_bundle_redacts_registered_secret(tmp_path, monkeypatch):
from praisonaiagents import secrets as secrets_mod
secret = "TOKEN-abcdef0123456789"
secrets_mod.register_secret_for_redaction(secret)
# Force the log section to contain the secret so we can prove redaction.
monkeypatch.setattr(
"praisonai_bot.gateway.diagnostics._collect_logs",
lambda lines=200: secrets_mod.redact_secrets(f"line with {secret}"),
)
result = build_diagnostics_bundle(None, output_dir=str(tmp_path))
with zipfile.ZipFile(result["path"]) as zf:
logs = zf.read("logs.txt").decode()
assert secret not in logs
assert "[REDACTED]" in logs
def test_bundle_survives_missing_config(tmp_path):
result = build_diagnostics_bundle(None, output_dir=str(tmp_path))
with zipfile.ZipFile(result["path"]) as zf:
shape = json.loads(zf.read("config_shape.json"))
assert "error" in shape
def test_rapid_exports_do_not_overwrite(tmp_path, monkeypatch):
# Pin the second-resolution stamp so both exports would collide without the
# uniqueness guard, proving neither bundle is silently truncated (Greptile
# P1).
monkeypatch.setattr(
"praisonai_bot.gateway.diagnostics._now_stamp",
lambda: "20260101-000000",
)
first = build_diagnostics_bundle(None, output_dir=str(tmp_path))
second = build_diagnostics_bundle(None, output_dir=str(tmp_path))
assert first["path"] != second["path"]
import os
assert os.path.exists(first["path"])
assert os.path.exists(second["path"])
@@ -289,15 +289,6 @@ def test_start_preflight_soft_fails_on_ssl_only(monkeypatch, tmp_path):
cfg = tmp_path / "gateway.yaml"
cfg.write_text("channels:\n telegram:\n platform: telegram\n token: t\n")
# The model turn pre-flight (#4042) is default-on; stub it green so this
# test isolates the SSL-only channel soft-fail path (#2845).
async def _turn_ok(config_path, channel_name=None, prompt="ping"):
return True, "pong"
monkeypatch.setattr(
"praisonai_bot.cli.commands.gateway._verify_turn_preflight", _turn_ok
)
import praisonai.cli.features.gateway as gw_feature
started = {}
@@ -367,16 +358,6 @@ def test_start_no_preflight_skips_probe(monkeypatch, tmp_path):
monkeypatch.setattr(Bot, "probe", _must_not_probe)
# --no-preflight skips the channel probe, but the model turn pre-flight
# (#4042) is governed by its OWN toggle and still runs by default; stub it
# green so this test isolates the channel-probe skip behaviour.
async def _turn_ok(config_path, channel_name=None, prompt="ping"):
return True, "pong"
monkeypatch.setattr(
"praisonai_bot.cli.commands.gateway._verify_turn_preflight", _turn_ok
)
import praisonai.cli.features.gateway as gw_feature
started = {}
@@ -101,165 +101,6 @@ def test_run_turn_test_mocked(tmp_path, monkeypatch):
assert message == "OK from test"
def test_run_turn_test_passes_provider_overrides(tmp_path, monkeypatch):
cfg = tmp_path / "bot.yaml"
cfg.write_text(
"channels:\n"
" slack:\n platform: slack\n token: x\n"
"agents:\n"
" assistant:\n"
" name: assistant\n"
" instructions: hi\n"
" model: gpt-4o-mini\n"
" base_url: https://proxy.example/v1\n"
" api_key: sk-test\n"
"routing:\n default: assistant\n"
)
captured = {}
class _FakeAgent:
def __init__(self, **kwargs):
captured.update(kwargs)
monkeypatch.setattr("praisonaiagents.Agent", _FakeAgent)
monkeypatch.setattr(
"praisonai_bot.bots._defaults.apply_bot_smart_defaults",
lambda agent, cfg: agent,
)
monkeypatch.setattr(
"praisonai_bot.bots._session.BotSessionManager.chat",
AsyncMock(return_value="pong"),
)
from praisonai_bot.gateway.preflight import run_turn_test
ok, _ = asyncio.run(run_turn_test(str(cfg), "slack", "ping"))
assert ok is True
assert captured.get("base_url") == "https://proxy.example/v1"
assert captured.get("api_key") == "sk-test"
def test_resolve_verify_turn_defaults_on(tmp_path):
cfg = tmp_path / "bot.yaml"
cfg.write_text("channels:\n slack:\n platform: slack\n token: x\n")
from praisonai_bot.gateway.preflight import resolve_verify_turn
enabled, prompt = resolve_verify_turn(str(cfg))
assert enabled is True
assert prompt == "ping"
def test_resolve_verify_turn_opt_out(tmp_path):
cfg = tmp_path / "bot.yaml"
cfg.write_text(
"gateway:\n"
" preflight:\n"
" verify_turn: false\n"
" verify_turn_prompt: hello\n"
)
from praisonai_bot.gateway.preflight import resolve_verify_turn
enabled, prompt = resolve_verify_turn(str(cfg))
assert enabled is False
assert prompt == "hello"
def test_verify_turn_preflight_uses_first_channel(tmp_path, monkeypatch):
cfg = tmp_path / "bot.yaml"
cfg.write_text(
"channels:\n"
" slack:\n platform: slack\n token: x\n"
"agents:\n assistant:\n name: assistant\n instructions: hi\n"
"routing:\n default: assistant\n"
)
mock_chat = AsyncMock(return_value="pong")
monkeypatch.setattr(
"praisonai_bot.bots._session.BotSessionManager.chat",
mock_chat,
)
from praisonai_bot.gateway.preflight import verify_turn_preflight
ok, message = asyncio.run(verify_turn_preflight(str(cfg), prompt="ping"))
assert ok is True
assert message == "pong"
def test_verify_turn_preflight_no_channels_skips(tmp_path):
cfg = tmp_path / "bot.yaml"
cfg.write_text("agents:\n assistant:\n instructions: hi\n")
from praisonai_bot.gateway.preflight import verify_turn_preflight
ok, message = asyncio.run(verify_turn_preflight(str(cfg)))
assert ok is True
assert "skipping" in message.lower()
def test_verify_turn_preflight_bounds_timeout(tmp_path, monkeypatch):
cfg = tmp_path / "bot.yaml"
cfg.write_text(
"channels:\n"
" slack:\n platform: slack\n token: x\n"
"agents:\n assistant:\n name: assistant\n instructions: hi\n"
"routing:\n default: assistant\n"
)
captured = {}
real_init = None
from praisonai_bot.bots._session import BotSessionManager
real_init = BotSessionManager.__init__
def spy_init(self, *args, **kwargs):
captured["run_timeout"] = kwargs.get("run_timeout")
return real_init(self, *args, **kwargs)
monkeypatch.setattr(BotSessionManager, "__init__", spy_init)
monkeypatch.setattr(
"praisonai_bot.bots._session.BotSessionManager.chat",
AsyncMock(return_value="pong"),
)
from praisonai_bot.gateway.preflight import (
VERIFY_TURN_TIMEOUT_DEFAULT,
verify_turn_preflight,
)
ok, _ = asyncio.run(verify_turn_preflight(str(cfg), prompt="ping"))
assert ok is True
assert captured["run_timeout"] == VERIFY_TURN_TIMEOUT_DEFAULT
def test_verify_turn_preflight_timeout_override(tmp_path, monkeypatch):
cfg = tmp_path / "bot.yaml"
cfg.write_text(
"gateway:\n preflight:\n verify_turn_timeout: 5\n"
"channels:\n"
" slack:\n platform: slack\n token: x\n"
"agents:\n assistant:\n name: assistant\n instructions: hi\n"
"routing:\n default: assistant\n"
)
captured = {}
from praisonai_bot.bots._session import BotSessionManager
real_init = BotSessionManager.__init__
def spy_init(self, *args, **kwargs):
captured["run_timeout"] = kwargs.get("run_timeout")
return real_init(self, *args, **kwargs)
monkeypatch.setattr(BotSessionManager, "__init__", spy_init)
monkeypatch.setattr(
"praisonai_bot.bots._session.BotSessionManager.chat",
AsyncMock(return_value="pong"),
)
from praisonai_bot.gateway.preflight import verify_turn_preflight
ok, _ = asyncio.run(verify_turn_preflight(str(cfg), prompt="ping"))
assert ok is True
assert captured["run_timeout"] == 5.0
def test_parse_since_window():
from praisonai_bot.gateway.preflight import parse_since_window
@@ -68,9 +68,6 @@ def test_resolver_known_and_unknown():
assert ss.resolve_standalone_sender("telegram") is not None
assert ss.resolve_standalone_sender("Slack") is not None
assert ss.resolve_standalone_sender("discord") is not None
# Platforms with a live adapter now also have a standalone sender (#4050).
assert ss.resolve_standalone_sender("whatsapp") is not None
assert ss.resolve_standalone_sender("Signal") is not None
assert ss.resolve_standalone_sender("irc") is None
assert ss.resolve_standalone_sender("") is None
assert ss.resolve_standalone_sender(None) is None # type: ignore[arg-type]
@@ -240,159 +237,3 @@ def test_live_handler_still_wins(monkeypatch):
assert result.delivered is True
assert live == [("123", "echo:hello")]
# ── new-platform standalone senders (#4050) ──────────────────────────
def test_whatsapp_standalone_send(monkeypatch):
captured: list = []
def fake_post(url, payload, headers=None):
captured.append((url, payload, headers))
monkeypatch.setattr(ss, "_post_json", fake_post)
monkeypatch.setenv("WHATSAPP_ACCESS_TOKEN", "WATOKEN")
monkeypatch.setenv("WHATSAPP_PHONE_NUMBER_ID", "PN123")
ex = _agent_executor(delivery_handler=None)
result = _run(ex._execute_one(_job(deliver="whatsapp:15551234")))
assert result.delivered is True
assert captured, "expected a whatsapp send"
url, payload, headers = captured[0]
assert "/PN123/messages" in url
assert payload["to"] == "15551234"
assert payload["text"]["body"] == "echo:hello"
assert headers["Authorization"] == "Bearer WATOKEN"
def test_whatsapp_thread_id_omits_reply_context(monkeypatch):
# A scheduler thread_id is NOT a WhatsApp message id, so it must never be
# mapped to context.message_id (which the Cloud API would reject as invalid).
captured: list = []
def fake_post(url, payload, headers=None):
captured.append(payload)
monkeypatch.setattr(ss, "_post_json", fake_post)
monkeypatch.setenv("WHATSAPP_ACCESS_TOKEN", "WATOKEN")
monkeypatch.setenv("WHATSAPP_PHONE_NUMBER_ID", "PN123")
ex = _agent_executor(delivery_handler=None)
result = _run(ex._execute_one(_job(deliver="whatsapp:15551234:threadABC")))
assert result.delivered is True
assert captured, "expected a whatsapp send"
assert "context" not in captured[0]
def test_whatsapp_missing_token_records_delivery_error(monkeypatch):
monkeypatch.delenv("WHATSAPP_ACCESS_TOKEN", raising=False)
ex = _agent_executor(delivery_handler=None)
result = _run(ex._execute_one(_job(deliver="whatsapp:15551234")))
assert result.status == "succeeded"
assert result.delivered is False
assert result.delivery_error is not None
assert "WHATSAPP_ACCESS_TOKEN" in result.delivery_error
def test_signal_standalone_send(monkeypatch):
captured: list = []
def fake_post(url, payload, headers=None):
captured.append((url, payload))
monkeypatch.setattr(ss, "_post_json", fake_post)
monkeypatch.setenv("SIGNAL_ACCOUNT", "+15550000")
monkeypatch.setenv("SIGNAL_BRIDGE_URL", "http://bridge:9090")
ex = _agent_executor(delivery_handler=None)
result = _run(ex._execute_one(_job(deliver="signal:+15551234")))
assert result.delivered is True
assert captured, "expected a signal send"
url, payload = captured[0]
assert url == "http://bridge:9090/v2/send"
assert payload["number"] == "+15550000"
assert payload["recipients"] == ["+15551234"]
assert payload["message"] == "echo:hello"
# ── bounded retry (#4050) ────────────────────────────────────────────
async def _no_sleep(*_a, **_k):
# Zero-delay stand-in so the retry loop does not actually wait in tests.
return None
def test_transient_failure_is_retried_then_succeeds(monkeypatch):
calls: list = []
def flaky_post(url, payload, headers=None):
calls.append(payload)
if len(calls) == 1:
raise RuntimeError("HTTP 503: service unavailable")
monkeypatch.setattr(ss, "_post_json", flaky_post)
monkeypatch.setattr(ss.asyncio, "sleep", _no_sleep)
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "T0KEN")
ex = _agent_executor(delivery_handler=None)
result = _run(ex._execute_one(_job(deliver="telegram:123")))
assert result.delivered is True
assert result.delivery_error is None
assert len(calls) == 2 # first attempt failed, retry succeeded
def test_http_date_retry_after_is_honoured():
# A 429 with an HTTP-date Retry-After must not be discarded: the shared
# server_retry_after helper parses the date form off the error's headers.
from email.utils import format_datetime
from datetime import datetime, timezone, timedelta
from praisonai_bot.bots._resilience import server_retry_after
future = datetime.now(timezone.utc) + timedelta(seconds=45)
err = ss._HttpSendError(429, "too many requests", format_datetime(future))
wait = server_retry_after(err)
assert wait is not None
# Allow scheduling slack; the mandated wait is ~45s, never zero/None.
assert 30.0 <= wait <= 45.0
def test_integer_retry_after_is_honoured():
from praisonai_bot.bots._resilience import server_retry_after
err = ss._HttpSendError(429, "slow down", "30")
assert server_retry_after(err) == 30.0
def test_permanent_failure_is_not_retried(monkeypatch):
calls: list = []
def bad_post(url, payload, headers=None):
calls.append(payload)
raise RuntimeError("HTTP 403: forbidden")
slept: list = []
async def _record_sleep(*_a, **_k):
slept.append(True)
monkeypatch.setattr(ss, "_post_json", bad_post)
monkeypatch.setattr(ss.asyncio, "sleep", _record_sleep)
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "T0KEN")
ex = _agent_executor(delivery_handler=None)
result = _run(ex._execute_one(_job(deliver="telegram:123")))
assert result.delivered is False
assert result.delivery_error is not None
assert len(calls) == 1 # permanent 403 not retried
assert not slept
@@ -662,91 +662,6 @@ def session_show(
)
@app.command("handoff")
def session_handoff(
session_id: Optional[str] = typer.Argument(
None,
help="Session ID to hand off (default: most recent session)",
),
copy: bool = typer.Option(
False,
"--copy",
help="Copy the continuation prompt to the clipboard (best-effort).",
),
):
"""Print a self-contained continuation prompt assembled from durable state.
Unlike ``resume`` (which restores conversation) this is an additive,
read-only surface: it turns already-persisted goal/workflow/recap state into
the one artifact a fresh context needs — a generated continuation prompt —
without an LLM call.
"""
output = get_output_controller()
# Default to the most recent session when no id is given, mirroring the
# "most recent session" default the issue asks for.
if session_id is None:
try:
from ..state.project_sessions import list_project_sessions
recent = list_project_sessions(limit=1)
except Exception:
recent = []
if not recent:
output.print_error(
"No sessions found",
remediation="Start a session first, or pass a session id",
)
raise typer.Exit(1)
session_id = recent[0].get("session_id") or recent[0].get("id")
from ..state.session_resolver import resolve_session
session = resolve_session(session_id)
if not session.found:
output.print_error(
f"Session not found: {session_id}",
remediation="Use 'praisonai session list' to see available sessions",
)
raise typer.Exit(1)
from praisonaiagents.compaction import build_recap
from praisonaiagents.session import build_handoff_prompt
chat_history = session.chat_history or []
recap_text = build_recap(chat_history) if chat_history else ""
goal_state = (session.metadata or {}).get("goal_state")
checkpoint = (session.metadata or {}).get("workflow_checkpoint")
prompt = build_handoff_prompt(
recap=recap_text,
goal_state=goal_state if isinstance(goal_state, dict) else None,
workflow_checkpoint=checkpoint if isinstance(checkpoint, dict) else None,
)
if output.is_json_mode:
output.print_json(
{
"session_id": session_id,
"handoff": prompt,
"has_goal": isinstance(goal_state, dict),
"has_checkpoint": isinstance(checkpoint, dict),
}
)
return
if copy:
try:
import pyperclip # type: ignore
pyperclip.copy(prompt)
output.print_info("Continuation prompt copied to clipboard.")
except Exception:
output.print_info("Clipboard unavailable; printing prompt instead.")
output.print_panel(prompt, title="Session Handoff")
def _shares_dir() -> Path:
"""Directory holding published transcripts (``~/.praisonai/shares``).
@@ -16,20 +16,16 @@ def workflow_run(
model: str = typer.Option(None, "--model", "-m", help="LLM model to use"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
dry_run: bool = typer.Option(False, "--dry-run", help="Preview steps without executing (job workflows only)"),
resume: bool = typer.Option(False, "--resume", help="Resume from the last saved checkpoint"),
checkpoint: str = typer.Option(None, "--checkpoint", help="Checkpoint name to save/resume under"),
rebase_checkpoint: bool = typer.Option(False, "--rebase-checkpoint", help="Force resume at the same step index despite a workflow definition change"),
):
"""Run a workflow.
Use --var key=value to pass variables to YAML workflows.
Use --dry-run to preview job workflows without executing.
Use --resume to continue an interrupted run from its last checkpoint.
Can be specified multiple times.
Example:
praisonai workflow run my_workflow.yaml --var topic="AI Tools"
praisonai workflow run release-notes.md --resume
praisonai workflow run publish-pypi.yaml --dry-run
"""
from praisonai_code._wrapper_bridge import run_wrapper_command
@@ -42,30 +38,10 @@ def workflow_run(
argv.append('--verbose')
if dry_run:
argv.append('--dry-run')
if resume:
argv.append('--resume')
if checkpoint:
argv.extend(['--checkpoint', checkpoint])
if rebase_checkpoint:
argv.append('--rebase-checkpoint')
run_wrapper_command(argv, feature="workflow")
@app.command("checkpoints")
def workflow_checkpoints(
delete: str = typer.Option(None, "--delete", help="Delete the named checkpoint"),
):
"""List saved workflow checkpoints, or delete one with --delete."""
from praisonai_code._wrapper_bridge import run_wrapper_command
argv = ['workflow', 'checkpoints']
if delete:
argv.extend(['--delete', delete])
run_wrapper_command(argv, feature="workflow")
@app.command("list")
def workflow_list():
"""List available workflows."""
@@ -76,18 +52,6 @@ def workflow_list():
run_wrapper_command(argv, feature="workflow")
@app.command("validate")
def workflow_validate(
file: str = typer.Argument(..., help="Workflow file path"),
):
"""Validate a workflow file."""
from praisonai_code._wrapper_bridge import run_wrapper_command
argv = ['workflow', 'validate', file]
run_wrapper_command(argv, feature="workflow")
@app.command("create")
def workflow_create(
name: str = typer.Argument(..., help="Workflow name"),
@@ -7,18 +7,9 @@ directories:
- .praisonai/commands/*.md - Reusable named commands with template interpolation
- .praisonai/tools/*.py - Project-local Python tools (auto-loaded)
For agents and commands, the conventional ecosystem locations ``.claude/`` and
``.agents/`` are also honoured (mirroring skills/rules discovery), so existing
project assets are reusable with zero migration:
- .claude/agents/*.md|*.yaml, .agents/agents/*.md|*.yaml
- .claude/commands/*.md, .agents/commands/*.md
Tools stay ``.praisonai``-only because loading them executes user code.
Discovery order (later wins on name collision):
1. User-global: ~/.praisonai/{agents,commands,tools}/
2. Project-level (walk up to repo root): ``.claude`` and ``.agents`` first,
then ``.praisonai`` last so project-native definitions take precedence over
imported ones on a name collision.
2. Project-level: ./.praisonai/{agents,commands,tools}/ (walk up to repo root)
"""
import inspect
@@ -317,52 +308,23 @@ class CustomDefinitionsDiscovery:
return Path.home() / ".praisonai"
def _find_project_dirs(self) -> List[Path]:
"""Find project-level asset directories by walking up.
Enumerates the conventional ecosystem roots ``.claude`` and ``.agents``
in addition to ``.praisonai`` (mirroring skills/rules discovery) so
existing project agents/commands are reusable with zero migration.
The returned list is ordered for the "later wins" discovery pass so
that on a name collision:
* a **nearer** directory beats a more distant ancestor (a nested
project's definition overrides one inherited from a parent repo), and
* within a single level ``.praisonai`` beats imported
``.claude``/``.agents`` roots (project-native wins over imported).
Both are achieved by yielding the most-distant ancestor first and the
current directory last, and — within each level — placing
``.praisonai`` last. This prevents an ancestor ``.claude``/``.agents``
definition from silently overriding a nested ``.praisonai`` one.
"""
# Collect each level's roots nearest-first, then reverse the levels so
# the nearest directory is applied last (wins) under "later wins".
levels: List[List[Path]] = []
"""Find project-level .praisonai directories by walking up."""
dirs = []
current = Path.cwd()
# Walk up to git root or filesystem root
git_root = get_git_root()
stop_at = git_root if git_root else Path("/")
while current != current.parent:
level_dirs: List[Path] = []
# ``.praisonai`` last so it wins within this level on collision.
for root in (".claude", ".agents", ".praisonai"):
candidate = current / root
if candidate.exists() and candidate.is_dir():
level_dirs.append(candidate)
if level_dirs:
levels.append(level_dirs)
praisonai_dir = current / ".praisonai"
if praisonai_dir.exists() and praisonai_dir.is_dir():
dirs.append(praisonai_dir)
if current == stop_at:
break
current = current.parent
# Ancestors first, current directory last, so nearer wins overall.
dirs: List[Path] = []
for level_dirs in reversed(levels):
dirs.extend(level_dirs)
return dirs
def _discover_from_dir(self, base_dir: Path, source: str) -> None:
@@ -388,15 +350,8 @@ class CustomDefinitionsDiscovery:
# Discover tools (project-local Python modules). Loading executes
# user code, so it is gated by the same PRAISONAI_ALLOW_LOCAL_TOOLS
# opt-in the rest of the wrapper enforces via load_user_module.
# Only ``.praisonai/tools`` is honoured: imported ecosystem roots
# (``.claude``/``.agents``) contribute agents/commands but never
# auto-executed code, so a drop-in checkout cannot run arbitrary tools.
tools_dir = base_dir / "tools"
if (
base_dir.name == ".praisonai"
and tools_dir.exists()
and tools_dir.is_dir()
):
if tools_dir.exists() and tools_dir.is_dir():
for file_path in sorted(tools_dir.iterdir()):
if file_path.suffix == ".py" and not file_path.name.startswith("_"):
for tool in self._load_tools(file_path, source):
@@ -964,11 +919,7 @@ def count_project_tool_files() -> Tuple[int, int]:
"""
discovery = CustomDefinitionsDiscovery()
user_dir = discovery._get_user_dir()
# Only ``.praisonai`` roots carry auto-loaded tools (mirrors
# _discover_from_dir), so ignore imported ``.claude``/``.agents`` roots here.
project_dirs = [
d for d in discovery._find_project_dirs() if d.name == ".praisonai"
]
project_dirs = discovery._find_project_dirs()
seen: set = set()
@@ -589,20 +589,8 @@ class JobWorkflowExecutor:
criteria = judge_config.get("criteria", "Output is high quality")
threshold = float(judge_config.get("threshold", 7.0))
model = judge_config.get("model", "gpt-4o-mini")
on_fail = judge_config.get("on_fail", "stop") # stop | warn
# 'retry' would require re-running the upstream gated step, which the
# judge step does not control. Reject it clearly rather than silently
# behaving like 'stop'.
if on_fail not in ("stop", "warn"):
return {
"ok": False,
"error": (
f"Unsupported on_fail value '{on_fail}' for judge step. "
"Supported values: 'stop', 'warn'."
),
}
on_fail = judge_config.get("on_fail", "stop") # stop | warn | retry
try:
judge = Judge(criteria=criteria, model=model)
result = judge.evaluate(input_text)
@@ -1,205 +0,0 @@
"""Tests for ecosystem-directory agent/command discovery.
Named agents and commands are discovered from the conventional ``.claude/``
and ``.agents/`` layouts in addition to ``.praisonai/`` (mirroring skills/rules
discovery), so existing project assets are reusable with zero migration.
``.praisonai/`` wins on name collision, and tools stay ``.praisonai``-only.
"""
from pathlib import Path
import pytest
from praisonai_code.cli.features.custom_definitions import (
CustomDefinitionsDiscovery,
count_project_tool_files,
)
AGENT_MD = "---\nrole: {role}\n---\nYou are {role}.\n"
COMMAND_MD = "---\ndescription: {desc}\n---\n{body}\n"
@pytest.fixture
def project(tmp_path, monkeypatch):
"""cwd into an empty project with the walk-up bounded to this tree."""
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(
"praisonai_code.cli.features.custom_definitions.get_git_root",
lambda: tmp_path,
)
# Keep user-global discovery out of the picture.
monkeypatch.setattr(
CustomDefinitionsDiscovery,
"_get_user_dir",
lambda self: tmp_path / "no-such-user-home",
)
return tmp_path
def _write(base: Path, kind: str, name: str, content: str) -> None:
d = base / kind
d.mkdir(parents=True, exist_ok=True)
(d / name).write_text(content)
class TestClaudeAndAgentsDiscovery:
def test_agent_from_claude_dir(self, project):
_write(
project / ".claude", "agents", "reviewer.md",
AGENT_MD.format(role="Reviewer"),
)
agent = CustomDefinitionsDiscovery().get_agent("reviewer")
assert agent is not None
assert agent.role == "Reviewer"
def test_agent_from_agents_dir(self, project):
_write(
project / ".agents", "agents", "planner.md",
AGENT_MD.format(role="Planner"),
)
agent = CustomDefinitionsDiscovery().get_agent("planner")
assert agent is not None
assert agent.role == "Planner"
def test_command_from_claude_dir(self, project):
_write(
project / ".claude", "commands", "deploy.md",
COMMAND_MD.format(desc="Deploy it", body="do $ARGUMENTS"),
)
cmd = CustomDefinitionsDiscovery().get_command("deploy")
assert cmd is not None
assert cmd.description == "Deploy it"
def test_command_from_agents_dir(self, project):
_write(
project / ".agents", "commands", "lint.md",
COMMAND_MD.format(desc="Lint it", body="lint $ARGUMENTS"),
)
cmd = CustomDefinitionsDiscovery().get_command("lint")
assert cmd is not None
class TestPraisonaiPrecedence:
def test_praisonai_agent_wins_over_claude(self, project):
_write(
project / ".claude", "agents", "shared.md",
AGENT_MD.format(role="FromClaude"),
)
_write(
project / ".praisonai", "agents", "shared.md",
AGENT_MD.format(role="FromPraisonai"),
)
agent = CustomDefinitionsDiscovery().get_agent("shared")
assert agent.role == "FromPraisonai"
def test_praisonai_command_wins_over_agents(self, project):
_write(
project / ".agents", "commands", "shared.md",
COMMAND_MD.format(desc="FromAgents", body="x"),
)
_write(
project / ".praisonai", "commands", "shared.md",
COMMAND_MD.format(desc="FromPraisonai", body="x"),
)
cmd = CustomDefinitionsDiscovery().get_command("shared")
assert cmd.description == "FromPraisonai"
class TestAncestorWalk:
def test_agent_discovered_from_ancestor_claude(self, project):
# Asset lives at the repo root's .claude; cwd is a nested package.
_write(
project / ".claude", "agents", "root_agent.md",
AGENT_MD.format(role="RootAgent"),
)
nested = project / "packages" / "web"
nested.mkdir(parents=True)
import os
os.chdir(nested)
agent = CustomDefinitionsDiscovery().get_agent("root_agent")
assert agent is not None
assert agent.role == "RootAgent"
class TestCrossLevelPrecedence:
"""A nearer directory must win over a more distant ancestor, even when the
nearer definition lives in ``.praisonai`` and the ancestor's in ``.claude``.
Guards the "later wins" ordering so an inherited ancestor asset never
silently overrides a nested project-native one.
"""
def test_nested_praisonai_wins_over_ancestor_claude(self, project):
# Ancestor (repo root) .claude defines the agent...
_write(
project / ".claude", "agents", "shared.md",
AGENT_MD.format(role="FromAncestorClaude"),
)
# ...but a nested package's .praisonai overrides it.
nested = project / "packages" / "web"
_write(
nested / ".praisonai", "agents", "shared.md",
AGENT_MD.format(role="FromNestedPraisonai"),
)
import os
os.chdir(nested)
agent = CustomDefinitionsDiscovery().get_agent("shared")
assert agent.role == "FromNestedPraisonai"
def test_nested_claude_wins_over_ancestor_praisonai(self, project):
# Even a nested ecosystem root beats an ancestor's .praisonai, because
# proximity dominates: the nearer directory is the more specific one.
_write(
project / ".praisonai", "commands", "shared.md",
COMMAND_MD.format(desc="FromAncestorPraisonai", body="x"),
)
nested = project / "packages" / "web"
_write(
nested / ".claude", "commands", "shared.md",
COMMAND_MD.format(desc="FromNestedClaude", body="x"),
)
import os
os.chdir(nested)
cmd = CustomDefinitionsDiscovery().get_command("shared")
assert cmd.description == "FromNestedClaude"
class TestToolsStayPraisonaiOnly:
def test_tools_not_loaded_from_claude(self, project, monkeypatch):
monkeypatch.setenv("PRAISONAI_ALLOW_LOCAL_TOOLS", "true")
_write(
project / ".claude", "tools", "greet.py",
"def greet(name):\n return name\n",
)
discovery = CustomDefinitionsDiscovery()
assert discovery.get_tool("greet.greet") is None
def test_count_ignores_claude_tools(self, project, monkeypatch):
monkeypatch.delenv("PRAISONAI_ALLOW_LOCAL_TOOLS", raising=False)
_write(
project / ".claude", "tools", "greet.py",
"def greet(name):\n return name\n",
)
_write(
project / ".praisonai", "tools", "hello.py",
"def hello(name):\n return name\n",
)
# Only the .praisonai tool is counted.
assert count_project_tool_files()[0] == 1
class TestNoEcosystemDirsUnchanged:
def test_only_praisonai_still_works(self, project):
_write(
project / ".praisonai", "agents", "native.md",
AGENT_MD.format(role="Native"),
)
agent = CustomDefinitionsDiscovery().get_agent("native")
assert agent is not None
assert agent.role == "Native"
@@ -1,6 +1,6 @@
{
"version": "1.5.87",
"lastUpdated": "2026-08-19",
"lastUpdated": "2026-08-18",
"generatedBy": "praisonai._dev.parity.generator",
"sourceOfTruth": "Python SDK (praisonaiagents)",
"status": "PARITY_ACHIEVED",
@@ -1,6 +1,5 @@
"""Shared utilities for safe shell command handling across sandbox backends."""
import re
import shlex
from typing import List, Union
@@ -37,38 +36,3 @@ def build_argv(command: Union[str, List[str]], shell: bool = False) -> List[str]
else:
# Direct argv execution - no shell
return cmd_list
def policy_scan_parts(cmd: list) -> list:
"""Expand a `sh -c "..."` argv into the tokens a policy check should see.
With shell=True an argv is ``["sh", "-c", "cat /etc/passwd"]``. A path scan
that iterates argv sees one opaque string starting with "cat", so
``blocked_paths`` silently stops matching. Splitting the payload back out
restores the check without changing how the command runs.
"""
parts = list(cmd)
for i, token in enumerate(cmd):
if token == "-c" and i + 1 < len(cmd):
try:
parts.extend(shlex.split(cmd[i + 1]))
except ValueError:
parts.extend(cmd[i + 1].split())
return parts
def strip_heredoc_bodies(command: str) -> str:
"""Remove ``<<'DELIM' ... DELIM`` bodies from a command before policy checks.
A quoted heredoc body is never executed -- it is the literal bytes of a
file. Scanning it as if it were a command means you cannot write a file
that merely *mentions* a blocked pattern: saving a shell tutorial
containing "rm -rf" was rejected as an attempt to run it, and a file whose
text contained "/etc/passwd" was rejected as an attempt to read it.
Only the quoted form (``<<'EOF'``) is stripped. An unquoted heredoc still
undergoes shell expansion, so its contents can execute and must stay in
scope for the checks.
"""
pattern = re.compile(r"<<'([^']+)'\n.*?\n\1", re.DOTALL)
return pattern.sub("<<'HEREDOC'", command)
@@ -7,7 +7,6 @@ Provides serverless code execution on Modal's cloud platform.
from __future__ import annotations
import logging
import sys
import time
import uuid
from typing import Any, Dict, List, Optional, Union
@@ -22,48 +21,6 @@ logger = logging.getLogger(__name__)
TIMEOUT_EXIT_CODE = 124
def _modal_scaling_kwargs() -> dict:
"""Scaling arguments spelled for whichever Modal SDK is installed.
``allow_concurrent_inputs`` became ``max_concurrent_inputs`` and
``keep_warm`` became ``min_containers`` on 2025-02-24. The old names now
raise a DeprecationError rather than warning, so hardcoding either spelling
breaks half the installed base.
"""
import inspect
try:
import modal
params = inspect.signature(modal.App.function).parameters
except Exception: # pragma: no cover - fall back to the current spelling
return {"min_containers": 1, "max_concurrent_inputs": 10}
kwargs = {}
kwargs["min_containers" if "min_containers" in params else "keep_warm"] = 1
if "max_concurrent_inputs" in params:
kwargs["max_concurrent_inputs"] = 10
elif "allow_concurrent_inputs" in params:
kwargs["allow_concurrent_inputs"] = 10
return kwargs
def _modal_base_image(reference: str):
"""Build the Modal image, only grafting Python on when it is missing.
``add_python=`` installs a Python into an image that has none. Passing it
for an image that already ships one -- the default here is ``python:3.11``
-- makes the build fail, so `run_in="modal"` died during image build.
"""
import modal
name = (reference or "").lower()
already_has_python = name.startswith("python:") or "/python:" in name
if already_has_python:
return modal.Image.from_registry(reference)
return modal.Image.from_registry(reference, add_python="3.11")
class ModalSandbox:
"""Modal-based sandbox for serverless code execution.
@@ -83,7 +40,7 @@ class ModalSandbox:
def __init__(
self,
gpu: Optional[str] = None,
image: Optional[str] = None,
image: str = "python:3.11",
timeout: int = 300,
app_name: Optional[str] = None,
):
@@ -96,16 +53,11 @@ class ModalSandbox:
app_name: Optional Modal app name
"""
self.gpu = gpu
# Default to the RUNNING interpreter's Python. A serialized function
# is pickled locally and unpickled in the image, so the two Pythons
# must match -- a hardcoded 3.11 image failed for anyone on 3.12 with
# "defined with Python 3.12, but its Image has 3.11".
self.image = image or f"python:{sys.version_info.major}.{sys.version_info.minor}"
self.image = image
self.timeout = timeout
self.app_name = app_name or f"praisonai-sandbox-{uuid.uuid4().hex[:8]}"
self._app = None
self._app_ctx = None
self._function = None
self._is_running = False
@@ -146,9 +98,15 @@ class ModalSandbox:
modal.gpu.A100() if self.gpu.upper() == "A100" else \
modal.gpu.T4() # Default fallback
image = _modal_base_image(self.image).pip_install("torch", "numpy")
image = modal.Image.from_registry(
self.image,
add_python="3.11"
).pip_install("torch", "numpy")
else:
image = _modal_base_image(self.image)
image = modal.Image.from_registry(
self.image,
add_python="3.11"
)
gpu_config = None
# Create function for code execution
@@ -156,15 +114,8 @@ class ModalSandbox:
image=image,
gpu=gpu_config,
timeout=self.timeout,
# Modal renamed these on 2025-02-24 and the old spellings now
# raise rather than warn, so `run_in="modal"` failed before the
# sandbox was ever built. Both names are passed through a
# compatibility shim so this works on either SDK generation.
# The decorated function is defined inside a method, not at
# module scope, which Modal only accepts when it is told to
# serialise it.
serialized=True,
**_modal_scaling_kwargs(),
allow_concurrent_inputs=10,
keep_warm=1,
)
def execute_code(code: str, language: str = "python", env_vars: Dict[str, str] = None):
"""Execute code in Modal environment."""
@@ -245,16 +196,6 @@ class ModalSandbox:
)
self._function = execute_code
# Enter the app context and hold it. A Modal function only carries
# the metadata it needs to run while its App is running, so
# defining the function and then calling .remote() outside a
# running app failed with "Function has not been hydrated".
self._app_ctx = self._app.run()
try:
await self._app_ctx.__aenter__()
except AttributeError: # older SDKs expose a sync context
self._app_ctx.__enter__()
self._is_running = True
logger.info(f"Modal sandbox initialized with app: {self.app_name}")
@@ -263,15 +204,6 @@ class ModalSandbox:
async def stop(self) -> None:
"""Stop/cleanup the Modal app."""
ctx, self._app_ctx = getattr(self, "_app_ctx", None), None
if ctx is not None:
try:
try:
await ctx.__aexit__(None, None, None)
except AttributeError:
ctx.__exit__(None, None, None)
except Exception as exc: # pragma: no cover - teardown must be quiet
logger.debug("Modal app context did not close cleanly: %s", exc)
self._app = None
self._function = None
self._is_running = False
@@ -92,12 +92,7 @@ class SubprocessSandbox:
if not cmd:
return "Empty command"
from ._shell import strip_heredoc_bodies
# A quoted heredoc body is file content, not a command. Leaving it in
# made write_file() reject any file whose text happened to contain a
# blocked pattern.
cmd_str = strip_heredoc_bodies(" ".join(cmd))
cmd_str = " ".join(cmd)
for blocked in policy.blocked_commands:
if blocked and blocked in cmd_str:
return f"Blocked command pattern: {blocked}"
@@ -113,11 +108,7 @@ class SubprocessSandbox:
if os.path.basename(cmd[0]) in shell_bins:
return "Subprocess execution is disabled by security policy"
# Scan the shell payload too: with shell=True the real path lives
# inside `sh -c "..."`, where a plain argv walk cannot see it.
from ._shell import policy_scan_parts
for part in policy_scan_parts([strip_heredoc_bodies(c) for c in cmd]):
for part in cmd:
if not part.startswith(("/", "~", ".")):
continue
expanded = os.path.realpath(os.path.expanduser(part))
@@ -143,43 +134,18 @@ class SubprocessSandbox:
try:
import resource
if limits.memory_mb and limits.memory_mb > 0:
bytes_ = limits.memory_mb * 1024 * 1024
resource.setrlimit(resource.RLIMIT_AS, (bytes_, bytes_))
if limits.max_processes and limits.max_processes > 0:
resource.setrlimit(resource.RLIMIT_NPROC, (limits.max_processes, limits.max_processes))
if limits.max_open_files and limits.max_open_files > 0:
resource.setrlimit(resource.RLIMIT_NOFILE, (limits.max_open_files, limits.max_open_files))
# Note: RLIMIT_CPU is process CPU time, not wall clock time - timeout is handled separately
except ImportError:
logger.warning("Resource module not available - resource limits not enforced")
return
import sys
# Each limit gets its own try/except. One shared block meant the first
# failure skipped the rest -- and RLIMIT_AS always fails on macOS, so a
# single unsupported limit silently voided NPROC and NOFILE too.
wanted = []
if limits.memory_mb and limits.memory_mb > 0:
# RLIMIT_AS is unlimited-by-default on Darwin and raises
# "current limit exceeds maximum limit"; skip rather than warn.
if sys.platform != "darwin":
bytes_ = limits.memory_mb * 1024 * 1024
wanted.append(("RLIMIT_AS", resource.RLIMIT_AS, (bytes_, bytes_)))
# RLIMIT_NPROC is deliberately NOT set. POSIX defines it per *real user
# ID*, not per process tree, so a value like max_processes=10 counts
# every process the user already has running -- the sandbox then cannot
# fork at all and even `echo a | tr a-z A-Z` dies with
# "sh: fork: Resource temporarily unavailable".
# This was latent before: RLIMIT_AS raised first and skipped the rest,
# so NPROC never applied. Capping a sandbox's process count needs a
# cgroup or a container, not setrlimit.
if limits.max_open_files and limits.max_open_files > 0:
wanted.append(("RLIMIT_NOFILE", resource.RLIMIT_NOFILE,
(limits.max_open_files, limits.max_open_files)))
# Note: RLIMIT_CPU is process CPU time, not wall clock - timeout is separate.
for label, which, value in wanted:
try:
resource.setrlimit(which, value)
except (OSError, ValueError) as e:
# This runs inside the forked child, so a warning here lands in
# the sandbox's stderr and is returned to the model as tool
# output. Stay silent; the limit simply does not apply.
logger.debug("Could not set %s: %s", label, e)
except (OSError, ValueError) as e:
logger.warning(f"Failed to set resource limits: {e}")
@property
def is_available(self) -> bool:
+1 -8
View File
@@ -273,14 +273,7 @@ class TestModalSandbox:
sandbox = ModalSandbox()
assert sandbox.gpu is None
# The default image follows the RUNNING interpreter, not a hardcoded
# 3.11. A serialized Modal function is pickled locally and unpickled in
# the image, so a mismatch fails with "defined with Python 3.12, but
# its Image has 3.11" -- which is what a fixed 3.11 default caused for
# everyone not on 3.11.
import sys
assert sandbox.image == f"python:{sys.version_info.major}.{sys.version_info.minor}"
assert sandbox.image == "python:3.11"
assert sandbox.timeout == 300
assert sandbox.app_name.startswith("praisonai-sandbox-")
@@ -149,7 +149,7 @@ class TestEnvironmentIsolation:
class TestResourceLimits:
"""Tests for POSIX resource limits enforcement."""
def test_apply_rlimits_sets_memory_and_file_limits(self):
def test_apply_rlimits_sets_memory_limit(self):
"""Resource limits should be applied via setrlimit on POSIX systems."""
if os.name != "posix":
pytest.skip("Resource limits only supported on POSIX systems")
@@ -169,54 +169,10 @@ class TestResourceLimits:
with patch("resource.RLIMIT_NOFILE", 8, create=True):
sandbox._apply_rlimits(limits)
mock_setrlimit.assert_any_call(8, (50, 50))
# RLIMIT_AS is skipped on macOS, where it is unlimited by default
# and setting it raises "current limit exceeds maximum limit".
# Everywhere else it carries the memory cap.
import sys as _sys
applied = [call.args[0] for call in mock_setrlimit.call_args_list]
expected_memory = 128 * 1024 * 1024
if _sys.platform == "darwin":
assert 9 not in applied, "RLIMIT_AS cannot be set on Darwin"
else:
mock_setrlimit.assert_any_call(9, (expected_memory, expected_memory))
# RLIMIT_NPROC (7) is deliberately NOT set. POSIX scopes it to the
# real USER ID, not the process tree, so max_processes=10 counts
# every process the user already has running and the sandbox cannot
# fork at all -- `echo a | tr a-z A-Z` dies with
# "sh: fork: Resource temporarily unavailable".
# Capping a sandbox's process count needs a cgroup or a container.
assert not any(
call.args[0] == 7 for call in mock_setrlimit.call_args_list
), "RLIMIT_NPROC is per-user; setting it breaks the sandbox entirely"
def test_apply_rlimits_applies_each_limit_independently(self):
"""One unsupported limit must not silently skip the rest.
The limits shared a single try/except, so RLIMIT_AS -- which always
raises on macOS -- swallowed every later setrlimit call. NOFILE was
never applied on Darwin as a result.
"""
if os.name != "posix":
pytest.skip("Resource limits only supported on POSIX systems")
sandbox = SubprocessSandbox()
limits = ResourceLimits(memory_mb=128, timeout_seconds=30, max_open_files=50)
def fail_on_memory(which, _limits):
if which == 9:
raise ValueError("current limit exceeds maximum limit")
with patch("resource.setrlimit", side_effect=fail_on_memory) as mock_setrlimit:
with patch("resource.RLIMIT_AS", 9, create=True):
with patch("resource.RLIMIT_NOFILE", 8, create=True):
sandbox._apply_rlimits(limits)
applied = [call.args[0] for call in mock_setrlimit.call_args_list]
assert 8 in applied, "a failing RLIMIT_AS must not skip RLIMIT_NOFILE"
mock_setrlimit.assert_any_call(9, (expected_memory, expected_memory))
mock_setrlimit.assert_any_call(7, (10, 10))
mock_setrlimit.assert_any_call(8, (50, 50))
def test_apply_rlimits_handles_missing_resource_module(self):
"""Should handle gracefully when resource module is not available."""
@@ -433,54 +389,4 @@ class TestSecurityRegressions:
assert env["HOME"] == "/tmp"
# The fallback is defensive - normally start() sets _temp_dir
# This test documents that /tmp is an acceptable fallback for HOME
class TestShellPayloadScanning:
"""blocked_paths must still match when the path is inside `sh -c`.
With shell semantics an argv is ``["sh", "-c", "cat /etc/passwd"]``. A
policy walk over argv sees one opaque string starting with "cat", so the
path check silently stopped matching -- literal `cat /etc/passwd` ran.
"""
def test_a_shell_payload_is_expanded_for_scanning(self):
from praisonai_sandbox._shell import policy_scan_parts
assert "/etc/passwd" in policy_scan_parts(["sh", "-c", "cat /etc/passwd"])
def test_unbalanced_quotes_fall_back_instead_of_raising(self):
from praisonai_sandbox._shell import policy_scan_parts
parts = policy_scan_parts(["sh", "-c", "cat /etc/passwd 'oops"])
assert "/etc/passwd" in parts
def test_a_quoted_heredoc_body_is_not_scanned_as_a_command(self):
"""A quoted heredoc body is the literal bytes of a file. Scanning it
rejected any file whose text merely mentioned a blocked pattern."""
from praisonai_sandbox._shell import strip_heredoc_bodies
command = "cat > f.txt <<'EOF'\nplease run rm -rf /tmp\nEOF"
assert "rm -rf" not in strip_heredoc_bodies(command)
def test_an_unquoted_heredoc_body_is_still_scanned(self):
"""Unquoted heredocs undergo shell expansion, so their contents really
can execute and must stay in scope."""
from praisonai_sandbox._shell import strip_heredoc_bodies
command = "cat > f.txt <<EOF\nrm -rf /tmp\nEOF"
assert "rm -rf" in strip_heredoc_bodies(command)
@pytest.mark.asyncio
async def test_a_blocked_path_inside_sh_c_is_refused(self):
from praisonai_sandbox import SubprocessSandbox
from praisonaiagents.sandbox import SandboxConfig, SecurityPolicy
policy = SecurityPolicy(allow_subprocess=True, blocked_paths=["/etc/passwd"])
sandbox = SubprocessSandbox(config=SandboxConfig(security_policy=policy))
await sandbox.start()
try:
result = await sandbox.run_command("cat /etc/passwd", shell=True)
assert result.error, "a blocked path must be refused, not executed"
assert "root:" not in (result.stdout or ""), "the file was actually read"
finally:
await sandbox.stop()
# This test documents that /tmp is an acceptable fallback for HOME
+1 -1
View File
@@ -1,6 +1,6 @@
{
"version": "1.5.87",
"lastUpdated": "2026-08-19",
"lastUpdated": "2026-08-18",
"generatedBy": "praisonai._dev.parity.generator",
"sourceOfTruth": "Python SDK (praisonaiagents)",
"summary": {
+1 -1
View File
@@ -1,6 +1,6 @@
# Feature Parity Tracker
> **Version:** 1.5.87 | **Last Updated:** 2026-08-19
> **Version:** 1.5.87 | **Last Updated:** 2026-08-18
> **Source of Truth:** Python SDK (praisonaiagents)
## Summary
+13 -42
View File
@@ -130,7 +130,7 @@ Each layer wraps the one inside it. When an agent misbehaves, the layer tells yo
| **3 · Harness** | Can it act, and be checked? | `tools=`, `MCP()`, `guardrails=`, `approval=`, `hooks=`, `sandbox=` |
| **4 · Loop** | When do we stop? | `execution=ExecutionConfig(...)`, `reflection=`, `autonomy=`, doom-loop detection |
| **5 · Graph** | Who runs when, and who checks whom? | `AgentFlow`, `route()`, `parallel()`, `loop()`, `repeat()` |
| **⬡ Managed** | *Where does it actually run?* | `tools_run_on="docker"` — one shared sandbox for the tools, or `run_on="anthropic"` for the whole agent |
| **⬡ Managed** | *Where does it actually run?* | `run_on="docker"` — one shared remote sandbox, or a fully hosted loop |
### Layer 1 · Prompt — *Did I say it clearly?*
@@ -235,7 +235,7 @@ The harness is commoditising; **where** the agent executes is the next multiplie
pip install praisonai
```
The simplest way in is `tools_run_on=` — one whole team or workflow shares **one** sandbox, so a file written by step 1 is there for step 2. Thinking stays on your machine:
The simplest way in is `run_on=` — one whole team or workflow shares **one** sandbox, so a file written by step 1 is there for step 2:
```python
from praisonaiagents import Agent, AgentFlow
@@ -243,7 +243,7 @@ from praisonaiagents import Agent, AgentFlow
writer = Agent(name="Writer", instructions="You write files.")
reader = Agent(name="Reader", instructions="You read files.")
flow = AgentFlow(tools_run_on="docker", steps=[writer, reader]) # or e2b | modal | daytona | flyio
flow = AgentFlow(run_on="docker", steps=[writer, reader]) # or e2b | modal | daytona | flyio
flow.run("Write 'hello' to /workspace/note.txt, then read it back")
```
@@ -251,7 +251,7 @@ Same thing with no Python at all:
```yaml
name: remote-demo
tools_run_on: docker # every step shares one sandbox
run_on: docker # every step shares one sandbox
agents:
writer: {role: Writer, goal: Write files}
reader: {role: Reader, goal: Read files}
@@ -262,51 +262,22 @@ steps:
action: "Read /workspace/note.txt"
```
For a single agent, two words cover it — and they answer different questions:
For a single agent, pick the axis you need — remote **tools** or a remote **loop**:
```python
from praisonaiagents import Agent
from praisonai import Agent, LocalAgent, LocalAgentConfig, HostedAgent
# A. Only the TOOLS move. Thinking stays on your machine.
agent = Agent(name="builder", instructions="You build things.",
tools_run_on="docker") # docker | e2b | modal | daytona | flyio
# tenki | sandlock | ssh | novita | subprocess
# A. Tools run in a remote sandbox; the agent loop stays local
agent = Agent(name="builder", backend=LocalAgent(
compute="e2b", # or modal | daytona | flyio | docker | tenki
config=LocalAgentConfig(model="gpt-4o-mini", name="RemoteTools"),
))
# B. The WHOLE agent moves — model calls, loop and tools
agent = Agent(name="teacher", instructions="You teach.", run_on="anthropic") # hosted
agent = Agent(name="builder", instructions="You build.", run_on="docker") # self-hosted
# B. The entire agent loop runs in the cloud (needs ANTHROPIC_API_KEY)
agent = Agent(name="teacher", backend=HostedAgent(provider="anthropic"))
agent.start("Write a Python script that prints the first 10 primes, then run it")
```
Ask any object where it runs, and it will tell you:
```python
>>> Agent(name="builder", instructions="x", tools_run_on="docker")
Agent(name='builder', thinks_on='this machine', tools_run_on='a Docker container')
>>> agent.where_does_it_run()
Thinking (the AI model calls) happens on this machine.
Tools run on a Docker container.
Your own tools (check_db) still run on this machine -- only shell, file and
code tools move. They read and write this machine's files.
```
Naming a place that cannot do the job is a typo, not a preference, so it says so:
```python
>>> Agent(name="x", instructions="i", run_on="e2b")
TypeError: Agent(run_on='e2b') is not valid: run_on= places the whole agent
-- model calls, loop and tools -- on a managed runtime, and 'e2b' runs
commands but cannot host an agent loop.
To run only the tools there: Agent(tools_run_on='e2b')
```
To run one block of code somewhere else, name the place on that call:
```python
agent.execute_code_sync("print(6 * 7)", run_in="sandlock") # kernel-enforced
```
See what is running and reclaim strays:
```bash
@@ -657,30 +657,9 @@ def ids_restore(
# sandbox lifecycle: ps / stop
# ─────────────────────────────────────────────────────────────────────────────
# Places worth polling for running sandboxes, derived from the registry rather
# than restated here. A hardcoded tuple meant a place contributed by another
# package could be provisioned but never listed or reclaimed -- and an
# unreclaimable cloud sandbox is a bill nobody sees.
#
# Excluded on purpose: "local" and "subprocess" run on this machine and have
# nothing to reclaim; "ssh" is someone else's machine, which is not ours to
# stop; "sandlock" is a process, not an instance; adapter-backed places have no
# cross-process registry to enumerate (see SandboxComputeAdapter).
_PS_EXCLUDED = frozenset({"local", "subprocess", "sandlock", "ssh", "native", "novita"})
def _ps_providers() -> tuple:
"""Places that can be listed and stopped, from the live registry."""
try:
from praisonaiagents.managed._compute_bridge import available_providers
return tuple(p for p in available_providers() if p not in _PS_EXCLUDED)
except Exception:
return ("docker", "e2b", "modal", "daytona", "flyio", "tenki")
#: Kept as a module attribute so existing imports and tests keep working.
_PS_PROVIDERS = _ps_providers()
# Providers worth polling for running sandboxes. "local" is excluded: it runs
# on this machine and has nothing to reclaim.
_PS_PROVIDERS = ("docker", "e2b", "modal", "daytona", "flyio", "tenki")
def _discover_instances(provider_filter: Optional[str] = None):
@@ -279,7 +279,7 @@ steps:
return "workflow"
def get_actions(self) -> List[str]:
return ["run", "validate", "list", "create", "auto", "checkpoints", "help"]
return ["run", "validate", "list", "create", "auto", "help"]
def get_help_text(self) -> str:
return """
@@ -349,81 +349,6 @@ Example:
if arg.endswith(('.yaml', '.yml')) and not arg.startswith('--'):
return arg
return None
_CHECKPOINT_FLAGS = {"--resume", "--rebase-checkpoint"}
_CHECKPOINT_VALUE_FLAGS = {"--checkpoint", "--var", "--model", "-m"}
def _get_markdown_target_from_args(self, args: List[str]) -> Optional[str]:
"""Return the first positional token (a .md file or a workflow name).
Skips known flags and their values so ``run release-notes.md --resume``
and ``run "Release Notes" --checkpoint foo`` both resolve correctly.
"""
skip_next = False
for arg in args:
if skip_next:
skip_next = False
continue
if arg in self._CHECKPOINT_VALUE_FLAGS:
skip_next = True
continue
if arg.startswith('--') or arg in ('-v',):
continue
return arg
return None
def _parse_checkpoint_flags(self, args: List[str], default_name: str):
"""Extract (checkpoint_name, resume, rebase_checkpoint) from run args."""
checkpoint_name = None
resume = False
rebase = False
for idx, arg in enumerate(args):
if arg == '--checkpoint' and idx + 1 < len(args):
checkpoint_name = args[idx + 1]
elif arg == '--resume':
resume = True
elif arg == '--rebase-checkpoint':
rebase = True
if not checkpoint_name:
checkpoint_name = default_name
return checkpoint_name, resume, rebase
def _run_markdown_workflow(self, target: str, args: List[str]) -> Any:
"""Run a markdown workflow by name (with checkpoint/resume support)."""
manager = self._get_manager()
if not manager:
return None
# Resolve a .md path to its workflow name (stem); names pass through.
workflow_name = target
if target.endswith('.md'):
workflow_name = os.path.splitext(os.path.basename(target))[0]
variables = self._parse_variables(args)
verbose = '--verbose' in args or '-v' in args
checkpoint_name, resume, rebase = self._parse_checkpoint_flags(args, workflow_name)
self.print_status(f"Running workflow: {workflow_name}", "info")
try:
result = manager.execute(
workflow_name,
variables=variables or {},
verbose=1 if verbose else 0,
checkpoint=checkpoint_name,
resume=checkpoint_name if resume else None,
rebase_checkpoint=rebase,
)
except Exception as e:
self.print_status(f"Workflow failed: {e}", "error")
return None
if not result.get("success"):
self.print_status(f"Workflow failed: {result.get('error', 'Unknown error')}", "error")
return result
if result.get("resumed_from_step") is not None:
self.print_status(f"Resumed from step {result['resumed_from_step'] + 1}", "info")
self.print_status("Workflow completed!", "success")
return result
def action_run(self, args: List[str], **kwargs) -> Any:
"""
@@ -441,11 +366,6 @@ Example:
"""
file_path = self._get_file_from_args(args)
if not file_path:
# Markdown workflows are referenced by name/path and run through the
# WorkflowManager (which supports checkpoint/resume), unlike YAML.
md_target = self._get_markdown_target_from_args(args)
if md_target:
return self._run_markdown_workflow(md_target, args)
self.print_status("Usage: praisonai workflow run <file.yaml>", "error")
return None
@@ -518,49 +438,7 @@ Example:
except Exception as e:
self.print_status(f"Workflow failed: {e}", "error")
return None
def action_checkpoints(self, args: List[str], **kwargs) -> Any:
"""List saved workflow checkpoints, or delete one with --delete <name>."""
manager = self._get_manager()
if not manager:
return None
delete_name = None
for idx, arg in enumerate(args):
if arg == '--delete' and idx + 1 < len(args):
delete_name = args[idx + 1]
if delete_name:
if manager.delete_checkpoint(delete_name):
self.print_status(f"Deleted checkpoint: {delete_name}", "success")
else:
self.print_status(f"Checkpoint not found: {delete_name}", "warning")
return None
checkpoints = manager.list_checkpoints()
if not checkpoints:
self.print_status("No checkpoints found.", "info")
return checkpoints
from rich import print as rprint
from rich.table import Table
table = Table(title="Workflow Checkpoints")
table.add_column("Name", style="cyan")
table.add_column("Workflow", style="white")
table.add_column("Completed Steps", style="green")
table.add_column("Fingerprint", style="magenta")
table.add_column("Saved At", style="white")
for cp in checkpoints:
table.add_row(
str(cp.get("name", "")),
str(cp.get("workflow", "")),
str(cp.get("completed_steps", 0)),
str(cp.get("definition_fingerprint") or "-"),
str(cp.get("saved_at", "")),
)
rprint(table)
return checkpoints
def action_validate(self, args: List[str], **kwargs) -> bool:
"""
Validate a YAML workflow file.
@@ -692,29 +570,23 @@ Example:
def action_list(self, args: List[str], **kwargs) -> List[str]:
"""
List available workflows in the project workflows directory.
List available workflows in .praison/workflows/ directory.
Returns:
List of workflow file names
"""
# Use the same directory the engine loads from (.praisonai/workflows).
try:
from praisonaiagents.paths import DEFAULT_DIR_NAME
workflows_subdir = f"{DEFAULT_DIR_NAME}/workflows"
except ImportError:
workflows_subdir = ".praisonai/workflows"
workflows_dir = Path(workflows_subdir)
workflows_dir = Path(".praison/workflows")
if not workflows_dir.exists():
self.print_status(f"No {workflows_subdir}/ directory found", "warning")
self.print_status(f"Create one with: mkdir -p {workflows_subdir}", "info")
self.print_status("No .praison/workflows/ directory found", "warning")
self.print_status("Create one with: mkdir -p .praison/workflows", "info")
return []
# Find all YAML files
yaml_files = list(workflows_dir.glob("*.yaml")) + list(workflows_dir.glob("*.yml"))
if not yaml_files:
self.print_status(f"No workflow files found in {workflows_subdir}/", "warning")
self.print_status("No workflow files found in .praison/workflows/", "warning")
return []
from rich import print as rprint
@@ -67,26 +67,6 @@ def handle_workflow_command(self, action: str, action_args: list, variables: dic
self._run_yaml_workflow(workflow_name, action_args, variables, args)
return
# Parse checkpoint/resume flags from the run args. These live in
# action_args because the markdown run path forwards raw flags.
checkpoint_name = None
resume_flag = False
rebase_checkpoint = False
for idx, arg in enumerate(action_args):
if arg == '--checkpoint' and idx + 1 < len(action_args):
checkpoint_name = action_args[idx + 1]
elif arg == '--resume':
resume_flag = True
elif arg == '--rebase-checkpoint':
rebase_checkpoint = True
# A checkpoint name defaults to the workflow name so plain --resume
# works without the user having to name the checkpoint.
if resume_flag and not checkpoint_name:
checkpoint_name = workflow_name
# Always checkpoint under the resolved name so a later --resume works.
if not checkpoint_name:
checkpoint_name = workflow_name
# Use global flags (--llm, --tools, --planning, --memory, --save, --verbose)
workflow_llm = getattr(args, 'llm', None) if args else None
workflow_tools_str = getattr(args, 'tools', None) if args else None
@@ -134,14 +114,9 @@ def handle_workflow_command(self, action: str, action_args: list, variables: dic
memory=memory,
planning=workflow_planning,
verbose=1 if workflow_verbose else 0,
checkpoint=checkpoint_name,
resume=checkpoint_name if resume_flag else None,
rebase_checkpoint=rebase_checkpoint,
on_step=lambda step, i: print(f"[cyan] → Step {i+1}: {step.name}[/cyan]"),
on_result=lambda step, output: print(f"[green] ✓ Completed: {step.name}[/green]")
)
if result.get("resumed_from_step") is not None:
print(f"[cyan]Resumed from step {result['resumed_from_step'] + 1}[/cyan]")
if result.get("success"):
print("[green]✅ Workflow completed successfully![/green]")
@@ -237,39 +212,7 @@ def handle_workflow_command(self, action: str, action_args: list, variables: dic
elif action == 'auto':
# Auto-generate workflow from topic
self._auto_generate_workflow(action_args)
elif action == 'checkpoints':
# Manage saved workflow checkpoints (list / delete).
delete_name = None
for idx, arg in enumerate(action_args):
if arg == '--delete' and idx + 1 < len(action_args):
delete_name = action_args[idx + 1]
if delete_name:
if manager.delete_checkpoint(delete_name):
print(f"[green]✅ Deleted checkpoint: {delete_name}[/green]")
else:
print(f"[yellow]Checkpoint not found: {delete_name}[/yellow]")
else:
checkpoints = manager.list_checkpoints()
if checkpoints:
table = Table(title="Workflow Checkpoints")
table.add_column("Name", style="cyan")
table.add_column("Workflow", style="white")
table.add_column("Completed Steps", style="green")
table.add_column("Fingerprint", style="magenta")
table.add_column("Saved At", style="white")
for cp in checkpoints:
table.add_row(
str(cp.get("name", "")),
str(cp.get("workflow", "")),
str(cp.get("completed_steps", 0)),
str(cp.get("definition_fingerprint") or "-"),
str(cp.get("saved_at", "")),
)
console.print(table)
else:
print("[yellow]No checkpoints found.[/yellow]")
elif action == 'help' or action == '--help':
print("[bold]Workflow Commands:[/bold]")
print(" praisonai workflow list - List available workflows")
@@ -280,8 +223,6 @@ def handle_workflow_command(self, action: str, action_args: list, variables: dic
print(" praisonai workflow validate <file.yaml> - Validate a YAML workflow")
print(" praisonai workflow template <name> - Create from template")
print(' praisonai workflow auto "topic" - Auto-generate workflow')
print(" praisonai workflow checkpoints - List saved checkpoints")
print(" praisonai workflow checkpoints --delete <name> - Delete a checkpoint")
print("\n[bold]Templates:[/bold]")
print(" simple, routing, parallel, loop, evaluator-optimizer")
print("\n[bold]Options (uses global flags):[/bold]")
@@ -16,17 +16,9 @@
"type": "string"
},
"backstory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Agent backstory",
"title": "Backstory"
"title": "Backstory",
"type": "string"
},
"instructions": {
"anyOf": [
@@ -956,21 +948,12 @@
"type": "object"
},
"WorkflowStep": {
"additionalProperties": true,
"description": "Configuration for a workflow step.\n\nAccepts both dialects the runtime understands:\n\n1. Explicit ``type``-based steps (``type: task|parallel|loop|route``), and\n2. The bare runtime forms the engine's ``YAMLWorkflowParser`` executes:\n ``- agent:`` / ``route:`` / ``if:`` / ``loop:`` / ``repeat:`` /\n ``parallel:`` / ``include:`` steps, where ``name``/``type`` are optional.\n\nA step is valid if it matches either dialect, so runnable example workflows\nno longer fail schema validation (and vice versa).",
"description": "Configuration for a workflow step.",
"properties": {
"name": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Step name",
"title": "Name"
"title": "Name",
"type": "string"
},
"type": {
"anyOf": [
@@ -981,7 +964,7 @@
"type": "null"
}
],
"default": null,
"default": "task",
"description": "Step type (task/route/parallel/loop)",
"title": "Type"
},
@@ -995,7 +978,7 @@
}
],
"default": null,
"description": "Agent for task/agent steps",
"description": "Agent for task steps",
"title": "Agent"
},
"task": {
@@ -1011,19 +994,6 @@
"description": "Task description",
"title": "Task"
},
"action": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Action prompt for agent steps",
"title": "Action"
},
"steps": {
"anyOf": [
{
@@ -1085,63 +1055,11 @@
"default": null,
"description": "Loop count",
"title": "Count"
},
"route": {
"anyOf": [
{},
{
"type": "null"
}
],
"default": null,
"description": "Route step: mapping of key -> [agents]",
"title": "Route"
},
"loop": {
"anyOf": [
{},
{
"type": "null"
}
],
"default": null,
"description": "Loop step configuration",
"title": "Loop"
},
"repeat": {
"anyOf": [
{},
{
"type": "null"
}
],
"default": null,
"description": "Repeat step configuration",
"title": "Repeat"
},
"parallel": {
"anyOf": [
{},
{
"type": "null"
}
],
"default": null,
"description": "Parallel step configuration",
"title": "Parallel"
},
"include": {
"anyOf": [
{},
{
"type": "null"
}
],
"default": null,
"description": "Include another workflow file",
"title": "Include"
}
},
"required": [
"name"
],
"title": "WorkflowStep",
"type": "object"
}
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff. Show more