mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-09 03:39:37 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccc4058b17 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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,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
@@ -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
Reference in new issue
Block a user