mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-08 17:31:00 -10:00
Compare commits
74
Commits
No files matched your search
@@ -74,7 +74,11 @@ function isClaudeTriggerNoise(c) {
|
||||
|
||||
function isClaudeFinalReplyComment(c) {
|
||||
const login = (c.user?.login || '').toLowerCase();
|
||||
if (!login.includes('praisonai-triage')) return false;
|
||||
const fromAutomation =
|
||||
login.includes('praisonai-triage') ||
|
||||
login.includes('github-actions') ||
|
||||
AUTO_ACTORS.some((a) => a.toLowerCase() === login);
|
||||
if (!fromAutomation) return false;
|
||||
return (c.body || '').includes('Claude finished');
|
||||
}
|
||||
|
||||
@@ -96,15 +100,47 @@ function isConflictRebaseTriggerComment(c) {
|
||||
|
||||
function isConflictRebaseCompletionComment(c) {
|
||||
const login = (c.user?.login || '').toLowerCase();
|
||||
if (!login.includes('praisonai-triage') && !login.includes('github-actions')) return false;
|
||||
const fromAutomation =
|
||||
login.includes('praisonai-triage') ||
|
||||
login.includes('github-actions') ||
|
||||
AUTO_ACTORS.some((a) => a.toLowerCase() === login);
|
||||
if (!fromAutomation) return false;
|
||||
const body = (c.body || '').toLowerCase();
|
||||
return (
|
||||
body.includes('rebase complete') ||
|
||||
body.includes('rebase onto') ||
|
||||
body.includes('force-pushed') ||
|
||||
(body.includes('conflict') && body.includes('resolved'))
|
||||
);
|
||||
}
|
||||
|
||||
/** HEAD landed after conflict rebase but no FINAL @claude since that push. */
|
||||
function needsFinalReReviewAfterConflictRebase(comments, headPushedAt) {
|
||||
if (!headPushedAt) return false;
|
||||
const headTime = new Date(headPushedAt).getTime();
|
||||
const conflictTriggers = comments.filter(isConflictRebaseTriggerComment);
|
||||
if (conflictTriggers.length === 0) return false;
|
||||
|
||||
const latestConflict = conflictTriggers.reduce((a, b) =>
|
||||
new Date(a.created_at) > new Date(b.created_at) ? a : b
|
||||
);
|
||||
const conflictTime = new Date(latestConflict.created_at).getTime();
|
||||
if (headTime <= conflictTime) return false;
|
||||
|
||||
const rebaseDone = comments.some(
|
||||
(c) =>
|
||||
new Date(c.created_at).getTime() > conflictTime && isConflictRebaseCompletionComment(c)
|
||||
);
|
||||
if (!rebaseDone) return false;
|
||||
|
||||
const finalsAfterHead = comments.filter(
|
||||
(c) =>
|
||||
isFinalClaudeTriggerComment(c) &&
|
||||
new Date(c.created_at).getTime() >= headTime - 60000
|
||||
);
|
||||
return finalsAfterHead.length === 0;
|
||||
}
|
||||
|
||||
function conflictRebaseQuiescent(comments, headPushedAt) {
|
||||
const conflictTriggers = comments.filter(isConflictRebaseTriggerComment);
|
||||
if (conflictTriggers.length === 0) return true;
|
||||
@@ -245,6 +281,9 @@ function shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusherLogin =
|
||||
if (headPusherLogin && isClaudeAutomationLogin(headPusherLogin)) {
|
||||
return { skip: true, reason: 'head pushed by Claude automation' };
|
||||
}
|
||||
if (needsFinalReReviewAfterConflictRebase(comments, headPushedAt)) {
|
||||
return { skip: false, reason: '' };
|
||||
}
|
||||
if (isPushSoonAfterLatestFinal(comments, headPushedAt)) {
|
||||
return {
|
||||
skip: true,
|
||||
@@ -916,6 +955,7 @@ module.exports = {
|
||||
hasRecentConflictComment,
|
||||
isConflictRebaseTriggerComment,
|
||||
isConflictRebaseCompletionComment,
|
||||
needsFinalReReviewAfterConflictRebase,
|
||||
conflictRebaseQuiescent,
|
||||
hasHumanChangesRequested,
|
||||
hasAnyChangesRequested,
|
||||
|
||||
@@ -24,6 +24,7 @@ jobs:
|
||||
CLAUDE_APP_PRIVATE_KEY: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
GH_TOKEN_VALUE: ${{ secrets.GH_TOKEN }}
|
||||
DOCS_GITHUB_TOKEN: ${{ secrets.SECRETS_ADMIN_PAT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
@@ -34,4 +35,7 @@ jobs:
|
||||
echo -n "$CLAUDE_APP_PRIVATE_KEY" | gh secret set CLAUDE_APP_PRIVATE_KEY --repo "$TARGET"
|
||||
echo -n "$CLAUDE_CODE_OAUTH_TOKEN" | gh secret set CLAUDE_CODE_OAUTH_TOKEN --repo "$TARGET"
|
||||
echo -n "$GH_TOKEN_VALUE" | gh secret set GH_TOKEN --repo "$TARGET"
|
||||
if [ -n "${DOCS_GITHUB_TOKEN:-}" ]; then
|
||||
echo -n "$DOCS_GITHUB_TOKEN" | gh secret set DOCS_GITHUB_TOKEN --repo "$TARGET"
|
||||
fi
|
||||
echo "Synced CLAUDE_APP_ID, CLAUDE_APP_PRIVATE_KEY, CLAUDE_CODE_OAUTH_TOKEN, GH_TOKEN → $TARGET"
|
||||
@@ -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?* | `backend=ManagedAgent(compute="e2b")` — remote sandbox, or a fully hosted loop |
|
||||
| **⬡ 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?*
|
||||
|
||||
@@ -236,22 +236,56 @@ The harness is commoditising; **where** the agent executes is the next multiplie
|
||||
pip install praisonai
|
||||
```
|
||||
|
||||
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 praisonai import Agent, ManagedAgent, LocalManagedConfig
|
||||
from praisonaiagents import Agent, AgentFlow
|
||||
|
||||
writer = Agent(name="Writer", instructions="You write files.")
|
||||
reader = Agent(name="Reader", instructions="You read files.")
|
||||
|
||||
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")
|
||||
```
|
||||
|
||||
Same thing with no Python at all:
|
||||
|
||||
```yaml
|
||||
name: remote-demo
|
||||
run_on: docker # every step shares one sandbox
|
||||
agents:
|
||||
writer: {role: Writer, goal: Write files}
|
||||
reader: {role: Reader, goal: Read files}
|
||||
steps:
|
||||
- agent: writer
|
||||
action: "Write 'hello' to /workspace/note.txt"
|
||||
- agent: reader
|
||||
action: "Read /workspace/note.txt"
|
||||
```
|
||||
|
||||
For a single agent, pick the axis you need — remote **tools** or a remote **loop**:
|
||||
|
||||
```python
|
||||
from praisonai import Agent, LocalAgent, LocalAgentConfig, HostedAgent
|
||||
|
||||
# A. Tools run in a remote sandbox; the agent loop stays local
|
||||
sandboxed = ManagedAgent(
|
||||
provider="local", compute="e2b", # or modal | daytona | flyio | docker | tenki
|
||||
config=LocalManagedConfig(model="gpt-4o-mini", name="RemoteTools"),
|
||||
)
|
||||
agent = Agent(name="builder", backend=sandboxed)
|
||||
agent = Agent(name="builder", backend=LocalAgent(
|
||||
compute="e2b", # or modal | daytona | flyio | docker | tenki
|
||||
config=LocalAgentConfig(model="gpt-4o-mini", name="RemoteTools"),
|
||||
))
|
||||
|
||||
# B. The entire agent loop runs in the cloud (needs ANTHROPIC_API_KEY;
|
||||
# with no key set, ManagedAgent() falls back to a local loop)
|
||||
agent = Agent(name="teacher", backend=ManagedAgent())
|
||||
# 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")
|
||||
```
|
||||
|
||||
See what is running and reclaim strays:
|
||||
|
||||
```bash
|
||||
praisonai managed ps # list running sandboxes
|
||||
praisonai managed stop --all # reclaim them
|
||||
```
|
||||
|
||||
Sandboxes shut themselves down when idle (`auto_shutdown`, `idle_timeout_s`), and a post-setup snapshot is reused so the next run skips the image pull and dependency install. Commit a `.praisonai/environment.yaml` and the environment travels with the repo.
|
||||
|
||||
> 📖 [20 runnable examples](examples/python/managed-agents/) · manage sessions with `praisonai managed sessions list <agent-id>` or `praisonai managed sessions resume <session-id> "<prompt>"`
|
||||
|
||||
@@ -7,7 +7,7 @@ Regenerate with: `praisonai docs api-md --write`
|
||||
|
||||
Types:
|
||||
```python
|
||||
from praisonaiagents import ContextPolicy, GuardrailResult, HandoffConfig, HandoffCycleError, HandoffDepthError, HandoffError, HandoffInputData, HandoffResult, HandoffTimeoutError, ReflectionOutput, StepResult, TaskOutput, ToolResult, ToolValidationError, WorkflowContext
|
||||
from praisonaiagents import ContextPolicy, GuardrailResult, HandoffConfig, HandoffCycleError, HandoffDepthError, HandoffError, HandoffInputData, HandoffResult, HandoffTimeoutError, ReflectionOutput, StepResult, ToolResult, ToolValidationError, WorkflowContext
|
||||
```
|
||||
|
||||
Methods:
|
||||
@@ -16,8 +16,6 @@ Methods:
|
||||
* <code title="class HandoffConfig">HandoffConfig.<a href="./src/praisonai-agents/praisonaiagents/agent/handoff.py">from_dict</a>(data: Dict[str, Any]) -> 'HandoffConfig'</code>
|
||||
* <code title="class HandoffConfig">HandoffConfig.<a href="./src/praisonai-agents/praisonaiagents/agent/handoff.py">to_dict</a>() -> Dict[str, Any]</code>
|
||||
* <code title="class HandoffResult">HandoffResult.<a href="./src/praisonai-agents/praisonaiagents/agent/handoff.py">from_outcome</a>(outcome: AgentRunOutcome, target_agent: Optional[str] = None, source_agent: Optional[str] = None, handoff_depth: int = 0) -> 'HandoffResult'</code>
|
||||
* <code title="class TaskOutput">TaskOutput.<a href="./src/praisonai-agents/praisonaiagents/main.py">json</a>() -> Optional[str]</code>
|
||||
* <code title="class TaskOutput">TaskOutput.<a href="./src/praisonai-agents/praisonaiagents/main.py">to_dict</a>() -> dict</code>
|
||||
* <code title="class ToolResult">ToolResult.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">is_multimodal</a>() -> bool</code>
|
||||
* <code title="class ToolResult">ToolResult.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">to_dict</a>() -> Dict[str, Any]</code>
|
||||
|
||||
@@ -890,11 +888,13 @@ Methods:
|
||||
* <code title="cli">praisonai managed list <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
|
||||
* <code title="cli">praisonai managed managed-callback <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
|
||||
* <code title="cli">praisonai managed multi <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
|
||||
* <code title="cli">praisonai managed ps <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
|
||||
* <code title="cli">praisonai managed restore <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
|
||||
* <code title="cli">praisonai managed resume <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
|
||||
* <code title="cli">praisonai managed run <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
|
||||
* <code title="cli">praisonai managed save <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
|
||||
* <code title="cli">praisonai managed show <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
|
||||
* <code title="cli">praisonai managed stop <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
|
||||
* <code title="cli">praisonai managed update <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
|
||||
* <code title="cli">praisonai n8n export <a href="./src/praisonai/praisonai/cli/commands/n8n.py">--help</a></code>
|
||||
* <code title="cli">praisonai n8n import <a href="./src/praisonai/praisonai/cli/commands/n8n.py">--help</a></code>
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.162" \
|
||||
"praisonai>=4.6.163" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.162" \
|
||||
"praisonai>=4.6.163" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.162" \
|
||||
"praisonai>=4.6.163" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@ allowed_dirs = [
|
||||
"/path/to/other/allowed/dir"
|
||||
]
|
||||
|
||||
# Use a single string command with allowed directories
|
||||
# Pass the executable as the first token and the allowed directories as args
|
||||
filesystem_agent = Agent(
|
||||
instructions="""You are a helpful assistant that can interact with the filesystem.
|
||||
Use the available tools when relevant to manage files and directories.""",
|
||||
llm="gpt-4o-mini",
|
||||
tools=MCP("npx -y @modelcontextprotocol/server-filesystem", args=allowed_dirs)
|
||||
tools=MCP("npx", args=["-y", "@modelcontextprotocol/server-filesystem", *allowed_dirs])
|
||||
)
|
||||
|
||||
filesystem_agent.start("List files in the allowed directories")
|
||||
@@ -14,9 +14,9 @@ datasets:
|
||||
# A small verified subset for a cheap smoke run. Verify against the registry
|
||||
# with `harbor datasets list` before editing.
|
||||
task_names:
|
||||
- hello-world
|
||||
- fix-permissions
|
||||
- csv-to-parquet
|
||||
- terminal-bench/build-pmars
|
||||
- terminal-bench/bn-fit-modify
|
||||
- terminal-bench/break-filter-js-from-html
|
||||
|
||||
agents:
|
||||
- import_path: examples.terminal_bench.praisonai_code_agent:PraisonAICodeAgent
|
||||
|
||||
@@ -81,17 +81,19 @@ class PraisonAICodeAgent(BaseInstalledAgent):
|
||||
model = self.model_name or "openai/gpt-4o-mini"
|
||||
|
||||
command = (
|
||||
"log_file=$(mktemp /tmp/praisonai_code.XXXXXX); "
|
||||
"chmod 600 \"$log_file\"; "
|
||||
f"praisonai code -p --output json {shlex.quote(instruction)} "
|
||||
f"--dangerously-skip-approval "
|
||||
f"--model {shlex.quote(model)} "
|
||||
"> /tmp/praisonai_code.log 2>&1; "
|
||||
"> \"$log_file\" 2>&1; "
|
||||
# Capture the real exit status so install/auth/startup failures still
|
||||
# surface instead of being silently masked (Harbor otherwise records a
|
||||
# completed attempt for a crashed agent).
|
||||
"status=$?; "
|
||||
# Echo the envelope so Harbor captures it for post-run accounting.
|
||||
"cp /tmp/praisonai_code.log /tmp/praisonai_code_run.log 2>/dev/null || true; "
|
||||
"cat /tmp/praisonai_code.log; "
|
||||
"cat \"$log_file\"; "
|
||||
"rm -f \"$log_file\"; "
|
||||
# `praisonai code` normally exits 0 (Harbor grades by task
|
||||
# verification), so a genuine benchmark miss won't fail here — but a
|
||||
# nonzero status means the assistant itself failed to run.
|
||||
@@ -128,7 +130,6 @@ class PraisonAICodeAgent(BaseInstalledAgent):
|
||||
"agent_type": "code-cli",
|
||||
"agent_name": self.name(),
|
||||
"model": self.model_name,
|
||||
"log_path": "/tmp/praisonai_code.log",
|
||||
"session_id": envelope.get("session_id") if envelope else None,
|
||||
"status": envelope.get("status") if envelope else "unknown",
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ except ImportError as e:
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.approval import get_approval_registry, AutoApproveBackend
|
||||
from praisonaiagents.config.feature_configs import ExecutionConfig
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
f"PraisonAI not installed: {e}\n"
|
||||
@@ -41,6 +42,13 @@ except ImportError as e:
|
||||
) from e
|
||||
|
||||
|
||||
TB_EXECUTION = ExecutionConfig(
|
||||
max_tool_calls_per_turn=80,
|
||||
max_iter=40,
|
||||
max_steps=80,
|
||||
)
|
||||
|
||||
|
||||
class PraisonAIExternalAgent(BaseAgent):
|
||||
"""External PraisonAI agent that drives a Harbor container environment."""
|
||||
|
||||
@@ -142,26 +150,14 @@ class PraisonAIExternalAgent(BaseAgent):
|
||||
),
|
||||
tools=[bash_tool],
|
||||
llm=self.model_name or "openai/gpt-4o-mini",
|
||||
execution=TB_EXECUTION,
|
||||
)
|
||||
|
||||
# Execute the agent with outer loop to handle premature stopping
|
||||
# Execute one deliberately long coding turn. Harbor's hidden verifier
|
||||
# is the only completion authority; model or pytest text is not.
|
||||
print(f"🚀 PraisonAI Agent starting task: {instruction[:100]}...")
|
||||
result = await agent.achat(instruction)
|
||||
for _iter in range(19):
|
||||
result_str = str(result)
|
||||
# Stop if test passed
|
||||
if any(sig in result_str.lower() for sig in [
|
||||
" passed", "passed ", "test passed", "all tests pass", "1 passed"
|
||||
]):
|
||||
break
|
||||
result = await agent.achat(
|
||||
"The task is NOT complete yet — keep working. Run bash_tool commands now. "
|
||||
"If you haven't already: (1) read all files in /app/, "
|
||||
"(2) run the test to see the exact failure, "
|
||||
"(3) implement a fix, (4) run the test again. "
|
||||
"Repeat until the test passes. What is your next bash_tool command?"
|
||||
)
|
||||
print(f"✅ PraisonAI Agent completed task")
|
||||
print("✅ PraisonAI Agent completed task")
|
||||
|
||||
# Populate Harbor context with metadata
|
||||
self._populate_context(agent, context, result)
|
||||
@@ -203,6 +199,10 @@ class PraisonAIExternalAgent(BaseAgent):
|
||||
"tools_used": ["bash_tool"],
|
||||
"framework": "praisonai",
|
||||
"version": self.version(),
|
||||
"stop_reason": getattr(agent, "last_stop_reason", None),
|
||||
"max_tool_calls_per_turn": TB_EXECUTION.max_tool_calls_per_turn,
|
||||
"max_iter": TB_EXECUTION.max_iter,
|
||||
"max_steps": TB_EXECUTION.max_steps,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
@@ -218,4 +218,4 @@ if __name__ == "__main__":
|
||||
print(" --model openai/gpt-4o-mini")
|
||||
print()
|
||||
print("Dependencies:")
|
||||
print(" pip install harbor praisonaiagents")
|
||||
print(" pip install harbor praisonaiagents")
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Static regression checks for the in-tree Harbor adapter configuration."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
EXAMPLES = Path(__file__).parent
|
||||
|
||||
|
||||
def test_external_adapter_sets_coding_execution_budget():
|
||||
source = (EXAMPLES / "praisonai_external_agent.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "ExecutionConfig" in source
|
||||
assert "max_tool_calls_per_turn=80" in source
|
||||
assert "max_iter=40" in source
|
||||
assert "max_steps=80" in source
|
||||
|
||||
|
||||
def test_external_adapter_does_not_guess_completion_from_pytest_text():
|
||||
source = (EXAMPLES / "praisonai_external_agent.py").read_text(encoding="utf-8")
|
||||
|
||||
assert '"1 passed"' not in source
|
||||
assert '"test passed"' not in source
|
||||
|
||||
|
||||
def test_smoke_yaml_uses_terminal_bench_21_registry_ids():
|
||||
job = yaml.safe_load(
|
||||
(EXAMPLES / "job_code_smoke.yaml").read_text(encoding="utf-8")
|
||||
)
|
||||
names = job["datasets"][0]["task_names"]
|
||||
|
||||
assert names
|
||||
assert all(name.startswith("terminal-bench/") for name in names)
|
||||
assert names == [
|
||||
"terminal-bench/build-pmars",
|
||||
"terminal-bench/bn-fit-modify",
|
||||
"terminal-bench/break-filter-js-from-html",
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
# Run a whole workflow's shell/file tools inside ONE shared remote sandbox.
|
||||
#
|
||||
# praisonai examples/yaml/workflows/remote_sandbox_workflow.yaml
|
||||
#
|
||||
# `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.
|
||||
#
|
||||
# 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
|
||||
#
|
||||
# See what is still running: praisonai managed ps
|
||||
# Reclaim strays: praisonai managed stop --all
|
||||
|
||||
name: remote-sandbox-demo
|
||||
run_on: docker
|
||||
|
||||
agents:
|
||||
writer:
|
||||
role: File Writer
|
||||
goal: Write files into the shared workspace
|
||||
instructions: >
|
||||
You have write_file and execute_command running on a remote Linux
|
||||
sandbox. Use them; never say you cannot run commands.
|
||||
|
||||
reader:
|
||||
role: File Reader
|
||||
goal: Read files back out of the shared workspace
|
||||
instructions: >
|
||||
You have read_file and execute_command running on the same remote
|
||||
sandbox the writer used. Use them.
|
||||
|
||||
steps:
|
||||
- agent: writer
|
||||
action: "Use write_file to put exactly 'hello from step 1' in /workspace/note.txt"
|
||||
|
||||
- agent: reader
|
||||
action: "Use read_file on /workspace/note.txt and report its exact contents"
|
||||
@@ -18,16 +18,16 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
| Category | Features | Docs | Lines |
|
||||
|----------|----------|------|-------|
|
||||
| ✅ AGUI | 1 | 2 | 420 |
|
||||
| ✅ Agent | 22 | 50 | 15809 |
|
||||
| ✅ Agent | 22 | 50 | 15813 |
|
||||
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2996 |
|
||||
| ✅ Agent-to-User (A2U) | 1 | 3 | 689 |
|
||||
| ✅ Approval | 1 | 5 | 2537 |
|
||||
| ✅ Audio | 2 | 12 | 1000 |
|
||||
| ✅ Auto Generation | 5 | 10 | 3049 |
|
||||
| ✅ Autonomy | 3 | 7 | 2744 |
|
||||
| ✅ Bots | 7 | 31 | 13314 |
|
||||
| ✅ Autonomy | 3 | 7 | 2752 |
|
||||
| ✅ Bots | 7 | 31 | 13366 |
|
||||
| ✅ Budget | 1 | 1 | 287 |
|
||||
| ✅ CLI | 5 | 121 | 36021 |
|
||||
| ✅ CLI | 5 | 121 | 36048 |
|
||||
| ✅ Chunking | 2 | 2 | 385 |
|
||||
| ✅ Citations | 2 | 1 | 202 |
|
||||
| ✅ Code Execution | 2 | 14 | 4583 |
|
||||
@@ -41,17 +41,17 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
| ✅ Events | 1 | 2 | 749 |
|
||||
| ✅ Execution | 3 | 4 | 1562 |
|
||||
| ✅ Failover | 2 | 1 | 432 |
|
||||
| ✅ Files | 2 | 7 | 2273 |
|
||||
| ✅ Files | 2 | 7 | 2275 |
|
||||
| ✅ Flow | 1 | 3 | 788 |
|
||||
| ✅ Gateway | 7 | 72 | 26391 |
|
||||
| ✅ Guardrails | 4 | 4 | 2017 |
|
||||
| ✅ Gateway | 7 | 72 | 26395 |
|
||||
| ✅ Guardrails | 4 | 4 | 2025 |
|
||||
| ✅ Handoffs | 11 | 6 | 2563 |
|
||||
| ✅ Hooks | 2 | 9 | 4317 |
|
||||
| ✅ Hooks | 2 | 9 | 4434 |
|
||||
| ✅ Image | 1 | 11 | 1255 |
|
||||
| ✅ Knowledge | 4 | 13 | 4333 |
|
||||
| ✅ LLM | 3 | 15 | 5192 |
|
||||
| ✅ Loops | 4 | 5 | 1422 |
|
||||
| ✅ MCP | 1 | 61 | 13847 |
|
||||
| ✅ MCP | 1 | 61 | 13993 |
|
||||
| ✅ Memory | 6 | 18 | 7070 |
|
||||
| ✅ OCR | 2 | 1 | 237 |
|
||||
| ✅ Observability | 2 | 23 | 2814 |
|
||||
@@ -59,18 +59,18 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
| ✅ Output | 3 | 5 | 1201 |
|
||||
| ✅ Parallel Execution | 3 | 2 | 497 |
|
||||
| ✅ Planning | 6 | 6 | 1549 |
|
||||
| ✅ Plugins | 8 | 4 | 2850 |
|
||||
| ✅ Plugins | 8 | 4 | 2858 |
|
||||
| ✅ Prompts | 2 | 9 | 1965 |
|
||||
| ✅ Providers | 1 | 56 | 7735 |
|
||||
| ✅ Providers | 1 | 56 | 7743 |
|
||||
| ✅ Query | 1 | 2 | 736 |
|
||||
| ✅ RAG | 5 | 15 | 3003 |
|
||||
| ✅ Realtime | 2 | 5 | 668 |
|
||||
| ✅ Reflection | 3 | 3 | 680 |
|
||||
| ✅ Retrieval | 2 | 5 | 1054 |
|
||||
| ✅ Routing | 1 | 2 | 430 |
|
||||
| ✅ Sandbox | 5 | 6 | 2918 |
|
||||
| ✅ Security | 1 | 3 | 3206 |
|
||||
| ✅ Sessions | 4 | 17 | 6963 |
|
||||
| ✅ Sandbox | 5 | 6 | 2982 |
|
||||
| ✅ Security | 1 | 3 | 3208 |
|
||||
| ✅ Sessions | 4 | 18 | 7158 |
|
||||
| ✅ Skills | 6 | 15 | 5638 |
|
||||
| ✅ Tasks | 2 | 6 | 2819 |
|
||||
| ✅ Telemetry | 1 | 2 | 604 |
|
||||
@@ -81,7 +81,7 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
| ✅ Video | 2 | 6 | 649 |
|
||||
| ✅ Vision | 2 | 1 | 329 |
|
||||
| ✅ Web | 3 | 9 | 2467 |
|
||||
| ✅ Workflows | 5 | 16 | 6589 |
|
||||
| ✅ Workflows | 5 | 16 | 6593 |
|
||||
|
||||
## Documentation Without Features
|
||||
|
||||
|
||||
@@ -2447,6 +2447,40 @@ Your Goal: {self.goal}
|
||||
# Sandbox configuration - initialize SandboxMixin
|
||||
super().__init__(sandbox=sandbox)
|
||||
|
||||
# NOTE: `sandbox=` deliberately does NOT add code-execution tools.
|
||||
#
|
||||
# An earlier version auto-attached execute_python_code /
|
||||
# execute_shell_command here. That was reverted: `sandbox=` is a
|
||||
# RESTRICTION, and it must never grant a capability the caller did not
|
||||
# ask for. Concretely, the injection
|
||||
# - escalated privilege: Agent(approval="read_only") exposes no tools,
|
||||
# but Agent(approval="read_only", sandbox=True) exposed two ungated
|
||||
# arbitrary-code-execution tools, since the injected names are not in
|
||||
# the approval registry's dangerous-tool presets; and
|
||||
# - advertised them to the model as "safely in sandbox" while the
|
||||
# default subprocess backend enforces none of its own SecurityPolicy
|
||||
# (network reachable despite allow_network=False; ~/.ssh readable
|
||||
# despite blocked_paths; `import subprocess` works despite
|
||||
# blocked_imports).
|
||||
#
|
||||
# `sandbox=` still configures isolation for the explicit, user-invoked
|
||||
# agent.execute_code() / run_shell_command() APIs, which is what it has
|
||||
# always meant. Giving the model a sandboxed execution tool should be an
|
||||
# explicit act by the caller, the way MCP() is.
|
||||
|
||||
# A managed backend takes over the whole turn before any local execution,
|
||||
# so a sandbox set alongside it silently does nothing. Say so.
|
||||
if self.sandbox_config is not None and getattr(self, "backend", None) is not None:
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"Agent(sandbox=..., backend=...): the managed backend handles the "
|
||||
"entire turn, so the sandbox= setting is ignored. Configure "
|
||||
"isolation on the backend instead (e.g. LocalAgent(compute='docker')).",
|
||||
FutureWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_tool_config(tool_config):
|
||||
"""Resolve tool_config parameter with backward compatibility."""
|
||||
@@ -2555,8 +2589,15 @@ Your Goal: {self.goal}
|
||||
'api_key': getattr(self, 'api_key', None),
|
||||
'auth': getattr(self, 'auth', None),
|
||||
|
||||
# Shallow copy tools to avoid deepcopy issues with nested objects
|
||||
'tools': list(self.tools) if self.tools else None,
|
||||
# Shallow copy tools to avoid deepcopy issues with nested objects.
|
||||
# Drop framework-generated sandbox tools: they close over the source
|
||||
# agent, so the constructor must regenerate clone-bound replacements
|
||||
# (see the sandbox wiring below). User tools with colliding names
|
||||
# lack the tag and are preserved.
|
||||
'tools': (
|
||||
[t for t in self.tools if not getattr(t, "_praison_sandbox_tool", False)]
|
||||
if self.tools else None
|
||||
),
|
||||
|
||||
# Skip handoffs entirely - they shouldn't be shared across channels
|
||||
# and can contain nested Agent instances that cause RLock issues
|
||||
@@ -2607,7 +2648,10 @@ Your Goal: {self.goal}
|
||||
'interrupt_controller': None, # Let new instance create its own
|
||||
|
||||
# Sandbox config
|
||||
'sandbox': getattr(self, '_sandbox_config', None),
|
||||
# SandboxMixin stores this as `sandbox_config`; reading the
|
||||
# underscore name (never assigned) made every clone silently drop
|
||||
# its sandbox and run unisolated.
|
||||
'sandbox': getattr(self, 'sandbox_config', None),
|
||||
}
|
||||
|
||||
# Handle deprecated parameters for backward compatibility
|
||||
@@ -5853,7 +5897,7 @@ Answer:"""
|
||||
|
||||
# Convert the result to a GuardrailResult
|
||||
return GuardrailResult.from_tuple(result)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Error in guardrail validation: {e}")
|
||||
# On error, return failure
|
||||
|
||||
@@ -110,14 +110,32 @@ class AsyncSafeState:
|
||||
def __init__(self, initial_value: Any = None):
|
||||
self.value = initial_value
|
||||
self._lock = DualLock()
|
||||
self._activity_lock = threading.Lock()
|
||||
self._async_holders = 0
|
||||
self._copying = False
|
||||
|
||||
def __deepcopy__(self, memo):
|
||||
"""Copy the protected value while replacing all lock state."""
|
||||
"""Copy protected state, rejecting overlap with asynchronous access."""
|
||||
result = type(self).__new__(type(self))
|
||||
memo[id(self)] = result
|
||||
with self.lock():
|
||||
result.value = copy.deepcopy(self.value, memo)
|
||||
with self._activity_lock:
|
||||
if self._async_holders:
|
||||
raise RuntimeError(
|
||||
"Cannot deepcopy AsyncSafeState during asynchronous access"
|
||||
)
|
||||
if self._copying:
|
||||
raise RuntimeError("AsyncSafeState deepcopy is already in progress")
|
||||
self._copying = True
|
||||
try:
|
||||
with self.lock():
|
||||
result.value = copy.deepcopy(self.value, memo)
|
||||
finally:
|
||||
with self._activity_lock:
|
||||
self._copying = False
|
||||
result._lock = DualLock()
|
||||
result._activity_lock = threading.Lock()
|
||||
result._async_holders = 0
|
||||
result._copying = False
|
||||
return result
|
||||
|
||||
@contextmanager
|
||||
@@ -130,7 +148,25 @@ class AsyncSafeState:
|
||||
async def async_lock(self):
|
||||
"""Acquire lock in async context."""
|
||||
async with self._lock.async_lock():
|
||||
yield self.value
|
||||
self._begin_async_access()
|
||||
try:
|
||||
yield self.value
|
||||
finally:
|
||||
self._end_async_access()
|
||||
|
||||
def _begin_async_access(self) -> None:
|
||||
"""Register async access unless a synchronous copy is in progress."""
|
||||
with self._activity_lock:
|
||||
if self._copying:
|
||||
raise RuntimeError(
|
||||
"Cannot access AsyncSafeState during synchronous deepcopy"
|
||||
)
|
||||
self._async_holders += 1
|
||||
|
||||
def _end_async_access(self) -> None:
|
||||
"""Unregister one active asynchronous accessor."""
|
||||
with self._activity_lock:
|
||||
self._async_holders -= 1
|
||||
|
||||
def __enter__(self):
|
||||
"""Support for synchronous context manager protocol (backward compatibility)."""
|
||||
@@ -146,12 +182,20 @@ class AsyncSafeState:
|
||||
"""Support for asynchronous context manager protocol."""
|
||||
async_lock = self._lock._get_async_lock()
|
||||
await async_lock.acquire()
|
||||
try:
|
||||
self._begin_async_access()
|
||||
except Exception:
|
||||
async_lock.release()
|
||||
raise
|
||||
return self.value
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Support for asynchronous context manager protocol."""
|
||||
async_lock = self._lock._get_async_lock()
|
||||
async_lock.release()
|
||||
try:
|
||||
self._end_async_access()
|
||||
finally:
|
||||
async_lock.release()
|
||||
return None
|
||||
|
||||
def get(self) -> Any:
|
||||
|
||||
@@ -106,8 +106,11 @@ class AutonomyConfig:
|
||||
track_changes: Whether to track filesystem changes via shadow git.
|
||||
Defaults to True when level="full_auto", False otherwise.
|
||||
When enabled, agent.undo()/redo()/diff() become available.
|
||||
sandbox: Optional SandboxConfig for execution isolation.
|
||||
When level="full_auto" and sandbox is None, subprocess sandbox is auto-enabled.
|
||||
sandbox: Optional SandboxConfig, carried for forward compatibility.
|
||||
NOT IMPLEMENTED: nothing currently reads this field, and no sandbox
|
||||
is auto-enabled at any autonomy level. To isolate execution, set it
|
||||
on the agent itself -- Agent(sandbox=...) -- or run the whole
|
||||
workflow remotely with AgentFlow(run_on="docker").
|
||||
snapshot_dir: Directory for snapshot storage. Defaults to ~/.praisonai/snapshots.
|
||||
|
||||
Usage::
|
||||
|
||||
@@ -2371,7 +2371,11 @@ Your Goal: {self.goal}"""
|
||||
task_name=task_name,
|
||||
task_description=task_description,
|
||||
task_id=task_id,
|
||||
agent_name=getattr(self, 'name', 'assistant')
|
||||
agent_name=getattr(self, 'name', 'assistant'),
|
||||
# Durable re-injection target for deferred tool results:
|
||||
# background jobs completing after this call must land in the
|
||||
# persistent history a later turn replays, not the loop copy.
|
||||
deferred_history_sink=getattr(self, 'chat_history', None),
|
||||
)
|
||||
return final_response
|
||||
except ValueError as e:
|
||||
@@ -2427,6 +2431,8 @@ Your Goal: {self.goal}"""
|
||||
]
|
||||
or None
|
||||
),
|
||||
# Durable re-injection target for deferred tool results (see above).
|
||||
deferred_history_sink=getattr(self, 'chat_history', None),
|
||||
)
|
||||
return final_response
|
||||
|
||||
|
||||
@@ -1456,6 +1456,18 @@ Write the complete compiled report:"""
|
||||
after_results = await hook_runner.execute(
|
||||
HookEvent.AFTER_TOOL, after_tool_input, target=function_name
|
||||
)
|
||||
# Honour a post-tool block so a redacted/denied result never
|
||||
# reaches the model (mirrors the BEFORE_TOOL block path above).
|
||||
if hook_runner.is_blocked(after_results):
|
||||
reason = hook_runner.get_blocking_reason(after_results)
|
||||
logging.warning(f"Tool {function_name} result blocked by AFTER_TOOL hook")
|
||||
return _record_async_loop_outcome(
|
||||
reason or f"Result of {function_name} was blocked by security policy."
|
||||
)
|
||||
# Read back any rewritten/redacted tool_output so a plugin can
|
||||
# scrub secrets before the result reaches the model.
|
||||
if getattr(after_tool_input, "tool_output", result) is not result:
|
||||
result = after_tool_input.tool_output
|
||||
extra_context = hook_runner.aggregate_context(after_results)
|
||||
if extra_context:
|
||||
if isinstance(result, str):
|
||||
|
||||
@@ -79,19 +79,30 @@ class SandboxMixin:
|
||||
)
|
||||
|
||||
# Security pre-check if enabled
|
||||
security_warnings = None
|
||||
if check_security:
|
||||
from ..sandbox import check_code_safety, format_warnings
|
||||
warnings = check_code_safety(code, language)
|
||||
if warnings:
|
||||
warning_text = format_warnings(warnings)
|
||||
security_warnings = check_code_safety(code, language)
|
||||
if security_warnings:
|
||||
warning_text = format_warnings(security_warnings)
|
||||
if self.verbose:
|
||||
logger.warning(f"Security warnings for code execution:\n{warning_text}")
|
||||
|
||||
# Store warnings in result metadata
|
||||
kwargs.setdefault('metadata', {})['security_warnings'] = warnings
|
||||
|
||||
|
||||
manager = self.get_sandbox_manager()
|
||||
return await manager.run_code(code, language=language, **kwargs)
|
||||
result = await manager.run_code(code, language=language, **kwargs)
|
||||
|
||||
# Attach warnings to the result rather than forwarding a `metadata`
|
||||
# kwarg: SandboxProtocol.execute() has no such parameter, so passing it
|
||||
# through raised TypeError for any code that tripped a warning -- i.e.
|
||||
# sandboxed execution failed exactly when the check mattered most.
|
||||
if security_warnings:
|
||||
try:
|
||||
existing = getattr(result, "metadata", None) or {}
|
||||
existing["security_warnings"] = security_warnings
|
||||
result.metadata = existing
|
||||
except Exception: # pragma: no cover - result may be immutable
|
||||
logger.debug("Could not attach security warnings to sandbox result")
|
||||
return result
|
||||
|
||||
def execute_code_sync(
|
||||
self,
|
||||
@@ -230,5 +241,15 @@ class SandboxMixin:
|
||||
return result.stdout or result.output
|
||||
else:
|
||||
return f"Error: {result.error or result.stderr}"
|
||||
|
||||
return [execute_python_code, execute_shell_command]
|
||||
|
||||
# Tag as framework-generated so clone_for_channel() can drop these
|
||||
# source-bound wrappers and regenerate clone-owned ones. Without the
|
||||
# tag, a clone would inherit tools closing over the *source* agent and
|
||||
# silently share its SandboxManager.
|
||||
generated = [execute_python_code, execute_shell_command]
|
||||
for _t in generated:
|
||||
try:
|
||||
_t._praison_sandbox_tool = True
|
||||
except Exception: # pragma: no cover - tool object may be immutable
|
||||
pass
|
||||
return generated
|
||||
@@ -22,6 +22,13 @@ from ..config.feature_configs import DEFAULT_TOOL_OUTPUT_LIMIT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Upper bound on how many timed-out (still-blocked) tool executors a single
|
||||
# agent may retire before it stops recycling and falls back to reusing the
|
||||
# existing bounded pool. This keeps the "fresh worker after a timeout"
|
||||
# throughput win while capping leaked threads from pathological repeated
|
||||
# timeouts so they cannot exhaust process resources.
|
||||
_MAX_ORPHANED_TOOL_EXECUTORS = 4
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
@@ -787,7 +794,7 @@ class ToolExecutionMixin:
|
||||
return self._execute_tool_with_circuit_breaker(function_name, arguments)
|
||||
|
||||
# Use reusable executor to prevent resource leaks
|
||||
if not hasattr(self, '_tool_executor'):
|
||||
if not hasattr(self, '_tool_executor') or self._tool_executor is None:
|
||||
self._tool_executor = concurrent.futures.ThreadPoolExecutor(
|
||||
max_workers=2, thread_name_prefix=f"tool-{self.name}"
|
||||
)
|
||||
@@ -801,6 +808,18 @@ class ToolExecutionMixin:
|
||||
future.cancel()
|
||||
logging.warning(f"Tool {function_name} timed out after {tool_timeout}s")
|
||||
result = {"error": f"Tool timed out after {tool_timeout}s", "timeout": True}
|
||||
# The timed-out thread cannot be reclaimed. Retire this
|
||||
# executor so the next call gets a fresh worker instead of
|
||||
# queuing behind a permanently stuck one — but bound how many
|
||||
# orphaned (still-blocked) threads we allow to accumulate:
|
||||
# once the cap is reached we stop recycling and keep reusing
|
||||
# the existing pool so repeated timeouts can't exhaust process
|
||||
# resources with an unbounded number of leaked threads.
|
||||
orphaned = getattr(self, '_tool_executor_orphaned', 0)
|
||||
if orphaned < _MAX_ORPHANED_TOOL_EXECUTORS:
|
||||
self._tool_executor.shutdown(wait=False)
|
||||
self._tool_executor = None
|
||||
self._tool_executor_orphaned = orphaned + 1
|
||||
else:
|
||||
with tool_progress_channel(_progress_sink), with_injection_context(state):
|
||||
result = self._execute_tool_with_circuit_breaker(function_name, arguments)
|
||||
@@ -1130,6 +1149,20 @@ class ToolExecutionMixin:
|
||||
)
|
||||
after_tool_results = self._hook_runner.execute_sync(HookEvent.AFTER_TOOL, after_tool_input, target=function_name)
|
||||
|
||||
# Honour a post-tool block: a plugin/hook can deny the result
|
||||
# (e.g. secret/PII detected) so the original output never
|
||||
# reaches the model. Mirrors the BEFORE_TOOL block path above.
|
||||
if self._hook_runner.is_blocked(after_tool_results):
|
||||
reason = self._hook_runner.get_blocking_reason(after_tool_results)
|
||||
logging.warning(f"Tool {function_name} result blocked by AFTER_TOOL hook")
|
||||
return reason or f"Result of {function_name} was blocked by security policy."
|
||||
|
||||
# Read back any rewritten/redacted tool_output so a plugin can
|
||||
# scrub secrets before the result reaches the model. The bridge
|
||||
# mutates ``after_tool_input.tool_output`` in place.
|
||||
if getattr(after_tool_input, "tool_output", result) is not result:
|
||||
result = after_tool_input.tool_output
|
||||
|
||||
# Surface any additional_context returned by AFTER_TOOL hooks
|
||||
# back to the model by appending it to the tool result (mirrors
|
||||
# the loop-guard injection pattern below). Without this the
|
||||
|
||||
@@ -660,13 +660,12 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
reflection: Optional[Any] = None, # Union[bool, ReflectionConfig] - self-reflection
|
||||
caching: Optional[Any] = None, # Union[bool, CachingConfig] - caching
|
||||
learn: Optional[Any] = None, # Union[bool, LearnConfig] - continuous learning
|
||||
# Union[str, ComputeProviderProtocol] - run every agent's shell/file
|
||||
# tools in ONE shared remote sandbox ("docker", "e2b", "modal",
|
||||
# "daytona", "flyio", "tenki", "local"). Agents share /workspace, so
|
||||
# files written by one agent are visible to the others. Orchestration
|
||||
# stays local. Pass a configured provider instance instead of a name to
|
||||
# customise image/resources.
|
||||
compute: Optional[Any] = None,
|
||||
# Union[str, ComputeProviderProtocol] - where every agent's shell/file
|
||||
# tools run: "docker", "e2b", "modal", "daytona", "flyio", "tenki",
|
||||
# "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.
|
||||
run_on: Optional[Any] = None,
|
||||
):
|
||||
"""
|
||||
Initialize AgentManager with consolidated feature parameters.
|
||||
@@ -898,7 +897,7 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
self.stream = _stream
|
||||
self.name = name
|
||||
# Remote execution: one shared sandbox for every agent on this team.
|
||||
self.compute = compute
|
||||
self.run_on = run_on
|
||||
|
||||
# Callbacks for workflow execution
|
||||
self.on_task_start = _on_task_start
|
||||
@@ -1464,7 +1463,25 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
if async_tasks_to_run:
|
||||
await self._gather_with_isolation(async_tasks_to_run)
|
||||
async_tasks_to_run = []
|
||||
|
||||
|
||||
def _deps_failed(task_id):
|
||||
"""Re-check dependency failure at consumption time.
|
||||
|
||||
asequential() runs this check inside the generator, but async
|
||||
tasks are buffered and drained later, so an upstream async task
|
||||
may not have failed yet when the generator yielded the dependent.
|
||||
Re-checking here — after pending async tasks are flushed — makes
|
||||
the failure cascade fire for the async_execution path too.
|
||||
"""
|
||||
task = self.tasks[task_id]
|
||||
if getattr(task, 'context', None):
|
||||
return any(
|
||||
self.tasks[dep.id].status == "failed"
|
||||
for dep in task.context
|
||||
if hasattr(dep, 'id') and dep.id in self.tasks
|
||||
)
|
||||
return False
|
||||
|
||||
async for task_id in process.asequential():
|
||||
if self.tasks[task_id].async_execution:
|
||||
# Collect async tasks to run in parallel
|
||||
@@ -1472,6 +1489,11 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
else:
|
||||
# Before running a sync task, execute all pending async tasks
|
||||
await flush_async_tasks()
|
||||
# A just-flushed async prerequisite may now be failed; skip
|
||||
# the dependent so we don't run it with missing upstream context.
|
||||
if _deps_failed(task_id):
|
||||
self.tasks[task_id].status = "failed"
|
||||
continue
|
||||
# Run sync task in an executor to avoid blocking the event loop
|
||||
# Use copy_context_to_callable to propagate contextvars (needed for trace emission)
|
||||
from ..trace.context_events import copy_context_to_callable
|
||||
@@ -1763,18 +1785,18 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
"""
|
||||
# Remote execution: provision ONE sandbox shared by every agent on the
|
||||
# team, and tear it down even if execution raises. Re-enters start()
|
||||
# with compute cleared so the wrapper applies exactly once.
|
||||
if getattr(self, "compute", None) is not None:
|
||||
# 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
|
||||
|
||||
compute, self.compute = self.compute, None
|
||||
run_on, self.run_on = self.run_on, None
|
||||
try:
|
||||
with SharedCompute(compute) as shared:
|
||||
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.compute = compute
|
||||
self.run_on = run_on
|
||||
|
||||
# Track execution via telemetry
|
||||
if hasattr(self, '_telemetry') and self._telemetry:
|
||||
|
||||
@@ -847,9 +847,11 @@ class ExecutionConfig:
|
||||
code_execution: bool = False
|
||||
code_mode: str = "safe" # "safe" or "unsafe"
|
||||
|
||||
# Code execution sandbox mode - "sandbox" (default) uses subprocess isolation
|
||||
# "direct" runs in current process (legacy, less secure)
|
||||
code_sandbox_mode: str = "sandbox" # "sandbox" or "direct"
|
||||
# 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
|
||||
|
||||
@@ -6,7 +6,7 @@ Provides recovery strategies when loops are detected.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from enum import Enum
|
||||
import time
|
||||
import hashlib
|
||||
@@ -117,7 +117,7 @@ class DoomLoopDetector:
|
||||
self._loop_events: List[DoomLoopEvent] = []
|
||||
self._recovery_attempts: int = 0
|
||||
self._start_time: Optional[float] = None
|
||||
self._progress_markers: List[str] = []
|
||||
self._progress_markers: List[Tuple[str, float]] = []
|
||||
self._current_backoff: float = self.config.initial_backoff
|
||||
self._content_chunk_counts: Dict[str, int] = {} # hash -> count
|
||||
|
||||
@@ -193,7 +193,7 @@ class DoomLoopDetector:
|
||||
Call this when the agent makes real progress (e.g., file modified,
|
||||
test passed, user goal partially achieved).
|
||||
"""
|
||||
self._progress_markers.append(marker)
|
||||
self._progress_markers.append((marker, time.time()))
|
||||
|
||||
def is_doom_loop(self) -> bool:
|
||||
"""
|
||||
@@ -379,10 +379,19 @@ class DoomLoopDetector:
|
||||
if len(self._actions) < self.config.max_no_progress_steps:
|
||||
return False
|
||||
|
||||
# If we have progress markers, we're making progress
|
||||
# Only count progress markers within the current no-progress window.
|
||||
# Without this recency filter a single early success would suppress
|
||||
# NO_PROGRESS detection for the rest of the session.
|
||||
# Use the boundary *before* the window (the completion timestamp of the
|
||||
# action immediately preceding it, or 0 if the window is the whole
|
||||
# history) so a marker recorded *during* the window's first action —
|
||||
# whose own record_action timestamp is set at completion, i.e. after the
|
||||
# marker — is still counted rather than filtered out.
|
||||
n = self.config.max_no_progress_steps
|
||||
boundary = self._actions[-n - 1].timestamp if len(self._actions) > n else 0.0
|
||||
recent_markers = [
|
||||
m for m in self._progress_markers
|
||||
if True # Could add timestamp filtering
|
||||
m for (m, ts) in self._progress_markers
|
||||
if ts >= boundary
|
||||
]
|
||||
if recent_markers:
|
||||
return False
|
||||
|
||||
@@ -1356,7 +1356,10 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
The completion response from litellm
|
||||
"""
|
||||
import litellm
|
||||
return self._call_with_retry(litellm.completion, **completion_params)
|
||||
response = self._call_with_retry(litellm.completion, **completion_params)
|
||||
if not completion_params.get("stream"):
|
||||
self._track_token_usage(response, self.model)
|
||||
return response
|
||||
|
||||
async def _acompletion_with_retry(self, **completion_params):
|
||||
"""Execute litellm.acompletion with automatic retry on rate limit errors.
|
||||
@@ -1371,7 +1374,13 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
The completion response from litellm
|
||||
"""
|
||||
import litellm
|
||||
return await self._call_with_retry_async(litellm.acompletion, **completion_params)
|
||||
response = await self._call_with_retry_async(
|
||||
litellm.acompletion,
|
||||
**completion_params,
|
||||
)
|
||||
if not completion_params.get("stream"):
|
||||
self._track_token_usage(response, self.model)
|
||||
return response
|
||||
|
||||
def _supports_web_search(self) -> bool:
|
||||
"""
|
||||
@@ -2646,7 +2655,11 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
resp = self._call_responses_api(**responses_params)
|
||||
response_text, tool_calls, _reasoning = self._extract_from_responses_output(resp)
|
||||
|
||||
# Track token usage
|
||||
# Usage accounting is independent from metrics display.
|
||||
# Quiet/default agents must still update the public
|
||||
# token collector after a paid model call.
|
||||
self._track_token_usage(resp, self.model)
|
||||
|
||||
if self.metrics and hasattr(resp, 'usage') and resp.usage:
|
||||
usage = resp.usage
|
||||
tokens_in = getattr(usage, 'input_tokens', 0) or 0
|
||||
@@ -2869,10 +2882,6 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
is_reasoning=False
|
||||
))
|
||||
|
||||
# Track token usage
|
||||
if self.metrics:
|
||||
self._track_token_usage(final_response, self.model)
|
||||
|
||||
# Trigger llm_end callback with metrics for debug output
|
||||
llm_latency_ms = (time.time() - current_time) * 1000
|
||||
|
||||
@@ -3086,9 +3095,6 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
response_content = final_response["choices"][0]["message"].get("content")
|
||||
response_text = response_content if response_content is not None else ""
|
||||
|
||||
# Track token usage
|
||||
if self.metrics:
|
||||
self._track_token_usage(final_response, self.model)
|
||||
|
||||
# Execute callbacks and display based on verbose setting
|
||||
if verbose and not interaction_displayed:
|
||||
@@ -3278,9 +3284,6 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
response_content = final_response["choices"][0]["message"].get("content")
|
||||
response_text = response_content if response_content is not None else ""
|
||||
|
||||
# Track token usage
|
||||
if self.metrics:
|
||||
self._track_token_usage(final_response, self.model)
|
||||
|
||||
# Execute callbacks and display based on verbose setting
|
||||
if verbose and not interaction_displayed:
|
||||
@@ -5414,27 +5417,66 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
litellm.callbacks.append(event)
|
||||
self._registered_callbacks = list(events)
|
||||
|
||||
def _track_token_usage(self, response: Dict[str, Any], model: str) -> Optional[TokenMetrics]:
|
||||
def _track_token_usage(self, response: Any, model: str) -> Optional[TokenMetrics]:
|
||||
"""Extract and track token usage from LLM response."""
|
||||
if not TokenMetrics or not get_token_collector:
|
||||
return None
|
||||
|
||||
# Note: metrics check moved to call sites for performance
|
||||
# This method should only be called when self.metrics=True
|
||||
|
||||
|
||||
try:
|
||||
usage = response.get("usage", {})
|
||||
if isinstance(response, dict):
|
||||
usage = response.get("usage", {})
|
||||
else:
|
||||
usage = getattr(response, "usage", None)
|
||||
if not usage:
|
||||
return None
|
||||
|
||||
|
||||
def _usage_value(*names: str) -> int:
|
||||
for name in names:
|
||||
if isinstance(usage, dict):
|
||||
value = usage.get(name)
|
||||
else:
|
||||
value = getattr(usage, name, None)
|
||||
if value is not None:
|
||||
return int(value or 0)
|
||||
return 0
|
||||
|
||||
def _detail_value(detail_names: tuple[str, ...], name: str) -> int:
|
||||
for detail_name in detail_names:
|
||||
details = (
|
||||
usage.get(detail_name)
|
||||
if isinstance(usage, dict)
|
||||
else getattr(usage, detail_name, None)
|
||||
)
|
||||
if details:
|
||||
value = (
|
||||
details.get(name)
|
||||
if isinstance(details, dict)
|
||||
else getattr(details, name, None)
|
||||
)
|
||||
if value is not None:
|
||||
return int(value or 0)
|
||||
return 0
|
||||
|
||||
# Extract token counts
|
||||
metrics = TokenMetrics(
|
||||
input_tokens=usage.get("prompt_tokens", 0),
|
||||
output_tokens=usage.get("completion_tokens", 0),
|
||||
cached_tokens=usage.get("cached_tokens", 0),
|
||||
reasoning_tokens=usage.get("reasoning_tokens", 0),
|
||||
audio_input_tokens=usage.get("audio_input_tokens", 0),
|
||||
audio_output_tokens=usage.get("audio_output_tokens", 0)
|
||||
input_tokens=_usage_value("prompt_tokens", "input_tokens"),
|
||||
output_tokens=_usage_value("completion_tokens", "output_tokens"),
|
||||
cached_tokens=_usage_value("cached_tokens") or _detail_value(
|
||||
("prompt_tokens_details", "input_tokens_details"),
|
||||
"cached_tokens",
|
||||
),
|
||||
reasoning_tokens=_usage_value("reasoning_tokens") or _detail_value(
|
||||
("completion_tokens_details", "output_tokens_details"),
|
||||
"reasoning_tokens",
|
||||
),
|
||||
audio_input_tokens=_usage_value("audio_input_tokens") or _detail_value(
|
||||
("prompt_tokens_details", "input_tokens_details"),
|
||||
"audio_tokens",
|
||||
),
|
||||
audio_output_tokens=_usage_value("audio_output_tokens") or _detail_value(
|
||||
("completion_tokens_details", "output_tokens_details"),
|
||||
"audio_tokens",
|
||||
),
|
||||
)
|
||||
|
||||
# Store metrics
|
||||
@@ -5942,7 +5984,9 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
Returns a ``ResponsesAPIResponse`` object.
|
||||
"""
|
||||
import litellm
|
||||
return await self._call_with_retry_async(litellm.aresponses, **params)
|
||||
response = await self._call_with_retry_async(litellm.aresponses, **params)
|
||||
self._track_token_usage(response, self.model)
|
||||
return response
|
||||
|
||||
def _extract_from_responses_output(self, response) -> tuple:
|
||||
"""
|
||||
|
||||
@@ -6,7 +6,9 @@ supporting both synchronous and asynchronous operations.
|
||||
"""
|
||||
|
||||
import os
|
||||
import base64
|
||||
import logging
|
||||
import mimetypes
|
||||
from praisonaiagents._logging import get_logger
|
||||
import time
|
||||
import json
|
||||
@@ -16,6 +18,7 @@ from typing import Any, Dict, List, Optional, Union, AsyncIterator, Iterator, Ca
|
||||
from pydantic import BaseModel
|
||||
from dataclasses import dataclass
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
|
||||
from ..errors import ToolExecutionError
|
||||
|
||||
@@ -34,6 +37,54 @@ def _durable_iteration_kwargs(execute_tool_fn: Callable, index: int) -> Dict[str
|
||||
return {"_durable_iteration_index": index}
|
||||
return {}
|
||||
|
||||
|
||||
def _handle_native_deferred_result(
|
||||
tool_result: Any,
|
||||
messages: List[Dict],
|
||||
tool_call_id: Optional[str],
|
||||
function_name: str,
|
||||
history_sink: Optional[List[Dict]] = None,
|
||||
) -> Any:
|
||||
"""Register a directly-returned ``DeferredToolResult`` on the native path.
|
||||
|
||||
Parity with the LiteLLM loop (``llm.py:_register_deferred_if_any``): a tool
|
||||
may return a ``DeferredToolResult`` to signal "started; will resolve later".
|
||||
On the native OpenAI-SDK loop the value was previously stringified and the
|
||||
handle lost, so the eventual background result was never re-injected. Here
|
||||
we register the handle on the shared resolver so a later
|
||||
``resolve_deferred(handle_id, value)`` appends a follow-up tool message that
|
||||
a subsequent turn will replay, and surface the ``note`` to the model now.
|
||||
|
||||
The re-injection target is ``history_sink`` when provided — this must be the
|
||||
caller's *durable* conversation history (e.g. the agent's ``chat_history``),
|
||||
since the background job typically completes after this native loop has
|
||||
already returned and its per-call ``messages`` copy has been discarded. When
|
||||
no durable sink is given we fall back to ``messages`` (correct only if the
|
||||
handle resolves while the loop is still open), preserving prior behaviour.
|
||||
|
||||
Returns the value to record for this turn (the deferred ``note`` string when
|
||||
deferred, otherwise the unchanged ``tool_result``). Never raises.
|
||||
"""
|
||||
from ..tools.call_executor import DeferredToolResult
|
||||
if not isinstance(tool_result, DeferredToolResult):
|
||||
return tool_result
|
||||
try:
|
||||
from ..tools.call_executor import get_deferred_resolver
|
||||
resolver = get_deferred_resolver()
|
||||
sink = history_sink if history_sink is not None else messages
|
||||
|
||||
def _reinject(handle_id: str, value: Any, session_id: Optional[str]) -> None:
|
||||
sink.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"content": f"[deferred:{function_name}] {value}",
|
||||
})
|
||||
|
||||
resolver.register_if_absent(tool_result.handle_id, _reinject)
|
||||
except Exception as e: # noqa: BLE001 — registration must never break the turn
|
||||
logging.debug("Native deferred registration skipped (non-fatal): %s", e)
|
||||
return tool_result.note
|
||||
|
||||
# Lazy imports for optional dependencies
|
||||
_openai_module = None
|
||||
_rich_console = None
|
||||
@@ -675,7 +726,11 @@ class OpenAIClient:
|
||||
"output": msg.get("content", ""),
|
||||
})
|
||||
else:
|
||||
input_items.append(msg)
|
||||
item = dict(msg)
|
||||
item["content"] = self._build_responses_content(
|
||||
msg.get("content", "")
|
||||
)
|
||||
input_items.append(item)
|
||||
|
||||
if instructions:
|
||||
params["instructions"] = instructions
|
||||
@@ -709,6 +764,65 @@ class OpenAIClient:
|
||||
|
||||
return params
|
||||
|
||||
@staticmethod
|
||||
def _normalise_responses_image_url(image_url: Any) -> str:
|
||||
"""Return an API-fetchable URL, encoding local image files as data URLs."""
|
||||
if isinstance(image_url, dict):
|
||||
image_url = image_url.get("url", "")
|
||||
if not isinstance(image_url, str) or not image_url:
|
||||
raise ValueError("Image content must include a non-empty URL or local path")
|
||||
if image_url.startswith(("http://", "https://", "data:")):
|
||||
return image_url
|
||||
|
||||
image_path = Path(image_url).expanduser()
|
||||
if not image_path.is_file():
|
||||
raise FileNotFoundError(
|
||||
f"Local image file does not exist: {image_path}"
|
||||
)
|
||||
mime_type = mimetypes.guess_type(image_path.name)[0]
|
||||
if not mime_type or not mime_type.startswith("image/"):
|
||||
raise ValueError(f"Unsupported local image type: {image_path}")
|
||||
encoded = base64.b64encode(image_path.read_bytes()).decode("ascii")
|
||||
return f"data:{mime_type};base64,{encoded}"
|
||||
|
||||
@classmethod
|
||||
def _build_responses_content(cls, content: Any) -> Any:
|
||||
"""Translate Chat Completions content parts to Responses API parts."""
|
||||
if not isinstance(content, list):
|
||||
return content
|
||||
|
||||
responses_content: List[Any] = []
|
||||
for part in content:
|
||||
if not isinstance(part, dict):
|
||||
responses_content.append(part)
|
||||
continue
|
||||
part_type = part.get("type")
|
||||
if part_type == "text":
|
||||
responses_content.append({
|
||||
"type": "input_text",
|
||||
"text": part.get("text", ""),
|
||||
})
|
||||
elif part_type == "image_url":
|
||||
image_item = {
|
||||
"type": "input_image",
|
||||
"image_url": cls._normalise_responses_image_url(
|
||||
part.get("image_url")
|
||||
),
|
||||
}
|
||||
image_value = part.get("image_url")
|
||||
if isinstance(image_value, dict) and image_value.get("detail"):
|
||||
image_item["detail"] = image_value["detail"]
|
||||
responses_content.append(image_item)
|
||||
elif part_type == "input_image":
|
||||
item = dict(part)
|
||||
item["image_url"] = cls._normalise_responses_image_url(
|
||||
part.get("image_url")
|
||||
)
|
||||
responses_content.append(item)
|
||||
else:
|
||||
responses_content.append(part)
|
||||
return responses_content
|
||||
|
||||
def _responses_to_chat_completion(self, response) -> ChatCompletion:
|
||||
"""
|
||||
Wrap a Responses API response into a ChatCompletion dataclass
|
||||
@@ -890,6 +1004,8 @@ class OpenAIClient:
|
||||
))
|
||||
|
||||
return final_response
|
||||
except (FileNotFoundError, ValueError):
|
||||
raise
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Responses API streaming failed, falling back: {e}")
|
||||
# Fall through to Chat Completions streaming
|
||||
@@ -1157,6 +1273,8 @@ class OpenAIClient:
|
||||
))
|
||||
|
||||
return final_response
|
||||
except (FileNotFoundError, ValueError):
|
||||
raise
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Responses API async streaming failed, falling back: {e}")
|
||||
# Fall through to Chat Completions streaming
|
||||
@@ -1352,6 +1470,8 @@ class OpenAIClient:
|
||||
)
|
||||
raw = self.sync_client.responses.create(**resp_params)
|
||||
return self._responses_to_chat_completion(raw)
|
||||
except (FileNotFoundError, ValueError):
|
||||
raise
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Responses API failed, falling back to Chat Completions: {e}")
|
||||
# Fall through to Chat Completions
|
||||
@@ -1411,6 +1531,8 @@ class OpenAIClient:
|
||||
)
|
||||
raw = await self.async_client.responses.create(**resp_params)
|
||||
return self._responses_to_chat_completion(raw)
|
||||
except (FileNotFoundError, ValueError):
|
||||
raise
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Responses API failed, falling back to Chat Completions: {e}")
|
||||
# Fall through to Chat Completions
|
||||
@@ -1487,6 +1609,11 @@ class OpenAIClient:
|
||||
# path so /stop and live steering work on the OpenAI-native loop too.
|
||||
cancel_token = kwargs.pop("cancel_token", None)
|
||||
steering_drain = kwargs.pop("steering_drain", None)
|
||||
# Durable sink for deferred tool re-injection: the caller's persistent
|
||||
# conversation history (e.g. the agent's ``chat_history``). Background
|
||||
# jobs usually complete after this loop returns, so late results must
|
||||
# land in a list a subsequent turn replays — not the per-call copy below.
|
||||
deferred_history_sink = kwargs.pop("deferred_history_sink", None)
|
||||
|
||||
def _is_cancelled() -> bool:
|
||||
return cancel_token is not None and getattr(cancel_token, "is_set", lambda: False)()
|
||||
@@ -1723,6 +1850,12 @@ class OpenAIClient:
|
||||
except Exception as tool_error:
|
||||
logging.warning(f"Tool '{function_name}' failed: {tool_error}")
|
||||
tool_result = {"error": str(tool_error)}
|
||||
# Parity with the LiteLLM loop: register a deferred handle so
|
||||
# its eventual background value is re-injected, not lost.
|
||||
tool_result = _handle_native_deferred_result(
|
||||
tool_result, messages, _tool_call_id, function_name,
|
||||
history_sink=deferred_history_sink,
|
||||
)
|
||||
try:
|
||||
results_str = json.dumps(tool_result) if tool_result else "Function returned an empty output"
|
||||
except (TypeError, ValueError):
|
||||
@@ -1812,6 +1945,8 @@ class OpenAIClient:
|
||||
# path so /stop and live steering work on the OpenAI-native loop too.
|
||||
cancel_token = kwargs.pop("cancel_token", None)
|
||||
steering_drain = kwargs.pop("steering_drain", None)
|
||||
# Durable sink for deferred tool re-injection (see sync counterpart).
|
||||
deferred_history_sink = kwargs.pop("deferred_history_sink", None)
|
||||
|
||||
def _is_cancelled() -> bool:
|
||||
return cancel_token is not None and getattr(cancel_token, "is_set", lambda: False)()
|
||||
@@ -2032,6 +2167,12 @@ class OpenAIClient:
|
||||
logging.warning(f"Tool '{function_name}' failed: {tool_error}")
|
||||
tool_result = {"error": str(tool_error)}
|
||||
|
||||
# Parity with the LiteLLM loop: register a deferred handle so
|
||||
# its eventual background value is re-injected, not lost.
|
||||
tool_result = _handle_native_deferred_result(
|
||||
tool_result, messages, _tool_call_id, function_name,
|
||||
history_sink=deferred_history_sink,
|
||||
)
|
||||
try:
|
||||
results_str = json.dumps(tool_result) if tool_result else "Function returned an empty output"
|
||||
except (TypeError, ValueError):
|
||||
@@ -2104,6 +2245,8 @@ class OpenAIClient:
|
||||
Yields:
|
||||
String chunks of the response as they are generated
|
||||
"""
|
||||
# Durable sink for deferred tool re-injection (see sync counterpart).
|
||||
deferred_history_sink = kwargs.pop("deferred_history_sink", None)
|
||||
# Format tools for OpenAI API
|
||||
formatted_tools = self.format_tools(tools)
|
||||
|
||||
@@ -2241,6 +2384,12 @@ class OpenAIClient:
|
||||
execute_tool_fn, iteration_count
|
||||
),
|
||||
)
|
||||
# Parity with the LiteLLM loop: register a deferred
|
||||
# handle so its eventual value is re-injected.
|
||||
tool_result = _handle_native_deferred_result(
|
||||
tool_result, messages, _tool_call_id, function_name,
|
||||
history_sink=deferred_history_sink,
|
||||
)
|
||||
results_str = json.dumps(tool_result) if tool_result else "Function returned an empty output"
|
||||
except ToolExecutionError:
|
||||
raise
|
||||
|
||||
@@ -742,36 +742,8 @@ class ReflectionOutput(BaseModel):
|
||||
reflection: str
|
||||
satisfactory: Literal["yes", "no"]
|
||||
|
||||
class TaskOutput(BaseModel):
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
description: str
|
||||
summary: Optional[str] = None
|
||||
raw: str
|
||||
pydantic: Optional[BaseModel] = None
|
||||
json_dict: Optional[Dict[str, Any]] = None
|
||||
agent: str
|
||||
output_format: Literal["RAW", "JSON", "Pydantic"] = "RAW"
|
||||
token_metrics: Optional['TokenMetrics'] = None # Add token metrics field
|
||||
callback_error: Optional[str] = None
|
||||
non_fatal_errors: Optional[list[str]] = None
|
||||
|
||||
def json(self) -> Optional[str]:
|
||||
if self.output_format == "JSON" and self.json_dict:
|
||||
return json.dumps(self.json_dict)
|
||||
return None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
output_dict = {}
|
||||
if self.json_dict:
|
||||
output_dict.update(self.json_dict)
|
||||
if self.pydantic:
|
||||
output_dict.update(self.pydantic.model_dump())
|
||||
return output_dict
|
||||
|
||||
def __str__(self):
|
||||
if self.pydantic:
|
||||
return str(self.pydantic)
|
||||
elif self.json_dict:
|
||||
return json.dumps(self.json_dict)
|
||||
else:
|
||||
return self.raw
|
||||
# Re-export the single canonical TaskOutput so main.TaskOutput and
|
||||
# output.models.TaskOutput are the SAME class. Previously two near-identical
|
||||
# classes both named TaskOutput existed, which broke guardrail validation
|
||||
# (GuardrailResult expected one class while agents produced the other).
|
||||
from .output.models import TaskOutput
|
||||
@@ -428,9 +428,15 @@ class MCP:
|
||||
self.is_npx = False
|
||||
return
|
||||
|
||||
# Handle the single string format for stdio client
|
||||
if isinstance(command_or_string, str) and args is None:
|
||||
# Split the string into command and args using shell-like parsing
|
||||
# Handle the single string format for stdio client.
|
||||
# Shell-split the command string first, then decide based on how many
|
||||
# tokens it yields. This makes multi-word forms like
|
||||
# MCP("npx -y pkg", args=["/tmp"]) work — the first token is the
|
||||
# executable and the remaining tokens are prepended to any explicitly
|
||||
# supplied args. Using shlex handles all whitespace (spaces, tabs,
|
||||
# newlines) and lets an executable path that contains spaces be passed
|
||||
# intact by quoting it, e.g. MCP('"/opt/my app"', args=["run"]).
|
||||
if isinstance(command_or_string, str):
|
||||
if platform.system() == 'Windows':
|
||||
# Use shlex with posix=False for Windows to handle quotes and paths with spaces
|
||||
parts = shlex.split(command_or_string, posix=False)
|
||||
@@ -438,13 +444,19 @@ class MCP:
|
||||
parts = [part.strip('"') for part in parts]
|
||||
else:
|
||||
parts = shlex.split(command_or_string)
|
||||
if not parts:
|
||||
raise ValueError("Empty command string")
|
||||
|
||||
cmd = parts[0]
|
||||
arguments = parts[1:] if len(parts) > 1 else []
|
||||
|
||||
if len(parts) > 1:
|
||||
# Multi-token string: first token is the executable, the rest
|
||||
# are prepended to any explicitly supplied args.
|
||||
cmd = parts[0]
|
||||
arguments = parts[1:] + (args or [])
|
||||
else:
|
||||
# Single token (or a quoted spaced path): treat the whole
|
||||
# string as a bare executable and keep explicit args as-is.
|
||||
cmd = parts[0] if parts else command_or_string
|
||||
arguments = args or []
|
||||
else:
|
||||
# Use the original format with separate command and args
|
||||
# Use the original format with a bare command and separate args
|
||||
cmd = command_or_string
|
||||
arguments = args or []
|
||||
|
||||
|
||||
@@ -6,11 +6,16 @@ It is kept separate from main.py to avoid importing rich at module level.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Optional, Dict, Any, Literal, TYPE_CHECKING
|
||||
from typing import Optional, Dict, Any, Literal
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# Import token metrics at runtime so the forward reference in TaskOutput can be
|
||||
# resolved by model_rebuild() below. Importing only under TYPE_CHECKING left the
|
||||
# model perpetually "not fully defined", making any instantiation raise.
|
||||
try:
|
||||
from ..telemetry.token_collector import TokenMetrics
|
||||
except ImportError:
|
||||
TokenMetrics = None
|
||||
|
||||
|
||||
class TaskOutput(BaseModel):
|
||||
@@ -52,3 +57,8 @@ class ReflectionOutput(BaseModel):
|
||||
"""Output model for self-reflection results."""
|
||||
reflection: str
|
||||
satisfactory: Literal["yes", "no"]
|
||||
|
||||
|
||||
# Resolve the 'TokenMetrics' forward reference now that it is available at
|
||||
# runtime, so TaskOutput can actually be instantiated.
|
||||
TaskOutput.model_rebuild()
|
||||
@@ -828,7 +828,16 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
|
||||
|
||||
if _overrides("after_tool"):
|
||||
def after_tool_hook(data, _p=plugin):
|
||||
_p.after_tool(getattr(data, "tool_name", ""), getattr(data, "tool_output", None))
|
||||
try:
|
||||
new_result = _p.after_tool(getattr(data, "tool_name", ""),
|
||||
getattr(data, "tool_output", None))
|
||||
except GuardrailBlocked as e:
|
||||
return HookResult.block(e.reason)
|
||||
decision = _as_decision(new_result)
|
||||
if decision is not None:
|
||||
return decision
|
||||
if new_result is not None and hasattr(data, "tool_output"):
|
||||
data.tool_output = new_result
|
||||
return HookResult.allow()
|
||||
yield HookEvent.AFTER_TOOL, after_tool_hook
|
||||
|
||||
|
||||
@@ -26,9 +26,12 @@ class GuardrailBlocked(Exception):
|
||||
A ``POLICY``/``GUARDRAIL`` plugin can raise this from any ``before_*``
|
||||
method (``before_tool``, ``before_llm``, ``before_agent``,
|
||||
``before_message``) to stop the tool call / LLM request / agent run /
|
||||
inbound message. The plugin bridge converts it into a denying
|
||||
``HookResult`` so the runtime's existing ``is_blocked`` enforcement skips
|
||||
the action and surfaces ``reason``.
|
||||
inbound message. It may also be raised from ``after_tool`` to block
|
||||
*propagation* of a tool result (e.g. secret/PII detected) — the tool has
|
||||
already run, but the output is suppressed before it reaches the model.
|
||||
The plugin bridge converts it into a denying ``HookResult`` so the
|
||||
runtime's existing ``is_blocked`` enforcement skips the action and
|
||||
surfaces ``reason``.
|
||||
"""
|
||||
|
||||
def __init__(self, reason: str = "Blocked by guardrail plugin"):
|
||||
@@ -177,7 +180,13 @@ class Plugin(ABC):
|
||||
return args
|
||||
|
||||
def after_tool(self, tool_name: str, result: Any) -> Any:
|
||||
"""Called after tool execution. Can modify result."""
|
||||
"""Called after tool execution.
|
||||
|
||||
Return a modified ``result`` (rewrite/redact) to replace the effective
|
||||
tool output before it reaches the model or a channel, a deny/block
|
||||
decision (``PluginDecision.deny(reason)`` / a ``HookResult`` / raise
|
||||
:class:`GuardrailBlocked`), or ``None`` for no-op.
|
||||
"""
|
||||
return result
|
||||
|
||||
def before_tool_definitions(
|
||||
|
||||
@@ -956,7 +956,19 @@ Workflow Finished: {self.workflow_finished} # ADDED: Workflow Finished Status
|
||||
async def asequential(self) -> AsyncGenerator[str, None]:
|
||||
"""Async version of sequential method"""
|
||||
for task_id in self.tasks:
|
||||
if self.tasks[task_id].status != "completed":
|
||||
task = self.tasks[task_id]
|
||||
if task.status == "failed":
|
||||
continue # Skip failed tasks
|
||||
# Check if any context dependency failed
|
||||
if hasattr(task, 'context') and task.context:
|
||||
deps_failed = any(
|
||||
self.tasks[dep.id].status == "failed"
|
||||
for dep in task.context if hasattr(dep, 'id') and dep.id in self.tasks
|
||||
)
|
||||
if deps_failed:
|
||||
task.status = "failed"
|
||||
continue
|
||||
if task.status != "completed":
|
||||
yield task_id
|
||||
|
||||
async def ahierarchical(self) -> AsyncGenerator[str, None]:
|
||||
|
||||
@@ -631,12 +631,12 @@ class AgentFlow:
|
||||
# ============================================================
|
||||
# REMOTE EXECUTION
|
||||
# ============================================================
|
||||
# Union[str, ComputeProviderProtocol] - run every step's shell/file tools in
|
||||
# ONE shared remote sandbox ("docker", "e2b", "modal", "daytona", "flyio",
|
||||
# "tenki", "local"). Agents share /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 image/resources.
|
||||
compute: Optional[Any] = None
|
||||
# Union[str, ComputeProviderProtocol] - where each step's shell/file tools
|
||||
# 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
|
||||
|
||||
# ============================================================
|
||||
# ROBUSTNESS PARAMS (debugging & audit trail)
|
||||
@@ -1115,20 +1115,27 @@ class AgentFlow:
|
||||
"separate Workflow instance per concurrent run."
|
||||
)
|
||||
try:
|
||||
if self.compute 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:
|
||||
shared.attach(self._collect_agents())
|
||||
return self._run_impl(input, llm, verbose, stream)
|
||||
# Expose the live sandbox so agents created *during* the run
|
||||
# (e.g. an action-only step's temporary agent) can be attached
|
||||
# too, instead of silently executing tools locally.
|
||||
self._active_shared_compute = shared
|
||||
try:
|
||||
return self._run_impl(input, llm, verbose, stream)
|
||||
finally:
|
||||
self._active_shared_compute = None
|
||||
finally:
|
||||
self._execution_lock.release()
|
||||
|
||||
def _shared_compute(self):
|
||||
"""Build the SharedCompute for this run (see the ``compute=`` field)."""
|
||||
"""Build the SharedCompute for this run (see the ``run_on=`` field)."""
|
||||
from ..managed.shared_compute import SharedCompute
|
||||
|
||||
return SharedCompute(self.compute)
|
||||
return SharedCompute(self.run_on)
|
||||
|
||||
def _collect_agents(self) -> List[Any]:
|
||||
"""Walk steps (including nested route/parallel/loop/repeat/if) for Agents.
|
||||
@@ -1516,6 +1523,13 @@ class AgentFlow:
|
||||
caching=self.caching, # Propagate caching config
|
||||
hooks=self.hooks, # Propagate hooks/callbacks
|
||||
)
|
||||
# An action-only step builds its agent here, after the
|
||||
# run's initial attach(). Bind it to the shared sandbox
|
||||
# too, so its shell/file tools land in the same instance
|
||||
# as every other step instead of running locally.
|
||||
shared = getattr(self, "_active_shared_compute", None)
|
||||
if shared is not None:
|
||||
shared.attach([temp_agent])
|
||||
# Substitute variables in action
|
||||
action = step.action
|
||||
action = _substitute_action_variables(action, all_variables, previous_output, input)
|
||||
|
||||
@@ -347,7 +347,25 @@ class YAMLWorkflowParser:
|
||||
|
||||
# Parse history flag for execution tracing (robustness feature)
|
||||
history_enabled = data.get('history', False)
|
||||
|
||||
|
||||
# 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"'run_on' must be a provider name string, got {type(run_on).__name__}"
|
||||
)
|
||||
from ..managed._compute_bridge import available_providers
|
||||
|
||||
if run_on.lower().strip() not in available_providers():
|
||||
raise ValueError(
|
||||
f"Unknown run_on provider {run_on!r}. "
|
||||
f"Available: {', '.join(available_providers())}"
|
||||
)
|
||||
run_on = run_on.lower().strip()
|
||||
|
||||
workflow = Workflow(
|
||||
name=name,
|
||||
steps=steps,
|
||||
@@ -360,6 +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)
|
||||
run_on=run_on, # Shared remote sandbox for every step (None = local)
|
||||
)
|
||||
|
||||
# Store additional attributes for feature parity with agents.yaml
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.166"
|
||||
version = "1.6.167"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Opt-in real-provider coverage for default token collection."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.network
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("RUN_REAL_KEY_TESTS") != "1"
|
||||
or not os.environ.get("OPENAI_API_KEY"),
|
||||
reason="Set RUN_REAL_KEY_TESTS=1 and OPENAI_API_KEY for the real agentic test",
|
||||
)
|
||||
def test_default_agent_start_records_real_provider_usage():
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.telemetry.token_collector import get_token_collector
|
||||
|
||||
collector = get_token_collector()
|
||||
collector.reset()
|
||||
result = Agent(
|
||||
name="token-collector-real-smoke",
|
||||
instructions="Reply with the single word OK.",
|
||||
llm="gpt-4o-mini",
|
||||
).start("Say OK")
|
||||
summary = collector.get_session_summary()
|
||||
|
||||
print(result)
|
||||
print(summary)
|
||||
assert result
|
||||
assert summary["total_interactions"] >= 1
|
||||
assert summary["total_metrics"]["input_tokens"] > 0
|
||||
assert summary["total_metrics"]["output_tokens"] > 0
|
||||
@@ -12,8 +12,8 @@ Required environment variables:
|
||||
- OPENAI_API_KEY: OpenAI API key
|
||||
"""
|
||||
import os
|
||||
import base64
|
||||
import sys
|
||||
import time
|
||||
import pytest
|
||||
|
||||
# Note: Only real API tests are gated by RUN_REAL_KEY_TESTS.
|
||||
@@ -230,6 +230,30 @@ class TestResponsesAPIRealAgent:
|
||||
assert len(response) > 100, f"Response too short ({len(response)} chars)"
|
||||
print(f"[PASS] gpt-4o-mini - {len(response)} chars")
|
||||
|
||||
def test_bare_model_accepts_local_image_attachment(self, tmp_path):
|
||||
"""Exercise Agent.start() through OpenAIClient with a local image."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
image_path = tmp_path / "pixel.png"
|
||||
image_path.write_bytes(base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8"
|
||||
"/x8AAusB9WlV9ZkAAAAASUVORK5CYII="
|
||||
))
|
||||
agent = Agent(
|
||||
name="LocalImageAgent",
|
||||
instructions="Answer image questions briefly.",
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
|
||||
response = agent.start(
|
||||
"Confirm that you can inspect the attached image.",
|
||||
attachments=[str(image_path)],
|
||||
stream=False,
|
||||
)
|
||||
|
||||
assert response
|
||||
print(response)
|
||||
|
||||
def test_responses_api_with_tool(self):
|
||||
"""
|
||||
Test that Responses API correctly handles tool calls — the key
|
||||
@@ -251,7 +275,7 @@ class TestResponsesAPIRealAgent:
|
||||
|
||||
assert response is not None
|
||||
assert "42" in response, f"Expected '42' in response, got: {response[:200]}"
|
||||
print(f"[PASS] Tool call via Responses API (Path P1) - response contains '42'")
|
||||
print("[PASS] Tool call via Responses API (Path P1) - response contains '42'")
|
||||
|
||||
def test_openai_client_path_with_tool(self):
|
||||
"""
|
||||
@@ -275,7 +299,7 @@ class TestResponsesAPIRealAgent:
|
||||
|
||||
assert response is not None
|
||||
assert "42" in response, f"Expected '42' in response, got: {response[:200]}"
|
||||
print(f"[PASS] Tool call via OpenAIClient Responses API (Path P2) - response contains '42'")
|
||||
print("[PASS] Tool call via OpenAIClient Responses API (Path P2) - response contains '42'")
|
||||
|
||||
|
||||
# ── OpenAIClient-specific unit tests ───────────────────────────────────
|
||||
@@ -321,6 +345,71 @@ class TestOpenAIClientResponsesAPI:
|
||||
# Tools should be in Responses API format (flattened)
|
||||
assert params["tools"] == [{"type": "function", "name": "add", "description": "Add", "parameters": {}}]
|
||||
|
||||
def test_build_responses_input_maps_chat_multimodal_parts(self):
|
||||
from praisonaiagents.llm.openai_client import OpenAIClient
|
||||
|
||||
client = OpenAIClient.__new__(OpenAIClient)
|
||||
messages = [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Describe this image"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": "https://example.com/image.png",
|
||||
"detail": "high",
|
||||
},
|
||||
},
|
||||
],
|
||||
}]
|
||||
|
||||
params = client._build_responses_input(messages, "gpt-4o-mini")
|
||||
|
||||
assert params["input"] == [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "Describe this image"},
|
||||
{
|
||||
"type": "input_image",
|
||||
"image_url": "https://example.com/image.png",
|
||||
"detail": "high",
|
||||
},
|
||||
],
|
||||
}]
|
||||
|
||||
def test_build_responses_input_encodes_local_image(self, tmp_path):
|
||||
from praisonaiagents.llm.openai_client import OpenAIClient
|
||||
|
||||
image_path = tmp_path / "pixel.png"
|
||||
image_path.write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||
client = OpenAIClient.__new__(OpenAIClient)
|
||||
|
||||
params = client._build_responses_input([{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": str(image_path)},
|
||||
}],
|
||||
}], "gpt-4o-mini")
|
||||
|
||||
image_url = params["input"][0]["content"][0]["image_url"]
|
||||
assert image_url == "data:image/png;base64,iVBORw0KGgo="
|
||||
|
||||
def test_build_responses_input_rejects_missing_local_image(self, tmp_path):
|
||||
from praisonaiagents.llm.openai_client import OpenAIClient
|
||||
|
||||
client = OpenAIClient.__new__(OpenAIClient)
|
||||
missing_path = tmp_path / "missing.png"
|
||||
|
||||
with pytest.raises(FileNotFoundError, match="Local image file does not exist"):
|
||||
client._build_responses_input([{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": str(missing_path)},
|
||||
}],
|
||||
}], "gpt-4o-mini")
|
||||
|
||||
def test_responses_to_chat_completion(self):
|
||||
from praisonaiagents.llm.openai_client import OpenAIClient
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
"""Opt-in real-provider coverage for streamed tool dispatch."""
|
||||
"""Opt-in real-provider coverage for streamed tool dispatch.
|
||||
|
||||
Run with ``RUN_REAL_KEY_TESTS=1`` and ``OPENAI_API_KEY`` set to exercise the
|
||||
provider-backed follow-up completion path.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
@@ -26,7 +30,9 @@ def test_real_agent_dispatches_tool_while_streaming():
|
||||
llm="gpt-4o-mini",
|
||||
instructions=(
|
||||
"Always call stream_lookup exactly once before answering. "
|
||||
"Include the tool's complete result in the final answer."
|
||||
"After the tool call, reply with the exact marker "
|
||||
"STREAM-FOLLOW-UP-COMPLETE and include the tool's complete "
|
||||
"result in the final answer."
|
||||
),
|
||||
tools=[stream_lookup],
|
||||
)
|
||||
@@ -39,4 +45,5 @@ def test_real_agent_dispatches_tool_while_streaming():
|
||||
|
||||
print(result)
|
||||
assert calls == ["NEBULA-7"]
|
||||
assert "STREAM-FOLLOW-UP-COMPLETE" in result
|
||||
assert "stream-result:NEBULA-7" in result
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Action-only steps must also join the shared sandbox.
|
||||
|
||||
A workflow step that carries an `action` but no pre-defined `agent` builds a
|
||||
temporary Agent *during* the run -- after the initial attach(). Without the fix
|
||||
that temp agent runs its shell/file tools locally, silently bypassing the
|
||||
`run_on:` sandbox. No Docker required: a fake compute records what it patches.
|
||||
"""
|
||||
|
||||
from praisonaiagents.workflows.workflows import Workflow
|
||||
from praisonaiagents.task.task import Task
|
||||
|
||||
|
||||
class _RecordingShared:
|
||||
"""Stand-in for SharedCompute that records every agent it attaches."""
|
||||
|
||||
def __init__(self):
|
||||
self.attached = []
|
||||
|
||||
def attach(self, agents):
|
||||
self.attached.extend(a for a in agents if a is not None)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
|
||||
def _build_workflow(monkeypatch):
|
||||
"""A one-step, action-only workflow wired to a recording shared compute."""
|
||||
shared = _RecordingShared()
|
||||
|
||||
wf = Workflow(
|
||||
name="temp-agent-run-on",
|
||||
steps=[Task(name="s1", action="write a file to /workspace")],
|
||||
run_on="docker",
|
||||
)
|
||||
monkeypatch.setattr(wf, "_shared_compute", lambda: shared)
|
||||
return wf, shared
|
||||
|
||||
|
||||
def test_action_only_temp_agent_is_attached(monkeypatch):
|
||||
wf, shared = _build_workflow(monkeypatch)
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_chat(self, action, **kwargs):
|
||||
# By the time the temp agent chats, it must already be attached.
|
||||
captured["names"] = [getattr(a, "name", None) for a in shared.attached]
|
||||
return "done"
|
||||
|
||||
from praisonaiagents.agent.agent import Agent
|
||||
monkeypatch.setattr(Agent, "chat", fake_chat)
|
||||
|
||||
result = wf.run(input="go")
|
||||
|
||||
assert result["output"] == "done"
|
||||
# The temp agent created for the action-only step reached shared.attach()
|
||||
# before its chat() ran -- i.e. it joined the sandbox, not run locally.
|
||||
assert captured.get("names"), "temp agent was never attached to shared compute"
|
||||
|
||||
|
||||
def test_active_shared_compute_cleared_after_run(monkeypatch):
|
||||
wf, _shared = _build_workflow(monkeypatch)
|
||||
|
||||
from praisonaiagents.agent.agent import Agent
|
||||
monkeypatch.setattr(Agent, "chat", lambda self, action, **kw: "done")
|
||||
|
||||
wf.run(input="go")
|
||||
|
||||
# The live-sandbox handle must not leak past the run.
|
||||
assert getattr(wf, "_active_shared_compute", None) is None
|
||||
@@ -237,9 +237,9 @@ def test_collect_agents_walks_nested_steps_and_dedupes():
|
||||
assert [x.name for x in collected] == ["A", "B"], "each agent exactly once, in order"
|
||||
|
||||
|
||||
def test_flow_without_compute_does_not_provision():
|
||||
def test_flow_without_run_on_does_not_provision():
|
||||
from praisonaiagents import AgentFlow
|
||||
|
||||
flow = AgentFlow(steps=[FakeAgent("A")])
|
||||
assert flow.compute is None
|
||||
assert flow.run_on is None
|
||||
assert flow._collect_agents()[0].tools == []
|
||||
@@ -0,0 +1,63 @@
|
||||
"""`run_on:` support in workflow YAML.
|
||||
|
||||
Gives no-code users the same shared-sandbox execution the Python API has.
|
||||
No Docker required: parsing and validation only.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.workflows.yaml_parser import YAMLWorkflowParser
|
||||
|
||||
BASE = """
|
||||
name: {name}
|
||||
{extra}agents:
|
||||
writer:
|
||||
role: Writer
|
||||
goal: write things
|
||||
steps:
|
||||
- agent: writer
|
||||
action: "write a file"
|
||||
"""
|
||||
|
||||
|
||||
def _parse(extra="", name="wf"):
|
||||
return YAMLWorkflowParser().parse_string(BASE.format(name=name, extra=extra))
|
||||
|
||||
|
||||
def test_run_on_is_parsed():
|
||||
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().run_on is None
|
||||
|
||||
|
||||
def test_run_on_is_case_and_space_insensitive():
|
||||
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").run_on == provider
|
||||
|
||||
|
||||
def test_unknown_provider_fails_at_load_time():
|
||||
"""A typo must fail when the file loads, not at the first tool call."""
|
||||
with pytest.raises(ValueError) as exc:
|
||||
_parse("run_on: dokcer\n")
|
||||
assert "dokcer" in str(exc.value)
|
||||
assert "Available:" in str(exc.value), "error should list valid names"
|
||||
|
||||
|
||||
def test_non_string_run_on_rejected():
|
||||
with pytest.raises(ValueError, match="must be a provider name string"):
|
||||
_parse("run_on: 123\n")
|
||||
|
||||
|
||||
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 "run_on" in getattr(type(wf), "__dataclass_fields__", {})
|
||||
assert wf.run_on == "docker"
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Native-path deferred-tool-result parity tests (Issue #3967).
|
||||
|
||||
The LiteLLM loop already registers a ``DeferredToolResult`` on the shared
|
||||
resolver so its eventual background value is re-injected. The native
|
||||
OpenAI-SDK loop (``llm/openai_client.py``) previously stringified the handle
|
||||
and lost it. These tests assert the native path now behaves identically:
|
||||
a deferred handle is registered, the ``note`` is surfaced to the model now,
|
||||
and a later ``resolve_deferred`` re-injects a follow-up tool message.
|
||||
"""
|
||||
|
||||
from praisonaiagents.llm.openai_client import _handle_native_deferred_result
|
||||
from praisonaiagents.tools.call_executor import (
|
||||
defer,
|
||||
get_deferred_resolver,
|
||||
resolve_deferred,
|
||||
)
|
||||
|
||||
|
||||
def test_plain_result_is_unchanged_and_not_registered():
|
||||
messages = []
|
||||
resolver = get_deferred_resolver()
|
||||
out = _handle_native_deferred_result("plain-value", messages, "cid-1", "tool_a")
|
||||
assert out == "plain-value"
|
||||
assert messages == []
|
||||
assert resolver.is_pending("cid-1") is False
|
||||
|
||||
|
||||
def test_deferred_result_surfaces_note_and_registers_handle():
|
||||
messages = []
|
||||
resolver = get_deferred_resolver()
|
||||
d = defer(note="queued job 99", handle_id="native-job-99")
|
||||
|
||||
out = _handle_native_deferred_result(d, messages, "cid-2", "tool_b")
|
||||
|
||||
# Note surfaced to the model now instead of the raw handle object.
|
||||
assert out == "queued job 99"
|
||||
# Handle registered so the background result is not lost.
|
||||
assert resolver.is_pending("native-job-99") is True
|
||||
|
||||
# Later background completion re-injects a follow-up tool message into the
|
||||
# same messages history the loop replays on the next turn.
|
||||
assert resolve_deferred("native-job-99", "final report") is True
|
||||
assert messages == [
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "cid-2",
|
||||
"content": "[deferred:tool_b] final report",
|
||||
}
|
||||
]
|
||||
assert resolver.is_pending("native-job-99") is False
|
||||
|
||||
|
||||
def test_registration_never_raises_on_bad_input():
|
||||
# A malformed deferred-like object must not break the turn; helper returns
|
||||
# the note attribute it can read and swallows any registration error.
|
||||
class _Broken:
|
||||
handle_id = None
|
||||
note = "still-runs"
|
||||
|
||||
# Not a DeferredToolResult instance -> returned unchanged (safe default).
|
||||
out = _handle_native_deferred_result(_Broken(), [], "cid-3", "tool_c")
|
||||
assert isinstance(out, _Broken)
|
||||
|
||||
|
||||
def test_late_result_lands_in_durable_sink_not_discarded_copy():
|
||||
# The real bug (Issue #3967 / reviewer P1): the native loop runs on a
|
||||
# per-call ``messages`` copy that is discarded once it returns. A background
|
||||
# job usually completes AFTER that, so the re-injection must target the
|
||||
# caller's durable history (e.g. the agent's ``chat_history``) — the list a
|
||||
# subsequent turn replays — not the transient copy.
|
||||
transient_loop_messages = [] # discarded after the loop returns
|
||||
durable_history = [] # replayed on the next turn
|
||||
d = defer(note="queued job 100", handle_id="native-job-100")
|
||||
|
||||
out = _handle_native_deferred_result(
|
||||
d, transient_loop_messages, "cid-4", "tool_d",
|
||||
history_sink=durable_history,
|
||||
)
|
||||
assert out == "queued job 100"
|
||||
|
||||
# Simulate the loop finishing and its copy being discarded, then the
|
||||
# background job completing later.
|
||||
transient_loop_messages.clear()
|
||||
assert resolve_deferred("native-job-100", "late report") is True
|
||||
|
||||
# Result survives in durable history; the discarded copy is untouched.
|
||||
assert transient_loop_messages == []
|
||||
assert durable_history == [
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "cid-4",
|
||||
"content": "[deferred:tool_d] late report",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_no_sink_falls_back_to_messages_for_backward_compat():
|
||||
# Absent a durable sink, prior behaviour is preserved: an in-loop resolution
|
||||
# re-injects into ``messages`` so existing callers are unaffected.
|
||||
messages = []
|
||||
d = defer(note="queued job 101", handle_id="native-job-101")
|
||||
_handle_native_deferred_result(d, messages, "cid-5", "tool_e")
|
||||
assert resolve_deferred("native-job-101", "in-loop report") is True
|
||||
assert messages == [
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "cid-5",
|
||||
"content": "[deferred:tool_e] in-loop report",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_early_resolution_delivers_to_durable_sink_immediately():
|
||||
# If the background job finishes before the loop registers (early
|
||||
# resolution), the buffered value is delivered on registration and must
|
||||
# still land in the durable sink.
|
||||
durable_history = []
|
||||
# Register-then-resolve is exercised above; here resolve first (buffered),
|
||||
# then register to confirm immediate delivery into the durable sink.
|
||||
assert resolve_deferred("native-job-102", "buffered report") is False
|
||||
d = defer(note="queued job 102", handle_id="native-job-102")
|
||||
_handle_native_deferred_result(
|
||||
d, [], "cid-6", "tool_f", history_sink=durable_history,
|
||||
)
|
||||
assert durable_history == [
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "cid-6",
|
||||
"content": "[deferred:tool_f] buffered report",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
import pytest
|
||||
sys.exit(pytest.main([__file__, "-v"]))
|
||||
@@ -11,6 +11,20 @@ import asyncio
|
||||
import copy
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class _BlockingDeepcopyValue:
|
||||
def __init__(self, entered, release):
|
||||
self.entered = entered
|
||||
self.release = release
|
||||
|
||||
def __deepcopy__(self, memo):
|
||||
self.entered.set()
|
||||
if not self.release.wait(timeout=5):
|
||||
raise TimeoutError("timed out waiting to finish deepcopy")
|
||||
return "copied"
|
||||
|
||||
|
||||
class TestAgentDeepCopy:
|
||||
"""Test Agent.__deepcopy__ and clone_for_channel()."""
|
||||
@@ -104,3 +118,82 @@ class TestAgentDeepCopy:
|
||||
assert cloned._approvals_lock is not agent._approvals_lock
|
||||
assert agent._approvals_lock.locked() is True
|
||||
assert cloned._approvals_lock.locked() is False
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"use_async_lock_helper",
|
||||
[True, False],
|
||||
ids=["async-lock-helper", "async-context-manager"],
|
||||
)
|
||||
def test_deepcopy_rejects_active_async_state_access(
|
||||
self,
|
||||
use_async_lock_helper,
|
||||
):
|
||||
from praisonaiagents.agent.async_safety import AsyncSafeState
|
||||
|
||||
async def _exercise():
|
||||
state = AsyncSafeState(["stable"])
|
||||
entered = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def _hold_state():
|
||||
context = state.async_lock() if use_async_lock_helper else state
|
||||
async with context:
|
||||
entered.set()
|
||||
await release.wait()
|
||||
|
||||
holder = asyncio.create_task(_hold_state())
|
||||
await entered.wait()
|
||||
try:
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="during asynchronous access",
|
||||
):
|
||||
copy.deepcopy(state)
|
||||
finally:
|
||||
release.set()
|
||||
await holder
|
||||
|
||||
cloned = copy.deepcopy(state)
|
||||
assert cloned.value == ["stable"]
|
||||
|
||||
asyncio.run(_exercise())
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"use_async_lock_helper",
|
||||
[True, False],
|
||||
ids=["async-lock-helper", "async-context-manager"],
|
||||
)
|
||||
def test_async_state_access_rejects_active_deepcopy(
|
||||
self,
|
||||
use_async_lock_helper,
|
||||
):
|
||||
from praisonaiagents.agent.async_safety import AsyncSafeState
|
||||
|
||||
async def _exercise():
|
||||
copy_entered = threading.Event()
|
||||
copy_release = threading.Event()
|
||||
state = AsyncSafeState(
|
||||
_BlockingDeepcopyValue(copy_entered, copy_release)
|
||||
)
|
||||
copy_task = asyncio.create_task(asyncio.to_thread(copy.deepcopy, state))
|
||||
assert await asyncio.to_thread(copy_entered.wait, 5)
|
||||
|
||||
try:
|
||||
context = state.async_lock() if use_async_lock_helper else state
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="during synchronous deepcopy",
|
||||
):
|
||||
async with context:
|
||||
pass
|
||||
finally:
|
||||
copy_release.set()
|
||||
|
||||
cloned = await copy_task
|
||||
assert cloned.value == "copied"
|
||||
|
||||
context = state.async_lock() if use_async_lock_helper else state
|
||||
async with context as value:
|
||||
assert value is state.value
|
||||
|
||||
asyncio.run(_exercise())
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Regression coverage for token accounting with quiet output defaults."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
from collections import defaultdict
|
||||
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
from praisonaiagents.telemetry.token_collector import get_token_collector
|
||||
|
||||
|
||||
def _display_functions():
|
||||
return defaultdict(MagicMock)
|
||||
|
||||
|
||||
def test_chat_completion_tracks_tokens_when_metrics_display_is_disabled(
|
||||
monkeypatch,
|
||||
):
|
||||
collector = get_token_collector()
|
||||
collector.reset()
|
||||
llm = LLM(model="custom/test-model", api_key="test")
|
||||
llm.metrics = False
|
||||
llm.verbose = False
|
||||
monkeypatch.setattr(llm, "_supports_responses_api", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
llm,
|
||||
"_call_with_retry",
|
||||
lambda _fn, **_kwargs: {
|
||||
"choices": [{"message": {"content": "ok"}}],
|
||||
"usage": {"prompt_tokens": 12, "completion_tokens": 3},
|
||||
},
|
||||
)
|
||||
|
||||
with patch(
|
||||
"praisonaiagents.llm.llm._get_display_functions",
|
||||
return_value=_display_functions(),
|
||||
):
|
||||
assert llm.get_response(prompt="hello", stream=False, verbose=False) == "ok"
|
||||
|
||||
summary = collector.get_session_summary()
|
||||
assert summary["total_interactions"] == 1
|
||||
assert summary["total_metrics"]["input_tokens"] == 12
|
||||
assert summary["total_metrics"]["output_tokens"] == 3
|
||||
|
||||
|
||||
def test_nested_usage_details_are_collected():
|
||||
collector = get_token_collector()
|
||||
collector.reset()
|
||||
llm = LLM(model="custom/test-model", api_key="test")
|
||||
|
||||
llm._track_token_usage({"usage": {
|
||||
"prompt_tokens": 12,
|
||||
"completion_tokens": 3,
|
||||
"prompt_tokens_details": {"cached_tokens": 4, "audio_tokens": 2},
|
||||
"completion_tokens_details": {"reasoning_tokens": 1, "audio_tokens": 1},
|
||||
}}, llm.model)
|
||||
|
||||
metrics = collector.get_session_summary()["total_metrics"]
|
||||
assert metrics["cached_tokens"] == 4
|
||||
assert metrics["reasoning_tokens"] == 1
|
||||
assert metrics["audio_input_tokens"] == 2
|
||||
assert metrics["audio_output_tokens"] == 1
|
||||
|
||||
|
||||
def test_responses_api_tracks_tokens_when_metrics_display_is_disabled(
|
||||
monkeypatch,
|
||||
):
|
||||
collector = get_token_collector()
|
||||
collector.reset()
|
||||
llm = LLM(model="openai/gpt-4o-mini", api_key="test")
|
||||
llm.metrics = False
|
||||
llm.verbose = False
|
||||
response = SimpleNamespace(
|
||||
usage=SimpleNamespace(input_tokens=8, output_tokens=2)
|
||||
)
|
||||
monkeypatch.setattr(llm, "_supports_responses_api", lambda: True)
|
||||
monkeypatch.setattr(llm, "_build_responses_params", lambda **_kwargs: {})
|
||||
monkeypatch.setattr(llm, "_call_responses_api", lambda **_kwargs: response)
|
||||
monkeypatch.setattr(
|
||||
llm,
|
||||
"_extract_from_responses_output",
|
||||
lambda _response: ("ok", [], None),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"praisonaiagents.llm.llm._get_display_functions",
|
||||
return_value=_display_functions(),
|
||||
):
|
||||
assert llm.get_response(prompt="hello", stream=False, verbose=False) == "ok"
|
||||
|
||||
summary = collector.get_session_summary()
|
||||
assert summary["total_interactions"] == 1
|
||||
assert summary["total_metrics"]["input_tokens"] == 8
|
||||
assert summary["total_metrics"]["output_tokens"] == 2
|
||||
@@ -21,6 +21,7 @@ from praisonaiagents.hooks.runner import HookRunner
|
||||
from praisonaiagents.hooks.types import HookEvent, HookResult
|
||||
from praisonaiagents.hooks.events import (
|
||||
AfterLLMInput,
|
||||
AfterToolInput,
|
||||
BeforeToolInput,
|
||||
SessionStartInput,
|
||||
SessionEndInput,
|
||||
@@ -48,6 +49,37 @@ class ToolArgPlugin(Plugin):
|
||||
return args
|
||||
|
||||
|
||||
class RedactToolResultPlugin(Plugin):
|
||||
@property
|
||||
def info(self):
|
||||
return PluginInfo(name="redacttool", hooks=[PluginHook.AFTER_TOOL])
|
||||
|
||||
def after_tool(self, tool_name, result):
|
||||
return str(result).replace("sk-SECRET123", "[REDACTED]")
|
||||
|
||||
|
||||
class BlockToolResultPlugin(Plugin):
|
||||
@property
|
||||
def info(self):
|
||||
return PluginInfo(name="blocktoolresult", hooks=[PluginHook.AFTER_TOOL])
|
||||
|
||||
def after_tool(self, tool_name, result):
|
||||
if "sk-SECRET123" in str(result):
|
||||
return PluginDecision.block("Secret detected in tool output")
|
||||
return result
|
||||
|
||||
|
||||
class RaiseBlockToolResultPlugin(Plugin):
|
||||
@property
|
||||
def info(self):
|
||||
return PluginInfo(name="raiseblocktool", hooks=[PluginHook.AFTER_TOOL])
|
||||
|
||||
def after_tool(self, tool_name, result):
|
||||
if "sk-SECRET123" in str(result):
|
||||
raise GuardrailBlocked("Secret detected in tool output")
|
||||
return result
|
||||
|
||||
|
||||
class NoopPlugin(Plugin):
|
||||
@property
|
||||
def info(self):
|
||||
@@ -151,6 +183,107 @@ class TestBridge:
|
||||
runner.execute_sync(HookEvent.BEFORE_TOOL, data)
|
||||
assert data.tool_input.get("injected") is True
|
||||
|
||||
def test_after_tool_plugin_redacts_result(self):
|
||||
reg = HookRegistry()
|
||||
mgr = PluginManager()
|
||||
mgr.register(RedactToolResultPlugin())
|
||||
mgr.wire_into_hook_registry(reg)
|
||||
|
||||
runner = HookRunner(registry=reg, cwd=os.getcwd())
|
||||
data = AfterToolInput(
|
||||
session_id="s", cwd=os.getcwd(), event_name=HookEvent.AFTER_TOOL,
|
||||
timestamp=str(time.time()), agent_name="a",
|
||||
tool_name="fetch", tool_output="token=sk-SECRET123 ok",
|
||||
)
|
||||
runner.execute_sync(HookEvent.AFTER_TOOL, data)
|
||||
assert data.tool_output == "token=[REDACTED] ok"
|
||||
assert "sk-SECRET123" not in data.tool_output
|
||||
|
||||
def test_after_tool_passthrough_leaves_result(self):
|
||||
reg = HookRegistry()
|
||||
mgr = PluginManager()
|
||||
mgr.register(BlockToolResultPlugin())
|
||||
mgr.wire_into_hook_registry(reg)
|
||||
|
||||
runner = HookRunner(registry=reg, cwd=os.getcwd())
|
||||
data = AfterToolInput(
|
||||
session_id="s", cwd=os.getcwd(), event_name=HookEvent.AFTER_TOOL,
|
||||
timestamp=str(time.time()), agent_name="a",
|
||||
tool_name="fetch", tool_output="clean output",
|
||||
)
|
||||
runner.execute_sync(HookEvent.AFTER_TOOL, data)
|
||||
assert data.tool_output == "clean output"
|
||||
|
||||
def test_after_tool_decision_blocks(self):
|
||||
reg = HookRegistry()
|
||||
mgr = PluginManager()
|
||||
mgr.register(BlockToolResultPlugin())
|
||||
mgr.wire_into_hook_registry(reg)
|
||||
|
||||
runner = HookRunner(registry=reg, cwd=os.getcwd())
|
||||
data = AfterToolInput(
|
||||
session_id="s", cwd=os.getcwd(), event_name=HookEvent.AFTER_TOOL,
|
||||
timestamp=str(time.time()), agent_name="a",
|
||||
tool_name="fetch", tool_output="token=sk-SECRET123",
|
||||
)
|
||||
results = runner.execute_sync(HookEvent.AFTER_TOOL, data)
|
||||
assert runner.is_blocked(results) is True
|
||||
|
||||
def test_after_tool_guardrail_blocked_raises(self):
|
||||
reg = HookRegistry()
|
||||
mgr = PluginManager()
|
||||
mgr.register(RaiseBlockToolResultPlugin())
|
||||
mgr.wire_into_hook_registry(reg)
|
||||
|
||||
runner = HookRunner(registry=reg, cwd=os.getcwd())
|
||||
data = AfterToolInput(
|
||||
session_id="s", cwd=os.getcwd(), event_name=HookEvent.AFTER_TOOL,
|
||||
timestamp=str(time.time()), agent_name="a",
|
||||
tool_name="fetch", tool_output="token=sk-SECRET123",
|
||||
)
|
||||
results = runner.execute_sync(HookEvent.AFTER_TOOL, data)
|
||||
assert runner.is_blocked(results) is True
|
||||
assert runner.get_blocking_reason(results) == "Secret detected in tool output"
|
||||
|
||||
def test_agent_execute_tool_redacts_output(self):
|
||||
"""End-to-end: an AFTER_TOOL plugin scrubs the value the executor returns."""
|
||||
from praisonaiagents import Agent, tool
|
||||
|
||||
@tool
|
||||
def leak() -> str:
|
||||
"""Return a secret token."""
|
||||
return "token=sk-SECRET123 ok"
|
||||
|
||||
reg = HookRegistry()
|
||||
mgr = PluginManager()
|
||||
mgr.register(RedactToolResultPlugin())
|
||||
mgr.wire_into_hook_registry(reg)
|
||||
|
||||
agent = Agent(name="a", role="r", goal="g", tools=[leak], hooks=reg)
|
||||
result = agent.execute_tool("leak", {})
|
||||
print(f"[test_agent_execute_tool_redacts_output] result={result!r}")
|
||||
assert "sk-SECRET123" not in str(result)
|
||||
assert "[REDACTED]" in str(result)
|
||||
|
||||
def test_agent_execute_tool_blocks_output(self):
|
||||
"""End-to-end: an AFTER_TOOL block stops secret output reaching the model."""
|
||||
from praisonaiagents import Agent, tool
|
||||
|
||||
@tool
|
||||
def leak() -> str:
|
||||
"""Return a secret token."""
|
||||
return "token=sk-SECRET123"
|
||||
|
||||
reg = HookRegistry()
|
||||
mgr = PluginManager()
|
||||
mgr.register(BlockToolResultPlugin())
|
||||
mgr.wire_into_hook_registry(reg)
|
||||
|
||||
agent = Agent(name="a", role="r", goal="g", tools=[leak], hooks=reg)
|
||||
result = agent.execute_tool("leak", {})
|
||||
print(f"[test_agent_execute_tool_blocks_output] result={result!r}")
|
||||
assert "sk-SECRET123" not in str(result)
|
||||
|
||||
def test_disabled_plugin_not_wired(self):
|
||||
reg = HookRegistry()
|
||||
mgr = PluginManager()
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Contract tests for `Agent(sandbox=...)`.
|
||||
|
||||
History worth keeping: an earlier version of this file asserted
|
||||
`child_pid != os.getpid()` and treated that as proof of isolation. That is a
|
||||
false equivalence -- a separate process is not a security boundary -- and it
|
||||
let a privilege-escalating change ship on a green suite. These tests assert the
|
||||
*contract* instead: `sandbox=` configures isolation for explicit execute_code()
|
||||
calls, and never silently grants the model new capabilities.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents import Agent
|
||||
|
||||
|
||||
def _tool_names(agent):
|
||||
return [getattr(t, "__name__", getattr(t, "name", None)) for t in (agent.tools or [])]
|
||||
|
||||
|
||||
def a_tool(x: str) -> str:
|
||||
"""A plain user tool."""
|
||||
return x
|
||||
|
||||
|
||||
# ── sandbox= must not grant capabilities ─────────────────────────────────────
|
||||
def test_sandbox_does_not_inject_tools():
|
||||
"""`sandbox=` is a restriction; it must never add tools the caller didn't ask for."""
|
||||
plain = Agent(name="A", instructions="x", tools=[a_tool])
|
||||
boxed = Agent(name="B", instructions="x", tools=[a_tool], sandbox=True)
|
||||
assert _tool_names(boxed) == _tool_names(plain) == ["a_tool"]
|
||||
|
||||
|
||||
def test_sandbox_does_not_bypass_approval_presets():
|
||||
"""Regression: approval='read_only' exposed no tools, but adding sandbox=True
|
||||
exposed two ungated arbitrary-code-execution tools, because the injected
|
||||
names are absent from the approval registry's dangerous-tool presets."""
|
||||
read_only = Agent(name="A", instructions="x", approval="read_only")
|
||||
with_sandbox = Agent(name="B", instructions="x", approval="read_only", sandbox=True)
|
||||
assert _tool_names(with_sandbox) == _tool_names(read_only), (
|
||||
"sandbox= must not widen what an approval preset exposes"
|
||||
)
|
||||
|
||||
|
||||
def test_sandbox_still_configures_explicit_execution():
|
||||
"""The documented meaning of sandbox=: configure agent.execute_code()."""
|
||||
agent = Agent(name="T", instructions="x", sandbox=True)
|
||||
assert agent.has_sandbox
|
||||
assert agent.sandbox_config is not None
|
||||
assert agent.get_sandbox_manager() is not None
|
||||
|
||||
|
||||
def test_no_sandbox_leaves_agent_untouched():
|
||||
agent = Agent(name="T", instructions="x", tools=[a_tool])
|
||||
assert _tool_names(agent) == ["a_tool"]
|
||||
assert agent.sandbox_config is None
|
||||
assert agent.has_sandbox is False
|
||||
|
||||
|
||||
# ── the metadata fix (kept from the reverted change) ─────────────────────────
|
||||
def test_security_warning_does_not_break_execution():
|
||||
"""`metadata` was forwarded into SandboxProtocol.execute(), which has no such
|
||||
parameter, so flagged code raised TypeError instead of executing."""
|
||||
agent = Agent(name="T", instructions="x", sandbox=True)
|
||||
result = agent.execute_code_sync("import os\nprint('flagged-ok')")
|
||||
assert "flagged-ok" in (result.stdout or result.output or "")
|
||||
|
||||
|
||||
def test_security_warnings_are_attached_to_result():
|
||||
agent = Agent(name="T", instructions="x", sandbox=True)
|
||||
result = agent.execute_code_sync("import os\nprint('x')")
|
||||
meta = getattr(result, "metadata", None) or {}
|
||||
assert "security_warnings" in meta, "flagged code should carry its warnings"
|
||||
|
||||
|
||||
# ── the clone fix (kept from the reverted change) ────────────────────────────
|
||||
def test_clone_keeps_sandbox():
|
||||
"""clone_for_channel() read `_sandbox_config`, which is never assigned, so
|
||||
every clone silently ran unisolated."""
|
||||
agent = Agent(name="T", instructions="x", sandbox=True)
|
||||
clone = agent.clone_for_channel()
|
||||
assert getattr(clone, "sandbox_config", None) is not None
|
||||
|
||||
|
||||
def test_clone_does_not_accumulate_tools():
|
||||
agent = Agent(name="T", instructions="x", tools=[a_tool], sandbox=True)
|
||||
once = agent.clone_for_channel()
|
||||
twice = once.clone_for_channel()
|
||||
assert _tool_names(twice) == _tool_names(agent)
|
||||
|
||||
|
||||
# ── the backend precedence warning (kept) ────────────────────────────────────
|
||||
class _FakeBackend:
|
||||
async def execute(self, prompt, **kwargs):
|
||||
return ""
|
||||
|
||||
|
||||
def test_sandbox_plus_backend_warns():
|
||||
"""A managed backend takes the whole turn, making sandbox= inert."""
|
||||
with pytest.warns(FutureWarning, match="sandbox") as caught:
|
||||
Agent(name="T", instructions="x", sandbox=True, backend=_FakeBackend())
|
||||
assert any("backend" in str(w.message) for w in caught)
|
||||
|
||||
|
||||
def test_backend_alone_does_not_warn(recwarn):
|
||||
Agent(name="T", instructions="x", backend=_FakeBackend())
|
||||
assert not [
|
||||
w for w in recwarn
|
||||
if issubclass(w.category, FutureWarning) and "sandbox" in str(w.message)
|
||||
]
|
||||
|
||||
|
||||
# ── the no-injection contract must stay documented at the call site ──────────
|
||||
def test_no_injection_contract_is_documented():
|
||||
"""The call site should explain *why* sandbox= adds no tools, so the next
|
||||
person does not "fix" the missing injection. Assert on the concepts rather
|
||||
than an exact phrase, so harmless wording edits don't fail the suite."""
|
||||
import inspect
|
||||
|
||||
from praisonaiagents.agent import agent as agent_mod
|
||||
|
||||
src = inspect.getsource(agent_mod.Agent.__init__).lower()
|
||||
assert "sandbox" in src
|
||||
assert "does not add" in src or "not add" in src or "no code-execution" in src
|
||||
assert "restriction" in src
|
||||
|
||||
|
||||
# ── inert fields must stay labelled ──────────────────────────────────────────
|
||||
def test_inert_fields_are_documented_as_inert():
|
||||
import inspect
|
||||
|
||||
from praisonaiagents.agent import autonomy
|
||||
from praisonaiagents.config import feature_configs
|
||||
|
||||
assert "NOT IMPLEMENTED" in (autonomy.AutonomyConfig.__doc__ or "")
|
||||
src = inspect.getsource(feature_configs.ExecutionConfig)
|
||||
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 ─────────
|
||||
@pytest.mark.skipif(
|
||||
not os.environ.get("OPENAI_API_KEY"),
|
||||
reason="requires a live LLM key; run locally to exercise the real path",
|
||||
)
|
||||
def test_sandbox_agent_start_adds_no_execution_tools():
|
||||
"""End-to-end guard: build a sandboxed Agent, run a real prompt through the
|
||||
LLM, print the output, and confirm the model-visible tool set never gained
|
||||
execute_python_code / execute_shell_command from `sandbox=` alone."""
|
||||
agent = Agent(name="E2E", instructions="Reply briefly.", sandbox=True)
|
||||
output = agent.start("Say the single word: ok")
|
||||
print(output)
|
||||
names = set(_tool_names(agent))
|
||||
assert "execute_python_code" not in names
|
||||
assert "execute_shell_command" not in names
|
||||
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
Regression tests for DoomLoopDetector._check_no_progress window boundary.
|
||||
|
||||
The no-progress heuristic only counts progress markers recorded within the
|
||||
current window. A marker recorded *during* the window's oldest action is
|
||||
timestamped before that action's record_action() timestamp (which is set at
|
||||
completion), so filtering on the oldest action's timestamp would wrongly drop
|
||||
it and report NO_PROGRESS despite genuine recent progress.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from praisonaiagents.escalation.doom_loop import (
|
||||
DoomLoopConfig,
|
||||
DoomLoopDetector,
|
||||
)
|
||||
|
||||
|
||||
def _make_detector(steps: int = 3) -> DoomLoopDetector:
|
||||
return DoomLoopDetector(DoomLoopConfig(max_no_progress_steps=steps))
|
||||
|
||||
|
||||
def test_marker_during_window_first_action_still_counts():
|
||||
"""Progress made during the window's oldest action must suppress NO_PROGRESS."""
|
||||
det = _make_detector(steps=3)
|
||||
|
||||
# Marker recorded *before* the first windowed action completes.
|
||||
det.mark_progress("did-real-work")
|
||||
time.sleep(0.001)
|
||||
for i in range(3):
|
||||
det.record_action("poll", {"i": i}, f"same-{0}", True)
|
||||
|
||||
assert det._check_no_progress() is False
|
||||
|
||||
|
||||
def test_marker_before_window_is_ignored():
|
||||
"""A stale marker predating the window must not suppress NO_PROGRESS."""
|
||||
det = _make_detector(steps=3)
|
||||
|
||||
# One early action + marker, then a full window of identical results with
|
||||
# no further progress. The early marker is outside the window and must be
|
||||
# ignored so the stuck window is detected.
|
||||
det.mark_progress("early-success")
|
||||
det.record_action("setup", {"i": -1}, "start", True)
|
||||
time.sleep(0.001)
|
||||
for i in range(3):
|
||||
det.record_action("poll", {"i": i}, "stuck", True)
|
||||
|
||||
assert det._check_no_progress() is True
|
||||
|
||||
|
||||
def test_recent_marker_within_window_suppresses():
|
||||
"""A marker recorded mid-window still suppresses NO_PROGRESS."""
|
||||
det = _make_detector(steps=3)
|
||||
|
||||
det.record_action("poll", {"i": 0}, "stuck", True)
|
||||
det.mark_progress("mid-window-progress")
|
||||
det.record_action("poll", {"i": 1}, "stuck", True)
|
||||
det.record_action("poll", {"i": 2}, "stuck", True)
|
||||
|
||||
assert det._check_no_progress() is False
|
||||
Generated
+1
-1
@@ -3082,7 +3082,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.166"
|
||||
version = "1.6.167"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.0.47"
|
||||
__version__ = "0.0.48"
|
||||
@@ -72,29 +72,34 @@ def approve(
|
||||
try:
|
||||
store = PairingStore(store_dir=store_dir)
|
||||
|
||||
# First check if the code exists and get its details without consuming it
|
||||
# Look up the pending entry for its channel_id metadata. Codes are
|
||||
# hashed at rest, so cross-process a pending entry may not expose the
|
||||
# raw code — fall back to matching the salted hash of the presented code.
|
||||
pending_pairings = store.list_pending()
|
||||
code_hash = store._hash(code)
|
||||
code_info = None
|
||||
for pairing in pending_pairings:
|
||||
if pairing.get('code') == code:
|
||||
if pairing.get('code') == code or pairing.get('code_hash') == code_hash:
|
||||
code_info = pairing
|
||||
break
|
||||
|
||||
if not code_info:
|
||||
typer.echo(f"❌ Invalid or expired code: {code}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Now verify and pair (this will consume the code)
|
||||
|
||||
# Verify and pair (this consumes the code). verify_and_pair performs the
|
||||
# authoritative hash lookup + HMAC check + brute-force lockout, so a
|
||||
# missing pre-match is not itself fatal — trust its return value.
|
||||
success = store.verify_and_pair(
|
||||
code=code,
|
||||
channel_id=channel_id,
|
||||
channel_type=platform,
|
||||
label=label
|
||||
)
|
||||
|
||||
|
||||
if success:
|
||||
# Use the actual channel_id from the code info if channel_id was auto-resolved
|
||||
resolved_channel_id = channel_id or code_info.get('channel_id', 'unknown')
|
||||
resolved_channel_id = (
|
||||
channel_id
|
||||
or (code_info.get('channel_id') if code_info else None)
|
||||
or 'unknown'
|
||||
)
|
||||
typer.echo(f"✅ Successfully paired {platform} channel {resolved_channel_id}")
|
||||
if label:
|
||||
typer.echo(f" Label: {label}")
|
||||
|
||||
@@ -904,158 +904,87 @@ def handle_gateway_command(args) -> int:
|
||||
Args:
|
||||
args: List of CLI arguments (from main.py unknown_args) or argparse Namespace.
|
||||
"""
|
||||
import argparse
|
||||
|
||||
if isinstance(args, list):
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="praisonai gateway",
|
||||
description="Manage the PraisonAI Gateway server",
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="gateway_command", help="Gateway commands")
|
||||
|
||||
# start subcommand
|
||||
start_parser = subparsers.add_parser("start", help="Start the gateway server")
|
||||
start_parser.add_argument("--host", default="127.0.0.1", help="Host to bind to (default: 127.0.0.1)")
|
||||
start_parser.add_argument("--port", type=int, default=8765, help="Port to listen on (default: 8765)")
|
||||
start_parser.add_argument("--agents", help="Path to agent configuration file")
|
||||
start_parser.add_argument("--config", dest="config_file", help="Path to gateway.yaml for multi-bot mode")
|
||||
start_parser.add_argument(
|
||||
"--drain-timeout", dest="drain_timeout", type=float, default=None,
|
||||
help="Seconds to wait for in-flight agent turns to finish on shutdown (0 disables; #2375)",
|
||||
)
|
||||
start_parser.add_argument(
|
||||
"--max-concurrent-runs", dest="max_concurrent_runs", type=int, default=None,
|
||||
help="Gateway-wide ceiling on simultaneously-running agent turns (0 disables; #2454)",
|
||||
)
|
||||
start_parser.add_argument(
|
||||
"--queue-depth", dest="queue_depth", type=int, default=None,
|
||||
help="Bounded wait queue depth when at the concurrency ceiling (#2454)",
|
||||
)
|
||||
start_parser.add_argument(
|
||||
"--overflow-policy", dest="overflow_policy", default=None,
|
||||
choices=["reject", "queue", "shed_oldest"],
|
||||
help="Behaviour when the wait queue is full (default: reject; #2454)",
|
||||
)
|
||||
start_parser.add_argument(
|
||||
"--reliability", dest="reliability", default=None,
|
||||
choices=["production", "default", "off"],
|
||||
help="Named reliability posture composing drain + admission in one "
|
||||
"switch (#2531)",
|
||||
)
|
||||
start_parser.add_argument(
|
||||
"--identity-store", dest="identity_store", default=None,
|
||||
help="Enable cross-platform conversation continuity: path to the "
|
||||
"identity link-map JSON (default ~/.praisonai/identity.json). "
|
||||
"Paired/linked users share one session + memory across "
|
||||
"channels (#3020)",
|
||||
)
|
||||
start_parser.add_argument(
|
||||
"--scale-to-zero", dest="scale_to_zero", action="store_true", default=None,
|
||||
help="Quiesce the gateway when idle for --idle-minutes (scale-to-zero; #3021)",
|
||||
)
|
||||
start_parser.add_argument(
|
||||
"--idle-minutes", dest="idle_minutes", type=float, default=None,
|
||||
help="Minutes of no inbound / in-flight work before quiescing (#3021)",
|
||||
)
|
||||
start_parser.add_argument(
|
||||
"--drain-marker", dest="drain_marker", default=None,
|
||||
help="Path to watch for an epoch-aware external drain marker file (#3021)",
|
||||
)
|
||||
start_parser.add_argument(
|
||||
"--watchdog", dest="watchdog", action="store_true", default=None,
|
||||
help="Enable the event-loop liveness watchdog: an OS-thread backstop "
|
||||
"that dumps stacks and hard-exits (restart code 75) if the loop "
|
||||
"freezes, so the supervisor relaunches the process (#3410)",
|
||||
)
|
||||
start_parser.add_argument(
|
||||
"--watchdog-timeout", dest="watchdog_timeout", type=float, default=None,
|
||||
help="Seconds the event loop may stall before the watchdog trips a "
|
||||
"restart (default ~15s = 5s x 3 strikes; #3410)",
|
||||
)
|
||||
# Route the public ``praisonai gateway ...`` entry point to the SINGLE
|
||||
# canonical Typer command app so it advertises exactly the same command
|
||||
# surface -- and the same safe ``start`` (auto-discovery, config-version
|
||||
# migration, credential + tool preflight, ``--openai-api``/``--mcp``) -- as
|
||||
# ``praisonai-bot gateway ...``. One command surface, one ``start``: the
|
||||
# gateway's own degraded-state retry hints (``praisonai gateway doctor`` /
|
||||
# ``doctor --fix`` / ``test``) now name commands the operator can actually
|
||||
# run from the ``praisonai`` binary (#3966).
|
||||
import click
|
||||
from typer.main import get_command
|
||||
|
||||
# status subcommand
|
||||
status_parser = subparsers.add_parser("status", help="Check gateway status")
|
||||
status_parser.add_argument("--host", default="127.0.0.1", help="Gateway host (default: 127.0.0.1)")
|
||||
status_parser.add_argument("--port", type=int, default=8765, help="Gateway port (default: 8765)")
|
||||
from praisonai_bot.cli.commands.gateway import app as gateway_app
|
||||
|
||||
# hooks subcommand — manage inbound trigger hooks (Issue #2281)
|
||||
hooks_parser = subparsers.add_parser(
|
||||
"hooks", help="Manage inbound trigger hooks (POST /hooks/<path>)"
|
||||
)
|
||||
hooks_sub = hooks_parser.add_subparsers(
|
||||
dest="hooks_command", help="Hook commands"
|
||||
)
|
||||
# Typer may drive the command with a vendored copy of click
|
||||
# (``typer._click``) whose exception classes are distinct from the
|
||||
# top-level ``click`` ones. Catch both families so unknown-subcommand /
|
||||
# bad-flag errors degrade to an exit code with a rendered message rather
|
||||
# than a traceback, regardless of the installed Typer version (#3966).
|
||||
click_exception_types = [click.ClickException]
|
||||
abort_types = [click.exceptions.Abort]
|
||||
try: # pragma: no cover - only one click family is present at runtime
|
||||
from typer import _click as _typer_click # type: ignore
|
||||
|
||||
hooks_add = hooks_sub.add_parser("add", help="Add a hook to gateway.yaml")
|
||||
hooks_add.add_argument("path", help="Hook path, e.g. 'gmail' -> POST /hooks/gmail")
|
||||
hooks_add.add_argument("--agent", help="Agent id to run (default: first agent)")
|
||||
hooks_add.add_argument(
|
||||
"--action", dest="action_type", default="agent",
|
||||
choices=["agent", "wake"], help="agent runs a turn, wake nudges a session",
|
||||
)
|
||||
hooks_add.add_argument("--auth", help="Bearer token / shared secret for this hook")
|
||||
hooks_add.add_argument("--session-key", dest="session_key", help="Session key template")
|
||||
hooks_add.add_argument(
|
||||
"--idempotency-key", dest="idempotency_key", help="Idempotency key template",
|
||||
)
|
||||
hooks_add.add_argument("--deliver-to", dest="deliver_to", help="channel:target for the reply")
|
||||
hooks_add.add_argument("--message", help="Message template from the payload")
|
||||
hooks_add.add_argument(
|
||||
"--config", dest="config_file", default="gateway.yaml",
|
||||
help="Path to gateway.yaml (default: gateway.yaml)",
|
||||
)
|
||||
click_exception_types.append(_typer_click.exceptions.ClickException)
|
||||
abort_types.append(_typer_click.exceptions.Abort)
|
||||
except (ImportError, AttributeError):
|
||||
# Typer without a vendored ``_click`` (or a differing layout): the
|
||||
# top-level ``click`` families already loaded above are sufficient.
|
||||
pass
|
||||
click_exception_types = tuple(click_exception_types)
|
||||
abort_types = tuple(abort_types)
|
||||
|
||||
hooks_list = hooks_sub.add_parser("list", help="List configured hooks")
|
||||
hooks_list.add_argument(
|
||||
"--config", dest="config_file", default="gateway.yaml",
|
||||
help="Path to gateway.yaml (default: gateway.yaml)",
|
||||
)
|
||||
# A pre-parsed argparse ``Namespace`` (legacy internal callers) is adapted
|
||||
# back to an argv list so there is exactly one dispatch path through the
|
||||
# canonical Typer app; a plain list is passed straight through.
|
||||
argv = args if isinstance(args, list) else _namespace_to_gateway_argv(args)
|
||||
|
||||
hooks_remove = hooks_sub.add_parser("remove", help="Remove a hook")
|
||||
hooks_remove.add_argument("path", help="Hook path to remove")
|
||||
hooks_remove.add_argument(
|
||||
"--config", dest="config_file", default="gateway.yaml",
|
||||
help="Path to gateway.yaml (default: gateway.yaml)",
|
||||
)
|
||||
|
||||
try:
|
||||
args = parser.parse_args(args)
|
||||
except SystemExit:
|
||||
return 1
|
||||
|
||||
handler = GatewayHandler()
|
||||
|
||||
subcommand = getattr(args, "gateway_command", None) or "start"
|
||||
|
||||
if subcommand == "start":
|
||||
# Propagate the supervisor-friendly exit code from start() (#2437):
|
||||
# 0 clean, 75 transient/restart, 78 fatal-config/do-not-restart.
|
||||
return handler.start(
|
||||
host=getattr(args, "host", "127.0.0.1"),
|
||||
port=getattr(args, "port", 8765),
|
||||
agent_file=getattr(args, "agents", None),
|
||||
config_file=getattr(args, "config_file", None),
|
||||
drain_timeout=getattr(args, "drain_timeout", None),
|
||||
max_concurrent_runs=getattr(args, "max_concurrent_runs", None),
|
||||
queue_depth=getattr(args, "queue_depth", None),
|
||||
overflow_policy=getattr(args, "overflow_policy", None),
|
||||
reliability=getattr(args, "reliability", None),
|
||||
identity_store=getattr(args, "identity_store", None),
|
||||
scale_to_zero=getattr(args, "scale_to_zero", None),
|
||||
idle_minutes=getattr(args, "idle_minutes", None),
|
||||
drain_marker=getattr(args, "drain_marker", None),
|
||||
watchdog=getattr(args, "watchdog", None),
|
||||
watchdog_timeout=getattr(args, "watchdog_timeout", None),
|
||||
)
|
||||
elif subcommand == "status":
|
||||
handler.status(
|
||||
host=getattr(args, "host", "127.0.0.1"),
|
||||
port=getattr(args, "port", 8765),
|
||||
)
|
||||
elif subcommand == "hooks":
|
||||
return handler.hooks(args)
|
||||
else:
|
||||
print(f"Unknown gateway command: {subcommand}")
|
||||
print("Available commands: start, status, hooks")
|
||||
command = get_command(gateway_app)
|
||||
try:
|
||||
# ``standalone_mode=False`` returns the command's value (exit code)
|
||||
# instead of calling ``sys.exit`` — so the caller (parse_args) can
|
||||
# ``sys.exit`` once at the top level, matching the argparse contract.
|
||||
return command(args=list(argv), standalone_mode=False) or 0
|
||||
except abort_types:
|
||||
# Ctrl+C at a prompt → clean interrupt.
|
||||
return 1
|
||||
return 0
|
||||
except click_exception_types as exc:
|
||||
# Unknown subcommand / bad flags: render the message (the way click
|
||||
# would in standalone mode) and surface a non-zero code instead of a
|
||||
# traceback, so the public CLI degrades gracefully like the canonical
|
||||
# binary rather than dead-ending (#3966).
|
||||
exc.show()
|
||||
return exc.exit_code
|
||||
except SystemExit as exc: # --help and other explicit exits
|
||||
code = exc.code
|
||||
return code if isinstance(code, int) else (0 if code is None else 1)
|
||||
|
||||
|
||||
def _namespace_to_gateway_argv(args) -> list:
|
||||
"""Adapt a legacy argparse ``Namespace`` to a ``gateway`` argv list.
|
||||
|
||||
Only ``start``/``status``/``hooks`` were ever produced by the old argparse
|
||||
parser; anything else falls back to ``start`` (its historical default) so
|
||||
the canonical Typer app handles it identically to the public path.
|
||||
"""
|
||||
subcommand = getattr(args, "gateway_command", None) or "start"
|
||||
argv: list = [subcommand]
|
||||
if subcommand in ("start", "status"):
|
||||
host = getattr(args, "host", None)
|
||||
port = getattr(args, "port", None)
|
||||
if host is not None:
|
||||
argv += ["--host", str(host)]
|
||||
if port is not None:
|
||||
argv += ["--port", str(port)]
|
||||
if subcommand == "start":
|
||||
if getattr(args, "agents", None):
|
||||
argv += ["--agents", str(args.agents)]
|
||||
if getattr(args, "config_file", None):
|
||||
argv += ["--config", str(args.config_file)]
|
||||
if subcommand == "hooks":
|
||||
hooks_command = getattr(args, "hooks_command", None)
|
||||
if hooks_command:
|
||||
argv.append(hooks_command)
|
||||
return argv
|
||||
@@ -30,6 +30,18 @@ _DEFAULT_STORE_DIR = os.path.join(
|
||||
)
|
||||
_DEFAULT_STORE_FILE = "pairing.json"
|
||||
|
||||
# Unambiguous alphabet for human-entered codes (drops 0/O/1/I) so a code
|
||||
# read aloud or retyped from another device is not mis-entered.
|
||||
_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
_CODE_LENGTH = 8
|
||||
|
||||
# Store-level brute-force ceiling defaults (per channel_type). These guard the
|
||||
# verification path uniformly across every approval surface (HTTP, in-chat
|
||||
# button, CLI) — the optional IP-keyed HTTP limiter remains an outer layer.
|
||||
_LOCKOUT_MAX_FAILURES = 5
|
||||
_LOCKOUT_WINDOW_SECONDS = 60.0
|
||||
_LOCKOUT_COOLDOWN_SECONDS = 300.0
|
||||
|
||||
|
||||
def _get_secret() -> str:
|
||||
"""Return the HMAC signing secret.
|
||||
@@ -216,15 +228,21 @@ class PairedChannel:
|
||||
class PairingStore:
|
||||
"""File-backed store of pairing codes and authorised channels.
|
||||
|
||||
The store generates *8-character* hex codes signed with HMAC-SHA256.
|
||||
A channel presents the code to the gateway; the gateway verifies
|
||||
the signature and adds the channel to the allow-list.
|
||||
The store generates *8-character* codes (from an unambiguous alphabet that
|
||||
drops ``0/O/1/I``) signed with HMAC-SHA256. A channel presents the code to
|
||||
the gateway; the gateway verifies the signature and adds the channel to the
|
||||
allow-list.
|
||||
|
||||
Thread-safe via ``threading.Lock``.
|
||||
|
||||
Security:
|
||||
- Codes are one-time use (consumed on verify).
|
||||
- HMAC-signed to prevent forgery.
|
||||
- Only a salted hash of each code is persisted — reading the state
|
||||
file never yields a usable pairing code.
|
||||
- Store-level brute-force ceiling: failed verifications are counted
|
||||
per channel_type and locked out after ``max_failures``, uniformly
|
||||
across every approval surface (HTTP, in-chat button, CLI).
|
||||
- Timing-safe comparison via ``hmac.compare_digest``.
|
||||
- Atomic file writes (tempfile + rename) to prevent corruption.
|
||||
- Configurable TTL for pending codes.
|
||||
@@ -256,6 +274,9 @@ class PairingStore:
|
||||
code_ttl: float = 300.0,
|
||||
secret: Optional[str] = None,
|
||||
max_pending: int = 100,
|
||||
max_failures: int = _LOCKOUT_MAX_FAILURES,
|
||||
lockout_window: float = _LOCKOUT_WINDOW_SECONDS,
|
||||
lockout_cooldown: float = _LOCKOUT_COOLDOWN_SECONDS,
|
||||
) -> None:
|
||||
self._dir = store_dir or _DEFAULT_STORE_DIR
|
||||
self._path = os.path.join(self._dir, _DEFAULT_STORE_FILE)
|
||||
@@ -264,11 +285,25 @@ class PairingStore:
|
||||
self._max_pending = max_pending
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# code -> {signature, channel_type, created_at}
|
||||
# code -> {signature, channel_type, created_at} (raw code in memory only)
|
||||
self._pending: Dict[str, dict] = {}
|
||||
# code_hash -> {signature, channel_type, created_at} (rehydrated from disk;
|
||||
# lets verification succeed cross-process without persisting the raw code)
|
||||
self._pending_by_hash: Dict[str, dict] = {}
|
||||
# (channel_id, channel_type) -> PairedChannel
|
||||
self._paired: Dict[tuple, PairedChannel] = {}
|
||||
|
||||
# Store-level brute-force ceiling, keyed by channel_type. Reuses the
|
||||
# gateway's own limiter so protection is uniform across every approval
|
||||
# surface (HTTP, in-chat button, CLI), not just the HTTP routes.
|
||||
from .rate_limiter import AuthRateLimiter
|
||||
|
||||
self._lockout = AuthRateLimiter(
|
||||
max_attempts=max_failures,
|
||||
window_seconds=lockout_window,
|
||||
lockout_seconds=lockout_cooldown,
|
||||
)
|
||||
|
||||
self._load()
|
||||
|
||||
# ── Code lifecycle ────────────────────────────────────────────────
|
||||
@@ -279,23 +314,29 @@ class PairingStore:
|
||||
The code is HMAC-signed so the gateway can verify it was not forged.
|
||||
Raises ``RuntimeError`` if max pending codes is reached.
|
||||
"""
|
||||
code = secrets.token_hex(4) # 8 hex chars
|
||||
code = "".join(secrets.choice(_CODE_ALPHABET) for _ in range(_CODE_LENGTH))
|
||||
sig = self._sign(code)
|
||||
|
||||
with self._lock:
|
||||
self._prune_expired()
|
||||
if len(self._pending) >= self._max_pending:
|
||||
# Count *all* logical pending entries, including ones rehydrated
|
||||
# from disk into ``_pending_by_hash`` after a restart. ``_pending``
|
||||
# alone under-counts cross-process state, letting repeated restarts
|
||||
# grow ``pairing.json`` past ``max_pending``.
|
||||
if len(self._pending_by_hash) >= self._max_pending:
|
||||
raise RuntimeError(
|
||||
f"Too many pending pairing codes (max={self._max_pending}). "
|
||||
"Wait for existing codes to expire or be consumed."
|
||||
)
|
||||
self._pending[code] = {
|
||||
info = {
|
||||
"signature": sig,
|
||||
"channel_type": channel_type,
|
||||
"channel_id": channel_id,
|
||||
"created_at": time.time(),
|
||||
}
|
||||
self._save() # NEW — persist pending codes
|
||||
self._pending[code] = info
|
||||
self._pending_by_hash[self._hash(code)] = dict(info)
|
||||
self._save() # NEW — persist pending codes (hashed at rest)
|
||||
return code
|
||||
|
||||
def verify_and_pair(
|
||||
@@ -311,20 +352,41 @@ class PairingStore:
|
||||
The code is consumed (one-time use) regardless of outcome.
|
||||
If ``channel_id`` is omitted, a channel-bound pending code may provide it.
|
||||
"""
|
||||
# Store-level brute-force ceiling: refuse verification for a channel
|
||||
# type that has exceeded its failed-attempt budget, regardless of which
|
||||
# approval surface (HTTP, in-chat, CLI) is calling. Checking the lockout
|
||||
# here does not itself count as an attempt — only failures are counted.
|
||||
if self._locked_out(channel_type):
|
||||
logger.warning(
|
||||
"Pairing verification locked out for channel_type=%s", channel_type
|
||||
)
|
||||
return False
|
||||
|
||||
with self._lock:
|
||||
self._prune_expired()
|
||||
pending = self._pending.pop(code, None)
|
||||
if pending is None:
|
||||
# Cross-process fallback: the raw code was never persisted, so
|
||||
# match against the salted hash rehydrated from disk.
|
||||
pending = self._pending_by_hash.pop(self._hash(code), None)
|
||||
if pending is not None:
|
||||
self._pending_by_hash.pop(self._hash(code), None)
|
||||
self._save() # NEW — persist the pop
|
||||
|
||||
if pending is None:
|
||||
self._record_failure(channel_type)
|
||||
return False
|
||||
|
||||
# Timing-safe comparison
|
||||
expected_sig = self._sign(code)
|
||||
if not hmac.compare_digest(pending["signature"], expected_sig):
|
||||
self._record_failure(channel_type)
|
||||
return False
|
||||
|
||||
# Validate the channel binding *before* clearing the failure counter.
|
||||
# A valid HMAC presented against the wrong ``channel_id`` still fails
|
||||
# to pair — clearing the lockout here would hand subsequent guesses a
|
||||
# fresh attempt budget after an unsuccessful pairing.
|
||||
pending_channel_id = pending.get("channel_id")
|
||||
if pending_channel_id and channel_id and channel_id != pending_channel_id:
|
||||
return False
|
||||
@@ -333,6 +395,10 @@ class PairingStore:
|
||||
if not resolved_channel_id:
|
||||
return False
|
||||
|
||||
# Binding is valid — this was a legitimate, successful verification, so
|
||||
# the brute-force counter for this channel_type is cleared.
|
||||
self._clear_failures(channel_type)
|
||||
|
||||
paired = PairedChannel(
|
||||
channel_id=resolved_channel_id,
|
||||
channel_type=channel_type,
|
||||
@@ -407,10 +473,12 @@ class PairingStore:
|
||||
pending_list = []
|
||||
now = time.time()
|
||||
|
||||
in_memory_hashes = set()
|
||||
for code, info in self._pending.items():
|
||||
in_memory_hashes.add(self._hash(code))
|
||||
if channel_type and info.get("channel_type") != channel_type:
|
||||
continue
|
||||
|
||||
|
||||
ct = info.get("channel_type", "unknown")
|
||||
cid = info.get("channel_id")
|
||||
created_at = info.get("created_at", now)
|
||||
@@ -425,7 +493,31 @@ class PairingStore:
|
||||
"user_name": f"User {code}",
|
||||
"age_seconds": int(now - created_at),
|
||||
})
|
||||
|
||||
|
||||
# Cross-process pending codes rehydrated from disk carry only a
|
||||
# salted hash — the raw code was never persisted. Surface them so
|
||||
# operators still see a pending request exists (and can approve by
|
||||
# re-entering the code from chat), but never expose a usable code.
|
||||
for chash, info in self._pending_by_hash.items():
|
||||
if chash in in_memory_hashes:
|
||||
continue
|
||||
if channel_type and info.get("channel_type") != channel_type:
|
||||
continue
|
||||
ct = info.get("channel_type", "unknown")
|
||||
cid = info.get("channel_id")
|
||||
created_at = info.get("created_at", now)
|
||||
pending_list.append({
|
||||
"code": None,
|
||||
"code_hash": chash,
|
||||
"channel_type": ct,
|
||||
"channel_id": cid,
|
||||
"created_at": created_at,
|
||||
"channel": ct,
|
||||
"user_id": cid,
|
||||
"user_name": f"User {cid}" if cid else "pending",
|
||||
"age_seconds": int(now - created_at),
|
||||
})
|
||||
|
||||
return pending_list
|
||||
|
||||
# ── Persistence ───────────────────────────────────────────────────
|
||||
@@ -437,13 +529,25 @@ class PairingStore:
|
||||
"""
|
||||
try:
|
||||
Path(self._dir).mkdir(parents=True, exist_ok=True)
|
||||
# Merge in-memory (raw-code) pending with any cross-process entries
|
||||
# rehydrated from disk. Only a salted hash of the code is persisted —
|
||||
# reading pairing.json never yields a usable code.
|
||||
pending_out = []
|
||||
seen_hashes = set()
|
||||
for c, info in self._pending.items():
|
||||
chash = self._hash(c)
|
||||
seen_hashes.add(chash)
|
||||
pending_out.append({"code_hash": chash, **info})
|
||||
for chash, info in self._pending_by_hash.items():
|
||||
if chash in seen_hashes:
|
||||
continue
|
||||
pending_out.append({"code_hash": chash, **info})
|
||||
|
||||
data = {
|
||||
"paired": [
|
||||
asdict(ch) for ch in self._paired.values()
|
||||
],
|
||||
"pending": [
|
||||
{"code": c, **info} for c, info in self._pending.items()
|
||||
],
|
||||
"pending": pending_out,
|
||||
}
|
||||
# Atomic write: tempfile → rename
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
@@ -473,11 +577,22 @@ class PairingStore:
|
||||
for entry in data.get("paired", []):
|
||||
ch = PairedChannel(**entry)
|
||||
self._paired[(ch.channel_id, ch.channel_type)] = ch
|
||||
# Load pending codes
|
||||
# Load pending codes. New format persists only a salted ``code_hash``;
|
||||
# the legacy plaintext ``code`` field is still read for backward
|
||||
# compatibility so existing on-disk stores keep working.
|
||||
for entry in data.get("pending", []):
|
||||
code = entry.pop("code")
|
||||
self._pending[code] = entry
|
||||
logger.debug("Loaded %d paired channels, %d pending codes", len(self._paired), len(self._pending))
|
||||
if "code" in entry: # legacy plaintext store
|
||||
code = entry.pop("code")
|
||||
self._pending[code] = entry
|
||||
self._pending_by_hash[self._hash(code)] = dict(entry)
|
||||
elif "code_hash" in entry: # hashed-at-rest store
|
||||
chash = entry.pop("code_hash")
|
||||
self._pending_by_hash[chash] = entry
|
||||
logger.debug(
|
||||
"Loaded %d paired channels, %d pending codes",
|
||||
len(self._paired),
|
||||
len(self._pending_by_hash),
|
||||
)
|
||||
except (OSError, json.JSONDecodeError, TypeError) as exc:
|
||||
logger.warning("Failed to load pairing store: %s", exc)
|
||||
|
||||
@@ -486,6 +601,27 @@ class PairingStore:
|
||||
def _sign(self, code: str) -> str:
|
||||
return hmac.new(self._secret, code.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
def _hash(self, code: str) -> str:
|
||||
"""Salted SHA-256 of a code for at-rest storage.
|
||||
|
||||
The salt is derived from the per-install HMAC secret, so the hash is
|
||||
stable across processes (enabling cross-process verification) yet a
|
||||
precomputed rainbow table is useless without the secret.
|
||||
"""
|
||||
return hashlib.sha256(self._secret + b":" + code.encode()).hexdigest()
|
||||
|
||||
# ── Brute-force ceiling helpers ───────────────────────────────────
|
||||
|
||||
def _locked_out(self, channel_type: str) -> bool:
|
||||
return self._lockout.time_until_allowed("pairing_verify", channel_type) > 0
|
||||
|
||||
def _record_failure(self, channel_type: str) -> None:
|
||||
# ``allow`` counts this attempt and trips the lockout at the ceiling.
|
||||
self._lockout.allow("pairing_verify", channel_type)
|
||||
|
||||
def _clear_failures(self, channel_type: str) -> None:
|
||||
self._lockout.reset("pairing_verify", channel_type)
|
||||
|
||||
def _prune_expired(self) -> None:
|
||||
"""Remove expired pending codes (caller holds lock)."""
|
||||
now = time.time()
|
||||
@@ -494,4 +630,11 @@ class PairingStore:
|
||||
if (now - info["created_at"]) >= self._code_ttl
|
||||
]
|
||||
for c in expired:
|
||||
self._pending_by_hash.pop(self._hash(c), None)
|
||||
del self._pending[c]
|
||||
expired_hashes = [
|
||||
h for h, info in self._pending_by_hash.items()
|
||||
if (now - info["created_at"]) >= self._code_ttl
|
||||
]
|
||||
for h in expired_hashes:
|
||||
del self._pending_by_hash[h]
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-bot"
|
||||
version = "0.0.47"
|
||||
version = "0.0.48"
|
||||
description = "Bots, gateway, and channel CLI for PraisonAI — messaging platforms and WebSocket control plane extracted from the praisonai wrapper."
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
@@ -141,25 +141,40 @@ async def test_exec_approval_allowlist():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_persists_across_instances():
|
||||
"""Pending codes should survive PairingStore recreation (cross-process test)."""
|
||||
"""Pending codes should survive PairingStore recreation (cross-process test).
|
||||
|
||||
Codes are hashed at rest, so the raw code is NOT recoverable from a fresh
|
||||
instance — but the pending request is still visible and verifiable.
|
||||
"""
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
import json
|
||||
|
||||
# Create temporary directory for this test
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Instance A generates a code (no explicit secret - uses persisted secret file)
|
||||
store_a = PairingStore(store_dir=tmpdir)
|
||||
code = store_a.generate_code(channel_type="telegram", channel_id="tg_42")
|
||||
assert len(code) == 8
|
||||
|
||||
# Instance B (simulating CLI in separate process) should see the pending code
|
||||
|
||||
# The raw code must never be written to disk in plaintext.
|
||||
with open(os.path.join(tmpdir, "pairing.json")) as fh:
|
||||
raw = fh.read()
|
||||
assert code not in raw
|
||||
assert "code_hash" in json.loads(raw)["pending"][0]
|
||||
|
||||
# Instance B (simulating CLI in separate process) should see the pending
|
||||
# request (metadata) but not the raw code.
|
||||
store_b = PairingStore(store_dir=tmpdir)
|
||||
pending = store_b.list_pending()
|
||||
assert len(pending) == 1
|
||||
assert pending[0]["code"] == code
|
||||
assert pending[0]["code"] is None
|
||||
assert pending[0]["channel_type"] == "telegram"
|
||||
assert pending[0]["channel_id"] == "tg_42"
|
||||
|
||||
# And it can still verify the code presented from chat (hash match).
|
||||
assert store_b.verify_and_pair(code, None, "telegram") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_secret_persists_across_instances():
|
||||
@@ -366,3 +381,124 @@ async def test_cli_approve_survives_restart():
|
||||
# Verify pairing persisted (fresh instance uses same secret file)
|
||||
final_store = PairingStore(store_dir=tmpdir)
|
||||
assert final_store.is_paired("tg_restart", "telegram") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_codes_use_unambiguous_alphabet():
|
||||
"""Generated codes must avoid ambiguous characters (0/O/1/I)."""
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store = PairingStore(store_dir=tmpdir)
|
||||
for _ in range(50):
|
||||
code = store.generate_code(channel_type="telegram")
|
||||
assert len(code) == 8
|
||||
assert set(code) <= set("ABCDEFGHJKLMNPQRSTUVWXYZ23456789")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_codes_hashed_at_rest():
|
||||
"""The raw pairing code must never be written to pairing.json."""
|
||||
import tempfile
|
||||
import os
|
||||
import json
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store = PairingStore(store_dir=tmpdir)
|
||||
code = store.generate_code(channel_type="telegram", channel_id="tg_hash")
|
||||
|
||||
with open(os.path.join(tmpdir, "pairing.json")) as fh:
|
||||
data = json.load(fh)
|
||||
entry = data["pending"][0]
|
||||
assert "code" not in entry
|
||||
assert entry["code_hash"] != code
|
||||
assert len(entry["code_hash"]) == 64 # sha256 hexdigest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_level_brute_force_lockout():
|
||||
"""After N failed verifications a channel_type is locked out uniformly."""
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store = PairingStore(
|
||||
store_dir=tmpdir,
|
||||
max_failures=3,
|
||||
lockout_window=60.0,
|
||||
lockout_cooldown=300.0,
|
||||
)
|
||||
good = store.generate_code(channel_type="telegram", channel_id="tg_bf")
|
||||
|
||||
# Enough wrong codes to exceed the ceiling and trip the lockout window.
|
||||
for _ in range(4):
|
||||
assert store.verify_and_pair("WRONGXXX", "tg_bf", "telegram") is False
|
||||
|
||||
# Once locked out, even the VALID code is refused while the cooldown is
|
||||
# active — protection is store-level (not tied to a single surface).
|
||||
assert store.verify_and_pair(good, "tg_bf", "telegram") is False
|
||||
assert store.is_paired("tg_bf", "telegram") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_verify_clears_failures():
|
||||
"""A successful pairing resets the failed-attempt counter."""
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store = PairingStore(store_dir=tmpdir, max_failures=3)
|
||||
# Two failures, then a success should clear the counter.
|
||||
assert store.verify_and_pair("WRONGXXX", "tg_ok", "telegram") is False
|
||||
assert store.verify_and_pair("WRONGYYY", "tg_ok", "telegram") is False
|
||||
code = store.generate_code(channel_type="telegram", channel_id="tg_ok")
|
||||
assert store.verify_and_pair(code, "tg_ok", "telegram") is True
|
||||
|
||||
# Counter cleared: a fresh burst of failures is allowed again without
|
||||
# the earlier two counting toward the ceiling.
|
||||
code2 = store.generate_code(channel_type="telegram", channel_id="tg_ok2")
|
||||
assert store.verify_and_pair("WRONGZZZ", "tg_ok2", "telegram") is False
|
||||
assert store.verify_and_pair("WRONGWWW", "tg_ok2", "telegram") is False
|
||||
assert store.verify_and_pair(code2, "tg_ok2", "telegram") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_id_mismatch_does_not_reset_failures():
|
||||
"""A valid code presented with the wrong channel_id must not clear the
|
||||
brute-force counter — otherwise each rejected binding hands the next guess
|
||||
a fresh attempt budget."""
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store = PairingStore(store_dir=tmpdir, max_failures=3)
|
||||
# Three failed guesses prime the counter right up to the ceiling.
|
||||
assert store.verify_and_pair("WRONGXXX", "tg_x", "telegram") is False
|
||||
assert store.verify_and_pair("WRONGYYY", "tg_x", "telegram") is False
|
||||
assert store.verify_and_pair("WRONGZZZ", "tg_x", "telegram") is False
|
||||
|
||||
# A valid code bound to tg_bound, but presented with the WRONG
|
||||
# channel_id: it is rejected and must NOT reset the primed counter.
|
||||
good = store.generate_code(channel_type="telegram", channel_id="tg_bound")
|
||||
assert store.verify_and_pair(good, "tg_wrong", "telegram") is False
|
||||
|
||||
# Counter was preserved, so the next failure trips the lockout.
|
||||
assert store.verify_and_pair("WRONGWWW", "tg_x", "telegram") is False
|
||||
# Locked out now: even a fresh valid code is refused during cooldown.
|
||||
good2 = store.generate_code(channel_type="telegram", channel_id="tg_bound2")
|
||||
assert store.verify_and_pair(good2, "tg_bound2", "telegram") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_max_pending_counts_rehydrated_entries():
|
||||
"""max_pending must count entries rehydrated from disk after a restart,
|
||||
not just in-memory raw codes — otherwise repeated restarts grow the store
|
||||
past the configured bound."""
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store_a = PairingStore(store_dir=tmpdir, max_pending=2)
|
||||
store_a.generate_code(channel_type="telegram", channel_id="tg_1")
|
||||
store_a.generate_code(channel_type="telegram", channel_id="tg_2")
|
||||
|
||||
# Fresh instance rehydrates both pending entries as hashes only.
|
||||
store_b = PairingStore(store_dir=tmpdir, max_pending=2)
|
||||
with pytest.raises(RuntimeError):
|
||||
store_b.generate_code(channel_type="telegram", channel_id="tg_3")
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Regression: ``praisonai gateway`` and ``praisonai-bot gateway`` share one surface.
|
||||
|
||||
The public ``praisonai gateway <cmd>`` entry point used to route through a
|
||||
reduced argparse handler that only wired ``start``/``status``/``hooks`` and
|
||||
dead-ended on everything else — even though the gateway's own degraded-state
|
||||
retry hints tell operators to run ``praisonai gateway doctor``/``test`` (#3966).
|
||||
|
||||
These tests pin the unified behaviour: the public list-args handler dispatches
|
||||
to the single canonical Typer command app, so the two entry points advertise
|
||||
the same subcommands and ``gateway doctor`` resolves rather than printing
|
||||
``Unknown gateway command: doctor``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import click
|
||||
|
||||
from praisonai_bot.cli.commands.gateway import app as gateway_app
|
||||
from praisonai_bot.cli.features.gateway import handle_gateway_command
|
||||
|
||||
|
||||
def _canonical_subcommands() -> set[str]:
|
||||
from typer.main import get_command
|
||||
|
||||
command = get_command(gateway_app)
|
||||
return set(command.list_commands(click.Context(command)))
|
||||
|
||||
|
||||
def test_public_and_canonical_expose_same_gateway_surface():
|
||||
"""The full operational command set is reachable from ``praisonai gateway``."""
|
||||
surface = _canonical_subcommands()
|
||||
# Every operational verb the issue enumerates must be present on the single
|
||||
# canonical app the public handler now routes to.
|
||||
for cmd in (
|
||||
"start", "status", "stop", "restart", "doctor", "test", "channels",
|
||||
"pause", "resume", "reconnect", "install", "uninstall", "mint-link",
|
||||
"logs", "send", "hooks", "sessions",
|
||||
):
|
||||
assert cmd in surface, f"missing gateway subcommand: {cmd}"
|
||||
|
||||
|
||||
def test_gateway_doctor_resolves_from_public_handler():
|
||||
"""``praisonai gateway doctor`` resolves instead of dead-ending (#3966)."""
|
||||
# ``--help`` exercises the routing/parse path without running diagnostics.
|
||||
rc = handle_gateway_command(["doctor", "--help"])
|
||||
assert rc == 0
|
||||
|
||||
|
||||
def test_gateway_start_exposes_openai_and_mcp_flags():
|
||||
"""The public ``start`` is the canonical safe ``start`` with compat surfaces.
|
||||
|
||||
The reduced argparse ``start`` omitted ``--openai-api``/``--mcp``; routing to
|
||||
the canonical app means the public ``start`` now advertises them (#3966).
|
||||
"""
|
||||
from typer.main import get_command
|
||||
|
||||
command = get_command(gateway_app)
|
||||
start = command.get_command(click.Context(command), "start")
|
||||
opts = [opt for param in start.params for opt in param.opts]
|
||||
assert "--openai-api" in opts
|
||||
assert "--mcp" in opts
|
||||
|
||||
|
||||
def test_namespace_caller_still_dispatches():
|
||||
"""Legacy argparse callers keep working through argv adaptation (#3966).
|
||||
|
||||
``_namespace_to_gateway_argv`` depends on exact canonical option names; pin
|
||||
that every long option it emits is actually accepted by the target Typer
|
||||
command so an option-name drift is caught at review time.
|
||||
"""
|
||||
from argparse import Namespace
|
||||
|
||||
from typer.main import get_command
|
||||
|
||||
from praisonai_bot.cli.features.gateway import _namespace_to_gateway_argv
|
||||
|
||||
argv = _namespace_to_gateway_argv(
|
||||
Namespace(gateway_command="status", host="127.0.0.1", port=8765)
|
||||
)
|
||||
assert argv[0] == "status"
|
||||
|
||||
command = get_command(gateway_app)
|
||||
status = command.get_command(click.Context(command), "status")
|
||||
opts = {opt for param in status.params for opt in param.opts}
|
||||
for token in argv[1:]:
|
||||
if token.startswith("--"):
|
||||
assert token in opts, f"status does not accept {token}"
|
||||
|
||||
|
||||
def test_unknown_gateway_command_no_longer_dead_ends(capsys):
|
||||
"""An unknown verb yields a non-zero code, not the old static dead-end."""
|
||||
rc = handle_gateway_command(["definitely-not-a-command"])
|
||||
assert rc != 0
|
||||
combined = capsys.readouterr()
|
||||
assert "Available commands: start, status, hooks" not in (
|
||||
combined.out + combined.err
|
||||
)
|
||||
Generated
+2
-2
@@ -1416,7 +1416,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonai-bot"
|
||||
version = "0.0.47"
|
||||
version = "0.0.48"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -1498,7 +1498,7 @@ provides-extras = ["gateway", "bot", "bot-whatsapp-web", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.166"
|
||||
version = "1.6.167"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.0.13"
|
||||
__version__ = "0.0.14"
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-browser"
|
||||
version = "0.0.13"
|
||||
version = "0.0.14"
|
||||
description = "Browser automation for PraisonAI — Chrome extension bridge, CDP, and Playwright agents extracted from the praisonai wrapper."
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
Generated
+2
-2
@@ -920,7 +920,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonai-browser"
|
||||
version = "0.0.13"
|
||||
version = "0.0.14"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -964,7 +964,7 @@ provides-extras = ["server", "playwright", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.166"
|
||||
version = "1.6.167"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -12,6 +12,6 @@ the remaining terminal-agent modules, with PEP 562 shims left behind at the
|
||||
old ``praisonai.*`` paths for backward compatibility.
|
||||
"""
|
||||
|
||||
__version__ = "0.0.62"
|
||||
__version__ = "0.0.63"
|
||||
|
||||
__all__ = ["__version__"]
|
||||
@@ -491,13 +491,6 @@ def _run_print_code(
|
||||
except Exception:
|
||||
resolved_session = None
|
||||
|
||||
workspace = os.environ.get("PRAISONAI_WORKSPACE") or os.getcwd()
|
||||
tool_groups = ["basic", "acp", "edit", "search", "lsp"]
|
||||
code_tools = _get_headless_code_tools(
|
||||
groups=tool_groups,
|
||||
workspace=workspace,
|
||||
)
|
||||
|
||||
agent_config = {
|
||||
"name": "CodeAgent",
|
||||
"role": "Code Assistant",
|
||||
@@ -510,7 +503,6 @@ def _run_print_code(
|
||||
# A minimal output preset keeps the agent from printing its own
|
||||
# decorations so stdout carries only our envelope.
|
||||
"output": "minimal",
|
||||
"tools": code_tools,
|
||||
}
|
||||
if model:
|
||||
agent_config["llm"] = model
|
||||
@@ -532,6 +524,11 @@ def _run_print_code(
|
||||
result = None
|
||||
error_message = None
|
||||
try:
|
||||
workspace = os.environ.get("PRAISONAI_WORKSPACE") or os.getcwd()
|
||||
agent_config["tools"] = _get_headless_code_tools(
|
||||
groups=["acp", "edit", "search", "lsp"],
|
||||
workspace=workspace,
|
||||
)
|
||||
agent = Agent(**agent_config)
|
||||
if thinking_budget is not None:
|
||||
agent.thinking_budget = thinking_budget
|
||||
|
||||
@@ -300,9 +300,25 @@ def session_resume(
|
||||
|
||||
if restored.chat_history:
|
||||
output.print("\n--- Restored Conversation ---\n")
|
||||
for msg in restored.chat_history[-10:]:
|
||||
limit = 20
|
||||
total = len(restored.chat_history)
|
||||
shown = restored.chat_history[-limit:] if total > limit else restored.chat_history
|
||||
if total > len(shown):
|
||||
output.print(f"… {total - len(shown)} earlier turns\n")
|
||||
for msg in shown:
|
||||
role = msg.get("role", "?")
|
||||
content = msg.get("content", "")
|
||||
if not content:
|
||||
tool_calls = msg.get("tool_calls")
|
||||
if isinstance(tool_calls, list) and tool_calls:
|
||||
names = ", ".join(
|
||||
(tc.get("function", {}) or {}).get("name", "tool")
|
||||
for tc in tool_calls
|
||||
if isinstance(tc, dict)
|
||||
)
|
||||
content = f"[tool call] {names}" if names else "[tool call]"
|
||||
else:
|
||||
continue
|
||||
output.print(f"[{role}] {content}")
|
||||
return
|
||||
|
||||
|
||||
@@ -232,30 +232,146 @@ def _load_basic_tools() -> Dict[str, Callable]:
|
||||
return tools
|
||||
|
||||
|
||||
def _load_search_tools() -> Dict[str, Callable]:
|
||||
def _load_search_tools(config: ToolConfig) -> Dict[str, Callable]:
|
||||
"""Lazy load fast codebase-search tools from praisonaiagents.
|
||||
|
||||
Wires the existing core search builtins into the default interactive
|
||||
toolset so the coding agent no longer has to shell out to a raw
|
||||
``grep -rn``: ripgrep-backed ``grep`` and ``glob`` (gitignore-aware,
|
||||
result-capped, truncation-safe) and structural ``ast_grep_search``.
|
||||
|
||||
The core ``grep``/``glob`` contain paths to ``os.getcwd()`` and
|
||||
``ast_grep_search`` performs no containment at all. In headless
|
||||
``code -p --workspace <dir>`` mode the process is not chdir'd into the
|
||||
selected workspace, so a model-supplied ``path`` (relative *or* absolute)
|
||||
could read outside it. Fail closed by binding every search tool's ``path``
|
||||
to the configured workspace before delegating — mirroring the containment
|
||||
the edit/ACP/LSP groups already enforce. If containment is unavailable we
|
||||
do not expose the search tools rather than expose an unbounded reader.
|
||||
|
||||
The wrapper resolves the caller path against the workspace root and then
|
||||
delegates with the process cwd temporarily pinned to that root, so the
|
||||
core tool's own ``os.getcwd()``-based containment agrees with ours instead
|
||||
of rejecting a legitimate absolute in-workspace path.
|
||||
"""
|
||||
tools = {}
|
||||
tools: Dict[str, Callable] = {}
|
||||
|
||||
try:
|
||||
from praisonaiagents.tools import grep
|
||||
import contextlib
|
||||
from pathlib import Path
|
||||
|
||||
from praisonaiagents.tools.path_safety import resolve_within_root
|
||||
except ImportError as e:
|
||||
logger.warning(
|
||||
f"Search tools disabled: path containment unavailable: {e}"
|
||||
)
|
||||
return tools
|
||||
|
||||
root = str(Path(config.workspace).resolve())
|
||||
|
||||
def _contained(path: Optional[str]) -> Optional[str]:
|
||||
"""Resolve *path* under the workspace root, or ``None`` if it escapes."""
|
||||
return resolve_within_root(path or ".", root)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _in_workspace():
|
||||
"""Pin the process cwd to the workspace root for the delegated call.
|
||||
|
||||
The core search builtins hard-bind containment/relative resolution to
|
||||
``os.getcwd()``. Restored in a ``finally`` so it never leaks. Best
|
||||
effort: if the chdir fails we still return the (already contained)
|
||||
absolute path to the core tool.
|
||||
"""
|
||||
prev = None
|
||||
try:
|
||||
prev = os.getcwd()
|
||||
except OSError:
|
||||
prev = None
|
||||
try:
|
||||
os.chdir(root)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if prev is not None:
|
||||
try:
|
||||
os.chdir(prev)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from praisonaiagents.tools import grep as _grep
|
||||
|
||||
def grep(pattern: str, path: str = ".", glob: Optional[str] = None,
|
||||
case_insensitive: bool = False, max_results: int = 100) -> str:
|
||||
"""Search file contents for a regex/literal pattern in the workspace.
|
||||
|
||||
Args:
|
||||
pattern: Regex/literal to search for.
|
||||
path: Directory/file under the workspace to search.
|
||||
glob: Optional filename glob (e.g. ``"*.py"``).
|
||||
case_insensitive: Case-insensitive matching when True.
|
||||
max_results: Hard cap on the number of matching lines returned.
|
||||
|
||||
Returns:
|
||||
``path:line: match`` entries, or an error string.
|
||||
"""
|
||||
safe = _contained(path)
|
||||
if safe is None:
|
||||
return f"Error: path {path!r} escapes the workspace."
|
||||
with _in_workspace():
|
||||
return _grep(pattern, safe, glob, case_insensitive, max_results)
|
||||
|
||||
tools["grep"] = grep
|
||||
except ImportError:
|
||||
logger.debug("grep not available")
|
||||
|
||||
try:
|
||||
from praisonaiagents.tools import glob
|
||||
from praisonaiagents.tools import glob as _glob
|
||||
|
||||
def glob(pattern: str, path: str = ".", max_results: int = 100) -> str:
|
||||
"""Return files matching a glob pattern under the workspace.
|
||||
|
||||
Args:
|
||||
pattern: Glob pattern (e.g. ``"**/*.py"``).
|
||||
path: Directory under the workspace to search.
|
||||
max_results: Hard cap on the number of paths returned.
|
||||
|
||||
Returns:
|
||||
Newline-joined relative file paths, or an error string.
|
||||
"""
|
||||
safe = _contained(path)
|
||||
if safe is None:
|
||||
return f"Error: path {path!r} escapes the workspace."
|
||||
with _in_workspace():
|
||||
return _glob(pattern, safe, max_results)
|
||||
|
||||
tools["glob"] = glob
|
||||
except ImportError:
|
||||
logger.debug("glob not available")
|
||||
|
||||
try:
|
||||
from praisonaiagents.tools import ast_grep_search
|
||||
from praisonaiagents.tools import ast_grep_search as _ast_grep_search
|
||||
|
||||
def ast_grep_search(pattern: str, lang: str, path: str = ".",
|
||||
json_output: bool = True) -> str:
|
||||
"""Structural (AST) code search under the workspace.
|
||||
|
||||
Args:
|
||||
pattern: AST pattern (e.g. ``"def $FN($$$)"``).
|
||||
lang: Programming language (python, javascript, ...).
|
||||
path: Directory/file under the workspace to search.
|
||||
json_output: Return JSON when True.
|
||||
|
||||
Returns:
|
||||
Search results, or an error string.
|
||||
"""
|
||||
safe = _contained(path)
|
||||
if safe is None:
|
||||
return f"Error: path {path!r} escapes the workspace."
|
||||
return _ast_grep_search(pattern, lang, safe, json_output)
|
||||
|
||||
tools["ast_grep_search"] = ast_grep_search
|
||||
except ImportError:
|
||||
logger.debug("ast_grep_search not available")
|
||||
@@ -557,7 +673,7 @@ def get_interactive_tools(
|
||||
|
||||
# Load fast search tools (always available, no runtime needed)
|
||||
if config.enable_search and not (disable and "search" in disable):
|
||||
search_tools = _load_search_tools()
|
||||
search_tools = _load_search_tools(config)
|
||||
all_tools.update(search_tools)
|
||||
|
||||
# Load targeted/fuzzy edit tools (no runtime needed)
|
||||
|
||||
@@ -800,6 +800,43 @@ class AsyncTUI:
|
||||
return None
|
||||
return self._registry
|
||||
|
||||
def _replay_history(self, history, limit: int = 20) -> None:
|
||||
"""Redraw a restored conversation as live chat messages.
|
||||
|
||||
Reuses the same renderer that draws live turns so a resumed session
|
||||
shows prior user/assistant turns (and tool activity) exactly as they
|
||||
appeared, instead of only a one-line summary. Bounded to the last
|
||||
``limit`` turns with a note about older turns.
|
||||
"""
|
||||
if not history:
|
||||
return
|
||||
|
||||
total = len(history)
|
||||
shown = history[-limit:] if limit and total > limit else history
|
||||
if total > len(shown):
|
||||
self.messages.append(ChatMessage(
|
||||
role="system",
|
||||
content=f"… {total - len(shown)} earlier turns",
|
||||
))
|
||||
|
||||
for msg in shown:
|
||||
role = msg.get("role", "assistant")
|
||||
content = msg.get("content", "")
|
||||
if role == "tool":
|
||||
role = "system"
|
||||
if not content:
|
||||
tool_calls = msg.get("tool_calls")
|
||||
if isinstance(tool_calls, list) and tool_calls:
|
||||
names = ", ".join(
|
||||
(tc.get("function", {}) or {}).get("name", "tool")
|
||||
for tc in tool_calls
|
||||
if isinstance(tc, dict)
|
||||
)
|
||||
content = f"[tool call] {names}" if names else "[tool call]"
|
||||
else:
|
||||
continue
|
||||
self.messages.append(ChatMessage(role=role, content=content))
|
||||
|
||||
def _handle_command(self, command: str) -> bool:
|
||||
"""Handle slash commands. Returns True if handled."""
|
||||
parts = command.split(maxsplit=1)
|
||||
@@ -1000,13 +1037,24 @@ Tips:
|
||||
session = store.load(sorted_sessions[0].get("session_id"))
|
||||
if session:
|
||||
self.session_id = session.session_id
|
||||
# Load history
|
||||
history = session.get_chat_history()
|
||||
# Load the full stored history (not the default
|
||||
# 50-message tail) so the replay's "… N earlier
|
||||
# turns" note reflects the true total and older
|
||||
# turns aren't silently dropped before bounding.
|
||||
history = session.get_chat_history(
|
||||
max_messages=max(session.message_count, 1)
|
||||
)
|
||||
self._conversation_history = history
|
||||
# Reset the display so repeated /continue calls (or
|
||||
# continuing after prior activity) redraw the
|
||||
# restored conversation cleanly instead of appending
|
||||
# to — and duplicating — stale on-screen turns.
|
||||
self.messages.clear()
|
||||
self.messages.append(ChatMessage(
|
||||
role="system",
|
||||
content=f"Continued session: {self.session_id} ({len(history)} messages)"
|
||||
))
|
||||
self._replay_history(history)
|
||||
else:
|
||||
self.messages.append(ChatMessage(role="system", content="Could not load session."))
|
||||
except Exception as e:
|
||||
@@ -1721,10 +1769,22 @@ Example: /handoff code "refactor the auth module" """
|
||||
continue
|
||||
|
||||
if user_input.startswith("/"):
|
||||
before = len(self.messages)
|
||||
self._handle_command(user_input)
|
||||
for msg in self.messages:
|
||||
if msg.role == "system":
|
||||
# Commands like /continue clear and repopulate the message
|
||||
# list with restored user/assistant turns. Render every
|
||||
# newly-appended message (not just system lines) so the
|
||||
# replayed conversation is visible in fallback mode too.
|
||||
new_msgs = self.messages[before:] if len(self.messages) >= before else list(self.messages)
|
||||
for msg in new_msgs:
|
||||
if msg.role == "user":
|
||||
print(f"› {msg.content}")
|
||||
elif msg.role == "assistant":
|
||||
print(f"● {msg.content}")
|
||||
else:
|
||||
print(f" {msg.content}")
|
||||
# Drop rendered system lines; keep user/assistant turns as
|
||||
# conversational context for subsequent prompts.
|
||||
self.messages = [m for m in self.messages if m.role != "system"]
|
||||
continue
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-code"
|
||||
version = "0.0.62"
|
||||
version = "0.0.63"
|
||||
description = "Agentic terminal CLI for PraisonAI — the terminal-native agent (run, chat, code, runtime, cli backends) extracted from the praisonai wrapper."
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
@@ -132,7 +132,7 @@ def test_code_print_uses_supported_agent_kwargs_and_workspace_tools(monkeypatch,
|
||||
assert captured["output"] == "minimal"
|
||||
assert captured["tools"]
|
||||
assert captured["workspace"] == str(tmp_path)
|
||||
assert captured["tool_groups"] == ["basic", "acp", "edit", "search", "lsp"]
|
||||
assert captured["tool_groups"] == ["acp", "edit", "search", "lsp"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
@@ -154,6 +154,7 @@ def test_code_print_real_agent_writes_workspace(tmp_path):
|
||||
"gpt-4o-mini",
|
||||
"--workspace",
|
||||
str(tmp_path),
|
||||
"--dangerously-skip-approval",
|
||||
f"Create {target.name} containing exactly HEADLESS_OK.",
|
||||
],
|
||||
)
|
||||
@@ -164,6 +165,40 @@ def test_code_print_real_agent_writes_workspace(tmp_path):
|
||||
assert target.read_text(encoding="utf-8").strip() == "HEADLESS_OK"
|
||||
|
||||
|
||||
def test_headless_search_tools_are_workspace_bound(tmp_path):
|
||||
"""Search tools must contain reads to --workspace, not the process cwd.
|
||||
|
||||
The core ``grep``/``glob`` default containment to ``os.getcwd()`` and
|
||||
``ast_grep_search`` performs none, so the headless loader must bind them to
|
||||
the configured workspace. A model-supplied path that escapes the workspace
|
||||
(absolute or ``..``) must be rejected without touching the filesystem.
|
||||
"""
|
||||
from praisonai_code.cli.features.interactive_tools import (
|
||||
ToolConfig,
|
||||
_load_search_tools,
|
||||
)
|
||||
|
||||
(tmp_path / "inside.txt").write_text("needle here", encoding="utf-8")
|
||||
|
||||
config = ToolConfig(workspace=str(tmp_path))
|
||||
tools = _load_search_tools(config)
|
||||
by_name = {t.__name__: t for t in tools.values()}
|
||||
|
||||
# grep is always available (pure-Python fallback), so assert on it.
|
||||
grep = by_name["grep"]
|
||||
assert "escapes the workspace" in grep("needle", path="/etc")
|
||||
assert "escapes the workspace" in grep("needle", path="../../..")
|
||||
# An in-workspace search still works and finds the seeded match.
|
||||
assert "inside.txt" in grep("needle", path=".")
|
||||
|
||||
if "glob" in by_name:
|
||||
assert "escapes the workspace" in by_name["glob"]("*", path="/etc")
|
||||
if "ast_grep_search" in by_name:
|
||||
assert "escapes the workspace" in by_name["ast_grep_search"](
|
||||
"x", lang="python", path="/etc"
|
||||
)
|
||||
|
||||
|
||||
def test_code_print_text_mode_clean_stdout(monkeypatch):
|
||||
"""`-p --output text` prints just the result text, no envelope or decorations."""
|
||||
import praisonaiagents
|
||||
|
||||
Generated
+3
-3
@@ -5,8 +5,8 @@ resolution-markers = [
|
||||
"python_full_version >= '3.14' and sys_platform == 'win32'",
|
||||
"python_full_version >= '3.14' and sys_platform != 'win32'",
|
||||
"python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'",
|
||||
"python_full_version < '3.11' and sys_platform == 'win32'",
|
||||
"python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'",
|
||||
"python_full_version < '3.11' and sys_platform == 'win32'",
|
||||
"python_full_version < '3.11' and sys_platform != 'win32'",
|
||||
]
|
||||
|
||||
@@ -1358,7 +1358,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonai-code"
|
||||
version = "0.0.62"
|
||||
version = "0.0.63"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -1391,7 +1391,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.166"
|
||||
version = "1.6.167"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.0.6"
|
||||
__version__ = "0.0.7"
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-deploy"
|
||||
version = "0.0.6"
|
||||
version = "0.0.7"
|
||||
description = "Deployment for PraisonAI — API, Docker, and cloud (AWS, Azure, GCP) deployments extracted from the praisonai wrapper."
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
Generated
+2
-2
@@ -965,7 +965,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonai-deploy"
|
||||
version = "0.0.6"
|
||||
version = "0.0.7"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -1002,7 +1002,7 @@ provides-extras = ["api", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.166"
|
||||
version = "1.6.167"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.0.13"
|
||||
__version__ = "0.0.14"
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-mcp"
|
||||
version = "0.0.13"
|
||||
version = "0.0.14"
|
||||
description = "MCP server hosting for PraisonAI — expose agents and tools to Cursor, Claude Desktop, and other MCP clients."
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
Generated
+3
-3
@@ -5,8 +5,8 @@ resolution-markers = [
|
||||
"python_full_version >= '3.14' and sys_platform == 'win32'",
|
||||
"python_full_version >= '3.14' and sys_platform != 'win32'",
|
||||
"python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'",
|
||||
"python_full_version < '3.11' and sys_platform == 'win32'",
|
||||
"python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'",
|
||||
"python_full_version < '3.11' and sys_platform == 'win32'",
|
||||
"python_full_version < '3.11' and sys_platform != 'win32'",
|
||||
]
|
||||
|
||||
@@ -1032,7 +1032,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonai-mcp"
|
||||
version = "0.0.13"
|
||||
version = "0.0.14"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -1072,7 +1072,7 @@ provides-extras = ["server", "auth", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.166"
|
||||
version = "1.6.167"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "1.5.87",
|
||||
"lastUpdated": "2026-08-14",
|
||||
"lastUpdated": "2026-08-15",
|
||||
"generatedBy": "praisonai._dev.parity.generator",
|
||||
"sourceOfTruth": "Python SDK (praisonaiagents)",
|
||||
"status": "PARITY_ACHIEVED",
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.0.11"
|
||||
__version__ = "0.0.12"
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-sandbox"
|
||||
version = "0.0.11"
|
||||
version = "0.0.12"
|
||||
description = "Isolated agent code execution for PraisonAI — Docker, E2B, Modal, Novita, Sandlock, SSH backends extracted from the praisonai wrapper."
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
Generated
+31
-4
@@ -1331,6 +1331,28 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "novita-sandbox"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "dockerfile-parse" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "httpx" },
|
||||
{ name = "packaging" },
|
||||
{ name = "protobuf" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "rich" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "wcmatch" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/40/7bc405ef0335a292296c131a8789361df251864127f9903903fde770c272/novita_sandbox-2.1.0.tar.gz", hash = "sha256:dab6508370ea53b037db754ccf9e7bb9e559ce6966586ab8921c44365c0a55cb", size = 495277, upload-time = "2026-08-12T05:42:02.023Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/1a/c9d35a60ca68d263f2955eca325e0ff0ebd0333623ce6794ca50265d11d1/novita_sandbox-2.1.0-py3-none-any.whl", hash = "sha256:834d007a88b960345238463a68a1e4d3d4e225378e96948b199824fc93e33a8a", size = 623296, upload-time = "2026-08-12T05:42:00.293Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "obstore"
|
||||
version = "0.8.2"
|
||||
@@ -1584,7 +1606,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonai-sandbox"
|
||||
version = "0.0.11"
|
||||
version = "0.0.12"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -1600,6 +1622,7 @@ all = [
|
||||
{ name = "docker" },
|
||||
{ name = "e2b-code-interpreter" },
|
||||
{ name = "modal" },
|
||||
{ name = "novita-sandbox" },
|
||||
{ name = "sandlock" },
|
||||
]
|
||||
daytona = [
|
||||
@@ -1614,6 +1637,9 @@ e2b = [
|
||||
modal = [
|
||||
{ name = "modal" },
|
||||
]
|
||||
novita = [
|
||||
{ name = "novita-sandbox" },
|
||||
]
|
||||
sandlock = [
|
||||
{ name = "sandlock" },
|
||||
]
|
||||
@@ -1629,17 +1655,18 @@ requires-dist = [
|
||||
{ name = "docker", marker = "extra == 'docker'", specifier = ">=7.0.0" },
|
||||
{ name = "e2b-code-interpreter", marker = "extra == 'e2b'", specifier = ">=1.0.0" },
|
||||
{ name = "modal", marker = "extra == 'modal'", specifier = ">=0.64.0" },
|
||||
{ name = "praisonai-sandbox", extras = ["docker", "e2b", "sandlock", "ssh", "modal", "daytona"], marker = "extra == 'all'" },
|
||||
{ name = "novita-sandbox", marker = "extra == 'novita'", specifier = ">=2.0.0" },
|
||||
{ name = "praisonai-sandbox", extras = ["docker", "e2b", "sandlock", "ssh", "modal", "daytona", "novita"], marker = "extra == 'all'" },
|
||||
{ name = "praisonaiagents", directory = "../praisonai-agents" },
|
||||
{ name = "rich", specifier = ">=13.7" },
|
||||
{ name = "sandlock", marker = "extra == 'sandlock'", specifier = ">=0.1.0" },
|
||||
{ name = "typer", specifier = ">=0.12.0" },
|
||||
]
|
||||
provides-extras = ["docker", "e2b", "sandlock", "ssh", "modal", "daytona", "all"]
|
||||
provides-extras = ["docker", "e2b", "sandlock", "ssh", "modal", "daytona", "novita", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.166"
|
||||
version = "1.6.167"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.0.17"
|
||||
__version__ = "0.0.18"
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-train"
|
||||
version = "0.0.17"
|
||||
version = "0.0.18"
|
||||
description = "Training for PraisonAI — LLM fine-tuning (Unsloth) and iterative agent training extracted from the praisonai wrapper."
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
Generated
+2
-2
@@ -2472,7 +2472,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonai-train"
|
||||
version = "0.0.17"
|
||||
version = "0.0.18"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -2558,7 +2558,7 @@ provides-extras = ["llm", "vision", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.166"
|
||||
version = "1.6.167"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "1.5.87",
|
||||
"lastUpdated": "2026-08-14",
|
||||
"lastUpdated": "2026-08-15",
|
||||
"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-14
|
||||
> **Version:** 1.5.87 | **Last Updated:** 2026-08-15
|
||||
> **Source of Truth:** Python SDK (praisonaiagents)
|
||||
|
||||
## Summary
|
||||
|
||||
+222
-20
@@ -41,10 +41,6 @@ curl -fsSL https://praison.ai/install.sh | bash
|
||||
<img src=".github/images/dashboard.png" alt="PraisonAI Dashboard" width="800" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/images/agentflow.gif" alt="PraisonAI AgentFlow" width="800" />
|
||||
</p>
|
||||
|
||||
```
|
||||
██████╗ ██████╗ █████╗ ██╗███████╗ ██████╗ ███╗ ██╗ █████╗ ██╗
|
||||
██╔══██╗██╔══██╗██╔══██╗██║██╔════╝██╔═══██╗████╗ ██║ ██╔══██╗██║
|
||||
@@ -56,12 +52,6 @@ curl -fsSL https://praison.ai/install.sh | bash
|
||||
pip install praisonai
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/images/latest_ai_news_and_crawl_each_url_to_find_info.gif" alt="PraisonAI command execution" width="800" />
|
||||
</p>
|
||||
|
||||
\* `export TAVILY_API_KEY=xxxxx`
|
||||
|
||||
<div align="center">
|
||||
<a href="https://docs.praison.ai">
|
||||
<p align="center">
|
||||
@@ -106,6 +96,204 @@ agent.start("Analyze the top 3 tech trends of 2026 and format as a markdown tabl
|
||||
|
||||
---
|
||||
|
||||
## 🧬 The Five-Layer Agent Stack
|
||||
|
||||
Most frameworks hand you one or two layers and leave the rest as homework. PraisonAI covers **all five** — plus the outer layer that decides *where* your agent actually runs.
|
||||
|
||||
Each layer wraps the one inside it. When an agent misbehaves, the layer tells you where to look.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ⬡ MANAGED AGENTS — Where does it actually run? │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 5 · GRAPH — Who runs when, and who checks whom? │ │
|
||||
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ 4 · LOOP — When do we stop? │ │ │
|
||||
│ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │
|
||||
│ │ │ │ 3 · HARNESS — Can it act, and be checked? │ │ │ │
|
||||
│ │ │ │ ┌─────────────────────────────────────────────────┐ │ │ │ │
|
||||
│ │ │ │ │ 2 · CONTEXT — Is the right thing in the window? │ │ │ │ │
|
||||
│ │ │ │ │ ┌─────────────────────────────────────────────┐ │ │ │ │ │
|
||||
│ │ │ │ │ │ 1 · PROMPT — Did I say it clearly? │ │ │ │ │ │
|
||||
│ │ │ │ │ └─────────────────────────────────────────────┘ │ │ │ │ │
|
||||
│ │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │
|
||||
│ │ │ └─────────────────────────────────────────────────────┘ │ │ │
|
||||
│ │ └─────────────────────────────────────────────────────────┘ │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| Layer | The question it answers | PraisonAI |
|
||||
|:--|:--|:--|
|
||||
| **1 · Prompt** | Did I say it clearly? | `instructions=`, `role`/`goal`/`backstory`, `output=`, `templates=` |
|
||||
| **2 · Context** | Is the right thing in the window? | `memory=`, `knowledge=`, `context=`, handoff `ContextPolicy` |
|
||||
| **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?* | `run_on="docker"` — one shared remote sandbox, or a fully hosted loop |
|
||||
|
||||
### Layer 1 · Prompt — *Did I say it clearly?*
|
||||
|
||||
Role, instructions, examples, output format.
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
role="Senior Data Analyst",
|
||||
goal="Turn raw numbers into decisions",
|
||||
output="verbose", # markdown-formatted output
|
||||
)
|
||||
agent.start("Summarise Q3 revenue trends")
|
||||
```
|
||||
|
||||
### Layer 2 · Context — *Is the right thing in the window?*
|
||||
|
||||
Write, select, compress, isolate — the four context operations, one parameter each.
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
instructions="You are a support engineer.",
|
||||
memory={"user_id": "u-42"}, # write — persists across runs (needs a user_id)
|
||||
knowledge=["docs/"], # select — retrieves only what's relevant
|
||||
context="summarize", # compress — auto-compacts before the limit
|
||||
)
|
||||
```
|
||||
|
||||
> **Isolate** is `handoffs=[specialist]` — a sub-agent inherits the last few messages and the intersection of your tools, not your whole transcript. [📖 Handoffs](https://docs.praison.ai/docs/concepts/handoffs)
|
||||
|
||||
### Layer 3 · Harness — *Can it act, and be checked?*
|
||||
|
||||
*Agent = Model + Harness.* Tool dispatch, plus the guides that steer before acting and the sensors that observe after.
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent, MCP, tool
|
||||
|
||||
@tool
|
||||
def deploy(env: str) -> str:
|
||||
"""Deploy the current build to an environment."""
|
||||
return f"Deployed to {env}"
|
||||
|
||||
agent = Agent(
|
||||
name="ReleaseEngineer",
|
||||
instructions="You are a release engineer.",
|
||||
tools=[deploy, MCP("npx -y @modelcontextprotocol/server-filesystem /tmp")],
|
||||
approval=True, # guide — human gate before risky tools run
|
||||
)
|
||||
agent.start("Deploy to staging, then list the files you can read")
|
||||
```
|
||||
|
||||
### Layer 4 · Loop — *When do we stop?*
|
||||
|
||||
Hard iteration caps, budget ceilings, no-progress detection and completion checks — every brake is explicit.
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent, ExecutionConfig
|
||||
|
||||
agent = Agent(
|
||||
instructions="Fix the failing tests.",
|
||||
execution=ExecutionConfig(max_iter=30, max_budget=0.50, on_budget_exceeded="stop"),
|
||||
reflection=True, # completion check — the agent grades its own answer
|
||||
autonomy=True, # required to drive the loop with run_autonomous()
|
||||
)
|
||||
result = agent.run_autonomous("Refactor the auth module", max_iterations=5)
|
||||
|
||||
print(result.completion_reason)
|
||||
# goal | no_tool_calls | max_iterations | timeout | doom_loop | needs_help | error
|
||||
# (with on_budget_exceeded="stop", hitting the cap raises BudgetExceededError,
|
||||
# surfaced here as completion_reason="error")
|
||||
```
|
||||
|
||||
> **Doom-loop detection is on by default.** Repeated identical tool calls and A→B→A→B oscillation get caught — while a poller whose output keeps changing does not. [📖 Doom Loop Detection](https://docs.praison.ai/docs/features/doom-loop-detection)
|
||||
|
||||
### Layer 5 · Graph — *Who runs when, and who checks whom?*
|
||||
|
||||
Topology as a versionable artifact: prompt chaining, routing, parallelisation, orchestrator-worker.
|
||||
|
||||
```python
|
||||
from praisonaiagents import AgentFlow
|
||||
from praisonaiagents.workflows import route, parallel, repeat
|
||||
|
||||
flow = AgentFlow(steps=[
|
||||
classifier,
|
||||
route({"bug": [bug_agent], "feature": [feature_agent], "default": [triage]}),
|
||||
parallel([reviewer, tester]), # fan out, join automatically
|
||||
repeat(editor, until=lambda ctx: "approved" in ctx.previous_result.lower(),
|
||||
max_iterations=3), # evaluator–optimizer
|
||||
])
|
||||
flow.run("Ticket #123: login fails on Safari")
|
||||
```
|
||||
|
||||
> The same graph is expressible in YAML with no Python at all. [📖 AgentFlow](https://docs.praison.ai/docs/concepts/agentflow)
|
||||
|
||||
### ⬡ Outside the stack: Managed Agents — *Where does it actually run?*
|
||||
|
||||
The harness is commoditising; **where** the agent executes is the next multiplier. Rather than burning your laptop's CPU, hand an agent a short-lived cloud sandbox — repo, tools and tests run there.
|
||||
|
||||
```bash
|
||||
pip install praisonai
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
writer = Agent(name="Writer", instructions="You write files.")
|
||||
reader = Agent(name="Reader", instructions="You read files.")
|
||||
|
||||
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")
|
||||
```
|
||||
|
||||
Same thing with no Python at all:
|
||||
|
||||
```yaml
|
||||
name: remote-demo
|
||||
run_on: docker # every step shares one sandbox
|
||||
agents:
|
||||
writer: {role: Writer, goal: Write files}
|
||||
reader: {role: Reader, goal: Read files}
|
||||
steps:
|
||||
- agent: writer
|
||||
action: "Write 'hello' to /workspace/note.txt"
|
||||
- agent: reader
|
||||
action: "Read /workspace/note.txt"
|
||||
```
|
||||
|
||||
For a single agent, pick the axis you need — remote **tools** or a remote **loop**:
|
||||
|
||||
```python
|
||||
from praisonai import Agent, LocalAgent, LocalAgentConfig, HostedAgent
|
||||
|
||||
# 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 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")
|
||||
```
|
||||
|
||||
See what is running and reclaim strays:
|
||||
|
||||
```bash
|
||||
praisonai managed ps # list running sandboxes
|
||||
praisonai managed stop --all # reclaim them
|
||||
```
|
||||
|
||||
Sandboxes shut themselves down when idle (`auto_shutdown`, `idle_timeout_s`), and a post-setup snapshot is reused so the next run skips the image pull and dependency install. Commit a `.praisonai/environment.yaml` and the environment travels with the repo.
|
||||
|
||||
> 📖 [20 runnable examples](examples/python/managed-agents/) · manage sessions with `praisonai managed sessions list <agent-id>` or `praisonai managed sessions resume <session-id> "<prompt>"`
|
||||
|
||||
<sub>Stack framing adapted from [The Five-Layer Agent Stack](https://mer.vin/2026/07/five-layer-agent-stack-match-bug-to-right-layer/) and [Agent Harnesses vs Orbs](https://mer.vin/2026/08/agent-harnesses-vs-orbs-why-remote-sandboxes-beat-local-agent-loops/).</sub>
|
||||
|
||||
---
|
||||
|
||||
## 🌌 The PraisonAI Ecosystem
|
||||
|
||||
Start simple with the core SDK, or expand to full visual builders and dashboards when you're ready.
|
||||
@@ -200,9 +388,9 @@ Powered by 100+ LLMs (OpenAI, Anthropic, Gemini & local models).
|
||||
| 🧠 | **Planning Mode** — plan → execute → reason | `planning=True` |
|
||||
| 🔍 | **Deep Research** — multi-step autonomous research | [Docs](https://docs.praison.ai/docs/agents/deep-research) |
|
||||
| 🤖 | **External Agents** — orchestrate Claude Code, Gemini CLI, Codex | [Docs](https://docs.praison.ai/docs/code/external-agents) |
|
||||
| 🔄 | **Agent Handoffs** — seamless conversation passing | `handoff=True` |
|
||||
| 🔄 | **Agent Handoffs** — seamless conversation passing | `handoffs=[other_agent]` |
|
||||
| 🛡️ | **Guardrails** — input/output validation | [Docs](https://docs.praison.ai/docs/concepts/guardrails) |
|
||||
| | **Web Search + Fetch** — native browsing | `web_search=True` |
|
||||
| | **Web Search + Fetch** — native browsing | `web=True` |
|
||||
| 🪞 | **Self Reflection** — agent reviews its own output | [Docs](https://docs.praison.ai/docs/concepts/reflection) |
|
||||
| 🔀 | **Workflow Patterns** — route, parallel, loop, repeat | [Docs](https://docs.praison.ai/docs/concepts/agentflow) |
|
||||
| 🧠 | **Memory (zero deps)** — works out of the box | `memory=True` |
|
||||
@@ -212,9 +400,9 @@ Powered by 100+ LLMs (OpenAI, Anthropic, Gemini & local models).
|
||||
|
||||
| | Feature | How |
|
||||
|--|---------|-----|
|
||||
| 💡 | **Prompt Caching** — reduce latency + cost | `prompt_caching=True` |
|
||||
| 💡 | **Prompt Caching** — reduce latency + cost | `caching=True` |
|
||||
| 💾 | **Sessions + Auto-Save** — persistent state across restarts | `auto_save="my-project"` |
|
||||
| 💭 | **Thinking Budgets** — control reasoning depth | `thinking_budget=1024` |
|
||||
| 💭 | **Thinking Budgets** — control reasoning depth | `agent.thinking_budget = 1024` |
|
||||
| 📚 | **RAG + Quality-Based RAG** — auto quality scoring retrieval | [Docs](https://docs.praison.ai/docs/concepts/rag) |
|
||||
| 📊 | **Model Router** — auto-routes to cheapest capable model | [Docs](https://docs.praison.ai/docs/features/model-router) |
|
||||
| 🧊 | **Shadow Git Checkpoints** — auto-rollback on failure | [Docs](https://docs.praison.ai/docs/features/checkpoints) |
|
||||
@@ -341,8 +529,10 @@ from praisonaiagents import Agent, db
|
||||
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
db=db(database_url="postgresql://localhost/mydb"),
|
||||
session_id="my-session"
|
||||
memory={
|
||||
"db": db(database_url="postgresql://localhost/mydb"),
|
||||
"session_id": "my-session",
|
||||
},
|
||||
)
|
||||
agent.chat("Hello!") # Auto-persists messages, runs, traces
|
||||
```
|
||||
@@ -788,6 +978,16 @@ PraisonAI is built for speed, with agent instantiation in around 14μs. This red
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/images/agentflow.gif" alt="PraisonAI AgentFlow" width="800" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/images/latest_ai_news_and_crawl_each_url_to_find_info.gif" alt="PraisonAI command execution" width="800" />
|
||||
</p>
|
||||
|
||||
\* `export TAVILY_API_KEY=xxxxx`
|
||||
|
||||
## 🔍 Langfuse Tracing
|
||||
|
||||
```bash
|
||||
@@ -891,8 +1091,10 @@ from praisonaiagents import Agent, db
|
||||
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
db=db(database_url="postgresql://localhost/mydb"),
|
||||
session_id="my-session"
|
||||
memory={
|
||||
"db": db(database_url="postgresql://localhost/mydb"),
|
||||
"session_id": "my-session",
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
@@ -908,8 +1110,8 @@ from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
memory=True, # Enables file-based memory (no extra deps!)
|
||||
user_id="user123"
|
||||
# Enables file-based memory (no extra deps!)
|
||||
memory={"user_id": "user123"},
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ class Praisonai < Formula
|
||||
|
||||
desc "AI tools for various AI applications"
|
||||
homepage "https://github.com/MervinPraison/PraisonAI"
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.162.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.162.tar.gz | shasum -a 256`.split.first
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.163.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.163.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -59,10 +59,11 @@ class ACPServer:
|
||||
self._sessions: Dict[str, ACPSession] = {}
|
||||
self._client = None # ACP client connection
|
||||
self._cancelled_sessions: set = set()
|
||||
self._agents_by_workspace: Dict[str, Any] = {}
|
||||
|
||||
_setup_stderr_logging(self.config.debug)
|
||||
|
||||
def _get_agent(self):
|
||||
def _get_agent(self, workspace: Optional[Path] = None):
|
||||
"""Lazy load or create agent."""
|
||||
if self._agent is not None:
|
||||
return self._agent
|
||||
@@ -70,18 +71,85 @@ class ACPServer:
|
||||
if self._agents is not None:
|
||||
return self._agents
|
||||
|
||||
# Create default agent
|
||||
workspace = Path(workspace or self.config.workspace).resolve()
|
||||
cache_key = str(workspace)
|
||||
if cache_key in self._agents_by_workspace:
|
||||
return self._agents_by_workspace[cache_key]
|
||||
|
||||
# Create a workspace-scoped default agent.
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
self._agent = Agent(
|
||||
name="PraisonAI",
|
||||
instructions="You are a helpful AI coding assistant.",
|
||||
model=self.config.model or "gpt-4o-mini",
|
||||
tools = self._filter_workspace_tools(
|
||||
self._load_workspace_tools(workspace)
|
||||
)
|
||||
return self._agent
|
||||
agent = Agent(
|
||||
name="PraisonAI",
|
||||
instructions=(
|
||||
"You are a coding assistant inside an editor. Use the "
|
||||
"provided tools to inspect and modify files when permitted. "
|
||||
"Keep all file operations inside the workspace."
|
||||
),
|
||||
model=self.config.model or "gpt-4o-mini",
|
||||
tools=tools,
|
||||
output="minimal",
|
||||
approval=True if self.config.approval_mode == "auto" else False,
|
||||
)
|
||||
self._agents_by_workspace[cache_key] = agent
|
||||
return agent
|
||||
except ImportError:
|
||||
logger.error("praisonaiagents not installed")
|
||||
raise RuntimeError("praisonaiagents package required")
|
||||
|
||||
def _load_workspace_tools(self, workspace: Path) -> List[Any]:
|
||||
"""Load the canonical coding tools bound to an ACP session workspace."""
|
||||
from praisonai_code.cli.features.interactive_tools import (
|
||||
ToolConfig,
|
||||
get_interactive_tools,
|
||||
)
|
||||
|
||||
tool_config = ToolConfig(
|
||||
workspace=str(workspace),
|
||||
approval_mode=self.config.approval_mode,
|
||||
)
|
||||
return get_interactive_tools(
|
||||
groups=["basic", "acp", "edit", "search", "lsp"],
|
||||
config=tool_config,
|
||||
workspace=str(workspace),
|
||||
)
|
||||
|
||||
def _filter_workspace_tools(self, tools: List[Any]) -> List[Any]:
|
||||
"""Apply ACPConfig capability gates before tools reach the model."""
|
||||
read_tools = {
|
||||
"read_file", "list_files", "grep", "glob", "ast_grep_search",
|
||||
"lsp_list_symbols", "lsp_find_definition", "lsp_find_references",
|
||||
"lsp_get_diagnostics",
|
||||
}
|
||||
write_tools = {
|
||||
"write_file", "edit_file", "apply_patch", "acp_create_file",
|
||||
"acp_edit_file", "acp_delete_file",
|
||||
}
|
||||
shell_tools = {"execute_command", "acp_execute_command"}
|
||||
network_tools = {"internet_search", "web_crawl"}
|
||||
|
||||
allowed = set(read_tools)
|
||||
if self.config.can_write():
|
||||
allowed.update(write_tools)
|
||||
if self.config.can_execute_shell():
|
||||
allowed.update(shell_tools)
|
||||
if self.config.allow_network:
|
||||
allowed.update(network_tools)
|
||||
return [
|
||||
tool for tool in tools
|
||||
if getattr(tool, "__name__", "") in allowed
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _reject_unsupported_mcp_servers(mcp_servers: List[Dict[str, Any]]) -> None:
|
||||
"""Fail closed until client-provided MCP lifecycle is implemented."""
|
||||
if mcp_servers:
|
||||
raise ValueError(
|
||||
"MCP servers are not supported by the PraisonAI ACP server yet"
|
||||
)
|
||||
|
||||
def on_connect(self, client) -> None:
|
||||
"""Called when client connects."""
|
||||
@@ -121,6 +189,11 @@ class ACPServer:
|
||||
"http": False,
|
||||
"sse": False,
|
||||
},
|
||||
"sessionCapabilities": {
|
||||
"fork": {},
|
||||
"list": {},
|
||||
"resume": {},
|
||||
},
|
||||
},
|
||||
"agentInfo": {
|
||||
"name": "praisonai",
|
||||
@@ -149,6 +222,8 @@ class ACPServer:
|
||||
mcp_servers: List of MCP server configurations
|
||||
"""
|
||||
logger.info(f"New session: cwd={cwd}")
|
||||
|
||||
self._reject_unsupported_mcp_servers(mcp_servers)
|
||||
|
||||
workspace = Path(cwd).resolve()
|
||||
session = ACPSession.create(
|
||||
@@ -180,6 +255,8 @@ class ACPServer:
|
||||
Replays conversation history via session/update notifications.
|
||||
"""
|
||||
logger.info(f"Load session: session_id={session_id}")
|
||||
|
||||
self._reject_unsupported_mcp_servers(mcp_servers)
|
||||
|
||||
# Try to load from store
|
||||
session = self._session_store.load(session_id)
|
||||
@@ -286,7 +363,7 @@ class ACPServer:
|
||||
|
||||
try:
|
||||
# Get agent response
|
||||
agent = self._get_agent()
|
||||
agent = self._get_agent(session.workspace)
|
||||
|
||||
# Send thinking update
|
||||
await self._send_thought(session_id, "Processing your request...")
|
||||
|
||||
@@ -922,6 +922,51 @@ class AgentsGenerator:
|
||||
name, v, tightest,
|
||||
)
|
||||
return tightest
|
||||
|
||||
# Mirror the legacy CLI's --tool-timeout argparse default so the workflow
|
||||
# validation can tell an implicit default apart from a user request.
|
||||
_CLI_TOOL_TIMEOUT_DEFAULT = 60
|
||||
|
||||
def _resolve_explicit_workflow_tool_timeout(self, config):
|
||||
"""Resolve a tool_timeout the user *explicitly* requested, else ``None``.
|
||||
|
||||
The workflow path cannot enforce per-tool timeouts, so it fails fast on
|
||||
a requested one. But the legacy CLI always injects its --tool-timeout
|
||||
argparse default into ``cli_config`` (see direct_prompt.py), so a plain
|
||||
``_resolve_effective_tool_timeout`` would flag every ordinary workflow
|
||||
run. Here a per-agent/role YAML timeout always counts as explicit; a CLI
|
||||
value only counts when the user actually changed it away from the bare
|
||||
argparse default. The per-agent/role resolution is delegated to
|
||||
``_resolve_effective_tool_timeout`` so both paths share one source of
|
||||
truth and its override warnings still fire.
|
||||
"""
|
||||
# A per-agent/role YAML timeout is always an explicit request. Resolve it
|
||||
# from config alone (ignoring the always-injected CLI default) via the
|
||||
# shared effective resolver, so the tightest-value warning still fires.
|
||||
cli_timeout = (self.cli_config or {}).get("tool_timeout")
|
||||
cli_changed = (
|
||||
isinstance(cli_timeout, (int, float))
|
||||
and not isinstance(cli_timeout, bool)
|
||||
and float(cli_timeout) != float(self._CLI_TOOL_TIMEOUT_DEFAULT)
|
||||
)
|
||||
if cli_changed:
|
||||
return float(cli_timeout)
|
||||
|
||||
# No user-changed CLI value: fall back to a YAML-declared timeout, if any.
|
||||
# Temporarily mask the injected CLI default so the effective resolver
|
||||
# reports only what the YAML declared.
|
||||
saved_cli_config = self.cli_config
|
||||
try:
|
||||
if isinstance(self.cli_config, dict) and "tool_timeout" in self.cli_config:
|
||||
self.cli_config = {
|
||||
k: v for k, v in self.cli_config.items() if k != "tool_timeout"
|
||||
}
|
||||
declared = self._resolve_effective_tool_timeout(config)
|
||||
finally:
|
||||
self.cli_config = saved_cli_config
|
||||
if declared and declared > 0:
|
||||
return float(declared)
|
||||
return None
|
||||
|
||||
def _select_framework(self, framework: str, config: Dict[str, Any]) -> Any:
|
||||
"""Select and resolve the appropriate framework adapter.
|
||||
@@ -1337,15 +1382,27 @@ class AgentsGenerator:
|
||||
# tool_timeout is enforced by _wrap_tool_with_timeout on the
|
||||
# sequential/hierarchical path via tools_dict. The native workflow engine
|
||||
# resolves its own tools by name, so that wrapper hook does not apply
|
||||
# here. Surface the gap explicitly rather than dropping the field
|
||||
# silently, so a user who set a timeout knows it is not enforced on this
|
||||
# path.
|
||||
effective_timeout = self._resolve_effective_tool_timeout(config)
|
||||
if effective_timeout and effective_timeout > 0:
|
||||
self.logger.warning(
|
||||
"tool_timeout=%s is not enforced on the workflow (process: workflow) "
|
||||
"path; per-tool timeouts apply to sequential/hierarchical runs only.",
|
||||
effective_timeout,
|
||||
# here. Fail fast rather than accepting the field and silently ignoring
|
||||
# it, mirroring _validate_workflow_cli_capabilities which already
|
||||
# refuses --resume/--session/--fork and --output stream-json on this
|
||||
# path. A warn-only no-op left the same CLI+YAML surface with silently
|
||||
# different execution semantics gated on the process: value.
|
||||
#
|
||||
# Only an *explicit* request is rejected: the legacy CLI always injects
|
||||
# its --tool-timeout argparse default (60) into cli_config, so treating
|
||||
# that implicit value as a user request would break every ordinary
|
||||
# workflow YAML run. We therefore ignore the bare CLI default and only
|
||||
# fail on a per-agent/role YAML tool_timeout or a CLI value the user
|
||||
# actually changed. The per-agent/role resolution is delegated to
|
||||
# _resolve_effective_tool_timeout so the sequential/hierarchical and
|
||||
# workflow paths share one source of truth.
|
||||
explicit_timeout = self._resolve_explicit_workflow_tool_timeout(config)
|
||||
if explicit_timeout and explicit_timeout > 0:
|
||||
raise ValueError(
|
||||
f"tool_timeout={explicit_timeout} is not supported for "
|
||||
"'process: workflow' YAMLs; per-tool timeouts are only enforced "
|
||||
"on the sequential/hierarchical path. Either remove tool_timeout "
|
||||
"or convert to a sequential/hierarchical process."
|
||||
)
|
||||
|
||||
# Pass model from config_list to workflow as default_llm
|
||||
|
||||
@@ -1039,7 +1039,10 @@ class AutoGenerator(BaseAutoGenerator):
|
||||
is_async=is_async,
|
||||
)
|
||||
json_data = json.loads(response.model_dump_json())
|
||||
self.convert_and_save(json_data, merge=merge)
|
||||
# Off-loop the blocking merge/YAML-dump/fsync so a running event loop
|
||||
# (e.g. under `praisonai serve`) is not parked; mirrors the run path's
|
||||
# asyncio.to_thread offload in AgentsGenerator._aload_config.
|
||||
await asyncio.to_thread(self.convert_and_save, json_data, merge=merge)
|
||||
full_path = os.path.abspath(self.agent_file)
|
||||
return full_path
|
||||
|
||||
@@ -1546,10 +1549,16 @@ Respond with:
|
||||
)
|
||||
|
||||
json_data = json.loads(response.model_dump_json())
|
||||
|
||||
if merge and os.path.exists(self.workflow_file):
|
||||
return self._save_workflow(self.merge_with_existing_workflow(json_data), pattern)
|
||||
return self._save_workflow(json_data, pattern)
|
||||
|
||||
# Off-loop the blocking merge/read + YAML-dump/fsync so a running event
|
||||
# loop (e.g. under `praisonai serve`) is not parked; mirrors the run
|
||||
# path's asyncio.to_thread offload in AgentsGenerator._aload_config.
|
||||
def _merge_and_save():
|
||||
if merge and os.path.exists(self.workflow_file):
|
||||
return self._save_workflow(self.merge_with_existing_workflow(json_data), pattern)
|
||||
return self._save_workflow(json_data, pattern)
|
||||
|
||||
return await asyncio.to_thread(_merge_and_save)
|
||||
|
||||
def merge_with_existing_workflow(self, new_data: Dict) -> Dict:
|
||||
"""
|
||||
@@ -1955,8 +1964,11 @@ class JobWorkflowAutoGenerator(BaseAutoGenerator):
|
||||
],
|
||||
is_async=is_async,
|
||||
)
|
||||
|
||||
return self._save_workflow(response)
|
||||
|
||||
# Off-loop the blocking YAML-dump/fsync so a running event loop (e.g.
|
||||
# under `praisonai serve`) is not parked; mirrors the run path's
|
||||
# asyncio.to_thread offload in AgentsGenerator._aload_config.
|
||||
return await asyncio.to_thread(self._save_workflow, response)
|
||||
|
||||
def _get_prompt(self, include_judge: bool, include_approve: bool) -> str:
|
||||
"""Generate the prompt for job workflow generation."""
|
||||
|
||||
@@ -653,6 +653,191 @@ def ids_restore(
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Register sub-apps onto main app
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# sandbox lifecycle: ps / stop
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# 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):
|
||||
"""Return (instances, errors) for reachable providers.
|
||||
|
||||
``instances`` is [(provider_name, InstanceInfo), ...].
|
||||
``errors`` is [(provider_name, message), ...] for providers that were
|
||||
*available* (installed + configured) but failed while listing — those must
|
||||
surface, otherwise ``ps``/``stop --all`` would wrongly report an empty,
|
||||
successful inventory while sandboxes keep running.
|
||||
|
||||
Providers that are not installed or not configured are skipped silently --
|
||||
absence of an E2B key is a normal state, not an error worth printing.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from praisonaiagents.managed._compute_bridge import resolve_compute
|
||||
|
||||
names = [provider_filter] if provider_filter else list(_PS_PROVIDERS)
|
||||
found = []
|
||||
errors = []
|
||||
|
||||
async def _collect():
|
||||
for name in names:
|
||||
try:
|
||||
provider = resolve_compute(name)
|
||||
except Exception:
|
||||
# Not installed / unknown provider. Silent unless explicitly
|
||||
# requested, where the user deserves to know it went nowhere.
|
||||
if provider_filter:
|
||||
errors.append((name, "provider not installed or unknown"))
|
||||
continue
|
||||
try:
|
||||
if not getattr(provider, "is_available", True):
|
||||
continue # daemon down / no credentials — a normal state
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
for info in await provider.list_instances():
|
||||
found.append((name, info))
|
||||
except Exception as e:
|
||||
# Available but listing failed: real error, never hide it.
|
||||
errors.append((name, str(e)))
|
||||
|
||||
asyncio.run(_collect())
|
||||
return found, errors
|
||||
|
||||
|
||||
def _format_uptime(created_at: float) -> str:
|
||||
import time
|
||||
|
||||
if not created_at:
|
||||
return "-"
|
||||
seconds = max(0, int(time.time() - created_at))
|
||||
if seconds < 60:
|
||||
return f"{seconds}s"
|
||||
if seconds < 3600:
|
||||
return f"{seconds // 60}m"
|
||||
if seconds < 86400:
|
||||
return f"{seconds // 3600}h{(seconds % 3600) // 60:02d}m"
|
||||
return f"{seconds // 86400}d{(seconds % 86400) // 3600:02d}h"
|
||||
|
||||
|
||||
@app.command("ps")
|
||||
def managed_ps(
|
||||
provider: Optional[str] = typer.Option(
|
||||
None, "--provider", "-p", help="Only this provider (docker, e2b, modal, ...)"
|
||||
),
|
||||
as_json: bool = typer.Option(False, "--json", help="Machine-readable output"),
|
||||
):
|
||||
"""List running agent sandboxes.
|
||||
|
||||
Sandboxes normally shut themselves down when a run finishes, but a crashed
|
||||
or killed run can leave one behind. This shows what is still up, so nothing
|
||||
keeps burning resources unnoticed.
|
||||
|
||||
Example:
|
||||
praisonai managed ps
|
||||
praisonai managed ps --provider docker
|
||||
praisonai managed ps --json
|
||||
"""
|
||||
rows, errors = _discover_instances(provider)
|
||||
|
||||
if as_json:
|
||||
typer.echo(json.dumps({
|
||||
"sandboxes": [
|
||||
{
|
||||
"provider": name,
|
||||
"instance_id": info.instance_id,
|
||||
"status": getattr(info.status, "value", str(info.status)),
|
||||
"endpoint": info.endpoint,
|
||||
"created_at": info.created_at,
|
||||
"metadata": info.metadata,
|
||||
}
|
||||
for name, info in rows
|
||||
],
|
||||
"errors": [{"provider": name, "error": msg} for name, msg in errors],
|
||||
}, indent=2))
|
||||
raise typer.Exit(1 if errors else 0)
|
||||
|
||||
if not rows:
|
||||
typer.echo("No running sandboxes.")
|
||||
else:
|
||||
typer.echo(f"{'INSTANCE ID':<26} {'PROVIDER':<10} {'STATUS':<10} {'UPTIME':<8} IMAGE")
|
||||
typer.echo("-" * 78)
|
||||
for name, info in rows:
|
||||
status = getattr(info.status, "value", str(info.status))
|
||||
image = (info.metadata or {}).get("image") or info.endpoint or ""
|
||||
typer.echo(
|
||||
f"{info.instance_id:<26} {name:<10} {status:<10} "
|
||||
f"{_format_uptime(info.created_at):<8} {image}"
|
||||
)
|
||||
typer.echo(f"\n{len(rows)} running. Stop with: praisonai managed stop <instance-id>")
|
||||
|
||||
if errors:
|
||||
typer.echo("\nSome providers could not be queried (sandboxes may still be running):")
|
||||
for name, msg in errors:
|
||||
typer.echo(f" {name}: {msg}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command("stop")
|
||||
def managed_stop(
|
||||
instance_id: Optional[str] = typer.Argument(None, help="Instance ID to stop"),
|
||||
all_instances: bool = typer.Option(False, "--all", help="Stop every running sandbox"),
|
||||
provider: Optional[str] = typer.Option(
|
||||
None, "--provider", "-p", help="Only this provider"
|
||||
),
|
||||
):
|
||||
"""Stop a running agent sandbox (or all of them).
|
||||
|
||||
Example:
|
||||
praisonai managed stop docker_a1b2c3d4
|
||||
praisonai managed stop --all
|
||||
praisonai managed stop --all --provider docker
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from praisonaiagents.managed._compute_bridge import resolve_compute
|
||||
|
||||
if not instance_id and not all_instances:
|
||||
typer.echo("Give an instance ID or --all. See: praisonai managed ps")
|
||||
raise typer.Exit(1)
|
||||
|
||||
rows, errors = _discover_instances(provider)
|
||||
if instance_id:
|
||||
rows = [r for r in rows if r[1].instance_id == instance_id]
|
||||
if not rows:
|
||||
typer.echo(f"No running sandbox with ID {instance_id!r}. See: praisonai managed ps")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not rows and not errors:
|
||||
typer.echo("No running sandboxes.")
|
||||
return
|
||||
|
||||
stopped, failed = 0, 0
|
||||
for name, info in rows:
|
||||
try:
|
||||
asyncio.run(resolve_compute(name).shutdown(info.instance_id))
|
||||
typer.echo(f"Stopped {info.instance_id} ({name})")
|
||||
stopped += 1
|
||||
except Exception as e:
|
||||
typer.echo(f"Failed to stop {info.instance_id} ({name}): {e}")
|
||||
failed += 1
|
||||
|
||||
typer.echo(f"\n{stopped} stopped" + (f", {failed} failed" if failed else ""))
|
||||
|
||||
if errors:
|
||||
# A provider we could not even inventory may still hold live sandboxes
|
||||
# we never got the chance to stop. Never claim a clean sweep.
|
||||
typer.echo("\nSome providers could not be queried (sandboxes may still be running):")
|
||||
for name, msg in errors:
|
||||
typer.echo(f" {name}: {msg}")
|
||||
|
||||
if failed or errors:
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
app.add_typer(sessions_app, name="sessions")
|
||||
app.add_typer(agents_app, name="agents")
|
||||
app.add_typer(envs_app, name="envs")
|
||||
|
||||
@@ -295,7 +295,10 @@ class ClaudeCodeIntegration(BaseCLIIntegration):
|
||||
Yields:
|
||||
dict: Parsed JSON events from the CLI
|
||||
"""
|
||||
# Use stream-json format for streaming (local parameter, no instance mutation)
|
||||
# Use stream-json format for streaming (per-call override, no instance
|
||||
# mutation); drop a caller-supplied output_format so the forced value
|
||||
# wins instead of raising "multiple values for keyword argument".
|
||||
options.pop("output_format", None)
|
||||
cmd = self._build_command(prompt, output_format="stream-json", **options)
|
||||
|
||||
async for line in self.stream_async(cmd):
|
||||
|
||||
@@ -392,28 +392,53 @@ class DockerCompute:
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, self._shutdown_sync, instance_id)
|
||||
|
||||
def _find_container(self, instance_id: str):
|
||||
"""Look up a container by name, for instances this process didn't start.
|
||||
|
||||
``_containers`` only knows about instances provisioned by *this*
|
||||
process, so a CLI invocation (``praisonai managed stop``) would
|
||||
otherwise find nothing and silently no-op.
|
||||
"""
|
||||
try:
|
||||
return self._get_client().containers.get(f"praisonai_{instance_id}")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _shutdown_sync(self, instance_id: str) -> None:
|
||||
info = self._containers.pop(instance_id, None)
|
||||
if info:
|
||||
container = info["container"]
|
||||
try:
|
||||
container.stop(timeout=10)
|
||||
if info["config"].auto_shutdown:
|
||||
container.remove(force=True)
|
||||
except Exception as e:
|
||||
logger.warning("[docker_compute] shutdown error: %s", e)
|
||||
logger.info("[docker_compute] shutdown: %s", instance_id)
|
||||
container = info["container"] if info else self._find_container(instance_id)
|
||||
if container is None:
|
||||
# Report rather than silently succeeding: callers (and the CLI)
|
||||
# must be able to tell "stopped it" from "never found it".
|
||||
raise ValueError(f"No docker container found for instance {instance_id!r}")
|
||||
|
||||
# Containers started by another process have no in-process config;
|
||||
# default to removing, matching ComputeConfig.auto_shutdown's default.
|
||||
auto_shutdown = info["config"].auto_shutdown if info else True
|
||||
try:
|
||||
container.stop(timeout=10)
|
||||
if auto_shutdown:
|
||||
container.remove(force=True)
|
||||
except Exception as e:
|
||||
logger.warning("[docker_compute] shutdown error: %s", e)
|
||||
raise
|
||||
logger.info("[docker_compute] shutdown: %s", instance_id)
|
||||
|
||||
async def get_status(self, instance_id: str) -> Any:
|
||||
from praisonaiagents.managed.protocols import InstanceInfo, InstanceStatus
|
||||
|
||||
info = self._containers.get(instance_id)
|
||||
if not info:
|
||||
return InstanceInfo(
|
||||
instance_id=instance_id,
|
||||
status=InstanceStatus.STOPPED,
|
||||
provider="docker",
|
||||
)
|
||||
# May have been started by another process; look it up by name
|
||||
# before declaring it stopped.
|
||||
container = self._find_container(instance_id)
|
||||
if container is None:
|
||||
return InstanceInfo(
|
||||
instance_id=instance_id,
|
||||
status=InstanceStatus.STOPPED,
|
||||
provider="docker",
|
||||
)
|
||||
info = {"container": container}
|
||||
|
||||
try:
|
||||
info["container"].reload()
|
||||
@@ -528,23 +553,75 @@ class DockerCompute:
|
||||
return False
|
||||
|
||||
async def list_instances(self) -> list:
|
||||
"""List every running praisonai-managed container on this host.
|
||||
|
||||
Enumerates Docker by the ``praisonai=managed`` label rather than the
|
||||
in-process ``_containers`` registry, so instances started by an earlier
|
||||
process (a finished script, a crashed run) are still discoverable --
|
||||
which is the whole point of being able to find and reclaim strays.
|
||||
"""
|
||||
from praisonaiagents.managed.protocols import InstanceInfo, InstanceStatus
|
||||
|
||||
result = []
|
||||
for iid, info in self._containers.items():
|
||||
import calendar
|
||||
import email.utils
|
||||
|
||||
def _created_at(container, fallback: float) -> float:
|
||||
"""Best-effort container start time as a POSIX timestamp."""
|
||||
created = (container.attrs or {}).get("Created")
|
||||
if not isinstance(created, str) or not created:
|
||||
return fallback
|
||||
try:
|
||||
info["container"].reload()
|
||||
if info["container"].status == "running":
|
||||
result.append(InstanceInfo(
|
||||
instance_id=iid,
|
||||
status=InstanceStatus.RUNNING,
|
||||
endpoint=f"docker://{info['container'].id[:12]}",
|
||||
provider="docker",
|
||||
created_at=info.get("created_at", 0),
|
||||
))
|
||||
# Docker reports RFC3339 with nanosecond precision, which
|
||||
# datetime cannot parse directly; trim to microseconds.
|
||||
text = created.replace("Z", "+00:00")
|
||||
if "." in text:
|
||||
head, _, tail = text.partition(".")
|
||||
frac, sign, offset = (
|
||||
tail.partition("+") if "+" in tail else tail.partition("-")
|
||||
)
|
||||
text = f"{head}.{frac[:6]}{sign}{offset}"
|
||||
from datetime import datetime
|
||||
|
||||
return datetime.fromisoformat(text).timestamp()
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
try:
|
||||
return calendar.timegm(email.utils.parsedate(created))
|
||||
except Exception:
|
||||
return fallback
|
||||
|
||||
def _list_sync():
|
||||
client = self._get_client()
|
||||
containers = client.containers.list(
|
||||
filters={"label": "praisonai=managed", "status": "running"}
|
||||
)
|
||||
found = []
|
||||
for container in containers:
|
||||
instance_id = (container.labels or {}).get("instance_id")
|
||||
if not instance_id:
|
||||
# Fall back to the naming convention for older containers.
|
||||
instance_id = (container.name or "").removeprefix("praisonai_")
|
||||
if not instance_id:
|
||||
continue
|
||||
known = self._containers.get(instance_id, {})
|
||||
found.append(InstanceInfo(
|
||||
instance_id=instance_id,
|
||||
status=InstanceStatus.RUNNING,
|
||||
endpoint=f"docker://{container.id[:12]}",
|
||||
provider="docker",
|
||||
created_at=_created_at(container, known.get("created_at", 0)),
|
||||
metadata={
|
||||
"image": (container.image.tags or [""])[0] if container.image else "",
|
||||
"name": container.name or "",
|
||||
},
|
||||
))
|
||||
return found
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, _list_sync)
|
||||
except Exception as exc:
|
||||
logger.debug("[docker_compute] list_instances failed: %s", exc)
|
||||
return []
|
||||
|
||||
def _install_packages_sync(self, container, packages: Dict[str, list]) -> None:
|
||||
pip_pkgs = packages.get("pip", [])
|
||||
|
||||
@@ -1087,15 +1087,15 @@ def ManagedAgent(
|
||||
- provider="anthropic" → HostedAgent(provider="anthropic", ...)
|
||||
- provider in {"openai","gemini","ollama","local"} → LocalAgent(...)
|
||||
(DeprecationWarning: "use LocalAgent directly; put LLM name in model=")
|
||||
- provider in {"e2b","modal","flyio","daytona","docker"} → raise ValueError
|
||||
("Cloud compute belongs on LocalAgent(compute=...). Hosted runtimes for
|
||||
these providers are not yet available.")
|
||||
- provider in {"e2b","modal","flyio","daytona","docker","tenki"} → emits a
|
||||
DeprecationWarning and delegates to LocalManagedAgent(compute=<provider>).
|
||||
(It does NOT raise; prefer LocalAgent(compute="...") directly.)
|
||||
|
||||
Returns:
|
||||
An instance satisfying ``ManagedBackendProtocol``.
|
||||
|
||||
Raises:
|
||||
ValueError: For compute-provider names that should use LocalAgent(compute=).
|
||||
An instance satisfying ``ManagedBackendProtocol``. Compute-provider
|
||||
names ({"e2b","modal","flyio","daytona","docker","tenki"}) do NOT
|
||||
raise — they emit a ``DeprecationWarning`` and delegate to
|
||||
``LocalManagedAgent(compute=<provider>)``.
|
||||
"""
|
||||
# Track if provider was auto-detected to avoid spurious deprecation warnings
|
||||
auto_detected = provider is None
|
||||
|
||||
@@ -190,8 +190,9 @@ def find_latest_report_dir(suite: str, base_dir: Optional[Path] = None) -> Optio
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
# Sort by mtime (newest first)
|
||||
candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
# Sort by mtime (newest first), breaking ties by name so timestamp-named
|
||||
# dirs (e.g. "20260109_140000") remain deterministic when mtimes collide.
|
||||
candidates.sort(key=lambda p: (p.stat().st_mtime, p.name), reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.6.162"
|
||||
__version__ = "4.6.163"
|
||||
@@ -12,14 +12,14 @@ dependencies = [
|
||||
"rich>=13.7",
|
||||
"markdown>=3.5",
|
||||
"pyparsing>=3.0.0",
|
||||
"praisonaiagents>=1.6.166",
|
||||
"praisonai-code>=0.0.62",
|
||||
"praisonai-bot>=0.0.47",
|
||||
"praisonai-train>=0.0.17",
|
||||
"praisonai-browser>=0.0.13",
|
||||
"praisonai-mcp>=0.0.13",
|
||||
"praisonai-sandbox>=0.0.11",
|
||||
"praisonai-deploy>=0.0.6",
|
||||
"praisonaiagents>=1.6.167",
|
||||
"praisonai-code>=0.0.63",
|
||||
"praisonai-bot>=0.0.48",
|
||||
"praisonai-train>=0.0.18",
|
||||
"praisonai-browser>=0.0.14",
|
||||
"praisonai-mcp>=0.0.14",
|
||||
"praisonai-sandbox>=0.0.12",
|
||||
"praisonai-deploy>=0.0.7",
|
||||
"python-dotenv>=0.19.0",
|
||||
"litellm>=1.83.14,<2",
|
||||
"PyYAML>=6.0",
|
||||
|
||||
@@ -106,6 +106,32 @@ class TestACPRealLLM:
|
||||
# Log for verification (no secrets)
|
||||
print(f"[TEST] Prompt completed: stopReason={prompt_result['stopReason']}")
|
||||
print(f"[TEST] Session: {session_id[:20]}...")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_writes_file_when_explicitly_allowed(self, tmp_path):
|
||||
"""Exercise the real ACP → Agent → workspace tool loop."""
|
||||
from praisonai.acp.config import ACPConfig
|
||||
from praisonai.acp.server import ACPServer
|
||||
|
||||
server = ACPServer(config=ACPConfig(
|
||||
workspace=tmp_path,
|
||||
model="gpt-4o-mini",
|
||||
read_only=False,
|
||||
allow_write=True,
|
||||
approval_mode="auto",
|
||||
))
|
||||
session = await server.new_session(cwd=str(tmp_path), mcp_servers=[])
|
||||
|
||||
result = await server.prompt(
|
||||
prompt=[{
|
||||
"type": "text",
|
||||
"text": "Create acp_probe.txt containing exactly ACP_TOOLS_OK.",
|
||||
}],
|
||||
session_id=session["sessionId"],
|
||||
)
|
||||
|
||||
assert result["stopReason"] == "end_turn"
|
||||
assert (tmp_path / "acp_probe.txt").read_text(encoding="utf-8").strip() == "ACP_TOOLS_OK"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_resume(self, acp_server):
|
||||
|
||||
@@ -38,6 +38,9 @@ class TestACPServer:
|
||||
assert result["protocolVersion"] == 1
|
||||
assert "agentCapabilities" in result
|
||||
assert result["agentCapabilities"]["loadSession"] is True
|
||||
assert set(result["agentCapabilities"]["sessionCapabilities"]) == {
|
||||
"fork", "list", "resume"
|
||||
}
|
||||
assert result["agentInfo"]["name"] == "praisonai"
|
||||
|
||||
def test_new_session(self):
|
||||
@@ -54,20 +57,71 @@ class TestACPServer:
|
||||
assert result["sessionId"] in server._sessions
|
||||
|
||||
def test_new_session_with_mcp_servers(self):
|
||||
"""Test new_session with MCP servers."""
|
||||
"""Unsupported MCP configs fail closed instead of being ignored."""
|
||||
import pytest
|
||||
|
||||
server = ACPServer()
|
||||
|
||||
mcp_servers = [
|
||||
{"name": "test", "command": "/usr/bin/test", "args": []}
|
||||
]
|
||||
|
||||
result = asyncio.run(server.new_session(
|
||||
cwd="/tmp/test",
|
||||
mcp_servers=mcp_servers,
|
||||
))
|
||||
|
||||
session = server._sessions[result["sessionId"]]
|
||||
assert session.mcp_servers == mcp_servers
|
||||
with pytest.raises(ValueError, match="MCP servers are not supported"):
|
||||
asyncio.run(server.new_session(
|
||||
cwd="/tmp/test",
|
||||
mcp_servers=mcp_servers,
|
||||
))
|
||||
|
||||
def test_read_only_agent_exposes_only_non_mutating_tools(self, monkeypatch):
|
||||
server = ACPServer(config=ACPConfig(read_only=True))
|
||||
tools = self._fake_tools()
|
||||
monkeypatch.setattr(server, "_load_workspace_tools", lambda _workspace: tools)
|
||||
|
||||
agent = server._get_agent()
|
||||
names = {tool.__name__ for tool in agent.tools}
|
||||
|
||||
assert {"read_file", "list_files", "grep"} <= names
|
||||
assert not names & {
|
||||
"write_file", "edit_file", "acp_create_file",
|
||||
"execute_command", "internet_search",
|
||||
}
|
||||
|
||||
def test_permission_flags_control_write_shell_and_network_tools(self, monkeypatch):
|
||||
config = ACPConfig(
|
||||
read_only=False,
|
||||
allow_write=True,
|
||||
allow_shell=True,
|
||||
allow_network=True,
|
||||
approval_mode="auto",
|
||||
)
|
||||
server = ACPServer(config=config)
|
||||
tools = self._fake_tools()
|
||||
monkeypatch.setattr(server, "_load_workspace_tools", lambda _workspace: tools)
|
||||
|
||||
agent = server._get_agent()
|
||||
names = {tool.__name__ for tool in agent.tools}
|
||||
|
||||
assert {
|
||||
"write_file", "edit_file", "acp_create_file",
|
||||
"execute_command", "internet_search",
|
||||
} <= names
|
||||
|
||||
@staticmethod
|
||||
def _fake_tools():
|
||||
def make(name):
|
||||
def tool():
|
||||
return name
|
||||
|
||||
tool.__name__ = name
|
||||
return tool
|
||||
|
||||
return [
|
||||
make(name) for name in (
|
||||
"read_file", "list_files", "grep", "write_file", "edit_file",
|
||||
"acp_create_file", "execute_command", "acp_execute_command",
|
||||
"internet_search", "web_crawl",
|
||||
)
|
||||
]
|
||||
|
||||
def test_set_session_mode(self):
|
||||
"""Test set_session_mode handler."""
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Tests for `praisonai managed ps` / `stop` and cross-process instance lookup.
|
||||
|
||||
No Docker required: providers are faked. The real Docker path is exercised
|
||||
manually; see the PR description.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from praisonai.cli.commands.managed import (
|
||||
_format_uptime,
|
||||
app,
|
||||
)
|
||||
import praisonai.cli.commands.managed as managed_cli
|
||||
|
||||
|
||||
class _Status:
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
|
||||
class _Info:
|
||||
def __init__(self, instance_id, created_at=None, image="python:3.12-slim"):
|
||||
self.instance_id = instance_id
|
||||
self.status = _Status("running")
|
||||
self.endpoint = f"docker://{instance_id[:12]}"
|
||||
self.provider = "docker"
|
||||
self.created_at = created_at if created_at is not None else time.time() - 90
|
||||
self.metadata = {"image": image}
|
||||
|
||||
|
||||
class FakeProvider:
|
||||
"""Stands in for a ComputeProviderProtocol implementation."""
|
||||
|
||||
def __init__(self, instances=None, available=True):
|
||||
self.is_available = available
|
||||
self._instances = list(instances or [])
|
||||
self.shutdowns = []
|
||||
|
||||
async def list_instances(self):
|
||||
return list(self._instances)
|
||||
|
||||
async def shutdown(self, instance_id):
|
||||
self.shutdowns.append(instance_id)
|
||||
self._instances = [i for i in self._instances if i.instance_id != instance_id]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner():
|
||||
return CliRunner()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def one_docker_instance(monkeypatch):
|
||||
"""Route every provider lookup to a single fake docker provider."""
|
||||
provider = FakeProvider([_Info("docker_abc123")])
|
||||
|
||||
def fake_resolve(name):
|
||||
if name == "docker":
|
||||
return provider
|
||||
raise ImportError(f"{name} not installed")
|
||||
|
||||
monkeypatch.setattr(managed_cli, "_PS_PROVIDERS", ("docker", "e2b"))
|
||||
monkeypatch.setattr(
|
||||
"praisonaiagents.managed._compute_bridge.resolve_compute", fake_resolve
|
||||
)
|
||||
return provider
|
||||
|
||||
|
||||
# ── uptime formatting ────────────────────────────────────────────────────────
|
||||
@pytest.mark.parametrize("seconds,expected", [
|
||||
(0, "-"), # unknown created_at renders as "-"
|
||||
(5, "5s"),
|
||||
(90, "1m"),
|
||||
(3700, "1h01m"),
|
||||
(90000, "1d01h"),
|
||||
])
|
||||
def test_format_uptime(seconds, expected):
|
||||
created = 0 if seconds == 0 else time.time() - seconds
|
||||
assert _format_uptime(created) == expected
|
||||
|
||||
|
||||
# ── ps ───────────────────────────────────────────────────────────────────────
|
||||
def test_ps_lists_running_sandbox(runner, one_docker_instance):
|
||||
result = runner.invoke(app, ["ps"])
|
||||
assert result.exit_code == 0
|
||||
assert "docker_abc123" in result.output
|
||||
assert "python:3.12-slim" in result.output
|
||||
assert "1 running" in result.output
|
||||
|
||||
|
||||
def test_ps_json_is_machine_readable(runner, one_docker_instance):
|
||||
import json
|
||||
|
||||
result = runner.invoke(app, ["ps", "--json"])
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
sandboxes = payload["sandboxes"]
|
||||
assert sandboxes[0]["instance_id"] == "docker_abc123"
|
||||
assert sandboxes[0]["provider"] == "docker"
|
||||
assert sandboxes[0]["status"] == "running"
|
||||
assert payload["errors"] == []
|
||||
|
||||
|
||||
def test_ps_reports_empty_state(runner, monkeypatch):
|
||||
monkeypatch.setattr(managed_cli, "_discover_instances", lambda *a, **k: ([], []))
|
||||
result = runner.invoke(app, ["ps"])
|
||||
assert result.exit_code == 0
|
||||
assert "No running sandboxes" in result.output
|
||||
|
||||
|
||||
def test_ps_skips_unavailable_providers(runner, monkeypatch):
|
||||
"""A provider with no credentials/daemon is a normal state, not an error."""
|
||||
monkeypatch.setattr(managed_cli, "_PS_PROVIDERS", ("docker",))
|
||||
monkeypatch.setattr(
|
||||
"praisonaiagents.managed._compute_bridge.resolve_compute",
|
||||
lambda name: FakeProvider([_Info("never_shown")], available=False),
|
||||
)
|
||||
result = runner.invoke(app, ["ps"])
|
||||
assert result.exit_code == 0
|
||||
assert "never_shown" not in result.output
|
||||
|
||||
|
||||
def test_ps_surfaces_listing_failure(runner, monkeypatch):
|
||||
"""An available provider that fails to list must NOT report clean success."""
|
||||
class Broken(FakeProvider):
|
||||
async def list_instances(self):
|
||||
raise RuntimeError("daemon exploded")
|
||||
|
||||
monkeypatch.setattr(managed_cli, "_PS_PROVIDERS", ("docker",))
|
||||
monkeypatch.setattr(
|
||||
"praisonaiagents.managed._compute_bridge.resolve_compute",
|
||||
lambda name: Broken(available=True),
|
||||
)
|
||||
result = runner.invoke(app, ["ps"])
|
||||
assert result.exit_code == 1
|
||||
assert "daemon exploded" in result.output
|
||||
|
||||
|
||||
def test_ps_explicit_unknown_provider_is_loud(runner, monkeypatch):
|
||||
"""`--provider foo` for an uninstalled/unknown provider should not be silent."""
|
||||
def fake_resolve(name):
|
||||
raise ImportError(f"{name} not installed")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"praisonaiagents.managed._compute_bridge.resolve_compute", fake_resolve
|
||||
)
|
||||
result = runner.invoke(app, ["ps", "--provider", "e2b"])
|
||||
assert result.exit_code == 1
|
||||
assert "e2b" in result.output
|
||||
|
||||
|
||||
# ── stop ─────────────────────────────────────────────────────────────────────
|
||||
def test_stop_requires_target(runner):
|
||||
result = runner.invoke(app, ["stop"])
|
||||
assert result.exit_code == 1
|
||||
assert "--all" in result.output
|
||||
|
||||
|
||||
def test_stop_unknown_id_fails_loudly(runner, one_docker_instance):
|
||||
result = runner.invoke(app, ["stop", "docker_does_not_exist"])
|
||||
assert result.exit_code == 1
|
||||
assert "No running sandbox" in result.output
|
||||
assert one_docker_instance.shutdowns == []
|
||||
|
||||
|
||||
def test_stop_by_id(runner, one_docker_instance):
|
||||
result = runner.invoke(app, ["stop", "docker_abc123"])
|
||||
assert result.exit_code == 0
|
||||
assert one_docker_instance.shutdowns == ["docker_abc123"]
|
||||
|
||||
|
||||
def test_stop_all(runner, one_docker_instance):
|
||||
result = runner.invoke(app, ["stop", "--all"])
|
||||
assert result.exit_code == 0
|
||||
assert one_docker_instance.shutdowns == ["docker_abc123"]
|
||||
assert "1 stopped" in result.output
|
||||
|
||||
|
||||
def test_stop_reports_failure_exit_code(runner, monkeypatch):
|
||||
class Failing(FakeProvider):
|
||||
async def shutdown(self, instance_id):
|
||||
raise RuntimeError("daemon gone")
|
||||
|
||||
provider = Failing([_Info("docker_abc123")])
|
||||
monkeypatch.setattr(managed_cli, "_PS_PROVIDERS", ("docker",))
|
||||
monkeypatch.setattr(
|
||||
"praisonaiagents.managed._compute_bridge.resolve_compute", lambda name: provider
|
||||
)
|
||||
result = runner.invoke(app, ["stop", "--all"])
|
||||
assert result.exit_code == 1
|
||||
assert "Failed to stop" in result.output
|
||||
|
||||
|
||||
# ── cross-process lookup (the reason ps/stop work at all) ────────────────────
|
||||
def test_docker_shutdown_raises_when_container_missing():
|
||||
"""A stop that found nothing must not report success."""
|
||||
docker_mod = pytest.importorskip("praisonai.integrations.compute.docker")
|
||||
compute = docker_mod.DockerCompute()
|
||||
compute._containers = {}
|
||||
compute._find_container = lambda instance_id: None
|
||||
|
||||
with pytest.raises(ValueError, match="No docker container"):
|
||||
compute._shutdown_sync("docker_missing")
|
||||
|
||||
|
||||
def test_docker_shutdown_falls_back_to_lookup_by_name():
|
||||
"""Instances started by another process must still be stoppable."""
|
||||
docker_mod = pytest.importorskip("praisonai.integrations.compute.docker")
|
||||
|
||||
class FakeContainer:
|
||||
def __init__(self):
|
||||
self.stopped = False
|
||||
self.removed = False
|
||||
|
||||
def stop(self, timeout=None):
|
||||
self.stopped = True
|
||||
|
||||
def remove(self, force=False):
|
||||
self.removed = True
|
||||
|
||||
container = FakeContainer()
|
||||
compute = docker_mod.DockerCompute()
|
||||
compute._containers = {} # nothing known in-process
|
||||
compute._find_container = lambda instance_id: container
|
||||
|
||||
compute._shutdown_sync("docker_from_other_process")
|
||||
assert container.stopped and container.removed
|
||||
@@ -208,6 +208,39 @@ def test_timeout_proxy_isolated_across_generators():
|
||||
exec_b.shutdown()
|
||||
|
||||
|
||||
def test_explicit_workflow_timeout_ignores_cli_default():
|
||||
# The legacy CLI always injects its --tool-timeout argparse default (60)
|
||||
# into cli_config, so a bare default must NOT be treated as a user request
|
||||
# (otherwise every ordinary workflow YAML run would raise). Regression for
|
||||
# the workflow tool_timeout fail-fast (PR #3963).
|
||||
gen = _make_generator()
|
||||
gen.cli_config = {"tool_timeout": 60}
|
||||
assert gen._resolve_explicit_workflow_tool_timeout({}) is None
|
||||
assert gen._resolve_explicit_workflow_tool_timeout(
|
||||
{"roles": {"a": {}}}
|
||||
) is None
|
||||
|
||||
|
||||
def test_explicit_workflow_timeout_flags_changed_cli_value():
|
||||
gen = _make_generator()
|
||||
gen.cli_config = {"tool_timeout": 30}
|
||||
assert gen._resolve_explicit_workflow_tool_timeout({}) == 30.0
|
||||
|
||||
|
||||
def test_explicit_workflow_timeout_flags_yaml_declared():
|
||||
gen = _make_generator()
|
||||
gen.cli_config = {"tool_timeout": 60} # bare CLI default
|
||||
config = {"roles": {"a": {"tool_timeout": 10}}}
|
||||
assert gen._resolve_explicit_workflow_tool_timeout(config) == 10.0
|
||||
|
||||
|
||||
def test_explicit_workflow_timeout_ignores_bool_yaml():
|
||||
gen = _make_generator()
|
||||
gen.cli_config = {}
|
||||
config = {"agents": {"a": {"tool_timeout": True}}}
|
||||
assert gen._resolve_explicit_workflow_tool_timeout(config) is None
|
||||
|
||||
|
||||
def test_ag2_not_in_default_priority():
|
||||
from praisonai.framework_adapters.registry import FrameworkAdapterRegistry
|
||||
|
||||
|
||||
Reference in new issue
Block a user