mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-08 01:08:39 -10:00
Compare commits
452
Commits
No files matched your search
@@ -98,6 +98,20 @@ jobs:
|
||||
--ignore=tests/unit/cli/test_message_queue.py \
|
||||
--maxfail=5
|
||||
|
||||
- name: Run praisonai-agents subscription-auth smoke tests
|
||||
env:
|
||||
PRAISONAI_TEST_TIER: smoke
|
||||
PRAISONAI_ALLOW_NETWORK: '0'
|
||||
OPENAI_MODEL_NAME: gpt-4o-mini
|
||||
OPENAI_API_KEY: 'test-key'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd src/praisonai-agents
|
||||
python -m pytest tests/smoke/test_subscription_auth.py \
|
||||
tests/unit/auth/test_agent_auth_wiring.py \
|
||||
-q --tb=line --timeout=60 --maxfail=5
|
||||
|
||||
- name: Standalone praisonai-code import smoke
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -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?* | `run_on="docker"` — one shared remote sandbox, or a fully hosted loop |
|
||||
| **⬡ Managed** | *Where does it actually run?* | `tools_run_on="docker"` — one shared sandbox for the tools, or `run_on="anthropic"` for the whole agent |
|
||||
|
||||
### Layer 1 · Prompt — *Did I say it clearly?*
|
||||
|
||||
@@ -235,7 +235,7 @@ The harness is commoditising; **where** the agent executes is the next multiplie
|
||||
pip install praisonai
|
||||
```
|
||||
|
||||
The simplest way in is `run_on=` — one whole team or workflow shares **one** sandbox, so a file written by step 1 is there for step 2:
|
||||
The simplest way in is `tools_run_on=` — one whole team or workflow shares **one** sandbox, so a file written by step 1 is there for step 2. Thinking stays on your machine:
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent, AgentFlow
|
||||
@@ -243,7 +243,7 @@ from praisonaiagents import Agent, AgentFlow
|
||||
writer = Agent(name="Writer", instructions="You write files.")
|
||||
reader = Agent(name="Reader", instructions="You read files.")
|
||||
|
||||
flow = AgentFlow(run_on="docker", steps=[writer, reader]) # or e2b | modal | daytona | flyio
|
||||
flow = AgentFlow(tools_run_on="docker", steps=[writer, reader]) # or e2b | modal | daytona | flyio
|
||||
flow.run("Write 'hello' to /workspace/note.txt, then read it back")
|
||||
```
|
||||
|
||||
@@ -251,7 +251,7 @@ Same thing with no Python at all:
|
||||
|
||||
```yaml
|
||||
name: remote-demo
|
||||
run_on: docker # every step shares one sandbox
|
||||
tools_run_on: docker # every step shares one sandbox
|
||||
agents:
|
||||
writer: {role: Writer, goal: Write files}
|
||||
reader: {role: Reader, goal: Read files}
|
||||
@@ -262,22 +262,51 @@ steps:
|
||||
action: "Read /workspace/note.txt"
|
||||
```
|
||||
|
||||
For a single agent, pick the axis you need — remote **tools** or a remote **loop**:
|
||||
For a single agent, two words cover it — and they answer different questions:
|
||||
|
||||
```python
|
||||
from praisonai import Agent, LocalAgent, LocalAgentConfig, HostedAgent
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# 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"),
|
||||
))
|
||||
# A. Only the TOOLS move. Thinking stays on your machine.
|
||||
agent = Agent(name="builder", instructions="You build things.",
|
||||
tools_run_on="docker") # docker | e2b | modal | daytona | flyio
|
||||
# tenki | sandlock | ssh | novita | subprocess
|
||||
|
||||
# B. The entire agent loop runs in the cloud (needs ANTHROPIC_API_KEY)
|
||||
agent = Agent(name="teacher", backend=HostedAgent(provider="anthropic"))
|
||||
# B. The WHOLE agent moves — model calls, loop and tools
|
||||
agent = Agent(name="teacher", instructions="You teach.", run_on="anthropic") # hosted
|
||||
agent = Agent(name="builder", instructions="You build.", run_on="docker") # self-hosted
|
||||
agent.start("Write a Python script that prints the first 10 primes, then run it")
|
||||
```
|
||||
|
||||
Ask any object where it runs, and it will tell you:
|
||||
|
||||
```python
|
||||
>>> Agent(name="builder", instructions="x", tools_run_on="docker")
|
||||
Agent(name='builder', thinks_on='this machine', tools_run_on='a Docker container')
|
||||
|
||||
>>> agent.where_does_it_run()
|
||||
Thinking (the AI model calls) happens on this machine.
|
||||
Tools run on a Docker container.
|
||||
Your own tools (check_db) still run on this machine -- only shell, file and
|
||||
code tools move. They read and write this machine's files.
|
||||
```
|
||||
|
||||
Naming a place that cannot do the job is a typo, not a preference, so it says so:
|
||||
|
||||
```python
|
||||
>>> Agent(name="x", instructions="i", run_on="e2b")
|
||||
TypeError: Agent(run_on='e2b') is not valid: run_on= places the whole agent
|
||||
-- model calls, loop and tools -- on a managed runtime, and 'e2b' runs
|
||||
commands but cannot host an agent loop.
|
||||
To run only the tools there: Agent(tools_run_on='e2b')
|
||||
```
|
||||
|
||||
To run one block of code somewhere else, name the place on that call:
|
||||
|
||||
```python
|
||||
agent.execute_code_sync("print(6 * 7)", run_in="sandlock") # kernel-enforced
|
||||
```
|
||||
|
||||
See what is running and reclaim strays:
|
||||
|
||||
```bash
|
||||
|
||||
+8
-1
@@ -8,11 +8,18 @@ PraisonAI takes security seriously. We appreciate responsible disclosure from re
|
||||
|---------|----------|-------------|
|
||||
| `praisonaiagents` | [PyPI](https://pypi.org/project/praisonaiagents/) | `src/praisonai-agents/` |
|
||||
| `praisonai` | [PyPI](https://pypi.org/project/praisonai/) | `src/praisonai/` |
|
||||
| `praisonai-platform` | [PyPI](https://pypi.org/project/praisonai-platform/) | `src/praisonai-platform/` |
|
||||
| `praisonai` | [npm](https://www.npmjs.com/package/praisonai) | `src/praisonai-ts/` |
|
||||
|
||||
Report issues against the **specific package** where the vulnerability exists. A platform-only issue does not necessarily affect the core SDK, and vice versa.
|
||||
|
||||
### Packages maintained in their own repositories
|
||||
|
||||
`praisonai-platform` now lives at
|
||||
[MervinPraison/PraisonAI-Platform](https://github.com/MervinPraison/PraisonAI-Platform).
|
||||
Report platform vulnerabilities through
|
||||
[that repository's advisories](https://github.com/MervinPraison/PraisonAI-Platform/security/advisories/new),
|
||||
not here — it is versioned and released independently of the SDK.
|
||||
|
||||
## In scope
|
||||
|
||||
- Authentication, authorisation, and multi-tenant isolation (e.g. workspace boundaries)
|
||||
|
||||
@@ -30,6 +30,7 @@ Methods:
|
||||
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">aclose</a>() -> None</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">add_mcp_server</a>(name: str, mcp: Any) -> Any</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">add_verification</a>(hook: Any) -> None</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">agent_id</a>() -> str</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">analyze_prompt</a>(prompt: str) -> set</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">auto_memory</a>() -> Optional[bool]</code>
|
||||
@@ -70,6 +71,7 @@ Methods:
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">redo</a>() -> bool</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">refresh_tools</a>() -> List[Any]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">remove_mcp_server</a>(name: str) -> bool</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">remove_verification</a>(name: str) -> bool</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">retrieval_config</a>() -> Optional[Any]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">retrieve</a>(query: str, **kwargs) -> 'ContextPack'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">rules_manager</a>() -> Optional[Any]</code>
|
||||
@@ -238,10 +240,12 @@ from praisonaiagents import Memory, MemoryBackend, MemoryConfig
|
||||
|
||||
Methods:
|
||||
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">acommit_memory_batch</a>(writes: List[tuple]) -> None</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">build_cache_optimized_context</a>(task_descr: str, user_id: Optional[str] = None, additional: str = '', max_items: int = 3, include_cache_boundary: bool = True, include_in_output: Optional[bool] = None) -> Dict[str, str]</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">build_context_for_task</a>(task_descr: str, user_id: Optional[str] = None, additional: str = '', max_items: int = 3, include_in_output: Optional[bool] = None) -> str</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">calculate_quality_metrics</a>(output: str, expected_output: str, llm: Optional[str] = None, custom_prompt: Optional[str] = None) -> Dict[str, float]</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">close_connections</a>()</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">commit_memory_batch</a>(writes: List[tuple]) -> None</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">compute_quality_score</a>(completeness: float, relevance: float, clarity: float, accuracy: float, weights: Dict[str, float] = None) -> float</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">delete_long_term</a>(memory_id: str) -> bool</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">delete_memories</a>(memory_ids: List[str]) -> int</code>
|
||||
@@ -251,6 +255,7 @@ Methods:
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">finalize_task_output</a>(content: str, agent_name: str, quality_score: float, threshold: float = 0.7, metrics: Dict[str, Any] = None, task_id: str = None)</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">forget</a>(**kwargs) -> int</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">get_all_memories</a>() -> List[Dict[str, Any]]</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">get_context</a>(query: Optional[str] = None, **kwargs) -> str</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">get_learn_context</a>() -> str</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">learn</a>()</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">recall</a>(query: str, **kwargs) -> List[Dict[str, Any]]</code>
|
||||
@@ -744,7 +749,7 @@ Methods:
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_token_usage_summary</a>() -> Dict[str, Any]</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">has_state</a>(key: str) -> bool</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">increment_state</a>(key: str, amount: float = 1, default: float = 0) -> float</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">launch</a>(path: str = '/agents', port: int = 8000, host: str = '0.0.0.0', debug: bool = False, protocol: str = 'http')</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">launch</a>(path: str = '/agents', port: int = 8000, host: str = '127.0.0.1', debug: bool = False, protocol: str = 'http')</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">restore_session_state</a>(session_id: str) -> bool</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">run</a>(content = None, return_dict = False, **kwargs)</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">run_all_tasks</a>()</code>
|
||||
@@ -878,6 +883,7 @@ Methods:
|
||||
* <code title="cli">praisonai recipe list <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
|
||||
* <code title="cli">praisonai recipe optimize <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
|
||||
* <code title="cli">praisonai recipe run <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
|
||||
* <code title="cli">praisonai recipe runs <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
|
||||
* <code title="cli">praisonai recipe serve <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
|
||||
* <code title="cli">praisonai replay cleanup <a href="./src/praisonai/praisonai/cli/commands/replay.py">--help</a></code>
|
||||
* <code title="cli">praisonai replay context <a href="./src/praisonai/praisonai/cli/commands/replay.py">--help</a></code>
|
||||
@@ -894,7 +900,11 @@ Methods:
|
||||
* <code title="cli">praisonai schedule describe <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule list <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule logs <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule pause <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule remove <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule restart <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule resume <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule runs <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule schedule-callback <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule start <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule stats <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
@@ -903,6 +913,7 @@ Methods:
|
||||
* <code title="cli">praisonai schedule suggestion-dismiss <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule suggestion-propose <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule suggestions <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule update <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai validate check <a href="./src/praisonai/praisonai/cli/commands/validate.py">--help</a></code>
|
||||
* <code title="cli">praisonai validate schema <a href="./src/praisonai/praisonai/cli/commands/validate.py">--help</a></code>
|
||||
* <code title="cli">praisonai validate validate <a href="./src/praisonai/praisonai/cli/commands/validate.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.164" \
|
||||
"praisonai>=4.7.1" \
|
||||
"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.164" \
|
||||
"praisonai>=4.7.1" \
|
||||
"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.164" \
|
||||
"praisonai>=4.7.1" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
HOL Guard Tool Policy Example - PraisonAI Agents
|
||||
|
||||
Demonstrates using HOL Guard (an optional, external runtime security engine)
|
||||
at the existing `wrap_tool_call` middleware boundary to inspect command-bearing
|
||||
tool calls *before* they execute.
|
||||
|
||||
HOL Guard is installed and versioned separately. Its side-effect-free CLI can
|
||||
classify a shell command before execution:
|
||||
|
||||
hol-guard command test '<command>' --json
|
||||
|
||||
Fail-closed policy: the wrapped tool only runs when Guard returns an explicit
|
||||
allow/benign classification. Review/block/unknown verdicts, malformed output,
|
||||
timeouts, or CLI failures all short-circuit the call so the tool never runs.
|
||||
|
||||
pip install hol-guard # optional, external dependency
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from praisonaiagents import Agent, tool
|
||||
from praisonaiagents.hooks import wrap_tool_call, ToolRequest, ToolResponse
|
||||
|
||||
|
||||
COMMAND_BEARING_TOOLS = {"run_shell"}
|
||||
GUARD_TIMEOUT_SECONDS = 10
|
||||
|
||||
|
||||
@tool
|
||||
def run_shell(command: str) -> str:
|
||||
"""Run a shell command and return its output."""
|
||||
result = subprocess.run(
|
||||
command, shell=True, capture_output=True, text=True
|
||||
)
|
||||
return result.stdout or result.stderr
|
||||
|
||||
|
||||
def _inspect_with_hol_guard(command: str) -> bool:
|
||||
"""Return True only if HOL Guard explicitly allows the command.
|
||||
|
||||
Fails closed (returns False) on any ambiguity: missing CLI, non-zero exit,
|
||||
timeout, malformed JSON, or a non-allow verdict.
|
||||
"""
|
||||
if not shutil.which("hol-guard"):
|
||||
return False
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["hol-guard", "command", "test", command, "--json"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=GUARD_TIMEOUT_SECONDS,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
return False
|
||||
|
||||
if completed.returncode != 0:
|
||||
return False
|
||||
|
||||
try:
|
||||
verdict = json.loads(completed.stdout)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return False
|
||||
|
||||
if not isinstance(verdict, dict):
|
||||
return False
|
||||
|
||||
decision = str(verdict.get("decision", "")).lower()
|
||||
return decision in ("allow", "benign")
|
||||
|
||||
|
||||
@wrap_tool_call
|
||||
def hol_guard_tool_policy(request: ToolRequest, call_next):
|
||||
"""Gate command-bearing tools through HOL Guard before execution."""
|
||||
if request.tool_name not in COMMAND_BEARING_TOOLS:
|
||||
return call_next(request)
|
||||
|
||||
command = request.arguments.get("command", "")
|
||||
|
||||
if _inspect_with_hol_guard(command):
|
||||
return call_next(request)
|
||||
|
||||
blocked_message = "Blocked by HOL Guard: command was not explicitly allowed."
|
||||
# Surface the denial in `result` too: the agent tool executor returns
|
||||
# `ToolResponse.result` to the model and discards `error`, so a denial with
|
||||
# only `error` set would reach the model as a null result. Keeping `error`
|
||||
# populated preserves the signal for after_tool hooks and logging.
|
||||
return ToolResponse(
|
||||
tool_name=request.tool_name,
|
||||
result=blocked_message,
|
||||
error=blocked_message,
|
||||
context=request.context,
|
||||
)
|
||||
|
||||
|
||||
agent = Agent(
|
||||
name="GuardedShellBot",
|
||||
instructions="You run shell commands only when they are safe.",
|
||||
tools=[run_shell],
|
||||
hooks=[hol_guard_tool_policy],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("HOL Guard tool policy registered on 'run_shell'.")
|
||||
print("Commands are only executed when Guard returns an explicit allow.")
|
||||
print("\n✓ HOL Guard example complete")
|
||||
@@ -14,7 +14,7 @@ Expected Output:
|
||||
Agent responds with state persisted to Redis
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents import Agent, MemoryConfig
|
||||
from praisonaiagents.db import db
|
||||
|
||||
print("=== Redis State Store (Agent-First) ===")
|
||||
@@ -28,8 +28,7 @@ my_db = db(
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
instructions="You are a helpful assistant.",
|
||||
memory=my_db,
|
||||
session_id="redis-state-example"
|
||||
memory=MemoryConfig(db=my_db, session_id="redis-state-example"),
|
||||
)
|
||||
|
||||
# Chat - state is automatically managed via Redis
|
||||
|
||||
@@ -23,6 +23,7 @@ from praisonaiagents.hooks.verification import (
|
||||
VerificationResult,
|
||||
BaseVerificationHook,
|
||||
CommandVerificationHook,
|
||||
FileCheckHook,
|
||||
)
|
||||
|
||||
|
||||
@@ -100,7 +101,19 @@ def main():
|
||||
print(f" Name: {cmd_hook.name}")
|
||||
print(f" Command: {cmd_hook.command}")
|
||||
print(f" Timeout: {cmd_hook.timeout_seconds}s")
|
||||
|
||||
|
||||
# 6. FileCheckHook: declarative, no-shell completion gate
|
||||
print("\n6. FileCheckHook (declarative completion gate):")
|
||||
file_hook = FileCheckHook(
|
||||
name="changelog", path="CHANGELOG.md", non_empty=True
|
||||
)
|
||||
file_result = file_hook.run()
|
||||
print(f" {file_hook.name}: success={file_result.success} — {file_result.output}")
|
||||
print(
|
||||
" Gate wiring: when verification_hooks are configured, the autonomous "
|
||||
"loop cannot finalise with a success outcome while a blocking hook fails."
|
||||
)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("✓ Verification hooks example completed successfully!")
|
||||
print("=" * 60)
|
||||
|
||||
@@ -24,7 +24,7 @@ Examples for all PraisonAI server types and the unified endpoints CLI.
|
||||
| `praisonai serve unified --port 8765` | Start unified server |
|
||||
| `praisonai serve agents --file agents.yaml --port 8000` | Start agents API |
|
||||
| `praisonai serve recipe --port 8765` | Start recipe server |
|
||||
| `praisonai serve mcp --transport http --port 8080` | Start MCP server |
|
||||
| `praisonai mcp serve --transport http-stream --port 8080` | Start MCP server |
|
||||
| `praisonai serve tools --port 8081` | Start tools MCP server |
|
||||
| `praisonai serve a2a --port 8082` | Start A2A server |
|
||||
| `praisonai serve a2u --port 8083` | Start A2U server |
|
||||
|
||||
@@ -13,7 +13,7 @@ Usage:
|
||||
python mcp_http_server.py
|
||||
|
||||
# Or use CLI:
|
||||
praisonai serve mcp --transport http --port 8080
|
||||
praisonai mcp serve --transport http-stream --port 8080
|
||||
|
||||
# Test with curl:
|
||||
curl http://localhost:8080/mcp/tools
|
||||
|
||||
@@ -2,20 +2,25 @@
|
||||
#
|
||||
# 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.
|
||||
# `tools_run_on:` is the only line that differs from a normal local workflow.
|
||||
# Every step shares the same sandbox and working directory, so the file the
|
||||
# writer creates is there for the reader. Thinking and orchestration still run
|
||||
# on your machine, and the sandbox is torn down when the run finishes.
|
||||
#
|
||||
# Providers: docker | e2b | modal | daytona | flyio | tenki | local
|
||||
# Places: docker | e2b | modal | daytona | flyio | tenki | local
|
||||
# subprocess | sandlock | ssh | novita
|
||||
# - docker needs a local Docker daemon and nothing else
|
||||
# - e2b / modal / daytona / flyio need that provider's API key
|
||||
# - sandlock is a kernel-enforced local boundary (Linux 6.12+)
|
||||
# - subprocess is process separation only, NOT a security boundary
|
||||
#
|
||||
# `run_on:` is still accepted here as the old spelling for this key.
|
||||
#
|
||||
# See what is still running: praisonai managed ps
|
||||
# Reclaim strays: praisonai managed stop --all
|
||||
|
||||
name: remote-sandbox-demo
|
||||
run_on: docker
|
||||
tools_run_on: docker
|
||||
|
||||
agents:
|
||||
writer:
|
||||
|
||||
@@ -99,13 +99,12 @@ pip index versions praisonaiagents | head -1
|
||||
pip index versions praisonai | head -1
|
||||
```
|
||||
|
||||
13. Publish **praisonai-platform** (only when the advisory targets platform code):
|
||||
```bash
|
||||
cd /Users/praison/praisonai-package/src/praisonai-platform
|
||||
praisonai publish pypi
|
||||
# or: uv lock && uv build && uv publish --token $PYPI_TOKEN
|
||||
```
|
||||
Platform releases are **independent** of `praisonai` / `praisonaiagents` version numbers. A platform-only fix (e.g. `0.1.8`) does **not** require bumping the main SDK.
|
||||
13. **praisonai-platform** advisories are handled in a different repository:
|
||||
[MervinPraison/PraisonAI-Platform](https://github.com/MervinPraison/PraisonAI-Platform).
|
||||
It is not part of this monorepo and is not published from here. Triage, patch,
|
||||
release and advisory publication all happen in that repo, on its own semver line.
|
||||
Nothing in this repository imports it, so a platform advisory never requires an
|
||||
SDK release.
|
||||
|
||||
14. Publish **praisonai (npm / TypeScript)** (only when the advisory targets `src/praisonai-ts`):
|
||||
```bash
|
||||
@@ -113,9 +112,8 @@ cd /Users/praison/praisonai-package/src/praisonai-ts
|
||||
npm version patch && npm publish
|
||||
```
|
||||
|
||||
15. Verify platform / npm when applicable:
|
||||
15. Verify npm when applicable:
|
||||
```bash
|
||||
pip index versions praisonai-platform | head -1
|
||||
npm view praisonai version
|
||||
```
|
||||
|
||||
@@ -139,7 +137,7 @@ gh api repos/MervinPraison/PraisonAI/security-advisories/<GHSA_ID> \
|
||||
}
|
||||
EOF
|
||||
```
|
||||
- `<PACKAGE_NAME>`: `praisonaiagents` (core SDK), `praisonai` (Python wrapper), `praisonai-platform` (platform layer), or `praisonai` (npm ecosystem for TypeScript)
|
||||
- `<PACKAGE_NAME>`: `praisonaiagents` (core SDK), `praisonai` (Python wrapper), or `praisonai` (npm ecosystem for TypeScript)
|
||||
- `<LAST_VULNERABLE_VERSION>`: version before the fix
|
||||
- `<FIRST_PATCHED_VERSION>`: the version just published with the fix
|
||||
- Always include credits for the reporter
|
||||
@@ -190,8 +188,8 @@ After validating each advisory in source code, assign **one** label:
|
||||
| Package | Version line | Notes |
|
||||
|---|---|---|
|
||||
| `praisonaiagents` + `praisonai` | Aligned patch (e.g. `1.6.62` / `4.6.62`) | Main SDK + wrapper; bump together via `bump_and_release.py` |
|
||||
| `praisonai-platform` | Independent semver (e.g. `0.1.8`) | Platform-only fixes; users need **not** upgrade the main SDK |
|
||||
| `praisonai` (npm) | Independent semver (e.g. `1.7.2`) | TypeScript SDK; publish from `src/praisonai-ts` |
|
||||
| `praisonai-platform` | Independent semver, **separate repository** | Not released from this repo — see [PraisonAI-Platform](https://github.com/MervinPraison/PraisonAI-Platform) |
|
||||
|
||||
Always set the advisory `patched_versions` to the **package that actually contains the fix**. Do not imply users must upgrade all packages when only one changed.
|
||||
|
||||
@@ -217,9 +215,11 @@ Close (or publish advisory with no code change only if already mitigated) when:
|
||||
|---|---|---|---|
|
||||
| praisonaiagents | pip | src/praisonai-agents/praisonaiagents/ | tools/, mcp/, memory/, agent/, sandbox/ |
|
||||
| praisonai | pip | src/praisonai/praisonai/ | cli/, capabilities/, mcp_server/, sandbox/, recipe/ |
|
||||
| praisonai-platform | pip | src/praisonai-platform/praisonai_platform/ | api/routes/, services/, auth |
|
||||
| praisonai | npm | src/praisonai-ts/src/ | agent/, mcp/, tools/, code-mode/ |
|
||||
|
||||
`praisonai-platform` is **not audited from this repository** — it lives in
|
||||
[MervinPraison/PraisonAI-Platform](https://github.com/MervinPraison/PraisonAI-Platform).
|
||||
|
||||
## RESOURCES & DOCUMENTATION
|
||||
|
||||
- **Official Repository**: https://github.com/MervinPraison/PraisonAI — Source code, issue tracking, and contribution guidelines
|
||||
@@ -234,7 +234,7 @@ Close (or publish advisory with no code change only if already mitigated) when:
|
||||
- ALWAYS set patched_versions before publishing advisory
|
||||
- ALWAYS publish to PyPI/npm BEFORE publishing advisories (so Dependabot can point users to a fix)
|
||||
- Python SDK: `praisonai publish pypi` from `src/praisonai-agents`, then `bump_and_release.py` for the wrapper
|
||||
- Platform-only: `praisonai publish pypi` from `src/praisonai-platform` — no SDK bump required
|
||||
- Platform-only: released from the separate [PraisonAI-Platform](https://github.com/MervinPraison/PraisonAI-Platform) repo, never from here
|
||||
- npm: `npm publish` from `src/praisonai-ts` when the TypeScript SDK is affected
|
||||
- ALWAYS request CVE AFTER publishing advisory
|
||||
- Use generic commit messages: "refactor: harden X" not "fix: SQL injection in Y"
|
||||
|
||||
@@ -1074,7 +1074,7 @@ PraisonAI's reliability engineering rests on three pillars — **C**ontext, **H*
|
||||
> **Evaluator status.** The `ContextEvaluator` / `HarnessEvaluator` / `LoopEvaluator` classes named in the rubric are **planned follow-ups** (PA-CHL-001–004), not yet in the repo. Until they land, the equivalent checks are covered by existing modules:
|
||||
>
|
||||
> - **Context** — `context/tokens.py` and `eval/tokens.py` (`estimate_tokens` / `count_tokens`) for budget; `compaction/compactor.py` (`ContextCompactor`) for compaction/retention.
|
||||
> - **Harness** — `runtime/turn_context.py` for turn-context parity; `runtime/example_harness.py` and `tests/` for harness/artifact checks.
|
||||
> - **Harness** — `runtime/turn_context.py` for turn-context parity; `examples/runtime/example_harness.py` and `tests/` for harness/artifact checks.
|
||||
> - **Loop** — `eval/loop.py` (`EvaluationLoop`) and `agent/autonomy.py` for convergence and doom-loop guards.
|
||||
>
|
||||
> Keep terminology consistent with the `agent/context_agent.py` (`ContextAgent`) PRP methodology.
|
||||
|
||||
@@ -18,76 +18,76 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
| Category | Features | Docs | Lines |
|
||||
|----------|----------|------|-------|
|
||||
| ✅ AGUI | 1 | 2 | 420 |
|
||||
| ✅ Agent | 22 | 50 | 15813 |
|
||||
| ✅ Agent | 22 | 53 | 17053 |
|
||||
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2996 |
|
||||
| ✅ Agent-to-User (A2U) | 1 | 3 | 689 |
|
||||
| ✅ Approval | 1 | 5 | 2537 |
|
||||
| ✅ Approval | 1 | 5 | 2658 |
|
||||
| ✅ Audio | 2 | 12 | 1000 |
|
||||
| ✅ Auto Generation | 5 | 11 | 3274 |
|
||||
| ✅ Autonomy | 3 | 7 | 2756 |
|
||||
| ✅ Bots | 7 | 31 | 13366 |
|
||||
| ✅ Auto Generation | 5 | 11 | 3542 |
|
||||
| ✅ Autonomy | 3 | 7 | 2790 |
|
||||
| ✅ Bots | 7 | 31 | 13411 |
|
||||
| ✅ Budget | 1 | 1 | 287 |
|
||||
| ✅ CLI | 5 | 121 | 36059 |
|
||||
| ✅ Chunking | 2 | 2 | 385 |
|
||||
| ✅ CLI | 5 | 122 | 36734 |
|
||||
| ✅ Chunking | 2 | 2 | 420 |
|
||||
| ✅ Citations | 2 | 1 | 202 |
|
||||
| ✅ Code Execution | 2 | 14 | 4583 |
|
||||
| ✅ Conditions | 1 | 3 | 1179 |
|
||||
| ✅ Configuration | 3 | 8 | 3047 |
|
||||
| ✅ Context Management | 16 | 34 | 13396 |
|
||||
| ✅ Code Execution | 2 | 14 | 4855 |
|
||||
| ✅ Conditions | 1 | 3 | 1347 |
|
||||
| ✅ Configuration | 3 | 8 | 3139 |
|
||||
| ✅ Context Management | 16 | 36 | 13988 |
|
||||
| ✅ Deep Research | 8 | 2 | 587 |
|
||||
| ✅ Display | 6 | 3 | 741 |
|
||||
| ✅ Embeddings | 6 | 23 | 2049 |
|
||||
| ✅ Evaluation | 1 | 7 | 2905 |
|
||||
| ✅ Evaluation | 1 | 7 | 2920 |
|
||||
| ✅ Events | 1 | 2 | 749 |
|
||||
| ✅ Execution | 3 | 4 | 1562 |
|
||||
| ✅ Failover | 2 | 1 | 432 |
|
||||
| ✅ Execution | 3 | 4 | 1606 |
|
||||
| ✅ Failover | 2 | 1 | 452 |
|
||||
| ✅ Files | 2 | 7 | 2275 |
|
||||
| ✅ Flow | 1 | 3 | 788 |
|
||||
| ✅ Gateway | 7 | 74 | 27180 |
|
||||
| ✅ Guardrails | 4 | 4 | 2025 |
|
||||
| ✅ Handoffs | 11 | 6 | 2563 |
|
||||
| ✅ Hooks | 2 | 9 | 4434 |
|
||||
| ✅ Gateway | 7 | 81 | 29955 |
|
||||
| ✅ Guardrails | 4 | 4 | 2205 |
|
||||
| ✅ Handoffs | 11 | 6 | 2626 |
|
||||
| ✅ Hooks | 2 | 9 | 4509 |
|
||||
| ✅ Image | 1 | 11 | 1255 |
|
||||
| ✅ Knowledge | 4 | 13 | 4333 |
|
||||
| ✅ LLM | 3 | 15 | 5192 |
|
||||
| ✅ Loops | 4 | 5 | 1422 |
|
||||
| ✅ MCP | 1 | 61 | 13993 |
|
||||
| ✅ Memory | 6 | 18 | 7070 |
|
||||
| ✅ Knowledge | 4 | 16 | 4942 |
|
||||
| ✅ LLM | 3 | 15 | 5372 |
|
||||
| ✅ Loops | 4 | 5 | 1475 |
|
||||
| ✅ MCP | 1 | 61 | 14135 |
|
||||
| ✅ Memory | 6 | 18 | 7187 |
|
||||
| ✅ OCR | 2 | 1 | 237 |
|
||||
| ✅ Observability | 2 | 23 | 2814 |
|
||||
| ✅ Observability | 2 | 23 | 2811 |
|
||||
| ✅ Optimizer | 1 | 2 | 792 |
|
||||
| ✅ Output | 3 | 5 | 1242 |
|
||||
| ✅ Output | 3 | 5 | 1265 |
|
||||
| ✅ Parallel Execution | 3 | 2 | 497 |
|
||||
| ✅ Planning | 6 | 6 | 1549 |
|
||||
| ✅ Plugins | 8 | 4 | 2858 |
|
||||
| ✅ Prompts | 2 | 9 | 1965 |
|
||||
| ✅ Providers | 1 | 56 | 7743 |
|
||||
| ✅ Planning | 6 | 6 | 1605 |
|
||||
| ✅ Plugins | 8 | 5 | 3120 |
|
||||
| ✅ Prompts | 2 | 9 | 1971 |
|
||||
| ✅ Providers | 1 | 56 | 7781 |
|
||||
| ✅ Query | 1 | 2 | 736 |
|
||||
| ✅ RAG | 5 | 15 | 3003 |
|
||||
| ✅ RAG | 5 | 15 | 3118 |
|
||||
| ✅ Realtime | 2 | 5 | 668 |
|
||||
| ✅ Reflection | 3 | 3 | 680 |
|
||||
| ✅ Reflection | 3 | 3 | 730 |
|
||||
| ✅ Retrieval | 2 | 5 | 1054 |
|
||||
| ✅ Routing | 1 | 2 | 430 |
|
||||
| ✅ Sandbox | 5 | 7 | 3202 |
|
||||
| ✅ Security | 1 | 3 | 3332 |
|
||||
| ✅ Sessions | 4 | 18 | 7158 |
|
||||
| ✅ Sandbox | 5 | 7 | 3483 |
|
||||
| ✅ Security | 1 | 3 | 3482 |
|
||||
| ✅ Sessions | 4 | 19 | 7631 |
|
||||
| ✅ Skills | 6 | 15 | 5638 |
|
||||
| ✅ Tasks | 2 | 6 | 2819 |
|
||||
| ✅ Telemetry | 1 | 2 | 604 |
|
||||
| ✅ Tasks | 2 | 6 | 2897 |
|
||||
| ✅ Telemetry | 1 | 2 | 648 |
|
||||
| ✅ Templates | 1 | 8 | 1663 |
|
||||
| ✅ Tools | 12 | 141 | 41246 |
|
||||
| ✅ Tools | 12 | 144 | 43113 |
|
||||
| ✅ Tracing | 3 | 2 | 139 |
|
||||
| ✅ Vector Store | 1 | 12 | 1211 |
|
||||
| ✅ Vector Store | 1 | 12 | 1215 |
|
||||
| ✅ Video | 2 | 6 | 649 |
|
||||
| ✅ Vision | 2 | 1 | 329 |
|
||||
| ✅ Web | 3 | 9 | 2467 |
|
||||
| ✅ Workflows | 5 | 16 | 6593 |
|
||||
| ✅ Web | 3 | 9 | 2487 |
|
||||
| ✅ Workflows | 5 | 18 | 7406 |
|
||||
|
||||
## Documentation Without Features
|
||||
|
||||
These docs exist but don't match any implemented feature category:
|
||||
|
||||
- ℹ️ Database (42 docs, 6713 lines)
|
||||
- ℹ️ Database (42 docs, 6959 lines)
|
||||
- ℹ️ Documents (1 docs, 787 lines)
|
||||
|
||||
---
|
||||
|
||||
+6
-6
@@ -14,16 +14,16 @@ import logging
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
from .turn_context import (
|
||||
from praisonaiagents.runtime.turn_context import (
|
||||
PreparedTurnContext,
|
||||
TurnRuntimeProtocol,
|
||||
RuntimeMode,
|
||||
)
|
||||
from .context_builder import default_context_builder
|
||||
from ..streaming.events import StreamEvent, StreamEventType
|
||||
from praisonaiagents.runtime.protocols import TurnRuntimeProtocol
|
||||
from praisonaiagents.runtime.context_builder import default_context_builder
|
||||
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..agent.protocols import AgentProtocol
|
||||
from praisonaiagents.agent.protocols import AgentProtocol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -363,4 +363,4 @@ async def demonstrate_harness_integration():
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run the demonstration
|
||||
asyncio.run(demonstrate_harness_integration())
|
||||
asyncio.run(demonstrate_harness_integration())
|
||||
@@ -92,20 +92,6 @@ from ._lazy import lazy_import, create_lazy_getattr_with_fallback
|
||||
# Thread-safe cache for lazy-loaded values
|
||||
_lazy_cache = {}
|
||||
|
||||
# Backward compatibility: _get_lazy_cache function for tests
|
||||
import threading
|
||||
_lazy_cache_local = threading.local()
|
||||
|
||||
def _get_lazy_cache():
|
||||
"""Get thread-local lazy cache dict. Thread-safe for concurrent access.
|
||||
|
||||
Note: This is kept for backward compatibility with tests.
|
||||
The main lazy loading now uses the centralized _lazy.py utility.
|
||||
"""
|
||||
if not hasattr(_lazy_cache_local, 'cache'):
|
||||
_lazy_cache_local.cache = {}
|
||||
return _lazy_cache_local.cache
|
||||
|
||||
# ============================================================================
|
||||
# LAZY IMPORT MAPPING
|
||||
# ============================================================================
|
||||
|
||||
@@ -27,7 +27,7 @@ SUPPRESSED_PATTERNS = [
|
||||
"The `dict` method is deprecated; use `model_dump` instead",
|
||||
"Pydantic serializer warnings",
|
||||
"PydanticSerializationUnexpectedValue",
|
||||
"Expected", # Catches all "Expected N fields but got M" patterns
|
||||
"fields but got", # litellm/pydantic "Expected N fields but got M"
|
||||
"Expected `StreamingChoices` but got `Choices`",
|
||||
"serialized value may not be as expected",
|
||||
"Mixing V1 models and V2 models",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -136,9 +136,8 @@ class AudioAgent:
|
||||
- AudioConfig: Full configuration object
|
||||
verbose: Verbosity level for output
|
||||
"""
|
||||
# Handle model= alias
|
||||
if llm is None and model is not None:
|
||||
llm = model
|
||||
from ..utils.model_alias import resolve_model_name
|
||||
llm = resolve_model_name(llm, model, "AudioAgent")
|
||||
|
||||
self.name = name or "AudioAgent"
|
||||
self.instructions = instructions
|
||||
|
||||
@@ -217,6 +217,13 @@ class AutonomyConfig:
|
||||
f"Invalid autonomy level: {level!r}. "
|
||||
f"Must be one of {sorted(VALID_AUTONOMY_LEVELS)}"
|
||||
)
|
||||
verification_hooks = data.get("verification_hooks")
|
||||
# Config parity: accept declarative `verification:` entries and map
|
||||
# `command:`/`file:` shapes onto the two hook classes.
|
||||
declarative = data.get("verification")
|
||||
if declarative:
|
||||
built = _build_verification_hooks(declarative)
|
||||
verification_hooks = (verification_hooks or []) + built
|
||||
return cls(
|
||||
enabled=data.get("enabled", True),
|
||||
level=level,
|
||||
@@ -227,7 +234,7 @@ class AutonomyConfig:
|
||||
observe=data.get("observe", False),
|
||||
completion_promise=data.get("completion_promise"),
|
||||
clear_context=data.get("clear_context", False),
|
||||
verification_hooks=data.get("verification_hooks"),
|
||||
verification_hooks=verification_hooks,
|
||||
track_changes=data.get("track_changes"),
|
||||
sandbox=data.get("sandbox"),
|
||||
snapshot_dir=data.get("snapshot_dir"),
|
||||
@@ -237,6 +244,49 @@ class AutonomyConfig:
|
||||
budget_action=data.get("budget_action", "pause"),
|
||||
)
|
||||
|
||||
|
||||
def _build_verification_hooks(entries: List[Any]) -> List[Any]:
|
||||
"""Map declarative ``verification:`` config entries onto hook instances.
|
||||
|
||||
Each entry is a dict with either a ``command`` key (→
|
||||
:class:`CommandVerificationHook`) or a ``file`` key (→
|
||||
:class:`FileCheckHook`). Unknown shapes are skipped.
|
||||
|
||||
Example::
|
||||
|
||||
verification:
|
||||
- {name: tests, command: "python -m pytest -q"}
|
||||
- {name: changelog, file: CHANGELOG.md, non_empty: true}
|
||||
"""
|
||||
from ..hooks.verification import CommandVerificationHook, FileCheckHook
|
||||
|
||||
hooks: List[Any] = []
|
||||
for entry in entries or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
blocking = entry.get("blocking", True)
|
||||
if "command" in entry:
|
||||
hooks.append(CommandVerificationHook(
|
||||
name=entry.get("name", "command"),
|
||||
command=entry["command"],
|
||||
cwd=entry.get("cwd"),
|
||||
timeout=entry.get("timeout", 60.0),
|
||||
blocking=blocking,
|
||||
))
|
||||
elif "file" in entry:
|
||||
hooks.append(FileCheckHook(
|
||||
name=entry.get("name", "file"),
|
||||
path=entry["file"],
|
||||
exists=entry.get("exists", True),
|
||||
non_empty=entry.get("non_empty", False),
|
||||
contains=entry.get("contains"),
|
||||
json_field=entry.get("json_field"),
|
||||
equals=entry.get("equals"),
|
||||
blocking=blocking,
|
||||
))
|
||||
return hooks
|
||||
|
||||
|
||||
class AutonomySignal(str, Enum):
|
||||
"""Signals detected from prompts for autonomy decisions.
|
||||
|
||||
|
||||
@@ -1211,7 +1211,15 @@ Your Goal: {self.goal}"""
|
||||
return content
|
||||
|
||||
def _extract_llm_response_content(self, response) -> Optional[str]:
|
||||
"""Return assistant message text, a tool-call summary, or str(response) as fallback."""
|
||||
"""Return assistant message text, or a tool-call summary.
|
||||
|
||||
A well-formed response that simply carries no content -- a content filter,
|
||||
a refusal, or ``finish_reason="length"`` with zero output tokens -- yields
|
||||
the empty string. ``str(response)`` is reserved for responses this method
|
||||
could not parse at all, where the repr is a diagnostic rather than an
|
||||
answer; returning it for a merely empty message put the raw SDK object's
|
||||
repr in front of the user and into ``chat_history``.
|
||||
"""
|
||||
if not response:
|
||||
return None
|
||||
try:
|
||||
@@ -1226,6 +1234,17 @@ Your Goal: {self.goal}"""
|
||||
if tool_calls:
|
||||
names = [getattr(tc.function, "name", "?") for tc in tool_calls]
|
||||
return f"[tool_calls: {', '.join(names)}]"
|
||||
# Parsed cleanly; the model returned no content. A refusal
|
||||
# (safety) is carried in ``message.refusal`` independently of
|
||||
# ``finish_reason`` -- surface it so an empty answer isn't silent.
|
||||
finish_reason = getattr(choice, "finish_reason", None)
|
||||
refusal = getattr(msg, "refusal", None)
|
||||
if refusal or finish_reason not in (None, "stop"):
|
||||
logging.warning(
|
||||
f"Agent {self.name}: model returned no content "
|
||||
f"(finish_reason={finish_reason!r}, refused={bool(refusal)})"
|
||||
)
|
||||
return ""
|
||||
except (AttributeError, IndexError, TypeError) as e:
|
||||
logging.warning(
|
||||
f"Failed to extract LLM response content (falling back to str): {e}"
|
||||
@@ -1725,7 +1744,7 @@ Your Goal: {self.goal}"""
|
||||
return max_retries
|
||||
return 2
|
||||
|
||||
def _chat_completion(self, messages, temperature=1.0, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, _retry_depth=0, _fallback_index=0):
|
||||
def _chat_completion(self, messages, temperature=None, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, _retry_depth=0, _fallback_index=0):
|
||||
start_time = time.time()
|
||||
|
||||
# --- Proactive Context Budget Management (default-on) ---
|
||||
@@ -1849,6 +1868,11 @@ Your Goal: {self.goal}"""
|
||||
stream = False # Set for the main execution below
|
||||
else:
|
||||
raise # Re-raise if it's a different ValueError
|
||||
except ToolExecutionError:
|
||||
# A tool failed during the streaming attempt. Re-raise so it is
|
||||
# not relabelled as an LLM error and so the tool is not executed
|
||||
# a second time by the non-streaming fallback below.
|
||||
raise
|
||||
except Exception as e:
|
||||
from ..errors import LLMError
|
||||
# Don't retry if it's an LLMError that has exhausted retries
|
||||
@@ -1953,6 +1977,8 @@ Your Goal: {self.goal}"""
|
||||
|
||||
except BudgetExceededError:
|
||||
raise
|
||||
except ToolExecutionError:
|
||||
raise
|
||||
except Exception as e:
|
||||
from ..errors import LLMError
|
||||
|
||||
@@ -2119,7 +2145,7 @@ Your Goal: {self.goal}"""
|
||||
self,
|
||||
exc: Exception,
|
||||
messages,
|
||||
temperature=1.0,
|
||||
temperature=None,
|
||||
tools=None,
|
||||
stream=True,
|
||||
reasoning_steps=False,
|
||||
@@ -2309,7 +2335,7 @@ Your Goal: {self.goal}"""
|
||||
def _execute_unified_chat_completion(
|
||||
self,
|
||||
messages,
|
||||
temperature=1.0,
|
||||
temperature=None,
|
||||
tools=None,
|
||||
stream=None,
|
||||
reasoning_steps=False,
|
||||
@@ -2443,7 +2469,7 @@ Your Goal: {self.goal}"""
|
||||
async def _execute_unified_achat_completion(
|
||||
self,
|
||||
messages,
|
||||
temperature=1.0,
|
||||
temperature=None,
|
||||
tools=None,
|
||||
stream=True, # Async methods keep stream=True default (async adapters support streaming vs sync smart fallback)
|
||||
reasoning_steps=False,
|
||||
@@ -2781,7 +2807,11 @@ Your Goal: {self.goal}"""
|
||||
|
||||
model_name = self.llm if isinstance(self.llm, str) else "gpt-4o-mini"
|
||||
model_limit = get_model_limit(model_name)
|
||||
current_tokens = estimate_messages_tokens(optimized)
|
||||
# Count the whole request (history + system prompt + tool schemas),
|
||||
# otherwise a large prompt/tool block can push the call over the
|
||||
# window while the history alone stays under the threshold.
|
||||
overhead = self._estimate_request_overhead_tokens(system_prompt, tools)
|
||||
current_tokens = estimate_messages_tokens(optimized) + overhead
|
||||
|
||||
# If over 95% of limit, apply emergency truncation
|
||||
if current_tokens > model_limit * 0.95:
|
||||
@@ -2789,20 +2819,67 @@ Your Goal: {self.goal}"""
|
||||
f"[{self.name}] Context at {current_tokens} tokens (limit: {model_limit}), "
|
||||
f"applying emergency truncation"
|
||||
)
|
||||
target = int(model_limit * 0.8) # Target 80% of limit
|
||||
# Leave room for the fixed overhead so the full request lands
|
||||
# near 80% of the window rather than the history alone.
|
||||
target = max(1, int(model_limit * 0.8) - overhead)
|
||||
optimized = self.context_manager.emergency_truncate(optimized, target)
|
||||
result["emergency_truncated"] = True
|
||||
result["tokens_after"] = estimate_messages_tokens(optimized)
|
||||
result["tokens_after"] = estimate_messages_tokens(optimized) + overhead
|
||||
except Exception as e:
|
||||
logging.debug(f"Hard limit check skipped: {e}")
|
||||
|
||||
return optimized, result
|
||||
|
||||
except Exception as e:
|
||||
# Context management should never break the chat flow
|
||||
logging.warning(f"Context management error (continuing without): {e}")
|
||||
# Context management should never break the chat flow, but silently
|
||||
# returning the unchanged history disables the hard-limit truncation
|
||||
# too and sends an over-budget request. Fall back to emergency
|
||||
# truncation so we still fit within the model window.
|
||||
logging.error(f"Context management failed: {e}")
|
||||
try:
|
||||
from ..context.budgeter import get_model_limit
|
||||
from ..context.tokens import estimate_messages_tokens
|
||||
|
||||
model_name = self.llm if isinstance(self.llm, str) else "gpt-4o-mini"
|
||||
model_limit = get_model_limit(model_name)
|
||||
# Include the system prompt and tool schemas in the budget: the
|
||||
# request can exceed the window on those alone even when the
|
||||
# history is under the threshold.
|
||||
overhead = self._estimate_request_overhead_tokens(system_prompt, tools)
|
||||
if estimate_messages_tokens(messages) + overhead > model_limit * 0.95:
|
||||
target = max(1, int(model_limit * 0.8) - overhead)
|
||||
truncated = self.context_manager.emergency_truncate(messages, target)
|
||||
return truncated, {"emergency_truncated": True}
|
||||
except Exception as inner:
|
||||
logging.debug(f"Emergency truncation fallback skipped: {inner}")
|
||||
return messages, None
|
||||
|
||||
def _estimate_request_overhead_tokens(
|
||||
self,
|
||||
system_prompt: str = "",
|
||||
tools: Optional[list] = None,
|
||||
) -> int:
|
||||
"""Estimate non-history request tokens (system prompt + tool schemas).
|
||||
|
||||
Emergency-truncation thresholds must budget the full request, not just
|
||||
the message history, or a large system prompt / tool block can push the
|
||||
call over the model window undetected.
|
||||
"""
|
||||
try:
|
||||
from ..context.tokens import (
|
||||
estimate_tokens_heuristic,
|
||||
estimate_tool_schema_tokens,
|
||||
)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
overhead = 0
|
||||
if system_prompt:
|
||||
overhead += estimate_tokens_heuristic(system_prompt)
|
||||
if tools:
|
||||
overhead += estimate_tool_schema_tokens(tools)
|
||||
return overhead
|
||||
|
||||
def _truncate_tool_output(self, tool_name: str, output: str, tool_call_id: str | None = None) -> str:
|
||||
"""
|
||||
Truncate tool output according to configured budget.
|
||||
@@ -2888,7 +2965,7 @@ Your Goal: {self.goal}"""
|
||||
)
|
||||
return rendered
|
||||
|
||||
def chat(self, prompt: str, temperature: float = 1.0, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None, seed: Optional[int] = None, cancel_token: Optional[Any] = None) -> Optional[str]:
|
||||
def chat(self, prompt: str, temperature: Optional[float] = None, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None, seed: Optional[int] = None, cancel_token: Optional[Any] = None) -> Optional[str]:
|
||||
"""
|
||||
Chat with the agent.
|
||||
|
||||
@@ -2901,6 +2978,14 @@ Your Goal: {self.goal}"""
|
||||
'required' forces the LLM to call a tool before responding.
|
||||
...other args...
|
||||
"""
|
||||
# Tools go to their sandbox before the first model call, once per
|
||||
# agent. Placed on chat/achat/_start_stream rather than run/start
|
||||
# because AgentTeam and AgentFlow call these three directly -- an
|
||||
# agent used inside a team would otherwise run its tools locally.
|
||||
if getattr(self, 'tools_run_on', None) is not None:
|
||||
from .tools_placement import ensure_tools_placed
|
||||
ensure_tools_placed(self)
|
||||
|
||||
# Slash-command invocation: /skill-name [args] renders the skill
|
||||
# body before any backend/LLM call.
|
||||
prompt = self._resolve_skill_invocation(prompt)
|
||||
@@ -3490,11 +3575,14 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
reflection_output = _get_display_functions()['ReflectionOutput'](**reflection_data)
|
||||
else:
|
||||
# Use OpenAI's structured output for OpenAI models
|
||||
_reflection_kwargs = {}
|
||||
if temperature is not None:
|
||||
_reflection_kwargs['temperature'] = temperature
|
||||
reflection_response = self._openai_client.sync_client.beta.chat.completions.parse(
|
||||
model=self.reflect_llm if self.reflect_llm else self.llm,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
response_format=_get_display_functions()['ReflectionOutput']
|
||||
response_format=_get_display_functions()['ReflectionOutput'],
|
||||
**_reflection_kwargs
|
||||
)
|
||||
|
||||
reflection_output = reflection_response.choices[0].message.parsed
|
||||
@@ -3559,6 +3647,22 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
logging.error("Reflection parsing failed.", exc_info=True)
|
||||
messages.append({"role": "assistant", "content": "Self Reflection failed."})
|
||||
reflection_count += 1
|
||||
if reflection_count >= self.max_reflect:
|
||||
# Bound the retry, as the async path does. Without this
|
||||
# a persistently unparseable reflection (e.g. a
|
||||
# structured-output refusal, where message.parsed is
|
||||
# None) loops forever, two LLM calls at a time.
|
||||
if self.verbose:
|
||||
_get_display_functions()['display_self_reflection']("Maximum reflection count reached after repeated parse errors, returning current response", console=self.console)
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
except Exception as guard_e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed after reflection error: {guard_e}")
|
||||
self._rollback_chat_history_to(chat_history_length)
|
||||
return None
|
||||
continue # Continue even after error to try again
|
||||
except Exception:
|
||||
# Catch any exception from the inner try block and re-raise to outer handler
|
||||
@@ -3578,7 +3682,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
return clean_triple_backticks(output)
|
||||
|
||||
async def achat(self, prompt: str, temperature: float = 1.0, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None, seed: Optional[int] = None, cancel_token: Optional[Any] = None):
|
||||
async def achat(self, prompt: str, temperature: Optional[float] = None, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None, seed: Optional[int] = None, cancel_token: Optional[Any] = None):
|
||||
"""Async version of chat method with self-reflection support.
|
||||
|
||||
Args:
|
||||
@@ -3586,6 +3690,14 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
attachments: Optional list of image/file paths that are ephemeral
|
||||
(used for THIS turn only, NEVER stored in history).
|
||||
"""
|
||||
# Tools go to their sandbox before the first model call, once per
|
||||
# agent. Placed on chat/achat/_start_stream rather than run/start
|
||||
# because AgentTeam and AgentFlow call these three directly -- an
|
||||
# agent used inside a team would otherwise run its tools locally.
|
||||
if getattr(self, 'tools_run_on', None) is not None:
|
||||
from .tools_placement import ensure_tools_placed
|
||||
ensure_tools_placed(self)
|
||||
|
||||
# Slash-command invocation: /skill-name [args] renders the skill body.
|
||||
prompt = self._resolve_skill_invocation(prompt)
|
||||
|
||||
@@ -4131,11 +4243,14 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
self._rollback_chat_history_to(chat_history_length)
|
||||
return None
|
||||
|
||||
_areflection_kwargs = {}
|
||||
if temperature is not None:
|
||||
_areflection_kwargs['temperature'] = temperature
|
||||
reflection_response = await self._openai_client.async_client.beta.chat.completions.parse(
|
||||
model=self.reflect_llm if self.reflect_llm else self.llm,
|
||||
messages=reflection_messages,
|
||||
temperature=temperature,
|
||||
response_format=_get_display_functions()['ReflectionOutput']
|
||||
response_format=_get_display_functions()['ReflectionOutput'],
|
||||
**_areflection_kwargs
|
||||
)
|
||||
|
||||
reflection_output = reflection_response.choices[0].message.parsed
|
||||
@@ -4249,9 +4364,10 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
tool_call_kwargs = dict(
|
||||
model=self.llm,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
tools=formatted_tools,
|
||||
)
|
||||
if temperature is not None:
|
||||
tool_call_kwargs['temperature'] = temperature
|
||||
if effective_tool_choice:
|
||||
tool_call_kwargs['tool_choice'] = effective_tool_choice
|
||||
response = await self._openai_client.async_client.chat.completions.create(
|
||||
@@ -4265,11 +4381,14 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
self._execute_callback_and_display(original_prompt, result, time.time() - start_time, task_name, task_description, task_id)
|
||||
return await self._atrigger_after_agent_hook(original_prompt, result, start_time)
|
||||
elif output_json or output_pydantic:
|
||||
_json_kwargs = {}
|
||||
if temperature is not None:
|
||||
_json_kwargs['temperature'] = temperature
|
||||
response = await self._openai_client.async_client.chat.completions.create(
|
||||
model=self.llm,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
response_format={"type": "json_object"}
|
||||
response_format={"type": "json_object"},
|
||||
**_json_kwargs
|
||||
)
|
||||
response_text = response.choices[0].message.content
|
||||
if get_logger().getEffectiveLevel() == logging.DEBUG:
|
||||
@@ -4279,10 +4398,13 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
self._execute_callback_and_display(original_prompt, response_text, time.time() - start_time, task_name, task_description, task_id)
|
||||
return await self._atrigger_after_agent_hook(original_prompt, response_text, start_time)
|
||||
else:
|
||||
_plain_kwargs = {}
|
||||
if temperature is not None:
|
||||
_plain_kwargs['temperature'] = temperature
|
||||
response = await self._openai_client.async_client.chat.completions.create(
|
||||
model=self.llm,
|
||||
messages=messages,
|
||||
temperature=temperature
|
||||
**_plain_kwargs
|
||||
)
|
||||
|
||||
response_text = response.choices[0].message.content
|
||||
@@ -4319,11 +4441,14 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
logging.debug(f"Agent.achat completed in {total_time:.2f} seconds")
|
||||
return await self._atrigger_after_agent_hook(original_prompt, response_text, start_time)
|
||||
|
||||
_areflection_kwargs = {}
|
||||
if temperature is not None:
|
||||
_areflection_kwargs['temperature'] = temperature
|
||||
reflection_response = await self._openai_client.async_client.beta.chat.completions.parse(
|
||||
model=self.reflect_llm if self.reflect_llm else self.llm,
|
||||
messages=reflection_messages,
|
||||
temperature=temperature,
|
||||
response_format=_get_display_functions()['ReflectionOutput']
|
||||
response_format=_get_display_functions()['ReflectionOutput'],
|
||||
**_areflection_kwargs
|
||||
)
|
||||
|
||||
reflection_output = reflection_response.choices[0].message.parsed
|
||||
@@ -4349,10 +4474,13 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
{"role": "user", "content": "Now regenerate your response using the reflection you made"}
|
||||
]
|
||||
|
||||
_regen_kwargs = {}
|
||||
if temperature is not None:
|
||||
_regen_kwargs['temperature'] = temperature
|
||||
new_response = await self._openai_client.async_client.chat.completions.create(
|
||||
model=self.llm,
|
||||
messages=regenerate_messages,
|
||||
temperature=temperature
|
||||
**_regen_kwargs
|
||||
)
|
||||
response_text = new_response.choices[0].message.content
|
||||
reflection_count += 1
|
||||
@@ -4439,7 +4567,12 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# after-context aggregation) are fired once, guarded, inside
|
||||
# execute_tool_async — no inline duplicate dispatch here.
|
||||
# Pass the tools list to honor task-scoped tools
|
||||
result = await self.execute_tool_async(function_name, arguments, tools_override=tools)
|
||||
result = await self.execute_tool_async(
|
||||
function_name,
|
||||
arguments,
|
||||
tool_call_id=getattr(tool_call, "id", None),
|
||||
tools_override=tools,
|
||||
)
|
||||
|
||||
results.append(result)
|
||||
except Exception as e:
|
||||
@@ -4540,6 +4673,14 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
def _start_stream(self, prompt: str, **kwargs) -> Generator[str, None, None]:
|
||||
"""Own the durable context for the complete streaming generator."""
|
||||
# Tools go to their sandbox before the first model call, once per
|
||||
# agent. Placed on chat/achat/_start_stream rather than run/start
|
||||
# because AgentTeam and AgentFlow call these three directly -- an
|
||||
# agent used inside a team would otherwise run its tools locally.
|
||||
if getattr(self, 'tools_run_on', None) is not None:
|
||||
from .tools_placement import ensure_tools_placed
|
||||
ensure_tools_placed(self)
|
||||
|
||||
durable_context = None
|
||||
durable_token = None
|
||||
try:
|
||||
@@ -5227,7 +5368,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
agent_id=getattr(self, 'name', None),
|
||||
)
|
||||
|
||||
def _chat_completion_with_retry(self, messages, temperature=1.0, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, stream_callback=None, emit_events=True):
|
||||
def _chat_completion_with_retry(self, messages, temperature=None, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, stream_callback=None, emit_events=True):
|
||||
"""
|
||||
Wrapper for _execute_unified_chat_completion that adds jittered exponential backoff retry logic.
|
||||
|
||||
@@ -5314,7 +5455,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# This should never be reached, but just in case
|
||||
raise RuntimeError("Retry loop completed without returning or raising an exception")
|
||||
|
||||
async def _achat_completion_with_retry(self, messages, temperature=1.0, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, stream_callback=None, emit_events=True):
|
||||
async def _achat_completion_with_retry(self, messages, temperature=None, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, stream_callback=None, emit_events=True):
|
||||
"""
|
||||
Async wrapper for _execute_unified_achat_completion that adds jittered exponential backoff retry logic.
|
||||
|
||||
|
||||
@@ -59,7 +59,8 @@ class CodeAgent:
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "CodeAgent",
|
||||
llm: Optional[str] = None,
|
||||
llm: Optional[str] = None, # Deprecated alias for model=
|
||||
model: Optional[str] = None,
|
||||
code: Optional[Union[bool, Dict, CodeConfig]] = None,
|
||||
instructions: Optional[str] = None,
|
||||
verbose: bool = True,
|
||||
@@ -69,14 +70,16 @@ class CodeAgent:
|
||||
|
||||
Args:
|
||||
name: Agent name
|
||||
llm: LLM model (default: gpt-4o-mini)
|
||||
llm: Deprecated alias for model=. Passing both raises TypeError.
|
||||
model: Model to use (default: gpt-4o-mini)
|
||||
code: Code configuration (bool, dict, or CodeConfig)
|
||||
instructions: System instructions
|
||||
verbose: Enable verbose output
|
||||
**kwargs: Additional arguments
|
||||
"""
|
||||
from ..utils.model_alias import resolve_model_name
|
||||
self.name = name
|
||||
self.llm = llm or "gpt-4o-mini"
|
||||
self.llm = resolve_model_name(llm, model, "CodeAgent") or "gpt-4o-mini"
|
||||
self.instructions = instructions
|
||||
self.verbose = verbose
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from ..runtime.journal import (
|
||||
KIND_MODEL_DECISION,
|
||||
KIND_TOOL_CALL,
|
||||
KIND_TOOL_RESULT,
|
||||
KIND_VERIFICATION,
|
||||
RunJournal,
|
||||
)
|
||||
|
||||
@@ -58,6 +59,85 @@ def _accepts_idempotency_key(execute_tool_fn: Callable) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _normalize_restart_safe(value: Any) -> Optional[bool]:
|
||||
"""Accept only genuine booleans as a restart-safety declaration.
|
||||
|
||||
``None`` means undeclared. Any other type (e.g. the string ``"False"``) is a
|
||||
malformed declaration and is treated as unsafe so we fail closed rather than
|
||||
coercing a truthy value into an unintended replay.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return False
|
||||
|
||||
|
||||
def _canonical_tool_name(name: Any) -> str:
|
||||
"""Normalise a tool name for tolerant comparison across repair variants.
|
||||
|
||||
``execute_tool`` may repair case/separator variants (e.g. ``Send-Email`` →
|
||||
``send_email``); the declaration lookup mirrors that so an explicit
|
||||
``restart_safe`` declaration is not missed for a repaired name.
|
||||
"""
|
||||
return "".join(ch for ch in str(name).lower() if ch.isalnum())
|
||||
|
||||
|
||||
def _declared_restart_safe(
|
||||
function_name: str, executor: Optional[Callable]
|
||||
) -> Optional[bool]:
|
||||
"""Return an explicit ``restart_safe`` declaration for ``function_name``.
|
||||
|
||||
Resolves the tool object first from the executor's owning agent (its live
|
||||
``tools``), then from the global tool registry, and reads its
|
||||
``restart_safe`` attribute. Returns ``None`` when the tool is undeclared or
|
||||
cannot be resolved.
|
||||
"""
|
||||
if not function_name:
|
||||
return False
|
||||
target = _canonical_tool_name(function_name)
|
||||
agent = getattr(executor, "__self__", None)
|
||||
tools = getattr(agent, "tools", None)
|
||||
if isinstance(tools, (list, tuple)):
|
||||
for tool in tools:
|
||||
name = getattr(tool, "name", None) or getattr(tool, "__name__", None)
|
||||
if (
|
||||
name is not None
|
||||
and _canonical_tool_name(name) == target
|
||||
and getattr(tool, "restart_safe", None) is not None
|
||||
):
|
||||
return _normalize_restart_safe(tool.restart_safe)
|
||||
try:
|
||||
from ..tools import get_registry as _get_tool_registry
|
||||
|
||||
tool = _get_tool_registry().get(function_name)
|
||||
except Exception: # pragma: no cover - registry lookup is best-effort
|
||||
tool = None
|
||||
declared = getattr(tool, "restart_safe", None) if tool is not None else None
|
||||
return _normalize_restart_safe(declared)
|
||||
|
||||
|
||||
def _is_restart_safe(function_name: str, executor: Optional[Callable] = None) -> bool:
|
||||
"""Resolve whether a tool is safe to re-run for an in-flight durable resume.
|
||||
|
||||
An explicit ``restart_safe`` declaration on the tool contract wins. When the
|
||||
tool is undeclared (or cannot be resolved), fall back to the read-only name
|
||||
heuristic and, ultimately, fail closed — an effectful/unknown tool is *not*
|
||||
replayed so a missing declaration never causes a duplicated side effect.
|
||||
"""
|
||||
if not function_name:
|
||||
return False
|
||||
declared = _declared_restart_safe(function_name, executor)
|
||||
if declared is not None:
|
||||
return declared
|
||||
try:
|
||||
from .tool_execution import ToolExecutionMixin
|
||||
|
||||
return bool(ToolExecutionMixin._is_read_only_tool(function_name))
|
||||
except Exception: # pragma: no cover - heuristic import is best-effort
|
||||
return False
|
||||
|
||||
|
||||
class DurableRunContext:
|
||||
"""Per-turn journal state; safe for parallel tool-call wrappers."""
|
||||
|
||||
@@ -80,6 +160,10 @@ class DurableRunContext:
|
||||
self._results_by_tool_call_id: Dict[str, Any] = {}
|
||||
self._pending_by_call_id: Dict[str, Tuple[int, str, Dict[str, Any], str]] = {}
|
||||
self._pending_by_fingerprint: Dict[str, Tuple[int, str, Dict[str, Any], str]] = {}
|
||||
# Names of in-flight tool calls (journalled tool_call with no tool_result
|
||||
# at the crash). Used only when replaying to gate replay by restart-safety.
|
||||
self._inflight_names_by_call_id: Dict[str, str] = {}
|
||||
self._inflight_names_by_fingerprint: Dict[str, str] = {}
|
||||
self._recorded_iterations = set()
|
||||
for event in events:
|
||||
if event.kind == KIND_TOOL_RESULT:
|
||||
@@ -106,8 +190,11 @@ class DurableRunContext:
|
||||
or f"{self.run_id}:{event.seq}:{name}"
|
||||
)
|
||||
pending = (event.seq, call_id, args, key)
|
||||
fingerprint = self._fingerprint(name, args)
|
||||
self._pending_by_call_id[call_id] = pending
|
||||
self._pending_by_fingerprint[self._fingerprint(name, args)] = pending
|
||||
self._pending_by_fingerprint[fingerprint] = pending
|
||||
self._inflight_names_by_call_id[call_id] = name
|
||||
self._inflight_names_by_fingerprint[fingerprint] = name
|
||||
|
||||
self._resume_completed_steps = len(self._recorded_iterations)
|
||||
|
||||
@@ -119,6 +206,44 @@ class DurableRunContext:
|
||||
def _fingerprint(function_name: str, arguments: Dict[str, Any]) -> str:
|
||||
return f"{function_name}:{json.dumps(_json_safe(arguments), sort_keys=True)}"
|
||||
|
||||
def _is_inflight_call(
|
||||
self,
|
||||
function_name: str,
|
||||
arguments: Dict[str, Any],
|
||||
tool_call_id: Optional[str],
|
||||
) -> bool:
|
||||
"""Whether this call was in-flight (journalled, no result) at the crash.
|
||||
|
||||
Only meaningful while replaying: such calls have a recorded ``tool_call``
|
||||
but no ``tool_result``, so re-driving the loop would re-execute their
|
||||
side effect. Completed calls (already have a result) are replayed
|
||||
upstream and never reach this check.
|
||||
"""
|
||||
if not self.replaying:
|
||||
return False
|
||||
call_id = str(tool_call_id) if tool_call_id is not None else None
|
||||
if call_id and call_id in self._inflight_names_by_call_id:
|
||||
return True
|
||||
return self._fingerprint(function_name, arguments) in (
|
||||
self._inflight_names_by_fingerprint
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _not_safely_resumable_result(function_name: str) -> Dict[str, Any]:
|
||||
"""Typed, operator-visible outcome for a gated in-flight effectful tool."""
|
||||
return {
|
||||
"error": (
|
||||
f"Tool '{function_name}' was in-flight when the durable run "
|
||||
"crashed and is not declared restart_safe; its outcome could not "
|
||||
"be confirmed. It was not re-executed on resume to avoid a "
|
||||
"duplicate side effect. Reconcile the external action manually "
|
||||
"or mark the tool @tool(restart_safe=True) if it is idempotent."
|
||||
),
|
||||
"error_type": "NotSafelyResumable",
|
||||
"not_safely_resumable": True,
|
||||
"restart_safe": False,
|
||||
}
|
||||
|
||||
def restore_messages(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Append completed decision/result pairs once, before the next model call."""
|
||||
if not self.replaying or self._restored:
|
||||
@@ -277,6 +402,26 @@ class DurableRunContext:
|
||||
)
|
||||
self.finalize("failed")
|
||||
|
||||
def record_verification(self, payloads: List[Dict[str, Any]]) -> None:
|
||||
"""Append verification-gate results as durable ``verification`` events.
|
||||
|
||||
Each payload gets its own fresh ``seq`` so the events append cleanly
|
||||
alongside the tool-call cursor without colliding on ``(run_id, seq,
|
||||
kind)``. Best-effort: journalling never raises into the gate.
|
||||
"""
|
||||
if self._closed:
|
||||
return
|
||||
with self._lock:
|
||||
for payload in payloads:
|
||||
seq = self._next_seq
|
||||
self._next_seq += 1
|
||||
try:
|
||||
self.journal.append(
|
||||
JournalEvent(self.run_id, seq, KIND_VERIFICATION, payload)
|
||||
)
|
||||
except Exception: # pragma: no cover - journalling is best-effort
|
||||
pass
|
||||
|
||||
def wrap_sync(self, execute_tool_fn: Optional[Callable]) -> Optional[Callable]:
|
||||
if execute_tool_fn is None:
|
||||
return None
|
||||
@@ -286,9 +431,19 @@ class DurableRunContext:
|
||||
call_id = str(tool_call_id) if tool_call_id is not None else None
|
||||
if call_id and call_id in self._results_by_tool_call_id:
|
||||
return self._results_by_tool_call_id[call_id]
|
||||
gate_inflight = self._is_inflight_call(
|
||||
function_name, arguments, tool_call_id
|
||||
) and not _is_restart_safe(function_name, execute_tool_fn)
|
||||
seq, call_id, idempotency_key = self._allocate_step(
|
||||
function_name, arguments, tool_call_id
|
||||
)
|
||||
if gate_inflight:
|
||||
return self._record_result(
|
||||
seq,
|
||||
call_id,
|
||||
self._not_safely_resumable_result(function_name),
|
||||
iteration_index,
|
||||
)
|
||||
token = _active_idempotency_key.set(idempotency_key)
|
||||
try:
|
||||
call_kwargs = dict(kwargs)
|
||||
@@ -319,9 +474,20 @@ class DurableRunContext:
|
||||
call_id = str(tool_call_id) if tool_call_id is not None else None
|
||||
if call_id and call_id in self._results_by_tool_call_id:
|
||||
return self._results_by_tool_call_id[call_id]
|
||||
gate_inflight = self._is_inflight_call(
|
||||
function_name, arguments, tool_call_id
|
||||
) and not _is_restart_safe(function_name, execute_tool_fn)
|
||||
seq, call_id, idempotency_key = await asyncio.to_thread(
|
||||
self._allocate_step, function_name, arguments, tool_call_id
|
||||
)
|
||||
if gate_inflight:
|
||||
return await asyncio.to_thread(
|
||||
self._record_result,
|
||||
seq,
|
||||
call_id,
|
||||
self._not_safely_resumable_result(function_name),
|
||||
iteration_index,
|
||||
)
|
||||
token = _active_idempotency_key.set(idempotency_key)
|
||||
try:
|
||||
call_kwargs = dict(kwargs)
|
||||
|
||||
@@ -110,8 +110,8 @@ class EmbeddingAgent:
|
||||
- EmbeddingConfig: Full configuration object
|
||||
verbose: Verbosity level for output
|
||||
"""
|
||||
if llm is None and model is not None:
|
||||
llm = model
|
||||
from ..utils.model_alias import resolve_model_name
|
||||
llm = resolve_model_name(llm, model, "EmbeddingAgent")
|
||||
|
||||
self.name = name or "EmbeddingAgent"
|
||||
self.instructions = instructions
|
||||
|
||||
@@ -43,24 +43,67 @@ HERE = "this machine"
|
||||
# a place that never executes.
|
||||
_ALIASES = {"native": "sandlock", "local": "subprocess"}
|
||||
|
||||
# ...but only for the SANDBOX registry. "local" resolves to two different
|
||||
# backends depending on which parameter carried it:
|
||||
# run_in="local" -> SandboxManager collapses it to subprocess:
|
||||
# scrubbed environment, blocked commands and paths
|
||||
# tools_run_on="local" -> the wrapper's LocalCompute: a plain shell in the
|
||||
# current directory, full environment inherited,
|
||||
# no security policy at all
|
||||
# Describing the second in the first's words overstates it, which is the exact
|
||||
# failure this module exists to prevent. One word, two backends -- so the
|
||||
# phrase has to depend on where the word came from.
|
||||
_COMPUTE_ONLY_WORDS = {"local": "a plain shell on this machine (no policy applied)"}
|
||||
|
||||
# Tiers that separate a process but do NOT contain it. Verified: under the
|
||||
# subprocess backend, outbound network still works and ~/.ssh is readable even
|
||||
# though SecurityPolicy declares them blocked.
|
||||
_NOT_A_BOUNDARY = {"subprocess", "local", "native"}
|
||||
# `native` is deliberately absent: it aliases sandlock, which applies Landlock,
|
||||
# seccomp, a scrubbed environment and deny-all networking. Listing it here made
|
||||
# tools_run_on="native" and tools_run_on="sandlock" -- the same backend --
|
||||
# disagree about whether they contain anything.
|
||||
_NOT_A_BOUNDARY = {"subprocess", "local"}
|
||||
|
||||
|
||||
def say_place(name: Any) -> str:
|
||||
"""Turn a provider name into a phrase a non-developer can read."""
|
||||
def say_place(name: Any, *, via: str = "sandbox") -> str:
|
||||
"""Turn a place name into a phrase a non-developer can read.
|
||||
|
||||
``via`` says which registry resolved the name -- ``"compute"`` for
|
||||
``tools_run_on=``, ``"sandbox"`` for ``run_in=``. It only matters for names
|
||||
that mean different backends in each (see ``_COMPUTE_ONLY_WORDS``).
|
||||
"""
|
||||
if name is None:
|
||||
return HERE
|
||||
if name is True:
|
||||
return _PLACE_WORDS["subprocess"]
|
||||
if not isinstance(name, str):
|
||||
# A configured provider instance: use its own declared name if it has one.
|
||||
# A configured provider instance: use its own declared name if it has
|
||||
# one. Normalise BEFORE the compute-only check, or a backend holding a
|
||||
# LocalCompute object (provider_name="local") skips the branch and gets
|
||||
# described in the subprocess backend's stronger words -- the exact
|
||||
# overstatement this module exists to prevent.
|
||||
name = getattr(name, "provider_name", None) or type(name).__name__
|
||||
if via == "compute" and name.lower() in _COMPUTE_ONLY_WORDS:
|
||||
return _COMPUTE_ONLY_WORDS[name.lower()]
|
||||
key = str(name).lower()
|
||||
key = _ALIASES.get(key, key)
|
||||
return _PLACE_WORDS.get(key, str(name))
|
||||
|
||||
known = _PLACE_WORDS.get(key)
|
||||
if known:
|
||||
return known
|
||||
|
||||
# A place contributed by another package can name itself; the bare name is
|
||||
# the last resort rather than the only option, because the phrase table is
|
||||
# a literal that cannot know about packages installed later.
|
||||
try:
|
||||
from ..managed._compute_bridge import contributed_display_name
|
||||
|
||||
declared = contributed_display_name(key)
|
||||
if declared:
|
||||
return declared
|
||||
except Exception:
|
||||
pass
|
||||
return str(name)
|
||||
|
||||
|
||||
def _backend_places(backend: Any) -> Optional[Dict[str, str]]:
|
||||
@@ -68,6 +111,21 @@ def _backend_places(backend: Any) -> Optional[Dict[str, str]]:
|
||||
if backend is None:
|
||||
return None
|
||||
|
||||
# Ask the backend what it is, rather than guessing from its class name.
|
||||
# Name-matching missed any backend that did not happen to contain "Hosted"
|
||||
# or "Anthropic" -- a self-hosted docker backend reported that it was
|
||||
# thinking on this machine while the whole loop ran in a container.
|
||||
declared = getattr(backend, "provider_name", None)
|
||||
if isinstance(declared, str):
|
||||
try:
|
||||
from .placement import managed_runtimes
|
||||
|
||||
if declared.lower() in managed_runtimes():
|
||||
where = say_place(declared)
|
||||
return {"thinks_on": where, "tools_run_on": where}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cls = type(backend).__name__
|
||||
# Hosted runtimes move the entire turn off this machine.
|
||||
if "Hosted" in cls or "Anthropic" in cls:
|
||||
@@ -75,10 +133,13 @@ def _backend_places(backend: Any) -> Optional[Dict[str, str]]:
|
||||
where = say_place(provider)
|
||||
return {"thinks_on": where, "tools_run_on": where}
|
||||
|
||||
# Local/sandboxed agents keep the loop here and may push tools out.
|
||||
# Local/sandboxed agents keep the loop here and may push tools out. A
|
||||
# backend's compute object is a COMPUTE-registry provider, so it is read
|
||||
# with the compute vocabulary: a LocalCompute here is the unrestricted
|
||||
# shell, not the subprocess backend "local" collapses to under run_in=.
|
||||
compute = getattr(backend, "_compute", None) or getattr(backend, "compute", None)
|
||||
if compute is not None:
|
||||
return {"thinks_on": HERE, "tools_run_on": say_place(compute)}
|
||||
return {"thinks_on": HERE, "tools_run_on": say_place(compute, via="compute")}
|
||||
return {"thinks_on": HERE, "tools_run_on": HERE}
|
||||
|
||||
|
||||
@@ -94,11 +155,21 @@ def describe(obj: Any, *, shared: bool = False) -> Dict[str, str]:
|
||||
if from_backend:
|
||||
places.update(from_backend)
|
||||
|
||||
run_on = getattr(obj, "run_on", None)
|
||||
if run_on is not None:
|
||||
where = say_place(run_on)
|
||||
where_tools = getattr(obj, "tools_run_on", None)
|
||||
if where_tools is not None:
|
||||
where = say_place(where_tools, via="compute")
|
||||
places["tools_run_on"] = f"{where} (shared)" if shared else where
|
||||
|
||||
# run_on= puts the WHOLE agent on a managed runtime, so it moves the
|
||||
# thinking as well. _backend_places() already reports that (run_on= is
|
||||
# turned into a backend at construction); this only covers an object
|
||||
# that carries the name without a built backend.
|
||||
whole = getattr(obj, "run_on", None)
|
||||
if whole is not None and not from_backend:
|
||||
everywhere = say_place(whole)
|
||||
places["thinks_on"] = everywhere
|
||||
places["tools_run_on"] = everywhere
|
||||
|
||||
sandbox_config = getattr(obj, "sandbox_config", None)
|
||||
if sandbox_config is not None:
|
||||
places["code_runs_on"] = say_place(
|
||||
@@ -109,6 +180,42 @@ def describe(obj: Any, *, shared: bool = False) -> Dict[str, str]:
|
||||
return places
|
||||
|
||||
|
||||
def _tools_staying_here(obj: Any) -> list:
|
||||
"""Names of the object's own tools, which do NOT move to the sandbox.
|
||||
|
||||
Only four tool names are bridged (``execute_command``, ``read_file``,
|
||||
``write_file``, ``list_files``); everything else the user passed keeps
|
||||
running in this process, against this filesystem. The capability prompt
|
||||
tells the model the sandbox is where things happen, so anything staying
|
||||
behind has to be said out loud.
|
||||
|
||||
Reads the pre-attach tool list when a sandbox is already attached: by then
|
||||
``agent.tools`` has been rewritten and the originals live in the sandbox's
|
||||
restore record.
|
||||
"""
|
||||
try:
|
||||
from ..managed.shared_compute import BRIDGED_TOOL_NAMES
|
||||
except Exception:
|
||||
BRIDGED_TOOL_NAMES = ("execute_command", "read_file", "write_file", "list_files")
|
||||
|
||||
tools = None
|
||||
shared = getattr(obj, "_tools_sandbox", None)
|
||||
if shared is not None:
|
||||
for patched, original_tools, _ in getattr(shared, "_patched", []):
|
||||
if patched is obj:
|
||||
tools = original_tools
|
||||
break
|
||||
if tools is None:
|
||||
tools = getattr(obj, "tools", None) or []
|
||||
|
||||
names = []
|
||||
for tool in tools:
|
||||
name = getattr(tool, "__name__", None) or getattr(tool, "name", None)
|
||||
if isinstance(name, str) and name not in BRIDGED_TOOL_NAMES:
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
def repr_fields(obj: Any, *, shared: bool = False) -> str:
|
||||
"""Render the places as ``key='value'`` pairs for a ``__repr__``."""
|
||||
return ", ".join(f"{k}={v!r}" for k, v in describe(obj, shared=shared).items())
|
||||
@@ -126,12 +233,20 @@ def explain(obj: Any, *, shared: bool = False) -> str:
|
||||
f"Tools run on {places['tools_run_on']}.",
|
||||
]
|
||||
|
||||
if shared and getattr(obj, "run_on", None) is not None:
|
||||
if shared and getattr(obj, "tools_run_on", None) is not None:
|
||||
lines.append(
|
||||
"Every step shares that same sandbox, so a file written by one step "
|
||||
"is visible to the next."
|
||||
)
|
||||
|
||||
staying = _tools_staying_here(obj)
|
||||
if staying and places["tools_run_on"] != places["thinks_on"]:
|
||||
listed = ", ".join(staying[:6]) + ("..." if len(staying) > 6 else "")
|
||||
lines.append(
|
||||
f"Your own tools ({listed}) still run on {HERE} -- only shell, file "
|
||||
f"and code tools move. They read and write this machine's files."
|
||||
)
|
||||
|
||||
if "code_runs_on" in places:
|
||||
lines.append(
|
||||
f"Code you run yourself with execute_code() runs on {places['code_runs_on']}."
|
||||
@@ -139,6 +254,7 @@ def explain(obj: Any, *, shared: bool = False) -> str:
|
||||
|
||||
# Say plainly when the chosen tier is not a security boundary.
|
||||
raw = [
|
||||
getattr(obj, "tools_run_on", None),
|
||||
getattr(obj, "run_on", None),
|
||||
getattr(getattr(obj, "sandbox_config", None), "sandbox_type", None),
|
||||
]
|
||||
@@ -147,7 +263,8 @@ def explain(obj: Any, *, shared: bool = False) -> str:
|
||||
):
|
||||
lines.append(
|
||||
"Note: a separate process is not a security boundary -- it can still "
|
||||
"reach the network and read your files. Use docker or a cloud "
|
||||
"reach the network and read your files, and its blocked-command "
|
||||
"list can be worked around by ordinary shell syntax. Use docker or a cloud "
|
||||
"provider for untrusted code."
|
||||
)
|
||||
|
||||
|
||||
@@ -1519,6 +1519,7 @@ Write the complete compiled report:"""
|
||||
run_id=getattr(self, '_current_run_id', 'unknown'),
|
||||
session_id=getattr(self, '_session_id', None) or 'default',
|
||||
tool_name=function_name,
|
||||
metadata={"tool_call_id": tool_call_id},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1564,18 +1565,32 @@ Write the complete compiled report:"""
|
||||
result.get("approval_error") or
|
||||
result.get("circuit_open") or
|
||||
result.get("loop_blocked") or
|
||||
result.get("retryable") is False):
|
||||
result.get("_praison_retryable") is False):
|
||||
result.pop("_praison_retryable", None)
|
||||
return result
|
||||
|
||||
# Determine error type for retry policy
|
||||
error_type = self._classify_error_type(result, last_exception)
|
||||
|
||||
# Check if we should retry
|
||||
if not retry_policy.should_retry(error_type, attempt):
|
||||
# Check if we should retry. A tool that *raised* is flattened
|
||||
# into an error dict before it can be classified, so it always
|
||||
# arrives here as "unknown" — which no default retry_on
|
||||
# contains. The sync path escalates exactly those error types
|
||||
# to its own retry loop (tool_execution.py:853-866); honouring
|
||||
# the impl's explicit `retryable` flag is how the async path
|
||||
# reaches the same decision instead of never retrying at all.
|
||||
# `_praison_retryable` is a private control-plane key (see
|
||||
# _execute_tool_async_impl): a tool's own result may legitimately
|
||||
# contain "retryable" (common for HTTP-API wrappers) and must
|
||||
# never steer our retry loop.
|
||||
if (not retry_policy.should_retry(error_type, attempt)
|
||||
and result.get("_praison_retryable") is not True):
|
||||
result.pop("_praison_retryable", None)
|
||||
return result
|
||||
|
||||
# Don't retry on last attempt
|
||||
if attempt == retry_policy.max_attempts - 1:
|
||||
result.pop("_praison_retryable", None)
|
||||
return result
|
||||
|
||||
# Emit retry hook event
|
||||
@@ -1774,7 +1789,7 @@ Write the complete compiled report:"""
|
||||
result = {
|
||||
"error": f"Tool timed out after {tool_timeout}s",
|
||||
"timeout": True,
|
||||
"retryable": False,
|
||||
"_praison_retryable": False,
|
||||
}
|
||||
else:
|
||||
result = await _invoke()
|
||||
@@ -1814,7 +1829,26 @@ Write the complete compiled report:"""
|
||||
# sync path. The next pre-execution check will then BLOCK/HALT.
|
||||
if loop_guard is not None:
|
||||
loop_guard.record(function_name, arguments, False, result=None)
|
||||
return {"error": f"Error executing {function_name}: {str(e)}"}
|
||||
# Carry the retry verdict with the flattened exception. Without it
|
||||
# the caller only sees an opaque error string, classifies it as
|
||||
# "unknown" (never in RetryPolicy.retry_on) and gives up — while
|
||||
# the sync path, which still has the exception object, retries.
|
||||
# Mirror the sync path exactly: a ToolExecutionError already
|
||||
# carries an explicit is_retryable verdict (e.g. a terminal
|
||||
# non-retryable failure) and must be honoured verbatim; for any
|
||||
# other exception apply the same terminal rule — programming
|
||||
# errors are terminal, everything else may be retried.
|
||||
if isinstance(e, ToolExecutionError):
|
||||
retryable = e.is_retryable
|
||||
else:
|
||||
retryable = not isinstance(e, (ValueError, TypeError, AttributeError))
|
||||
# Private control-plane key: a tool's own result may legitimately
|
||||
# contain "retryable" (common for HTTP-API wrappers) and must not
|
||||
# collide with the framework's verdict nor leak to the model.
|
||||
return {
|
||||
"error": f"Error executing {function_name}: {str(e)}",
|
||||
"_praison_retryable": retryable,
|
||||
}
|
||||
|
||||
except ToolExecutionError:
|
||||
# A loop-guard HALT must propagate to stop the run, mirroring the sync
|
||||
@@ -1824,18 +1858,26 @@ Write the complete compiled report:"""
|
||||
logging.error(f"Error in execute_tool_async: {str(e)}", exc_info=True)
|
||||
return {"error": f"Error in execute_tool_async: {str(e)}"}
|
||||
|
||||
def launch(self, path: str = '/', port: int = 8000, host: str = '0.0.0.0', debug: bool = False, protocol: str = "http"):
|
||||
def launch(self, path: str = '/', port: int = 8000, host: str = '127.0.0.1', debug: bool = False, protocol: str = "http"):
|
||||
"""
|
||||
Launch the agent as an HTTP API endpoint or MCP server.
|
||||
|
||||
This method now delegates to protocol-based launchers to follow
|
||||
the protocol-driven architecture. Heavy implementations (FastAPI/uvicorn)
|
||||
are only imported when needed through lazy loading.
|
||||
|
||||
|
||||
Request authorisation matches ``PraisonAIAgents.launch``: when
|
||||
``PRAISONAI_LAUNCH_AUTH_TOKEN`` is set, every HTTP route requires that
|
||||
bearer token (401 otherwise). When it is unset and ``host`` is
|
||||
non-loopback, a one-time token is generated and printed rather than
|
||||
serving an unauthenticated endpoint on all interfaces.
|
||||
|
||||
Args:
|
||||
path: API endpoint path (default: '/') for HTTP, or base path for MCP.
|
||||
port: Server port (default: 8000)
|
||||
host: Server host (default: '0.0.0.0')
|
||||
host: Server host (default: '127.0.0.1'; changed from '0.0.0.0' to
|
||||
avoid binding all interfaces by default — pass '0.0.0.0'
|
||||
explicitly to expose externally).
|
||||
debug: Enable debug mode (default: False)
|
||||
protocol: "http" to launch as FastAPI, "mcp" to launch as MCP server.
|
||||
|
||||
@@ -1859,6 +1901,8 @@ Write the complete compiled report:"""
|
||||
"""
|
||||
global _server_started, _registered_agents, _shared_apps, _server_lock
|
||||
|
||||
from .launch_security import authorise_launch_request, resolve_launch_host
|
||||
|
||||
# Try to import FastAPI dependencies - lazy loading
|
||||
try:
|
||||
import uvicorn
|
||||
@@ -1883,7 +1927,15 @@ Write the complete compiled report:"""
|
||||
print("\nOr install all API dependencies with:")
|
||||
print("pip install 'praisonaiagents[api]'")
|
||||
return None
|
||||
|
||||
|
||||
# Fail-closed bind guard: refuse to serve keyless on a non-loopback host
|
||||
# (auto-generates and prints a one-time token instead). Matches the
|
||||
# serve/jobs precedent and keeps this path in lock-step with
|
||||
# PraisonAIAgents.launch. Applied only after the dependency guard above
|
||||
# so a launch that bails out (missing deps) cannot mutate the
|
||||
# process-wide launch token and retroactively 401 a running endpoint.
|
||||
host = resolve_launch_host(host)
|
||||
|
||||
should_start = False
|
||||
with _server_lock:
|
||||
# Initialize port-specific collections if needed (once per port)
|
||||
@@ -1936,6 +1988,8 @@ Write the complete compiled report:"""
|
||||
# Define the endpoint handler
|
||||
@_shared_apps[port].post(path)
|
||||
async def handle_agent_query(request: Request, query_data: Optional[AgentQuery] = None):
|
||||
if not authorise_launch_request(request):
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
# Handle both direct JSON with query field and form data
|
||||
if query_data is None:
|
||||
try:
|
||||
|
||||
@@ -14,6 +14,7 @@ from typing import Optional, Any, Callable, Dict, List, Union, TYPE_CHECKING, Li
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
import inspect
|
||||
from contextlib import contextmanager
|
||||
from praisonaiagents._logging import get_logger
|
||||
import asyncio
|
||||
import contextvars
|
||||
@@ -469,12 +470,46 @@ class Handoff:
|
||||
return value
|
||||
return 'gpt-4o-mini'
|
||||
|
||||
@contextmanager
|
||||
def _seed_target_history(self, prior_messages: Optional[List[Any]]):
|
||||
"""Temporarily seed the filtered handoff context onto the target agent.
|
||||
|
||||
The configured ``context_policy``/``input_filter`` produce the messages the
|
||||
target should see; without seeding them the target only ever received the
|
||||
literal handoff prompt string and the configured policy had no effect.
|
||||
|
||||
The seeding is *invocation-scoped*: the prior messages are prepended for the
|
||||
duration of this handoff and the target's original ``chat_history`` is
|
||||
restored on exit. This keeps handoff context from leaking into later
|
||||
handoffs or ordinary chats that reuse the same target agent, and stops the
|
||||
target prompt from growing across sequential handoffs.
|
||||
No shared mutable state is retained between agents.
|
||||
"""
|
||||
existing = getattr(self.agent, 'chat_history', None)
|
||||
if not prior_messages or not isinstance(existing, list):
|
||||
yield
|
||||
return
|
||||
try:
|
||||
prior = list(prior_messages)
|
||||
except TypeError:
|
||||
yield
|
||||
return
|
||||
original = existing
|
||||
try:
|
||||
# Avoid re-seeding if the context is already at the head.
|
||||
if existing[:len(prior)] != prior:
|
||||
self.agent.chat_history = prior + existing
|
||||
yield
|
||||
finally:
|
||||
self.agent.chat_history = original
|
||||
|
||||
def _execute_with_runtime_resolution(
|
||||
self,
|
||||
source_agent: 'Agent',
|
||||
prompt: str,
|
||||
effective_tools: Optional[List[Any]],
|
||||
context: Dict[str, Any]
|
||||
context: Dict[str, Any],
|
||||
prior_messages: Optional[List[Any]] = None,
|
||||
) -> str:
|
||||
"""Execute handoff through the target agent's full chat pipeline."""
|
||||
target_model_ref = self._extract_model_ref(self.agent)
|
||||
@@ -484,14 +519,16 @@ class Handoff:
|
||||
target_model_ref,
|
||||
_get_handoff_depth(),
|
||||
)
|
||||
return self.agent.chat(prompt, tools=effective_tools)
|
||||
with self._seed_target_history(prior_messages):
|
||||
return self.agent.chat(prompt, tools=effective_tools)
|
||||
|
||||
async def _execute_with_runtime_resolution_async(
|
||||
self,
|
||||
source_agent: 'Agent',
|
||||
prompt: str,
|
||||
effective_tools: Optional[List[Any]],
|
||||
context: Dict[str, Any]
|
||||
context: Dict[str, Any],
|
||||
prior_messages: Optional[List[Any]] = None,
|
||||
) -> str:
|
||||
"""Execute handoff through the target agent's full async chat pipeline."""
|
||||
target_model_ref = self._extract_model_ref(self.agent)
|
||||
@@ -501,13 +538,14 @@ class Handoff:
|
||||
target_model_ref,
|
||||
_get_handoff_depth(),
|
||||
)
|
||||
async_chat = getattr(self.agent, 'achat', None)
|
||||
if callable(async_chat) and inspect.iscoroutinefunction(async_chat):
|
||||
return await async_chat(prompt, tools=effective_tools)
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(
|
||||
None, lambda: self.agent.chat(prompt, tools=effective_tools)
|
||||
)
|
||||
with self._seed_target_history(prior_messages):
|
||||
async_chat = getattr(self.agent, 'achat', None)
|
||||
if callable(async_chat) and inspect.iscoroutinefunction(async_chat):
|
||||
return await async_chat(prompt, tools=effective_tools)
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(
|
||||
None, lambda: self.agent.chat(prompt, tools=effective_tools)
|
||||
)
|
||||
|
||||
def _check_safety(self, source_agent: 'Agent') -> None:
|
||||
"""
|
||||
@@ -661,8 +699,9 @@ class Handoff:
|
||||
# Execute on_handoff callback
|
||||
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
|
||||
|
||||
# Prepare context (for future extensibility - context data available for hooks)
|
||||
_ = self._prepare_context(source_agent, kwargs)
|
||||
# Prepare context per the configured context_policy/input_filter and
|
||||
# thread the filtered history through to the target agent.
|
||||
handoff_data = self._prepare_context(source_agent, kwargs)
|
||||
|
||||
# Build prompt with context
|
||||
context_prefix = f"[Handoff from {source_agent.name}] "
|
||||
@@ -677,7 +716,8 @@ class Handoff:
|
||||
|
||||
# Resolve runtime at turn-time instead of using construction-time pin
|
||||
response = self._execute_with_runtime_resolution(
|
||||
source_agent, full_prompt, effective_tools, kwargs
|
||||
source_agent, full_prompt, effective_tools, kwargs,
|
||||
prior_messages=handoff_data.messages,
|
||||
)
|
||||
|
||||
result = HandoffResult(
|
||||
@@ -769,8 +809,9 @@ class Handoff:
|
||||
# Execute callback
|
||||
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
|
||||
|
||||
# Prepare context (for future extensibility)
|
||||
_ = self._prepare_context(source_agent, kwargs)
|
||||
# Prepare context per the configured context_policy/input_filter
|
||||
# and thread the filtered history through to the target agent.
|
||||
handoff_data = self._prepare_context(source_agent, kwargs)
|
||||
|
||||
# Build prompt
|
||||
context_prefix = f"[Handoff from {source_agent.name}] "
|
||||
@@ -785,7 +826,8 @@ class Handoff:
|
||||
|
||||
# Resolve runtime at turn-time for async execution
|
||||
response = await self._execute_with_runtime_resolution_async(
|
||||
source_agent, full_prompt, effective_tools, kwargs
|
||||
source_agent, full_prompt, effective_tools, kwargs,
|
||||
prior_messages=handoff_data.messages,
|
||||
)
|
||||
|
||||
result = HandoffResult(
|
||||
@@ -898,20 +940,39 @@ class Handoff:
|
||||
# Prepare handoff data with context policy
|
||||
handoff_data = self._prepare_context(source_agent, kwargs)
|
||||
|
||||
# Get the last user message or context to pass to target agent
|
||||
# Get the last user message or context to pass to target agent.
|
||||
# Track the index of the message chosen for ``prompt`` so exactly
|
||||
# that item is excluded from the seeded history (it may not be the
|
||||
# final list item if an assistant/tool message follows it).
|
||||
last_message = None
|
||||
for msg in reversed(handoff_data.messages):
|
||||
selected_index = None
|
||||
for i in range(len(handoff_data.messages) - 1, -1, -1):
|
||||
msg = handoff_data.messages[i]
|
||||
if isinstance(msg, dict) and msg.get('role') == 'user':
|
||||
last_message = msg.get('content', '')
|
||||
selected_index = i
|
||||
break
|
||||
|
||||
if not last_message and handoff_data.messages:
|
||||
last_msg = handoff_data.messages[-1]
|
||||
selected_index = len(handoff_data.messages) - 1
|
||||
last_msg = handoff_data.messages[selected_index]
|
||||
if isinstance(last_msg, dict):
|
||||
last_message = last_msg.get('content', '')
|
||||
else:
|
||||
last_message = str(last_msg)
|
||||
|
||||
|
||||
# Tool-style handoff (as_tool): ContextPolicy.NONE discards the
|
||||
# history, so the prompt must come from the generated ``task``/
|
||||
# ``prompt`` argument instead. Without this the sub-agent would
|
||||
# never run and the tool would report a hollow "completed". The
|
||||
# task is the sub-agent's whole instruction, so it is passed
|
||||
# verbatim rather than wrapped in a ``[Handoff from …]`` prefix.
|
||||
task_prompt = None
|
||||
if not last_message and not self.input_type:
|
||||
task_prompt = kwargs.get('task') or kwargs.get('prompt')
|
||||
if task_prompt:
|
||||
last_message = str(task_prompt)
|
||||
|
||||
# Prepare context information
|
||||
context_info = f"[Handoff from {source_agent.name}] "
|
||||
if kwargs and self.input_type:
|
||||
@@ -919,15 +980,26 @@ class Handoff:
|
||||
|
||||
# Execute the target agent with tool boundary enforcement
|
||||
if last_message:
|
||||
prompt = context_info + last_message
|
||||
prompt = last_message if task_prompt else context_info + last_message
|
||||
logger.info(f"Handing off to {self.agent.name} with prompt: {prompt}")
|
||||
|
||||
# Compute effective tools based on tool policy
|
||||
effective_tools = self._compute_effective_tools(source_agent)
|
||||
|
||||
# Resolve runtime at turn-time for tool function execution
|
||||
# Resolve runtime at turn-time for tool function execution.
|
||||
# The selected message is already folded into ``prompt`` above,
|
||||
# so exclude exactly that item from the seeded history to avoid
|
||||
# duplicating it while preserving any messages that follow it.
|
||||
if selected_index is None:
|
||||
prior_messages = list(handoff_data.messages)
|
||||
else:
|
||||
prior_messages = (
|
||||
handoff_data.messages[:selected_index]
|
||||
+ handoff_data.messages[selected_index + 1:]
|
||||
)
|
||||
response = self._execute_with_runtime_resolution(
|
||||
source_agent, prompt, effective_tools, kwargs
|
||||
source_agent, prompt, effective_tools, kwargs,
|
||||
prior_messages=prior_messages,
|
||||
)
|
||||
|
||||
result = HandoffResult(
|
||||
@@ -995,6 +1067,17 @@ class Handoff:
|
||||
)
|
||||
)
|
||||
handoff_tool.__signature__ = inspect.Signature(sig_params)
|
||||
elif self.config.context_policy == ContextPolicy.NONE:
|
||||
# Tool-style handoff (as_tool): no conversation context is passed, so
|
||||
# advertise an explicit ``task`` parameter the caller must fill. This
|
||||
# is what ``handoff_tool`` reads as the sub-agent's prompt above.
|
||||
handoff_tool.__signature__ = inspect.Signature([
|
||||
inspect.Parameter(
|
||||
'task',
|
||||
inspect.Parameter.KEYWORD_ONLY,
|
||||
annotation=str,
|
||||
)
|
||||
])
|
||||
|
||||
return handoff_tool
|
||||
|
||||
@@ -1468,8 +1551,9 @@ class TypedHandoff(Handoff, Generic[T]):
|
||||
# Execute on_handoff callback
|
||||
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
|
||||
|
||||
# Apply input filter if provided (fixes Greptile P1 issue)
|
||||
_ = self._prepare_context(source_agent, kwargs)
|
||||
# Apply context policy / input filter; the resulting messages are
|
||||
# seeded onto the target only for the duration of its chat below.
|
||||
handoff_data = self._prepare_context(source_agent, kwargs)
|
||||
|
||||
# Validate payload against schema
|
||||
try:
|
||||
@@ -1496,7 +1580,8 @@ class TypedHandoff(Handoff, Generic[T]):
|
||||
logger.info(f"Typed handoff to {self.agent.name} with validated payload")
|
||||
|
||||
# Execute with timeout if sync
|
||||
response = self.agent.chat(full_prompt)
|
||||
with self._seed_target_history(handoff_data.messages):
|
||||
response = self.agent.chat(full_prompt)
|
||||
|
||||
result = HandoffResult(
|
||||
success=True,
|
||||
@@ -1581,8 +1666,9 @@ class TypedHandoff(Handoff, Generic[T]):
|
||||
# Execute callback
|
||||
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
|
||||
|
||||
# Apply input filter if provided (fixes Greptile P1 issue)
|
||||
_ = self._prepare_context(source_agent, kwargs)
|
||||
# Apply context policy / input filter; the resulting messages are
|
||||
# seeded onto the target only for the duration of its chat below.
|
||||
handoff_data = self._prepare_context(source_agent, kwargs)
|
||||
|
||||
# Validate payload against schema
|
||||
try:
|
||||
@@ -1608,12 +1694,13 @@ class TypedHandoff(Handoff, Generic[T]):
|
||||
logger.info(f"Async typed handoff to {self.agent.name}")
|
||||
|
||||
# Execute - check for async chat method
|
||||
if hasattr(self.agent, 'achat'):
|
||||
response = await self.agent.achat(full_prompt)
|
||||
else:
|
||||
# Run sync chat in executor
|
||||
loop = asyncio.get_event_loop()
|
||||
response = await loop.run_in_executor(None, self.agent.chat, full_prompt)
|
||||
with self._seed_target_history(handoff_data.messages):
|
||||
if hasattr(self.agent, 'achat'):
|
||||
response = await self.agent.achat(full_prompt)
|
||||
else:
|
||||
# Run sync chat in executor
|
||||
loop = asyncio.get_event_loop()
|
||||
response = await loop.run_in_executor(None, self.agent.chat, full_prompt)
|
||||
|
||||
return HandoffResult(
|
||||
success=True,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Shared authorisation + bind guard for the ``launch()`` serving paths.
|
||||
|
||||
Both ``Agent.launch()`` (``agent/execution_mixin.py``) and
|
||||
``PraisonAIAgents.launch()`` (``agents/agents.py``) expose a prompt-execution
|
||||
HTTP endpoint. They must enforce identical request authorisation and must not
|
||||
silently bind all interfaces without a token. Factoring the two helpers here
|
||||
keeps that policy in one place so the two launch paths cannot drift apart.
|
||||
|
||||
Stdlib only — no heavy imports, no new public params.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets as _secrets
|
||||
from typing import Optional
|
||||
|
||||
__all__ = [
|
||||
"launch_auth_token",
|
||||
"authorise_launch_request",
|
||||
"resolve_launch_host",
|
||||
]
|
||||
|
||||
# Hosts that are safe to serve keyless on: loopback only.
|
||||
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
|
||||
|
||||
# One auto-generated token per process, reused across launches so a later
|
||||
# non-loopback launch cannot mint a fresh token and lock out an endpoint that
|
||||
# is already serving with the previously generated one.
|
||||
_GENERATED_TOKEN: Optional[str] = None
|
||||
|
||||
|
||||
def launch_auth_token() -> Optional[str]:
|
||||
"""Return the configured launch bearer token, if any."""
|
||||
return os.environ.get("PRAISONAI_LAUNCH_AUTH_TOKEN")
|
||||
|
||||
|
||||
def authorise_launch_request(request) -> bool:
|
||||
"""Return True if the request carries the configured bearer token.
|
||||
|
||||
When no token is configured this returns True (local-dev flow preserved).
|
||||
Accepts either ``Authorization: Bearer <token>`` or ``X-Auth-Token``.
|
||||
"""
|
||||
token = launch_auth_token()
|
||||
if not token:
|
||||
return True
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer ") and _secrets.compare_digest(auth[7:], token):
|
||||
return True
|
||||
supplied = request.headers.get("X-Auth-Token", "")
|
||||
return bool(supplied) and _secrets.compare_digest(supplied, token)
|
||||
|
||||
|
||||
def _is_loopback(host: str) -> bool:
|
||||
return host in _LOOPBACK_HOSTS
|
||||
|
||||
|
||||
def resolve_launch_host(host: str) -> str:
|
||||
"""Fail-closed bind guard, mirroring the ``serve``/jobs precedent.
|
||||
|
||||
* Token set → bind ``host`` unchanged (auth protects the endpoint).
|
||||
* Token unset + loopback host → bind unchanged (local dev unchanged).
|
||||
* Token unset + non-loopback host → auto-generate a token, export it via
|
||||
``PRAISONAI_LAUNCH_AUTH_TOKEN`` so every route enforces it, and print it
|
||||
once. This closes the unauthenticated-exposure hole without refusing to
|
||||
start.
|
||||
|
||||
Returns the host to bind (unchanged; the guard acts on the token, not the
|
||||
host, so an explicit ``0.0.0.0`` is still honoured — just never keyless).
|
||||
|
||||
A generated token is reused for the lifetime of the process: repeated
|
||||
non-loopback launches share the one token rather than each minting a fresh
|
||||
one, so a later launch cannot invalidate an already-running endpoint's
|
||||
clients.
|
||||
"""
|
||||
if launch_auth_token() or _is_loopback(host):
|
||||
return host
|
||||
|
||||
global _GENERATED_TOKEN
|
||||
if _GENERATED_TOKEN is None:
|
||||
_GENERATED_TOKEN = _secrets.token_urlsafe(32)
|
||||
print(
|
||||
f"🔐 launch() bound to non-loopback host {host!r} without "
|
||||
f"PRAISONAI_LAUNCH_AUTH_TOKEN set. Generated a one-time bearer token "
|
||||
f"(set PRAISONAI_LAUNCH_AUTH_TOKEN to override): {_GENERATED_TOKEN}"
|
||||
)
|
||||
os.environ["PRAISONAI_LAUNCH_AUTH_TOKEN"] = _GENERATED_TOKEN
|
||||
return host
|
||||
@@ -89,8 +89,8 @@ class OCRAgent:
|
||||
ocr: OCR configuration
|
||||
verbose: Verbosity level for output
|
||||
"""
|
||||
if llm is None and model is not None:
|
||||
llm = model
|
||||
from ..utils.model_alias import resolve_model_name
|
||||
llm = resolve_model_name(llm, model, "OCRAgent")
|
||||
|
||||
self.name = name or "OCRAgent"
|
||||
self.instructions = instructions
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
"""One vocabulary for "where does this run?", shared by Agent, AgentFlow and AgentTeam.
|
||||
|
||||
Two questions get asked about placement and they have different answers:
|
||||
|
||||
``run_on=``
|
||||
Where the **whole agent** runs -- the model calls, the loop, the tools, all
|
||||
of it on someone else's machine. Only a *managed runtime* can answer this,
|
||||
because running the loop remotely means something has to host the loop.
|
||||
|
||||
``tools_run_on=``
|
||||
Where **the tools** run. Thinking stays on this machine; only the shell,
|
||||
the file writes and the code execution move. Any sandbox or compute place
|
||||
can answer this, because all it has to do is run a command.
|
||||
|
||||
Keeping the two words apart is the entire point. The old surface used one word
|
||||
(``run_on=``) on ``AgentFlow``/``AgentTeam`` for the tools-only meaning, another
|
||||
(``compute=``) on ``LocalAgent`` for exactly the same meaning, and a third
|
||||
(``backend=``) for the whole-agent meaning -- so the same concept had two names
|
||||
and one name had two concepts.
|
||||
|
||||
Because the two questions have disjoint answer sets, a wrong pairing is a typo
|
||||
worth catching rather than a preference worth honouring. ``run_on="docker"``
|
||||
cannot mean anything: Docker does not host agent loops. It gets a ``TypeError``
|
||||
naming the parameter the user actually wanted, not a silent fallback.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal, NamedTuple, Optional, Union
|
||||
|
||||
# Runtimes that can host an entire agent loop. Resolved from the wrapper's
|
||||
# entry-point registry when it is installed so third-party backends work with
|
||||
# `run_on=` too; the literal is the floor, not the ceiling.
|
||||
_FALLBACK_MANAGED = ("anthropic",)
|
||||
|
||||
# Static spellings so editors can autocomplete and type-checkers can reject a
|
||||
# typo at the call site rather than at the first model turn. The runtime
|
||||
# functions below stay the authority for validation -- these exist for tooling,
|
||||
# which cannot call a function to learn a set.
|
||||
# Keep in sync with _compute_bridge._PROVIDERS | _sandbox_adapter.SANDBOX_ONLY.
|
||||
ManagedRuntime = Literal["anthropic"]
|
||||
|
||||
ToolPlace = Literal[
|
||||
"local", "subprocess", "sandlock", "docker", "e2b",
|
||||
"modal", "daytona", "flyio", "tenki", "novita", "ssh",
|
||||
]
|
||||
|
||||
|
||||
def managed_runtimes() -> tuple:
|
||||
"""Names that ``run_on=`` accepts -- places that host a whole agent."""
|
||||
try:
|
||||
from praisonai.integrations.backend_registry import get_backend_registry
|
||||
|
||||
names = tuple(get_backend_registry().list_all_names())
|
||||
if names:
|
||||
return tuple(sorted(set(names) | set(_FALLBACK_MANAGED)))
|
||||
except Exception:
|
||||
pass
|
||||
return _FALLBACK_MANAGED
|
||||
|
||||
|
||||
def tool_places() -> list:
|
||||
"""Names that ``tools_run_on=`` accepts -- places that can run a command.
|
||||
|
||||
Includes the resolver's aliases. Validation runs before resolution, so
|
||||
leaving them out rejected spellings the resolver handles happily:
|
||||
``tools_run_on="native"`` raised "not a known place" while
|
||||
``resolve_compute("native")`` returned a working sandlock adapter.
|
||||
"""
|
||||
try:
|
||||
from ..managed._compute_bridge import _ALIASES, available_providers
|
||||
|
||||
return sorted(set(available_providers()) | set(_ALIASES))
|
||||
except Exception:
|
||||
return ["local", "docker"]
|
||||
|
||||
|
||||
class Placement(NamedTuple):
|
||||
"""The resolved answer to "where does this run?"."""
|
||||
|
||||
whole: Optional[Any] = None # managed runtime name/object, or None
|
||||
tools: Optional[Any] = None # tools place name/object, or None
|
||||
|
||||
|
||||
def _name_of(value: Any) -> Optional[str]:
|
||||
"""The comparable name of a placement value, or None if it is an object.
|
||||
|
||||
Objects (a built provider, a live ``SSHSandbox``) carry their own config and
|
||||
are never second-guessed -- only bare strings get routed by name.
|
||||
"""
|
||||
return value.lower() if isinstance(value, str) else None
|
||||
|
||||
|
||||
def _quote_list(names) -> str:
|
||||
return ", ".join(repr(n) for n in names)
|
||||
|
||||
|
||||
def resolve_placement(
|
||||
owner: str,
|
||||
*,
|
||||
run_on: Any = None,
|
||||
tools_run_on: Any = None,
|
||||
backend: Any = None,
|
||||
supports_run_on: bool = True,
|
||||
) -> Placement:
|
||||
"""Validate placement arguments and return where things should run.
|
||||
|
||||
``owner`` is the class name, used only to make errors read naturally.
|
||||
``supports_run_on`` is False for AgentFlow/AgentTeam, which can place tools
|
||||
but cannot (yet) hand a whole multi-agent run to a managed runtime.
|
||||
|
||||
Raises:
|
||||
TypeError: on any combination that names two different places for the
|
||||
same thing, or a place that cannot do the job asked of it.
|
||||
"""
|
||||
managed = managed_runtimes()
|
||||
places = tool_places()
|
||||
|
||||
# ── a place that cannot do the job asked of it ───────────────────────────
|
||||
run_on_name = _name_of(run_on)
|
||||
if run_on_name is not None and run_on_name not in managed:
|
||||
if run_on_name in places:
|
||||
raise TypeError(
|
||||
f"{owner}(run_on={run_on!r}) is not valid: run_on= places the "
|
||||
f"whole agent -- model calls, loop and tools -- on a managed "
|
||||
f"runtime, and {run_on_name!r} runs commands but cannot host an "
|
||||
f"agent loop.\n"
|
||||
f" To run only the tools there: {owner}(tools_run_on={run_on!r})\n"
|
||||
f" Runtimes that host a whole agent: {_quote_list(managed)}"
|
||||
)
|
||||
raise TypeError(
|
||||
f"{owner}(run_on={run_on!r}) is not a known managed runtime.\n"
|
||||
f" Runtimes that host a whole agent: {_quote_list(managed)}\n"
|
||||
f" Places that can run tools: {_quote_list(places)} "
|
||||
f"(pass those as tools_run_on=)"
|
||||
)
|
||||
|
||||
tools_name = _name_of(tools_run_on)
|
||||
if tools_name is not None and tools_name in managed and tools_name not in places:
|
||||
raise TypeError(
|
||||
f"{owner}(tools_run_on={tools_run_on!r}) is not valid: {tools_name!r} "
|
||||
f"hosts an entire agent, not individual tools.\n"
|
||||
f" Did you mean: {owner}(run_on={tools_run_on!r})"
|
||||
)
|
||||
|
||||
# A typo here used to survive construction and only surface on the first
|
||||
# model turn -- after the prompt was built and, for a real run, after the
|
||||
# API spend. run_on= validates at the call site and YAML validates at load
|
||||
# time; this is the same standard for the third spelling.
|
||||
if tools_name is not None and tools_name not in places:
|
||||
raise TypeError(
|
||||
f"{owner}(tools_run_on={tools_run_on!r}) is not a known place.\n"
|
||||
f" Places that can run tools: {_quote_list(places)}\n"
|
||||
f" Runtimes that host a whole agent: {_quote_list(managed)} "
|
||||
f"(pass those as run_on=)"
|
||||
)
|
||||
|
||||
# Names that cannot work as a bare string, because the connection details
|
||||
# live in the object. Catch it now rather than at first use.
|
||||
if tools_name is not None:
|
||||
try:
|
||||
from ..managed._sandbox_adapter import NEEDS_INSTANCE
|
||||
except Exception:
|
||||
NEEDS_INSTANCE = {}
|
||||
if tools_name in NEEDS_INSTANCE:
|
||||
raise TypeError(
|
||||
f"{owner}(tools_run_on={tools_run_on!r}) needs more than a name: "
|
||||
f"{NEEDS_INSTANCE[tools_name]}"
|
||||
)
|
||||
|
||||
# ── two names for the same thing ─────────────────────────────────────────
|
||||
if run_on is not None and backend is not None:
|
||||
raise TypeError(
|
||||
f"{owner}(run_on=..., backend=...) sets the agent's runtime twice. "
|
||||
f"run_on={run_on!r} is the short form of backend=HostedAgent("
|
||||
f"provider={run_on!r}); pass one or the other.\n"
|
||||
f" Use backend= only when you need to configure the runtime "
|
||||
f"(model, system prompt, tools)."
|
||||
)
|
||||
|
||||
if run_on is not None and tools_run_on is not None:
|
||||
raise TypeError(
|
||||
f"{owner}(run_on={run_on!r}, tools_run_on={tools_run_on!r}) points "
|
||||
f"the tools at two machines. run_on= already runs everything -- "
|
||||
f"including tools -- on {run_on!r}.\n"
|
||||
f" Whole agent remote: {owner}(run_on={run_on!r})\n"
|
||||
f" Only tools remote: {owner}(tools_run_on={tools_run_on!r})"
|
||||
)
|
||||
|
||||
if backend is not None and tools_run_on is not None:
|
||||
raise TypeError(
|
||||
f"{owner}(backend=..., tools_run_on={tools_run_on!r}) points the "
|
||||
f"tools at two machines: backend= runs the whole agent (tools "
|
||||
f"included) on its managed runtime, so the tools cannot also run on "
|
||||
f"{tools_run_on!r}.\n"
|
||||
f" Drop tools_run_on= to keep the hosted runtime, or drop backend= "
|
||||
f"to keep local thinking with remote tools."
|
||||
)
|
||||
|
||||
# ── a class that cannot answer this question ─────────────────────────────
|
||||
if run_on is not None and not supports_run_on:
|
||||
raise TypeError(
|
||||
f"{owner}(run_on={run_on!r}) is not supported: run_on= hands one "
|
||||
f"agent's whole loop to a managed runtime, and a {owner} "
|
||||
f"orchestrates several agents locally.\n"
|
||||
f" To run every step's tools in one shared sandbox:\n"
|
||||
f" {owner}(tools_run_on={run_on!r})\n"
|
||||
f" To host a single agent's loop, put run_on= on that Agent."
|
||||
)
|
||||
|
||||
return Placement(whole=run_on, tools=tools_run_on)
|
||||
@@ -57,7 +57,8 @@ class RealtimeAgent:
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "RealtimeAgent",
|
||||
llm: Optional[str] = None,
|
||||
llm: Optional[str] = None, # Deprecated alias for model=
|
||||
model: Optional[str] = None,
|
||||
realtime: Optional[Union[bool, Dict, RealtimeConfig]] = None,
|
||||
instructions: Optional[str] = None,
|
||||
verbose: bool = True,
|
||||
@@ -67,14 +68,16 @@ class RealtimeAgent:
|
||||
|
||||
Args:
|
||||
name: Agent name
|
||||
llm: LLM model (default: gpt-4o-realtime-preview)
|
||||
llm: Deprecated alias for model=. Passing both raises TypeError.
|
||||
model: Model to use (default: gpt-4o-realtime-preview)
|
||||
realtime: Realtime configuration (bool, dict, or RealtimeConfig)
|
||||
instructions: System instructions
|
||||
verbose: Enable verbose output
|
||||
**kwargs: Additional arguments
|
||||
"""
|
||||
from ..utils.model_alias import resolve_model_name
|
||||
self.name = name
|
||||
self.llm = llm or "gpt-4o-realtime-preview"
|
||||
self.llm = resolve_model_name(llm, model, "RealtimeAgent") or "gpt-4o-realtime-preview"
|
||||
self.instructions = instructions
|
||||
self.verbose = verbose
|
||||
|
||||
|
||||
@@ -52,19 +52,61 @@ class SandboxMixin:
|
||||
|
||||
return self._sandbox_manager
|
||||
|
||||
|
||||
def _manager_for(self, run_in: Optional[Union[bool, str, 'SandboxConfig']]):
|
||||
"""Return the sandbox manager for a ``run_in=`` argument.
|
||||
|
||||
``run_in=`` names where a single ``execute_code()`` call runs, so the
|
||||
place lives on the call that uses it rather than on the constructor.
|
||||
``Agent(sandbox=...)`` still sets the default for every call.
|
||||
|
||||
Managers are cached per place: a fresh ``SandboxManager`` per call would
|
||||
start and tear down a container each time, which for Docker is seconds
|
||||
of latency and for any backend means the previous call's files are gone.
|
||||
"""
|
||||
if run_in is None:
|
||||
if not self.has_sandbox:
|
||||
raise RuntimeError(
|
||||
"No sandbox configured. Either name a place on the call --\n"
|
||||
" agent.execute_code_sync(code, run_in='docker')\n"
|
||||
"or set a default for every call with Agent(sandbox='docker')."
|
||||
)
|
||||
return self.get_sandbox_manager()
|
||||
|
||||
from ..sandbox import SandboxConfig, SandboxManager
|
||||
|
||||
if run_in is True:
|
||||
config = SandboxConfig.subprocess()
|
||||
elif isinstance(run_in, str):
|
||||
config = SandboxConfig(sandbox_type=run_in)
|
||||
else:
|
||||
config = run_in
|
||||
|
||||
key = getattr(config, "sandbox_type", None) or repr(config)
|
||||
cache = getattr(self, "_sandbox_managers", None)
|
||||
if cache is None:
|
||||
cache = self._sandbox_managers = {}
|
||||
if key not in cache:
|
||||
cache[key] = SandboxManager(config)
|
||||
return cache[key]
|
||||
|
||||
async def execute_code(
|
||||
self,
|
||||
code: str,
|
||||
language: str = "python",
|
||||
check_security: bool = True,
|
||||
run_in: Optional[Union[bool, str, 'SandboxConfig']] = None,
|
||||
**kwargs
|
||||
) -> 'SandboxResult':
|
||||
"""Execute code safely in configured sandbox.
|
||||
"""Execute code safely in a sandbox.
|
||||
|
||||
Args:
|
||||
code: Code to execute
|
||||
language: Programming language (python, bash, etc.)
|
||||
check_security: Whether to run security pre-checks
|
||||
run_in: Where to run this call -- "docker", "sandlock",
|
||||
"subprocess", a SandboxConfig, or True for the default local
|
||||
sandbox. Overrides Agent(sandbox=...) for this call only.
|
||||
**kwargs: Additional arguments passed to sandbox.execute()
|
||||
|
||||
Returns:
|
||||
@@ -73,10 +115,7 @@ class SandboxMixin:
|
||||
Raises:
|
||||
RuntimeError: If no sandbox is configured
|
||||
"""
|
||||
if not self.has_sandbox:
|
||||
raise RuntimeError(
|
||||
"No sandbox configured. Set sandbox=True or provide SandboxConfig to Agent()"
|
||||
)
|
||||
manager = self._manager_for(run_in)
|
||||
|
||||
# Security pre-check if enabled
|
||||
security_warnings = None
|
||||
@@ -88,7 +127,6 @@ class SandboxMixin:
|
||||
if self.verbose:
|
||||
logger.warning(f"Security warnings for code execution:\n{warning_text}")
|
||||
|
||||
manager = self.get_sandbox_manager()
|
||||
result = await manager.run_code(code, language=language, **kwargs)
|
||||
|
||||
# Attach warnings to the result rather than forwarding a `metadata`
|
||||
@@ -109,6 +147,7 @@ class SandboxMixin:
|
||||
code: str,
|
||||
language: str = "python",
|
||||
check_security: bool = True,
|
||||
run_in: Optional[Union[bool, str, 'SandboxConfig']] = None,
|
||||
**kwargs
|
||||
) -> 'SandboxResult':
|
||||
"""Synchronous wrapper for execute_code.
|
||||
@@ -123,12 +162,15 @@ class SandboxMixin:
|
||||
SandboxResult with execution details
|
||||
"""
|
||||
from ..approval.utils import run_coroutine_safely
|
||||
return run_coroutine_safely(self.execute_code(code, language, check_security, **kwargs))
|
||||
return run_coroutine_safely(
|
||||
self.execute_code(code, language, check_security, run_in=run_in, **kwargs)
|
||||
)
|
||||
|
||||
async def run_shell_command(
|
||||
self,
|
||||
command: Union[str, list],
|
||||
check_security: bool = True,
|
||||
run_in: Optional[Union[bool, str, 'SandboxConfig']] = None,
|
||||
**kwargs
|
||||
) -> 'SandboxResult':
|
||||
"""Run a shell command in the sandbox.
|
||||
@@ -141,8 +183,7 @@ class SandboxMixin:
|
||||
Returns:
|
||||
SandboxResult with execution details
|
||||
"""
|
||||
if not self.has_sandbox:
|
||||
raise RuntimeError("No sandbox configured")
|
||||
manager = self._manager_for(run_in)
|
||||
|
||||
# Convert command to string for security checking
|
||||
if isinstance(command, list):
|
||||
@@ -159,7 +200,7 @@ class SandboxMixin:
|
||||
if self.verbose:
|
||||
logger.warning(f"Security warnings for command:\n{warning_text}")
|
||||
|
||||
async with self.get_sandbox_manager() as sandbox:
|
||||
async with manager as sandbox:
|
||||
return await sandbox.run_command(command, **kwargs)
|
||||
|
||||
def get_sandbox_status(self) -> Dict[str, Any]:
|
||||
|
||||
@@ -374,6 +374,12 @@ class ToolExecutionMixin:
|
||||
|
||||
return resolve_tool_names(tool_names)
|
||||
|
||||
def _resolve_tools_list(self, tools):
|
||||
"""Resolve any tool-name strings inside a mixed ``tools=`` list."""
|
||||
from ..tools.resolver import resolve_tools_list
|
||||
|
||||
return resolve_tools_list(tools)
|
||||
|
||||
def _cast_arguments(self, func, arguments):
|
||||
"""Cast arguments to their expected types based on function signature."""
|
||||
if not callable(func) or not arguments:
|
||||
@@ -489,6 +495,7 @@ class ToolExecutionMixin:
|
||||
run_id=getattr(self, '_current_run_id', 'unknown'),
|
||||
session_id=getattr(self, '_session_id', None) or 'default',
|
||||
tool_name=function_name,
|
||||
metadata={"tool_call_id": tool_call_id},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -746,26 +753,21 @@ class ToolExecutionMixin:
|
||||
if blocked_result is not None:
|
||||
result = blocked_result
|
||||
else:
|
||||
# Apply tool retry logic with exponential backoff
|
||||
execution_config = getattr(self, '_execution_config', None)
|
||||
if execution_config is None:
|
||||
# Fall back to reading individual config attributes for backward compatibility
|
||||
max_retry_limit = getattr(self, 'max_retry_limit', 2)
|
||||
retry_initial_delay = 1.0
|
||||
retry_backoff_factor = 2.0
|
||||
retry_jitter = 0.1
|
||||
else:
|
||||
max_retry_limit = execution_config.max_retry_limit
|
||||
retry_initial_delay = execution_config.retry_initial_delay
|
||||
retry_backoff_factor = execution_config.retry_backoff_factor
|
||||
retry_jitter = execution_config.retry_jitter
|
||||
|
||||
# Apply tool retry logic with exponential backoff.
|
||||
# ONE vocabulary: the effective RetryPolicy caps how many times
|
||||
# the tool body may run, whichever of the two loops does the
|
||||
# retrying, and owns the backoff. ExecutionConfig.max_retry_limit
|
||||
# / retry_* are translated into it (see
|
||||
# _effective_tool_retry_policy) rather than run as a second,
|
||||
# independent budget in different units.
|
||||
retry_policy = self._effective_tool_retry_policy(function_name)
|
||||
max_attempts = retry_policy.max_attempts
|
||||
|
||||
result = None
|
||||
last_exception = None
|
||||
|
||||
# max_retry_limit is the number of retries (not total attempts)
|
||||
# So total attempts = 1 (initial) + max_retry_limit (retries)
|
||||
for attempt in range(1, max_retry_limit + 2):
|
||||
|
||||
# max_attempts is the total number of tool-body runs.
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
# P8/G11: Apply tool timeout if configured
|
||||
tool_timeout = getattr(self, '_tool_timeout', None)
|
||||
@@ -807,7 +809,12 @@ class ToolExecutionMixin:
|
||||
_progress_active.clear()
|
||||
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}
|
||||
# Tag as an OUTER wall-clock timeout: unlike an
|
||||
# inner-loop timeout (already retried to
|
||||
# exhaustion), the body ran once here and the
|
||||
# inner budget never saw it, so this one still
|
||||
# earns an outer retry pass under the single budget.
|
||||
result = {"error": f"Tool timed out after {tool_timeout}s", "timeout": True, "_outer_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
|
||||
@@ -826,59 +833,80 @@ class ToolExecutionMixin:
|
||||
|
||||
# Check if the result indicates a retryable error
|
||||
if isinstance(result, dict) and result.get("error"):
|
||||
# Check if this is a circuit breaker error (always retryable)
|
||||
if result.get("circuit_open"):
|
||||
raise ToolExecutionError(
|
||||
result["error"],
|
||||
tool_name=function_name,
|
||||
agent_id=self.name,
|
||||
is_retryable=True,
|
||||
)
|
||||
# Check if this is a timeout error (retryable)
|
||||
elif result.get("timeout"):
|
||||
raise ToolExecutionError(
|
||||
result["error"],
|
||||
tool_name=function_name,
|
||||
agent_id=self.name,
|
||||
is_retryable=True,
|
||||
)
|
||||
# For other error dicts: approval/permission denials are legitimate
|
||||
# non-retryable outcomes; everything else represents a tool failure
|
||||
# that should engage the outer retry/backoff loop.
|
||||
elif result.get("approval_denied") or result.get("permission_denied") or result.get("approval_error") or result.get("policy_denied") or result.get("guardrail_denied"):
|
||||
# Approval/permission/policy/guardrail denials are
|
||||
# legitimate non-retryable outcomes: stop immediately.
|
||||
if result.get("approval_denied") or result.get("permission_denied") or result.get("approval_error") or result.get("policy_denied") or result.get("guardrail_denied"):
|
||||
break
|
||||
else:
|
||||
# Avoid compounding with the inner retry loop in
|
||||
# _execute_tool_with_circuit_breaker: error types it already
|
||||
# retries (e.g. timeout/rate_limit/connection_error) are
|
||||
# exhausted by the time they reach here, so escalating them as
|
||||
# retryable would re-run the entire inner loop from scratch on
|
||||
# every outer attempt. Only escalate error types the inner loop
|
||||
# does NOT retry (e.g. "unknown") so they get one outer pass.
|
||||
error_type = self._classify_error_type(result, None)
|
||||
inner_policy = self._get_tool_retry_policy(function_name)
|
||||
is_retryable = error_type not in inner_policy.retry_on
|
||||
raise ToolExecutionError(
|
||||
result.get("error", f"Tool '{function_name}' failed"),
|
||||
tool_name=function_name,
|
||||
agent_id=self.name,
|
||||
is_retryable=is_retryable,
|
||||
)
|
||||
# ONE budget: the inner loop
|
||||
# (_execute_tool_with_circuit_breaker) already retries
|
||||
# its retry_on error types (timeout/rate_limit/
|
||||
# connection_error) to exhaustion before returning here,
|
||||
# so re-escalating them as retryable would restart the
|
||||
# whole inner budget on every outer attempt and run the
|
||||
# tool up to max_attempts² times.
|
||||
error_type = self._classify_error_type(result, None)
|
||||
inner_policy = self._get_tool_retry_policy(function_name)
|
||||
# Only escalate to an outer retry pass when the failure is
|
||||
# a transient the inner loop did NOT already re-drive:
|
||||
# • _outer_timeout — this loop's own wall-clock guard
|
||||
# tripped; the body ran once and the inner budget
|
||||
# never saw it. BUT the timed-out body is still
|
||||
# running in an orphaned thread (future.cancel() is
|
||||
# a no-op once started), so retrying a mutating tool
|
||||
# would duplicate its side effects. Only re-drive it
|
||||
# when the tool is known idempotent/read-only.
|
||||
# • circuit_open — returned by the inner loop WITHOUT a
|
||||
# body run, so retrying costs no duplicate side effect.
|
||||
# • _praison_retryable — a raised exception was flattened
|
||||
# into this error dict (see _execute_tool_impl); honour
|
||||
# that verdict, but ONLY for error types the inner loop
|
||||
# does not itself retry, or inner×outer would run the
|
||||
# body max_attempts² times.
|
||||
# A plain error dict the tool body *returned* carries no tag
|
||||
# — it is the tool's own reported outcome, not a transient
|
||||
# the framework must re-drive. The body already ran to
|
||||
# completion, so re-running it would duplicate its side
|
||||
# effects (charge money, send a message, write a row) for a
|
||||
# decision the tool already made. If a user genuinely wants
|
||||
# such an outcome retried, its error type belongs in the
|
||||
# RetryPolicy.retry_on the inner loop honours — not a word
|
||||
# in the tool's own payload. Surface it once.
|
||||
is_retryable = bool(
|
||||
(result.get("_outer_timeout")
|
||||
and self._is_tool_idempotent(function_name))
|
||||
or result.get("circuit_open")
|
||||
or (result.get("_praison_retryable") is True
|
||||
and error_type not in inner_policy.retry_on
|
||||
# The body already ran and may have completed
|
||||
# its side effect before raising. Honour an
|
||||
# explicit non-idempotent declaration here, as
|
||||
# the _outer_timeout clause above already does.
|
||||
and not self._tool_declares_not_idempotent(function_name))
|
||||
)
|
||||
# Strip the private control-plane tag before it can reach
|
||||
# the model or be re-surfaced as the tool's payload.
|
||||
result.pop("_praison_retryable", None)
|
||||
raise ToolExecutionError(
|
||||
result.get("error", f"Tool '{function_name}' failed"),
|
||||
tool_name=function_name,
|
||||
agent_id=self.name,
|
||||
is_retryable=is_retryable,
|
||||
)
|
||||
else:
|
||||
# Success path - return the result
|
||||
break
|
||||
|
||||
except ToolExecutionError as e:
|
||||
last_exception = e
|
||||
# Only retry if the error is marked as retryable and we have retries left
|
||||
# attempt starts at 1, so (attempt - 1) gives us the retry count
|
||||
if not e.is_retryable or (attempt - 1) >= max_retry_limit:
|
||||
# Only retry if the error is marked as retryable and the
|
||||
# policy still has attempts left.
|
||||
if not e.is_retryable or attempt >= max_attempts:
|
||||
raise e
|
||||
|
||||
# Calculate delay for exponential backoff
|
||||
delay = BackoffPolicy.delay(attempt, retry_initial_delay, retry_backoff_factor, retry_jitter)
|
||||
|
||||
# Calculate delay for exponential backoff (policy owns it)
|
||||
delay = retry_policy.get_delay_ms(attempt - 1) / 1000.0
|
||||
logging.warning(
|
||||
f"Tool {function_name} failed (attempt {attempt}/{max_retry_limit + 1}): {e}. "
|
||||
f"Tool {function_name} failed (attempt {attempt}/{max_attempts}): {e}. "
|
||||
f"Retrying in {delay:.2f}s..."
|
||||
)
|
||||
time.sleep(delay)
|
||||
@@ -894,15 +922,14 @@ class ToolExecutionMixin:
|
||||
is_retryable=is_retryable,
|
||||
)
|
||||
last_exception = tool_error
|
||||
|
||||
# attempt starts at 1, so (attempt - 1) gives us the retry count
|
||||
if not is_retryable or (attempt - 1) >= max_retry_limit:
|
||||
|
||||
if not is_retryable or attempt >= max_attempts:
|
||||
raise tool_error from e
|
||||
|
||||
# Calculate delay for exponential backoff
|
||||
delay = BackoffPolicy.delay(attempt, retry_initial_delay, retry_backoff_factor, retry_jitter)
|
||||
|
||||
# Calculate delay for exponential backoff (policy owns it)
|
||||
delay = retry_policy.get_delay_ms(attempt - 1) / 1000.0
|
||||
logging.warning(
|
||||
f"Tool {function_name} failed (attempt {attempt}/{max_retry_limit + 1}): {e}. "
|
||||
f"Tool {function_name} failed (attempt {attempt}/{max_attempts}): {e}. "
|
||||
f"Retrying in {delay:.2f}s..."
|
||||
)
|
||||
time.sleep(delay)
|
||||
@@ -1994,6 +2021,12 @@ class ToolExecutionMixin:
|
||||
if not decision.approved:
|
||||
error_msg = f"Tool execution denied: {decision.reason}"
|
||||
logging.warning(error_msg)
|
||||
feedback = getattr(decision, "feedback", None)
|
||||
if feedback:
|
||||
error_msg = (
|
||||
f"Tool '{function_name}' was denied by the user. "
|
||||
f"Do not retry it as-is. User guidance: {feedback}"
|
||||
)
|
||||
return {"error": error_msg, "approval_denied": True}
|
||||
|
||||
if decision.modified_args:
|
||||
@@ -2027,6 +2060,12 @@ class ToolExecutionMixin:
|
||||
if not decision.approved:
|
||||
error_msg = f"Tool execution denied: {decision.reason}"
|
||||
logging.warning(error_msg)
|
||||
feedback = getattr(decision, "feedback", None)
|
||||
if feedback:
|
||||
error_msg = (
|
||||
f"Tool '{function_name}' was denied by the user. "
|
||||
f"Do not retry it as-is. User guidance: {feedback}"
|
||||
)
|
||||
return {"error": error_msg, "approval_denied": True}
|
||||
|
||||
if decision.modified_args:
|
||||
@@ -2069,7 +2108,13 @@ class ToolExecutionMixin:
|
||||
result.get("guardrail_denied") or
|
||||
result.get("circuit_open")):
|
||||
return result
|
||||
|
||||
|
||||
# The body already ran and may have completed its side effect
|
||||
# before failing. Honour an explicit non-idempotent declaration
|
||||
# rather than re-driving it (send a second email, charge twice).
|
||||
if self._tool_declares_not_idempotent(function_name):
|
||||
return result
|
||||
|
||||
# Determine error type for retry policy
|
||||
error_type = self._classify_error_type(result, last_exception)
|
||||
|
||||
@@ -2551,8 +2596,20 @@ class ToolExecutionMixin:
|
||||
f"{error_msg} Expected parameters for '{function_name}' — "
|
||||
f"required: {schema['required']}, optional: {schema['optional']}."
|
||||
)
|
||||
return {"error": error_msg, "expected_parameters": schema}
|
||||
return {"error": error_msg}
|
||||
# An argument-binding error is a programming error — the same
|
||||
# call will always fail — so mark it terminal and never retry.
|
||||
return {"error": error_msg, "expected_parameters": schema, "_praison_retryable": False}
|
||||
# A tool that *raised* is flattened into an error dict here, which
|
||||
# erases the exception type the retry policy needs. Carry the same
|
||||
# verdict the exception paths use so the outer loop can retry a
|
||||
# transient failure without re-running a tool that merely *returned*
|
||||
# an error dict of its own. Programming errors are terminal;
|
||||
# everything else may be retried. Private key: never leaks to the
|
||||
# model and cannot collide with a tool's own "retryable" field.
|
||||
return {
|
||||
"error": error_msg,
|
||||
"_praison_retryable": not isinstance(e, (ValueError, TypeError, AttributeError)),
|
||||
}
|
||||
|
||||
# Unresolved: return a corrective, model-readable message so the model can
|
||||
# retry with a valid name instead of repeating the same mistake.
|
||||
@@ -2939,17 +2996,124 @@ class ToolExecutionMixin:
|
||||
else:
|
||||
return f"Unknown bridge tool: {function_name}"
|
||||
|
||||
def _tool_declares_not_idempotent(self, tool_name):
|
||||
"""True only when the tool *explicitly* declares itself unsafe to re-run.
|
||||
|
||||
Deliberately narrower than ``_is_tool_idempotent``, which answers False
|
||||
for unknown tools as a safe default: gating a retry on that would stop
|
||||
retrying every unregistered user tool.
|
||||
|
||||
This keys on an explicit declaration and nothing else. In particular it
|
||||
must NOT consult the shared ``MUTATING_TOOLS`` name registry -- that is a
|
||||
73-name list maintained for the escalation loop guard, and using it here
|
||||
silently removed the retry from any *undeclared* tool that happened to
|
||||
share a name with an entry (``write_file``, ``store_memory``, ``mkdir``...).
|
||||
"""
|
||||
tools = getattr(self, 'tools', [])
|
||||
if not isinstance(tools, (list, tuple)):
|
||||
tools = []
|
||||
for tool in tools:
|
||||
name_attr = getattr(tool, '__name__', None) or getattr(tool, 'name', None)
|
||||
if name_attr == tool_name:
|
||||
explicit = getattr(tool, 'idempotent', None)
|
||||
if not isinstance(explicit, bool):
|
||||
# ``restart_safe`` is the public replay-safety contract on
|
||||
# ``@tool`` (FunctionTool) and ``BaseTool``: ``False`` marks an
|
||||
# effectful tool that must never be silently re-executed.
|
||||
explicit = getattr(tool, 'restart_safe', None)
|
||||
if isinstance(explicit, bool):
|
||||
return not explicit
|
||||
return False
|
||||
# A tool from the process-global registry (only reachable under
|
||||
# ToolConfig(allow_global_tools=True)) can carry its own declaration;
|
||||
# ``_is_tool_idempotent`` consults it, so this must too.
|
||||
if getattr(self, '_allow_global_tools', False):
|
||||
try:
|
||||
from ..tools.registry import get_registry
|
||||
global_tool = get_registry().get(tool_name)
|
||||
for attr in ('idempotent', 'restart_safe'):
|
||||
explicit = getattr(global_tool, attr, None)
|
||||
if isinstance(explicit, bool):
|
||||
return not explicit
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
def _is_tool_idempotent(self, tool_name):
|
||||
"""Whether re-running ``tool_name`` is safe (no duplicated side effects).
|
||||
|
||||
Used to decide if an OUTER wall-clock timeout may be retried: the timed-out
|
||||
tool body is still running in an orphaned thread (``future.cancel()`` is a
|
||||
no-op once started), so auto-retrying a mutating tool would duplicate its
|
||||
side effects (send a second email, charge twice). Only tools classified as
|
||||
read-only/idempotent are safe to re-drive.
|
||||
|
||||
Precedence: explicit ``idempotent`` attribute on the tool > the shared
|
||||
read-only/mutating name registry (reused from the escalation loop guard).
|
||||
Unknown tools default to NOT idempotent (safe: surface the timeout once).
|
||||
"""
|
||||
tools = getattr(self, 'tools', [])
|
||||
if not isinstance(tools, (list, tuple)):
|
||||
tools = []
|
||||
for tool in tools:
|
||||
tool_name_attr = getattr(tool, '__name__', None) or getattr(tool, 'name', None)
|
||||
if tool_name_attr == tool_name:
|
||||
explicit = getattr(tool, 'idempotent', None)
|
||||
if isinstance(explicit, bool):
|
||||
return explicit
|
||||
break
|
||||
# A tool from the process-global registry (only reachable when
|
||||
# ToolConfig(allow_global_tools=True)) can carry its own explicit
|
||||
# ``idempotent`` flag even when its name is absent from the shared
|
||||
# read-only/mutating registries below.
|
||||
if getattr(self, '_allow_global_tools', False):
|
||||
try:
|
||||
from ..tools.registry import get_registry
|
||||
global_tool = get_registry().get(tool_name)
|
||||
explicit = getattr(global_tool, 'idempotent', None)
|
||||
if isinstance(explicit, bool):
|
||||
return explicit
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from ..escalation.loop_guard import IDEMPOTENT_TOOLS, MUTATING_TOOLS
|
||||
except Exception:
|
||||
return False
|
||||
if tool_name in IDEMPOTENT_TOOLS:
|
||||
return True
|
||||
if tool_name in MUTATING_TOOLS:
|
||||
return False
|
||||
return False
|
||||
|
||||
def _get_tool_retry_policy(self, tool_name):
|
||||
"""Get retry policy for a tool (tool-level > agent-level > default).
|
||||
|
||||
|
||||
Kept as the historical name; both retry loops now go through
|
||||
``_effective_tool_retry_policy`` so they share one policy object.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool to get retry policy for
|
||||
|
||||
Returns:
|
||||
RetryPolicy instance
|
||||
"""
|
||||
return self._effective_tool_retry_policy(tool_name)
|
||||
|
||||
def _effective_tool_retry_policy(self, tool_name):
|
||||
"""The one RetryPolicy that governs every retry of ``tool_name``.
|
||||
|
||||
Precedence: per-tool ``.retry_policy`` > agent-level
|
||||
``ToolConfig(retry_policy=...)`` > a policy translated from the
|
||||
ExecutionConfig spelling of the same knobs.
|
||||
|
||||
``ExecutionConfig`` speaks a second vocabulary for the same thing --
|
||||
``max_retry_limit`` counts *retries* (so total runs = limit + 1),
|
||||
``retry_initial_delay`` is in *seconds*, and ``retry_jitter`` is a
|
||||
*fraction*. Translating it here, once, is what keeps the two retry loops
|
||||
from running two independent budgets in two different units.
|
||||
"""
|
||||
from ..tools.retry import RetryPolicy
|
||||
|
||||
|
||||
# Check for tool-level retry policy first
|
||||
tools = getattr(self, 'tools', [])
|
||||
# Handle non-iterable tools (e.g., single MCP instance)
|
||||
@@ -2960,16 +3124,43 @@ class ToolExecutionMixin:
|
||||
tool_policy = getattr(tool, 'retry_policy', None) if hasattr(tool, 'retry_policy') else None
|
||||
if tool_name_attr == tool_name and tool_policy is not None:
|
||||
return tool_policy
|
||||
|
||||
|
||||
# Check for agent-level retry policy
|
||||
agent_policy = getattr(self, '_tool_retry_policy', None)
|
||||
if agent_policy is not None:
|
||||
return agent_policy
|
||||
|
||||
# Return default retry policy (cached class-level instance)
|
||||
if not hasattr(ToolExecutionMixin, '_default_retry_policy'):
|
||||
ToolExecutionMixin._default_retry_policy = RetryPolicy()
|
||||
return ToolExecutionMixin._default_retry_policy
|
||||
|
||||
# Translate the ExecutionConfig spelling into the RetryPolicy vocabulary.
|
||||
execution_config = getattr(self, '_execution_config', None)
|
||||
if execution_config is None:
|
||||
max_retry_limit = getattr(self, 'max_retry_limit', 2)
|
||||
initial_delay_s, backoff_factor, jitter_fraction = 1.0, 2.0, 0.1
|
||||
else:
|
||||
max_retry_limit = execution_config.max_retry_limit
|
||||
initial_delay_s = execution_config.retry_initial_delay
|
||||
backoff_factor = execution_config.retry_backoff_factor
|
||||
jitter_fraction = execution_config.retry_jitter
|
||||
|
||||
cached = getattr(self, '_translated_retry_policy', None)
|
||||
key = (max_retry_limit, initial_delay_s, backoff_factor, jitter_fraction)
|
||||
if cached is not None and cached[0] == key:
|
||||
return cached[1]
|
||||
# Reproduce BackoffPolicy.delay's 60s base cap so ExecutionConfig users
|
||||
# keep the exact same delay ceiling they had before this consolidation
|
||||
# (RetryPolicy defaults to a 30s cap). initial_delay_ms must not exceed
|
||||
# max_delay_ms per RetryPolicy validation, so lift the cap to whichever
|
||||
# is larger.
|
||||
initial_delay_ms = int(initial_delay_s * 1000)
|
||||
policy = RetryPolicy(
|
||||
max_attempts=max(1, max_retry_limit + 1),
|
||||
backoff_factor=max(1.0, backoff_factor),
|
||||
initial_delay_ms=initial_delay_ms,
|
||||
max_delay_ms=max(60000, initial_delay_ms),
|
||||
jitter=jitter_fraction > 0,
|
||||
jitter_factor=min(max(jitter_fraction, 0.0), 1.0),
|
||||
)
|
||||
self._translated_retry_policy = (key, policy)
|
||||
return policy
|
||||
|
||||
def _classify_error_type(self, error_dict, exception):
|
||||
"""Classify error type for retry policy matching.
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Give a single Agent's tools a sandbox, without touching its thinking.
|
||||
|
||||
``AgentFlow``/``AgentTeam`` already share one sandbox across a run and tear it
|
||||
down when the run ends. A lone ``Agent(tools_run_on=...)`` has no "run" to hang
|
||||
that lifetime on -- it is just an object someone calls ``chat()`` on, possibly
|
||||
many times -- so the sandbox is created on first use and lives as long as the
|
||||
agent does. That is deliberate: a file written in turn 1 is still there in turn
|
||||
2, which is what anyone who asked for a working directory expects.
|
||||
|
||||
Attachment is idempotent and happens at the top of ``chat``/``achat``/
|
||||
``_start_stream``, and in ``AgentTeam`` before the task's tool list is
|
||||
snapshotted. Doing it once, rather than wrapping each call in a context
|
||||
manager, sidesteps two real hazards in this codebase:
|
||||
|
||||
* ``_start_stream`` is a **generator**; a context manager around the call would
|
||||
exit before the caller consumed a single token, tearing down the sandbox while
|
||||
the model was still using it.
|
||||
* ``_start_stream`` falls back to ``self.chat``, so a wrapper would **re-enter**
|
||||
itself and provision a second sandbox.
|
||||
|
||||
Lifetime is tied to the agent by ``weakref.finalize`` rather than a process-exit
|
||||
hook over a weak set. The weak set leaked: an agent and its ``SharedCompute``
|
||||
reference each other, so a dropped agent was reclaimed by the cycle collector,
|
||||
vanished from the set, and its sandbox was never shut down. Gateways clone an
|
||||
agent **per request**, so that was one orphaned container per request.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import weakref
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# One lock per agent, so two unrelated agents never wait on each other and no
|
||||
# lock is ever held while another agent's provider is being imported. Held only
|
||||
# by ensure_tools_placed/close_tools_sandbox for that one agent.
|
||||
_AGENT_LOCKS: "weakref.WeakKeyDictionary" = weakref.WeakKeyDictionary()
|
||||
_LOCKS_GUARD = threading.Lock()
|
||||
|
||||
|
||||
def _lock_for(agent: Any) -> threading.Lock:
|
||||
"""The placement lock for ``agent``, created on demand.
|
||||
|
||||
``_LOCKS_GUARD`` covers only this dictionary lookup -- microseconds -- never
|
||||
provider construction or attachment. An earlier version held one global lock
|
||||
across ``SharedCompute(...)``, which imports the provider SDK: unrelated
|
||||
agents serialised behind each other, and a slow import could stall
|
||||
interpreter exit.
|
||||
"""
|
||||
with _LOCKS_GUARD:
|
||||
lock = _AGENT_LOCKS.get(agent)
|
||||
if lock is None:
|
||||
lock = threading.Lock()
|
||||
try:
|
||||
_AGENT_LOCKS[agent] = lock
|
||||
except TypeError: # pragma: no cover - unhashable/non-weakrefable
|
||||
pass
|
||||
return lock
|
||||
|
||||
|
||||
def _shutdown_orphan(shared: Any, name: str) -> None:
|
||||
"""Tear down a sandbox whose agent is gone.
|
||||
|
||||
Registered with ``weakref.finalize``, so it runs when the agent is collected
|
||||
*and* at interpreter exit. It must not close over the agent -- that would
|
||||
keep it alive and defeat the whole point.
|
||||
"""
|
||||
try:
|
||||
shared.shutdown()
|
||||
logger.debug("[tools_placement] released the sandbox belonging to %s", name)
|
||||
except Exception as exc: # pragma: no cover - teardown must stay quiet
|
||||
# Loud enough to notice: a failed release means a container, or a
|
||||
# billed cloud instance, is still running after the agent is gone.
|
||||
logger.warning(
|
||||
"[tools_placement] could not release the sandbox for %s: %s", name, exc
|
||||
)
|
||||
|
||||
|
||||
def ensure_tools_placed(agent: Any) -> None:
|
||||
"""Attach the sandbox tools to ``agent`` if it asked for them. Idempotent.
|
||||
|
||||
Called at the top of every entry point, so it must be cheap when there is
|
||||
nothing to do -- which is the overwhelmingly common case.
|
||||
"""
|
||||
place = getattr(agent, "tools_run_on", None)
|
||||
if place is None or getattr(agent, "_tools_sandbox", None) is not None:
|
||||
return
|
||||
|
||||
from ..managed.shared_compute import SharedCompute
|
||||
|
||||
with _lock_for(agent):
|
||||
# Re-check under the lock: two threads calling chat() at once must not
|
||||
# each provision a sandbox and leak one.
|
||||
if getattr(agent, "_tools_sandbox", None) is not None:
|
||||
return
|
||||
|
||||
shared = SharedCompute(place)
|
||||
# override_declared: this sandbox exists *because* the agent named a
|
||||
# place, so the skip-if-declared rule must not skip the agent itself.
|
||||
shared.attach([agent], override_declared=True)
|
||||
agent._tools_sandbox = shared
|
||||
agent._tools_sandbox_finalizer = weakref.finalize(
|
||||
agent, _shutdown_orphan, shared, str(getattr(agent, "name", agent))
|
||||
)
|
||||
|
||||
logger.info("[tools_placement] %r tools now run on %s", getattr(agent, "name", agent), place)
|
||||
|
||||
|
||||
def close_tools_sandbox(agent: Any) -> None:
|
||||
"""Tear down the agent's sandbox and put its original tools back."""
|
||||
with _lock_for(agent):
|
||||
# Everything happens under the lock. Clearing the marker first and
|
||||
# shutting down afterwards left a window where a concurrent chat()
|
||||
# re-attached and the in-flight shutdown then restored over it, leaving
|
||||
# the agent marked as sandboxed while holding no sandbox tools -- a
|
||||
# state ensure_tools_placed() could never repair, because its idempotent
|
||||
# early return sees the marker and does nothing.
|
||||
shared = getattr(agent, "_tools_sandbox", None)
|
||||
if shared is None:
|
||||
return
|
||||
finalizer = getattr(agent, "_tools_sandbox_finalizer", None)
|
||||
if finalizer is not None:
|
||||
finalizer.detach() # we are shutting down explicitly
|
||||
agent._tools_sandbox_finalizer = None
|
||||
shared.shutdown()
|
||||
agent._tools_sandbox = None
|
||||
@@ -282,7 +282,7 @@ class UnifiedExecutionMixin:
|
||||
task_id=task_id,
|
||||
config=config,
|
||||
tool_choice=tool_choice,
|
||||
parallel_tool_calls=getattr(self, 'parallel_tool_calls', False) # Gap 2 integration
|
||||
parallel_tool_calls=getattr(getattr(self, "execution", None), "parallel_tool_calls", False) # Gap 2 integration
|
||||
)
|
||||
else:
|
||||
# Standard LiteLLM path - delegate to existing LLM class
|
||||
@@ -304,7 +304,7 @@ class UnifiedExecutionMixin:
|
||||
task_id=task_id,
|
||||
config=config,
|
||||
tool_choice=tool_choice,
|
||||
parallel_tool_calls=getattr(self, 'parallel_tool_calls', False) # Gap 2 integration
|
||||
parallel_tool_calls=getattr(getattr(self, "execution", None), "parallel_tool_calls", False) # Gap 2 integration
|
||||
)
|
||||
|
||||
# Store response in memory if enabled
|
||||
|
||||
@@ -158,9 +158,8 @@ class VideoAgent:
|
||||
verbose: Verbosity level for output
|
||||
output: Output preset ("silent", "verbose", etc.)
|
||||
"""
|
||||
# Handle model= alias for llm=
|
||||
if llm is None and model is not None:
|
||||
llm = model
|
||||
from ..utils.model_alias import resolve_model_name
|
||||
llm = resolve_model_name(llm, model, "VideoAgent")
|
||||
|
||||
# Store core identity
|
||||
self.name = name or "VideoAgent"
|
||||
|
||||
@@ -112,8 +112,8 @@ class VisionAgent:
|
||||
- VisionConfig: Full configuration object
|
||||
verbose: Verbosity level for output
|
||||
"""
|
||||
if llm is None and model is not None:
|
||||
llm = model
|
||||
from ..utils.model_alias import resolve_model_name
|
||||
llm = resolve_model_name(llm, model, "VisionAgent")
|
||||
|
||||
self.name = name or "VisionAgent"
|
||||
self.instructions = instructions
|
||||
|
||||
@@ -2,6 +2,8 @@ import os
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
import contextlib
|
||||
import contextvars
|
||||
import threading
|
||||
from praisonaiagents._logging import get_logger
|
||||
from typing import Any, Dict, Optional, List, Tuple, Callable
|
||||
@@ -41,18 +43,13 @@ class TaskStatus(Enum):
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _launch_auth_token() -> Optional[str]:
|
||||
return os.environ.get("PRAISONAI_LAUNCH_AUTH_TOKEN")
|
||||
|
||||
|
||||
def _authorise_launch_request(request) -> bool:
|
||||
token = _launch_auth_token()
|
||||
if not token:
|
||||
return True
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer ") and auth[7:] == token:
|
||||
return True
|
||||
return request.headers.get("X-Auth-Token") == token
|
||||
# Request authorisation + fail-closed bind guard are shared with Agent.launch
|
||||
# so the single-agent and multi-agent serving paths cannot drift apart.
|
||||
from ..agent.launch_security import (
|
||||
launch_auth_token as _launch_auth_token,
|
||||
authorise_launch_request as _authorise_launch_request,
|
||||
resolve_launch_host as _resolve_launch_host,
|
||||
)
|
||||
|
||||
|
||||
# Agent server registry for thread-safe server management
|
||||
@@ -375,6 +372,18 @@ def _build_execution_context(agents_instance, task_id, skip_memory_init=False):
|
||||
if llm and hasattr(llm, 'set_current_agent'):
|
||||
llm.set_current_agent(executor_agent.display_name)
|
||||
|
||||
# Place the agent's tools BEFORE snapshotting them. This snapshot is passed
|
||||
# to chat() as an explicit `tools=`, which wins over `agent.tools` -- so if
|
||||
# the sandbox attaches later (inside chat()), the model is handed the
|
||||
# pre-attach list and every tool runs on the host while a sandbox sits
|
||||
# provisioned and unused. Silent, and worse than it sounds: in
|
||||
# start_for_each() row 1 got local tools and row 2 got the bridged set,
|
||||
# because row 1's chat() mutated agent.tools in time for row 2's snapshot.
|
||||
if executor_agent is not None and getattr(executor_agent, "tools_run_on", None) is not None:
|
||||
from ..agent.tools_placement import ensure_tools_placed
|
||||
|
||||
ensure_tools_placed(executor_agent)
|
||||
|
||||
# Ensure tools are available from both task and agent (create copy to avoid mutation)
|
||||
tools = list(task.tools or [])
|
||||
if executor_agent and executor_agent.tools:
|
||||
@@ -644,7 +653,8 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
manager_llm=None,
|
||||
name: Optional[str] = None,
|
||||
variables: Optional[Dict[str, Any]] = None,
|
||||
llm: Optional[str] = None, # Default LLM for all agents (API consistency)
|
||||
llm: Optional[str] = None, # Deprecated alias for model=
|
||||
model: Optional[str] = None, # Default model for members that did not name one
|
||||
# Consolidated feature params (agent-centric API)
|
||||
memory: Optional[Any] = False, # Union[bool, MultiAgentMemoryConfig]
|
||||
planning: Optional[Any] = False, # Union[bool, MultiAgentPlanningConfig]
|
||||
@@ -662,9 +672,12 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
learn: Optional[Any] = None, # Union[bool, LearnConfig] - continuous learning
|
||||
# Union[str, ComputeProviderProtocol] - where every agent's shell/file
|
||||
# tools run: "docker", "e2b", "modal", "daytona", "flyio", "tenki",
|
||||
# "local". The team shares ONE sandbox and /workspace, so files written
|
||||
# by one agent are visible to the others. Orchestration stays local.
|
||||
# "local", "subprocess", "sandlock", "ssh", "novita". The team shares
|
||||
# ONE sandbox and one working directory, so files written by one agent
|
||||
# are visible to the others. Orchestration and thinking stay local.
|
||||
# Pass a configured provider instance to customise resources.
|
||||
tools_run_on: Optional[Any] = None,
|
||||
# Accepted only to fail with a useful message -- see resolve_placement.
|
||||
run_on: Optional[Any] = None,
|
||||
):
|
||||
"""
|
||||
@@ -676,8 +689,11 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
process: Execution process type ("sequential", "workflow", "hierarchical").
|
||||
For parallel fan-out, set async_execution=True on individual Task
|
||||
objects within a "workflow" or "sequential" process.
|
||||
manager_llm: LLM model for manager agent
|
||||
llm: Default LLM model for all agents
|
||||
manager_llm: Model for the hierarchical manager agent. Unrelated to
|
||||
model=/llm=; it never touches the members.
|
||||
llm: Deprecated alias for model=. Passing both raises TypeError.
|
||||
model: Default model for members that did not name one themselves.
|
||||
An agent constructed with its own llm=/model=/auth= keeps it.
|
||||
name: Name for this agent collection
|
||||
variables: Global variables for substitution
|
||||
memory: Memory configuration (bool | MultiAgentMemoryConfig)
|
||||
@@ -695,7 +711,21 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
"""
|
||||
# Store new params for propagation to agents
|
||||
self._learn = learn
|
||||
self.llm = llm # Store default LLM for API consistency
|
||||
# model= is the canonical name; llm= is the deprecated alias. Passing
|
||||
# both is refused rather than silently resolved (see resolve_model_alias).
|
||||
# The resolved value is the default model for the members: applied to
|
||||
# every agent that never named a model of its own; an agent that did
|
||||
# keeps it. Same rule AgentFlow uses for the agents it builds.
|
||||
# Must UNWRAP, not just alias-resolve: this value is pushed into every
|
||||
# member via _apply_default_llm, and a member holding an LLMConfig fails
|
||||
# inside chat() with "'LLMConfig' object has no attribute 'lower'".
|
||||
from ..utils.model_alias import resolve_model_name
|
||||
self.llm = resolve_model_name(llm, model, type(self).__name__)
|
||||
if self.llm:
|
||||
for _member in (agents.values() if isinstance(agents, dict) else (agents or [])):
|
||||
_apply = getattr(_member, "_apply_default_llm", None)
|
||||
if callable(_apply):
|
||||
_apply(self.llm)
|
||||
self._autonomy = autonomy
|
||||
self._knowledge = knowledge
|
||||
self._guardrails = guardrails
|
||||
@@ -910,7 +940,19 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
self.stream = _stream
|
||||
self.name = name
|
||||
# Remote execution: one shared sandbox for every agent on this team.
|
||||
self.run_on = run_on
|
||||
from ..agent.placement import resolve_placement
|
||||
|
||||
resolve_placement(
|
||||
"AgentTeam", run_on=run_on, tools_run_on=tools_run_on,
|
||||
supports_run_on=False,
|
||||
)
|
||||
self.tools_run_on = tools_run_on
|
||||
# Kept readable rather than removed. `team.run_on` used to exist, and
|
||||
# code doing getattr(team, "run_on", None) for introspection or
|
||||
# serialisation would otherwise start raising AttributeError. None is
|
||||
# the honest value: a team orchestrates locally, so nothing about it
|
||||
# runs wholly on a managed runtime.
|
||||
self.run_on = None
|
||||
|
||||
# Callbacks for workflow execution
|
||||
self.on_task_start = _on_task_start
|
||||
@@ -1437,6 +1479,73 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
raise result
|
||||
return results
|
||||
|
||||
def _depends_on_pending(self, task, pending):
|
||||
"""True if ``task`` depends on a task still pending in the batch.
|
||||
|
||||
``pending`` is a list of ``(task_id, coroutine)`` pairs already queued
|
||||
for parallel execution. If one of the task's dependencies is in that
|
||||
set, its result isn't available yet, so the batch must be flushed before
|
||||
queuing this task (otherwise the dependent builds its prompt from an
|
||||
empty result for the still-in-progress dependency).
|
||||
|
||||
Two dependency edges are checked, since ``_build_task_context`` reads
|
||||
from both:
|
||||
- ``task.context``: explicit context tasks (by object ``id``).
|
||||
- ``task.previous_tasks``: workflow ``next_tasks`` edges, stored as
|
||||
task *names*; without this a workflow successor could be queued in
|
||||
the same async batch as its predecessor and read an empty result.
|
||||
"""
|
||||
if not pending:
|
||||
return False
|
||||
pending_ids = {tid for tid, _ in pending}
|
||||
|
||||
deps = getattr(task, 'context', None) or []
|
||||
if any(getattr(dep, 'id', None) in pending_ids for dep in deps):
|
||||
return True
|
||||
|
||||
previous = getattr(task, 'previous_tasks', None) or []
|
||||
if previous:
|
||||
pending_names = {
|
||||
self.tasks[tid].name
|
||||
for tid in pending_ids
|
||||
if tid in self.tasks
|
||||
}
|
||||
if any(name in pending_names for name in previous):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _deps_failed(self, task_id):
|
||||
"""True if any dependency of ``task_id`` finished in a failed state.
|
||||
|
||||
asequential()/aworkflow() run their own failure 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, for
|
||||
both dependency edges ``_build_task_context`` reads (context tasks and
|
||||
workflow ``previous_tasks``).
|
||||
"""
|
||||
task = self.tasks.get(task_id)
|
||||
if task is None:
|
||||
return False
|
||||
|
||||
for dep in getattr(task, 'context', None) or []:
|
||||
dep_id = getattr(dep, 'id', None)
|
||||
if dep_id in self.tasks and self.tasks[dep_id].status == "failed":
|
||||
return True
|
||||
|
||||
previous = getattr(task, 'previous_tasks', None) or []
|
||||
if previous:
|
||||
by_name = {t.name: t for t in self.tasks.values()}
|
||||
if any(
|
||||
name in by_name and by_name[name].status == "failed"
|
||||
for name in previous
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def arun_all_tasks(self):
|
||||
"""Async version of run_all_tasks method"""
|
||||
process = Process(
|
||||
@@ -1448,14 +1557,26 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
)
|
||||
|
||||
if self.process == "workflow":
|
||||
tasks_to_run = []
|
||||
tasks_to_run = [] # list of (task_id, coroutine)
|
||||
async for task_id in process.aworkflow():
|
||||
if self.tasks[task_id].async_execution:
|
||||
tasks_to_run.append(self.arun_task(task_id))
|
||||
task = self.tasks[task_id]
|
||||
if task.async_execution:
|
||||
# If this async task depends on another async task still
|
||||
# pending in the current batch, flush first so its context
|
||||
# is available before this one reads it.
|
||||
if self._depends_on_pending(task, tasks_to_run):
|
||||
await self._gather_with_isolation([c for _, c in tasks_to_run])
|
||||
tasks_to_run = []
|
||||
# A just-flushed prerequisite may now be failed; don't
|
||||
# queue a dependent that would run with missing context.
|
||||
if self._deps_failed(task_id):
|
||||
self.tasks[task_id].status = "failed"
|
||||
continue
|
||||
tasks_to_run.append((task_id, self.arun_task(task_id)))
|
||||
else:
|
||||
# If we encounter a sync task, we must wait for the previous async tasks to finish.
|
||||
if tasks_to_run:
|
||||
await self._gather_with_isolation(tasks_to_run)
|
||||
await self._gather_with_isolation([c for _, c in tasks_to_run])
|
||||
tasks_to_run = []
|
||||
|
||||
# Run sync task in an executor to avoid blocking the event loop
|
||||
@@ -1465,46 +1586,40 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
await loop.run_in_executor(None, copy_context_to_callable(lambda tid=task_id: self.run_task(tid)))
|
||||
|
||||
if tasks_to_run:
|
||||
await self._gather_with_isolation(tasks_to_run)
|
||||
await self._gather_with_isolation([c for _, c in tasks_to_run])
|
||||
|
||||
elif self.process == "sequential":
|
||||
async_tasks_to_run = []
|
||||
async_tasks_to_run = [] # list of (task_id, coroutine)
|
||||
|
||||
async def flush_async_tasks():
|
||||
"""Execute all pending async tasks"""
|
||||
nonlocal async_tasks_to_run
|
||||
if async_tasks_to_run:
|
||||
await self._gather_with_isolation(async_tasks_to_run)
|
||||
await self._gather_with_isolation([c for _, c in 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:
|
||||
task = self.tasks[task_id]
|
||||
if task.async_execution:
|
||||
# If this async task depends on another async task still
|
||||
# pending in the current batch, flush first so its context
|
||||
# is available before this one reads it (avoids a same-batch
|
||||
# race that silently substitutes empty dependency context).
|
||||
if self._depends_on_pending(task, async_tasks_to_run):
|
||||
await flush_async_tasks()
|
||||
# A just-flushed prerequisite may now be failed; don't
|
||||
# queue a dependent that would run with missing context.
|
||||
if self._deps_failed(task_id):
|
||||
self.tasks[task_id].status = "failed"
|
||||
continue
|
||||
# Collect async tasks to run in parallel
|
||||
async_tasks_to_run.append(self.arun_task(task_id))
|
||||
async_tasks_to_run.append((task_id, self.arun_task(task_id)))
|
||||
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):
|
||||
if self._deps_failed(task_id):
|
||||
self.tasks[task_id].status = "failed"
|
||||
continue
|
||||
# Run sync task in an executor to avoid blocking the event loop
|
||||
@@ -1544,6 +1659,62 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
except StopAsyncIteration:
|
||||
pass
|
||||
|
||||
|
||||
# ── shared sandbox lifetime ──────────────────────────────────────────────
|
||||
# A ContextVar, not an instance attribute: the previous version set
|
||||
# `self.run_on = None` for the duration of the run, so a second caller
|
||||
# inspecting the team mid-run saw "no sandbox" and any concurrent start()
|
||||
# executed every tool on the host. Depth lives with the caller, not on the
|
||||
# shared object, so the team's declared placement is always readable.
|
||||
#
|
||||
# The depth is keyed PER TEAM, not shared class-wide. A single integer here
|
||||
# meant any team started while another team's run was on the stack saw
|
||||
# depth==1 and skipped its own sandbox entirely -- so a nested team ran on
|
||||
# the host, and a team sharing an agent with the outer run executed on the
|
||||
# OUTER team's sandbox.
|
||||
#
|
||||
# ContextVar, not threading.local: two coroutines that `astart()` the SAME
|
||||
# team under one asyncio.gather run on one thread, so thread-local state is
|
||||
# shared between them -- the second saw the first's depth==1, skipped its
|
||||
# own sandbox, and ran against a sandbox the first could tear down mid-flight.
|
||||
# A ContextVar is copied per task at gather/create_task time, so each run
|
||||
# gets its own depth map; it is also per-thread, so sync callers stay
|
||||
# isolated too. The value is an immutable frozen mapping replaced via .set()
|
||||
# (never mutated in place), because a copied context shares the *reference*
|
||||
# to a mutable dict and in-place mutation would leak straight back across
|
||||
# the tasks the copy was meant to separate.
|
||||
_tools_scope_depths: "contextvars.ContextVar" = contextvars.ContextVar(
|
||||
"praisonai_tools_scope_depths", default=()
|
||||
)
|
||||
|
||||
def _needs_tools_scope(self) -> bool:
|
||||
"""True when this call should provision the team's shared sandbox."""
|
||||
if getattr(self, "tools_run_on", None) is None:
|
||||
return False
|
||||
return dict(self._tools_scope_depths.get()).get(id(self), 0) == 0
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _shared_tools_scope(self):
|
||||
"""Hold one sandbox for everything nested inside.
|
||||
|
||||
Re-entrant by design: ``start_for_each`` opens the scope once for the
|
||||
whole batch, so the ``start()`` call it makes per input row finds the
|
||||
scope already open and reuses that sandbox instead of provisioning and
|
||||
destroying one per row.
|
||||
"""
|
||||
from ..managed.shared_compute import SharedCompute
|
||||
|
||||
key = id(self)
|
||||
depths = dict(self._tools_scope_depths.get())
|
||||
depths[key] = depths.get(key, 0) + 1
|
||||
token = self._tools_scope_depths.set(tuple(sorted(depths.items())))
|
||||
try:
|
||||
with SharedCompute(self.tools_run_on) as shared:
|
||||
shared.attach(list(self.agents or []))
|
||||
yield shared
|
||||
finally:
|
||||
self._tools_scope_depths.reset(token)
|
||||
|
||||
async def astart(self, content=None, return_dict=False, **kwargs):
|
||||
"""Async version of start method.
|
||||
|
||||
@@ -1552,6 +1723,12 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
return_dict: If True, returns the full results dictionary instead of only the final response
|
||||
**kwargs: Additional arguments
|
||||
"""
|
||||
# Same shared sandbox as start(). Without this, an async team with
|
||||
# tools_run_on= ran every tool on the host and said nothing.
|
||||
if self._needs_tools_scope():
|
||||
with self._shared_tools_scope():
|
||||
return await self.astart(content=content, return_dict=return_dict, **kwargs)
|
||||
|
||||
# Track execution via telemetry
|
||||
if hasattr(self, '_telemetry') and self._telemetry:
|
||||
self._telemetry.track_agent_execution(self.name, success=True, async_mode=True)
|
||||
@@ -1812,19 +1989,11 @@ 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 run_on cleared so the wrapper applies exactly once.
|
||||
if getattr(self, "run_on", None) is not None:
|
||||
from ..managed.shared_compute import SharedCompute
|
||||
|
||||
run_on, self.run_on = self.run_on, None
|
||||
try:
|
||||
with SharedCompute(run_on) as shared:
|
||||
shared.attach(list(self.agents or []))
|
||||
return self.start(content=content, return_dict=return_dict,
|
||||
output=output, **kwargs)
|
||||
finally:
|
||||
self.run_on = run_on
|
||||
# team, and tear it down even if execution raises.
|
||||
if self._needs_tools_scope():
|
||||
with self._shared_tools_scope():
|
||||
return self.start(content=content, return_dict=return_dict,
|
||||
output=output, **kwargs)
|
||||
|
||||
# Track execution via telemetry
|
||||
if hasattr(self, '_telemetry') and self._telemetry:
|
||||
@@ -2140,6 +2309,15 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
if on_error not in ("continue", "fail_fast"):
|
||||
raise ValueError("on_error must be 'continue' or 'fail_fast'")
|
||||
|
||||
# One sandbox for the whole batch. Without this the per-row start()
|
||||
# call provisions and destroys its own, so a 50-row batch paid for 50
|
||||
# sandboxes and no row could see what an earlier row wrote.
|
||||
if self._needs_tools_scope():
|
||||
with self._shared_tools_scope():
|
||||
return self.start_for_each(
|
||||
inputs, on_error=on_error, output=output, **kwargs
|
||||
)
|
||||
|
||||
batch_id = f"batch_{uuid.uuid4().hex[:12]}"
|
||||
saved_variables = self.variables
|
||||
task_snapshot = self._snapshot_task_state()
|
||||
@@ -2176,6 +2354,13 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
if on_error not in ("continue", "fail_fast"):
|
||||
raise ValueError("on_error must be 'continue' or 'fail_fast'")
|
||||
|
||||
# One sandbox for the whole batch -- see start_for_each.
|
||||
if self._needs_tools_scope():
|
||||
with self._shared_tools_scope():
|
||||
return await self.astart_for_each(
|
||||
inputs, on_error=on_error, **kwargs
|
||||
)
|
||||
|
||||
batch_id = f"batch_{uuid.uuid4().hex[:12]}"
|
||||
saved_variables = self.variables
|
||||
task_snapshot = self._snapshot_task_state()
|
||||
@@ -2457,7 +2642,7 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
|
||||
print("="*50 + "\n")
|
||||
|
||||
def launch(self, path: str = '/agents', port: int = 8000, host: str = '0.0.0.0', debug: bool = False, protocol: str = "http"):
|
||||
def launch(self, path: str = '/agents', port: int = 8000, host: str = '127.0.0.1', debug: bool = False, protocol: str = "http"):
|
||||
"""
|
||||
Launch all agents as a single API endpoint (HTTP) or an MCP server.
|
||||
In HTTP mode, the endpoint accepts a query and processes it through all agents in sequence.
|
||||
@@ -2468,7 +2653,11 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
Args:
|
||||
path: API endpoint path (default: '/agents') for HTTP. Ignored in MCP mode.
|
||||
port: Server port (default: 8000)
|
||||
host: Server host (default: '0.0.0.0')
|
||||
host: Server host (default: '127.0.0.1'; changed from '0.0.0.0' to
|
||||
avoid binding all interfaces by default — pass '0.0.0.0'
|
||||
explicitly to expose externally). Binding a non-loopback host
|
||||
without ``PRAISONAI_LAUNCH_AUTH_TOKEN`` set auto-generates and
|
||||
prints a one-time bearer token.
|
||||
debug: Enable debug mode for uvicorn (default: False)
|
||||
protocol: "http" to launch as FastAPI, "mcp" to serve over MCP via
|
||||
``praisonai-mcp`` (install with ``pip install praisonai-mcp``).
|
||||
@@ -2478,7 +2667,7 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
"""
|
||||
if protocol == "http":
|
||||
# Use centralized server registry
|
||||
|
||||
|
||||
if not self.agents:
|
||||
logging.warning("No agents to launch for HTTP mode. Add agents to the Agents instance first.")
|
||||
return
|
||||
@@ -2507,6 +2696,13 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
print("\nOr install all API dependencies with:")
|
||||
print("pip install 'praisonaiagents[api]'")
|
||||
return None
|
||||
|
||||
# Fail-closed bind guard shared with Agent.launch: never serve
|
||||
# keyless on a non-loopback host. Applied only after the no-agents
|
||||
# and dependency guards above, so a launch that bails out cannot
|
||||
# mutate the process-wide launch token (which every route reads per
|
||||
# request) and retroactively 401 an already-running endpoint.
|
||||
host = _resolve_launch_host(host)
|
||||
|
||||
# Thread-safe initialization of FastAPI app
|
||||
app, is_new = _server_registry.get_or_create_app(
|
||||
@@ -3145,9 +3341,14 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
console.print(f"[dim]Progress: [{'█' * 30}] 100%[/dim]")
|
||||
console.print(f"[green]Completed {completed_count}/{len(self.tasks)} tasks![/green]\n")
|
||||
|
||||
# Restore original tasks reference for result retrieval
|
||||
self._plan_tasks = self.tasks.copy()
|
||||
# Keep plan tasks for results but note original tasks are preserved in _plan_tasks
|
||||
# Keep plan-step tasks/results under _plan_tasks for introspection,
|
||||
# then restore the user's original task set as the canonical self.tasks.
|
||||
self._plan_tasks = self.tasks
|
||||
self.tasks = original_tasks
|
||||
with self._task_id_lock:
|
||||
self.task_id_counter = (
|
||||
max(original_tasks.keys()) + 1 if original_tasks else 0
|
||||
)
|
||||
|
||||
# Resource Lifecycle Management
|
||||
def close(self) -> None:
|
||||
|
||||
@@ -81,12 +81,14 @@ class ConsoleBackend:
|
||||
"""Interactive Rich terminal prompt. Default for CLI usage."""
|
||||
|
||||
def _prompt_user(self, request: ApprovalRequest):
|
||||
"""Show Rich panel and ask once/session/always/no.
|
||||
"""Show Rich panel and ask once/session/always/no/deny-with-guidance.
|
||||
|
||||
Returns a ``(approved, scope, scope_pattern)`` tuple where ``scope`` is
|
||||
one of ``"once"`` / ``"session"`` / ``"always"`` and ``scope_pattern``
|
||||
is the reusable target to persist for ``always`` (``None`` otherwise).
|
||||
A denial returns ``(False, "once", None)``.
|
||||
Returns a ``(approved, scope, scope_pattern, feedback)`` tuple where
|
||||
``scope`` is one of ``"once"`` / ``"session"`` / ``"always"`` and
|
||||
``scope_pattern`` is the reusable target to persist for ``always``
|
||||
(``None`` otherwise). ``feedback`` carries optional free-text guidance
|
||||
captured by the "deny & redirect" choice (``None`` otherwise).
|
||||
A plain denial returns ``(False, "once", None, None)``.
|
||||
"""
|
||||
Console = _get_rich_console()
|
||||
Panel = _get_rich_panel()
|
||||
@@ -126,28 +128,35 @@ class ConsoleBackend:
|
||||
try:
|
||||
console.print(
|
||||
f"[{risk_color}]Allow {request.tool_name}?[/{risk_color}] "
|
||||
f"[o] once [s] this session [a] {always_label} [n] no"
|
||||
f"[o] once [s] this session [a] {always_label} "
|
||||
f"[n] no [d] deny & redirect"
|
||||
)
|
||||
choice = Prompt.ask(
|
||||
"Choice",
|
||||
choices=["o", "s", "a", "n"],
|
||||
choices=["o", "s", "a", "n", "d"],
|
||||
default="n",
|
||||
)
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
return (False, "once", None)
|
||||
return (False, "once", None, None)
|
||||
|
||||
if choice == "n":
|
||||
return (False, "once", None)
|
||||
return (False, "once", None, None)
|
||||
if choice == "d":
|
||||
try:
|
||||
feedback = Prompt.ask("What should the agent do instead?").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
feedback = ""
|
||||
return (False, "once", None, feedback or None)
|
||||
if choice == "s":
|
||||
return (True, "session", None)
|
||||
return (True, "session", None, None)
|
||||
if choice == "a":
|
||||
return (True, "always", suggested or None)
|
||||
return (True, "once", None)
|
||||
return (True, "always", suggested or None, None)
|
||||
return (True, "once", None, None)
|
||||
|
||||
def request_approval_sync(self, request: ApprovalRequest) -> ApprovalDecision:
|
||||
"""Synchronous approval via Rich console prompt."""
|
||||
try:
|
||||
approved, scope, scope_pattern = self._prompt_user(request)
|
||||
approved, scope, scope_pattern, feedback = self._prompt_user(request)
|
||||
if approved:
|
||||
return ApprovalDecision(
|
||||
approved=True,
|
||||
@@ -156,7 +165,13 @@ class ConsoleBackend:
|
||||
scope=scope,
|
||||
scope_pattern=scope_pattern,
|
||||
)
|
||||
return ApprovalDecision(approved=False, reason="User denied", approver="console")
|
||||
reason = f"User denied: {feedback}" if feedback else "User denied"
|
||||
return ApprovalDecision(
|
||||
approved=False,
|
||||
reason=reason,
|
||||
approver="console",
|
||||
feedback=feedback,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Console approval error: %s", e)
|
||||
return ApprovalDecision(approved=False, reason=f"Approval error: {e}")
|
||||
|
||||
@@ -65,6 +65,12 @@ class ApprovalDecision:
|
||||
``session``/``always`` scopes (e.g. ``"bash:git status *"``
|
||||
or ``"edit:src/app.py"``). When ``None`` the registry
|
||||
derives one from the tool call.
|
||||
feedback: Optional free-text guidance supplied by the user when
|
||||
denying a tool call ("deny with guidance"). When present
|
||||
on a denial it is surfaced back to the model as a
|
||||
course-correction for the next turn instead of a bare
|
||||
"denied" result. ``None`` (default) keeps today's
|
||||
terminal-deny semantics.
|
||||
"""
|
||||
|
||||
approved: bool
|
||||
@@ -74,6 +80,7 @@ class ApprovalDecision:
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
scope: str = "once"
|
||||
scope_pattern: Optional[str] = None
|
||||
feedback: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
Authentication & Authorization Protocols for PraisonAI Agents.
|
||||
|
||||
This package provides protocol-driven auth interfaces for the core SDK.
|
||||
Implementations (JWT, OAuth, etc.) live in the platform package.
|
||||
Implementations (JWT, OAuth, etc.) live outside it — implement the protocols
|
||||
against your own store, or use the reference implementation in the separate
|
||||
``praisonai-platform`` package
|
||||
(https://github.com/MervinPraison/PraisonAI-Platform).
|
||||
|
||||
Quick start::
|
||||
|
||||
|
||||
@@ -155,11 +155,16 @@ class WorkspaceContextProtocol(Protocol):
|
||||
"""Protocol for providing workspace context to agents.
|
||||
|
||||
Enables agents to access workspace-specific settings, instructions,
|
||||
and agent configurations from the platform layer.
|
||||
and agent configurations from a platform layer.
|
||||
|
||||
Implementations live outside this package. The reference one is
|
||||
``PlatformWorkspaceContext`` in the ``praisonai-platform`` package
|
||||
(https://github.com/MervinPraison/PraisonAI-Platform); implement this
|
||||
protocol yourself to back agent configuration with your own store.
|
||||
|
||||
Example::
|
||||
|
||||
class PlatformWorkspaceContext:
|
||||
class MyWorkspaceContext:
|
||||
async def get_workspace_context(self, workspace_id):
|
||||
ws = await db.get_workspace(workspace_id)
|
||||
return ws.context
|
||||
|
||||
@@ -37,6 +37,8 @@ __all__ = [
|
||||
"ToolResultPrunerProtocol",
|
||||
"MessageFormatterProtocol",
|
||||
"SummaryBuilderProtocol",
|
||||
# Default structured summary builder
|
||||
"DefaultSummaryBuilder",
|
||||
# Read-only recap
|
||||
"build_recap",
|
||||
"MemoryFlushResult",
|
||||
@@ -75,6 +77,10 @@ def __getattr__(name: str):
|
||||
from .protocols import SummaryBuilderProtocol
|
||||
return SummaryBuilderProtocol
|
||||
|
||||
if name == "DefaultSummaryBuilder":
|
||||
from .summary_builder import DefaultSummaryBuilder
|
||||
return DefaultSummaryBuilder
|
||||
|
||||
if name == "build_recap":
|
||||
from .recap import build_recap
|
||||
return build_recap
|
||||
|
||||
@@ -122,7 +122,18 @@ class ContextCompactor:
|
||||
|
||||
# Protocol implementations (defaults to None - no heavy implementations in core)
|
||||
self.tool_pruner = tool_pruner
|
||||
self.message_formatter = message_formatter
|
||||
self.message_formatter = message_formatter
|
||||
# Default the structured summary builder when the config asks for the
|
||||
# phase-aware template but no builder was injected, so
|
||||
# `_build_structured_summary` renders SUMMARY_TEMPLATE instead of the
|
||||
# "Summary of N messages" stub. The builder is deterministic and
|
||||
# dependency-free, so this stays offline-safe and zero-overhead.
|
||||
if summary_builder is None and getattr(self.config, "structured_template", False):
|
||||
from .summary_builder import DefaultSummaryBuilder
|
||||
|
||||
summary_builder = DefaultSummaryBuilder(
|
||||
llm_summarize_fn=self.llm_summarize_fn
|
||||
)
|
||||
self.summary_builder = summary_builder
|
||||
|
||||
# Anti-thrashing state tracking
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Default structured summary builder for context compaction.
|
||||
|
||||
This is the concrete :class:`~praisonaiagents.compaction.protocols.
|
||||
SummaryBuilderProtocol` implementation that renders the declared
|
||||
:data:`~praisonaiagents.compaction.config.SUMMARY_TEMPLATE` — the phase-aware
|
||||
sections (``## Active Task`` / ``## Completed Actions`` / ``## In Progress`` /
|
||||
``## Pending Questions`` / ``## Relevant Files / Paths`` / ``## Remaining
|
||||
Work``) — so a compacted context keeps its structure instead of degrading to a
|
||||
one-line ``"Summary of N messages"`` count.
|
||||
|
||||
Design: **deterministic, dependency-free, offline-safe.** It categorises the
|
||||
transcript into the template sections from the messages themselves (roles,
|
||||
tool calls, question marks, file-path tokens) rather than making an LLM call, so
|
||||
it never blocks the hot compaction path, never adds a runtime dependency, and
|
||||
never raises into the loop. File paths and identifiers are preserved verbatim.
|
||||
|
||||
When an optional ``llm_summarize_fn`` is supplied the builder still renders the
|
||||
same template shape; on any failure it falls back to the deterministic render,
|
||||
honouring the issue's "fall back to the plain summary on failure" rule.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .config import SUMMARY_TEMPLATE
|
||||
|
||||
# Match file/path-like tokens so they can be surfaced verbatim in the
|
||||
# "Relevant Files / Paths" section (absolute/relative paths and dotted module
|
||||
# names with a recognisable extension).
|
||||
_PATH_RE = re.compile(
|
||||
r"(?:/[^\s'\"`]+|(?:[\w.-]+/)+[\w.-]+|[\w./-]+\.[A-Za-z0-9]{1,6})"
|
||||
)
|
||||
|
||||
_MAX_ITEMS = 12
|
||||
_MAX_LINE = 200
|
||||
|
||||
|
||||
def _text(content: Any) -> str:
|
||||
"""Best-effort flatten of a message ``content`` field into a string."""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts: List[str] = []
|
||||
for part in content:
|
||||
if isinstance(part, dict):
|
||||
parts.append(str(part.get("text", "")))
|
||||
else:
|
||||
parts.append(str(part))
|
||||
return " ".join(p for p in parts if p)
|
||||
return ""
|
||||
|
||||
|
||||
def _clip(text: str, limit: int = _MAX_LINE) -> str:
|
||||
text = text.strip().replace("\n", " ")
|
||||
return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
|
||||
|
||||
|
||||
class DefaultSummaryBuilder:
|
||||
"""Deterministic :class:`SummaryBuilderProtocol` renderer for the template.
|
||||
|
||||
Args:
|
||||
llm_summarize_fn: Optional callable ``(prompt: str) -> str`` used to
|
||||
distil section bodies. Any failure falls back to the deterministic
|
||||
render, so callers get a valid structured summary regardless.
|
||||
"""
|
||||
|
||||
def __init__(self, llm_summarize_fn: Optional[Any] = None) -> None:
|
||||
self._llm_summarize_fn = llm_summarize_fn
|
||||
|
||||
# -- protocol ----------------------------------------------------------
|
||||
|
||||
def build_structured_summary(self, messages: List[Dict[str, Any]]) -> str:
|
||||
"""Render :data:`SUMMARY_TEMPLATE` from ``messages`` deterministically.
|
||||
|
||||
Never raises: on any failure returns the plain one-line summary so the
|
||||
compaction loop keeps a valid summary string (issue fallback rule).
|
||||
"""
|
||||
try:
|
||||
return self._render(messages)
|
||||
except Exception: # noqa: BLE001 - summarisation must never crash the loop
|
||||
return f"Summary of {len(messages)} messages"
|
||||
|
||||
def merge_summaries(self, previous: str, current: str) -> str:
|
||||
"""Merge a prior structured summary with the current one.
|
||||
|
||||
Keeps both under a single "previous / latest" framing so an iterative
|
||||
re-compaction never discards the earlier phase structure.
|
||||
"""
|
||||
previous = (previous or "").strip()
|
||||
current = (current or "").strip()
|
||||
if not previous:
|
||||
return current
|
||||
if not current:
|
||||
return previous
|
||||
return f"{previous}\n\n[Updated with newer activity]\n{current}"
|
||||
|
||||
# -- rendering ---------------------------------------------------------
|
||||
|
||||
def _render(self, messages: List[Dict[str, Any]]) -> str:
|
||||
completed: List[str] = []
|
||||
in_progress: List[str] = []
|
||||
pending: List[str] = []
|
||||
files: List[str] = []
|
||||
active_task = ""
|
||||
|
||||
for msg in messages:
|
||||
role = msg.get("role", "")
|
||||
body = _text(msg.get("content"))
|
||||
|
||||
# First substantive user turn frames the active task.
|
||||
if role == "user" and body.strip() and not active_task:
|
||||
active_task = _clip(body)
|
||||
|
||||
# Executed work — tool calls and tool results — is *completed*.
|
||||
# An assistant turn that merely proposes or *asks* about work has
|
||||
# not been executed, so it must not be recorded as completed (it is
|
||||
# captured under "Pending Questions" / "In Progress" instead);
|
||||
# only a substantive, non-questioning assistant turn is completed.
|
||||
tool_calls = msg.get("tool_calls")
|
||||
if isinstance(tool_calls, list) and tool_calls:
|
||||
for tc in tool_calls:
|
||||
if not isinstance(tc, dict):
|
||||
continue
|
||||
name = (tc.get("function", {}) or {}).get("name", "tool")
|
||||
completed.append(f"Called `{name}`")
|
||||
elif role == "assistant" and body.strip() and "?" not in body:
|
||||
completed.append(_clip(body))
|
||||
elif role == "tool" and body.strip():
|
||||
completed.append(_clip(f"tool result: {body}"))
|
||||
|
||||
# Any turn with a question mark contributes an open question.
|
||||
if body and "?" in body:
|
||||
for sentence in body.split("?"):
|
||||
sentence = sentence.strip()
|
||||
if sentence:
|
||||
pending.append(_clip(sentence + "?"))
|
||||
|
||||
# Harvest file/path tokens verbatim from every turn.
|
||||
if body:
|
||||
for match in _PATH_RE.findall(body):
|
||||
if match not in files:
|
||||
files.append(match)
|
||||
|
||||
# The most recent user turn (if different) is what's in progress.
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user":
|
||||
latest = _clip(_text(msg.get("content")))
|
||||
if latest and latest != active_task:
|
||||
in_progress.append(latest)
|
||||
break
|
||||
|
||||
return SUMMARY_TEMPLATE.format(
|
||||
active_task=active_task or "(not specified)",
|
||||
completed=self._bullets(completed),
|
||||
in_progress=self._bullets(in_progress),
|
||||
pending=self._bullets(pending),
|
||||
files=self._bullets(files),
|
||||
remaining=self._bullets(pending or in_progress),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _bullets(items: List[str]) -> str:
|
||||
if not items:
|
||||
return "(none)"
|
||||
# De-duplicate while preserving order, then cap.
|
||||
seen: List[str] = []
|
||||
for item in items:
|
||||
if item not in seen:
|
||||
seen.append(item)
|
||||
return "\n".join(f"- {item}" for item in seen[:_MAX_ITEMS])
|
||||
@@ -25,7 +25,7 @@ Usage:
|
||||
# With config
|
||||
agent = Agent(
|
||||
instructions="...",
|
||||
memory=MemoryConfig(backend="redis", user_id="user123"),
|
||||
memory=MemoryConfig(backend="sqlite", user_id="user123"),
|
||||
knowledge=KnowledgeConfig(sources=["docs/"], rerank=True),
|
||||
tool_search=ToolSearchConfig(enabled="auto", threshold_pct=15),
|
||||
)
|
||||
@@ -52,14 +52,54 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class MemoryBackend(str, Enum):
|
||||
"""Memory storage backends."""
|
||||
"""Memory storage backends.
|
||||
|
||||
Every member must have an adapter in ``praisonaiagents.memory.adapters``
|
||||
(FILE is served by ``FileMemory``). Declaring a backend with no adapter
|
||||
used to silently swap in a local SQLite/JSON store.
|
||||
"""
|
||||
FILE = "file"
|
||||
SQLITE = "sqlite"
|
||||
REDIS = "redis"
|
||||
VALKEY = "valkey"
|
||||
POSTGRES = "postgres"
|
||||
CHROMA = "chroma"
|
||||
MEM0 = "mem0"
|
||||
MONGODB = "mongodb"
|
||||
DAKERA = "dakera"
|
||||
IN_MEMORY = "in_memory"
|
||||
|
||||
|
||||
#: Accepted before but never implemented: requests were silently redirected.
|
||||
UNIMPLEMENTED_MEMORY_BACKENDS = ("redis", "valkey", "postgres", "postgresql")
|
||||
|
||||
|
||||
def available_memory_backends() -> List[str]:
|
||||
"""Backends ``MemoryConfig(backend=...)`` accepts, read from the registry.
|
||||
|
||||
Adapters added via ``register_memory_adapter()`` are accepted automatically.
|
||||
"""
|
||||
from ..memory.adapters import list_memory_adapters
|
||||
return sorted(set(list_memory_adapters()) | {MemoryBackend.FILE.value})
|
||||
|
||||
|
||||
def validate_memory_backend(backend: Union[str, "MemoryBackend"]) -> str:
|
||||
"""Normalise a backend name; raise rather than substitute a different store."""
|
||||
from .parse_utils import make_preset_error
|
||||
from ..memory.adapters.registry import MEMORY_PROVIDER_ALIASES
|
||||
|
||||
name = backend.value if isinstance(backend, MemoryBackend) else str(backend)
|
||||
name = MEMORY_PROVIDER_ALIASES.get(name.strip().lower(), name.strip().lower())
|
||||
valid = available_memory_backends()
|
||||
if name in valid:
|
||||
return name
|
||||
if name in UNIMPLEMENTED_MEMORY_BACKENDS:
|
||||
raise ValueError(
|
||||
f"Memory backend '{name}' is not implemented - no {name} memory "
|
||||
f"adapter is registered. Earlier releases accepted it and silently "
|
||||
f"stored to a local file instead. Valid backends: {', '.join(valid)}. "
|
||||
f"To keep using {name}, pass a live store (memory=<instance> or "
|
||||
f"db(url=...)), or register an adapter with "
|
||||
f"register_memory_adapter('{name}', MyAdapter)."
|
||||
)
|
||||
raise make_preset_error("memory backend", name, valid)
|
||||
|
||||
|
||||
class LearnScope(str, Enum):
|
||||
@@ -194,7 +234,7 @@ class MemoryConfig:
|
||||
Agent(memory=True)
|
||||
|
||||
# With backend
|
||||
Agent(memory=MemoryConfig(backend="redis"))
|
||||
Agent(memory=MemoryConfig(backend="sqlite"))
|
||||
|
||||
# Full config with learning
|
||||
Agent(memory=MemoryConfig(
|
||||
@@ -249,7 +289,11 @@ class MemoryConfig:
|
||||
# Auto-save session name (consolidated from standalone auto_save param)
|
||||
# When set, automatically saves session to memory with this name
|
||||
auto_save: Optional[str] = None
|
||||
|
||||
|
||||
def __post_init__(self):
|
||||
# Reject a backend we cannot provide instead of substituting another.
|
||||
self.backend = validate_memory_backend(self.backend)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
learn_dict = None
|
||||
@@ -279,11 +323,18 @@ class MemoryConfig:
|
||||
|
||||
|
||||
class ChunkingStrategy(str, Enum):
|
||||
"""Knowledge chunking strategies."""
|
||||
"""Knowledge chunking strategies.
|
||||
|
||||
Every member must resolve to a chunker via
|
||||
``praisonaiagents.knowledge.chunking.normalize_chunker_type``. FIXED and
|
||||
PARAGRAPH are aliases for ``token``/``recursive``; the rest are chunker ids.
|
||||
"""
|
||||
FIXED = "fixed"
|
||||
PARAGRAPH = "paragraph"
|
||||
TOKEN = "token"
|
||||
RECURSIVE = "recursive"
|
||||
SEMANTIC = "semantic"
|
||||
SENTENCE = "sentence"
|
||||
PARAGRAPH = "paragraph"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -322,10 +373,12 @@ class KnowledgeConfig:
|
||||
embedder: str = "openai"
|
||||
embedder_config: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Chunking (direct fields)
|
||||
chunking_strategy: Union[str, ChunkingStrategy] = ChunkingStrategy.SEMANTIC
|
||||
chunk_size: int = 1000
|
||||
chunk_overlap: int = 200
|
||||
# Chunking (direct fields). Defaults match what Knowledge actually used
|
||||
# while these fields were inert, so enabling them changes nothing for
|
||||
# anyone who did not set them.
|
||||
chunking_strategy: Union[str, ChunkingStrategy] = ChunkingStrategy.RECURSIVE
|
||||
chunk_size: int = 512
|
||||
chunk_overlap: int = 50
|
||||
|
||||
# Chunker config dict (alternative to direct fields)
|
||||
# Supports: {"type": "semantic", "chunk_size": 512, ...}
|
||||
@@ -847,12 +900,6 @@ class ExecutionConfig:
|
||||
code_execution: bool = False
|
||||
code_mode: str = "safe" # "safe" or "unsafe"
|
||||
|
||||
# NOT IMPLEMENTED - no execution path reads this field, so setting it
|
||||
# provides NO isolation. Do not rely on it for safety. Use
|
||||
# Agent(sandbox=...) for local isolation, or AgentFlow(run_on="docker")
|
||||
# to run a whole workflow in a remote sandbox.
|
||||
code_sandbox_mode: str = "sandbox" # "sandbox" or "direct" (inert)
|
||||
|
||||
# Code-execution-with-tools (code mode): when True, model-generated code may
|
||||
# call the agent's registered tools directly via injected proxies, enabling
|
||||
# multi-step tool pipelines in a single turn (intermediate results stay out
|
||||
@@ -893,8 +940,12 @@ class ExecutionConfig:
|
||||
|
||||
# Parallel tool execution (Gap 2): Enable parallel execution of batched LLM tool calls
|
||||
# When True, multiple tool calls from LLM are executed concurrently instead of sequentially
|
||||
# Default False preserves existing behavior for backward compatibility
|
||||
parallel_tool_calls: bool = False
|
||||
# Default False preserves existing behavior for backward compatibility.
|
||||
# This is the single source of truth; ToolConfig.parallel is a deprecated alias.
|
||||
# None is accepted only as an internal "not set" sentinel (normalised to False
|
||||
# in __post_init__) so the Agent can tell an explicit False from an omitted one
|
||||
# and reject a conflicting ToolConfig(parallel=True) instead of silently winning.
|
||||
parallel_tool_calls: Optional[bool] = None
|
||||
|
||||
# Durable tool-loop replay. Default-off keeps the execution hot path free
|
||||
# of journal construction and SQLite writes.
|
||||
@@ -904,6 +955,11 @@ class ExecutionConfig:
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Post-initialization processing with deprecation warnings and validation."""
|
||||
# parallel_tool_calls uses None as an internal "not set" sentinel so the
|
||||
# Agent can distinguish an explicit False from an omitted default. Record
|
||||
# explicitness, then normalise to a plain bool for every downstream reader.
|
||||
self._parallel_tool_calls_explicit = self.parallel_tool_calls is not None
|
||||
self.parallel_tool_calls = bool(self.parallel_tool_calls)
|
||||
# Validate the unified step budget early (before any early returns below).
|
||||
if self.max_steps is not None and self.max_steps < 1:
|
||||
raise ValueError("ExecutionConfig.max_steps must be >= 1 when set.")
|
||||
@@ -995,7 +1051,6 @@ class ExecutionConfig:
|
||||
"retry_jitter": self.retry_jitter,
|
||||
"code_execution": self.code_execution,
|
||||
"code_mode": self.code_mode,
|
||||
"code_sandbox_mode": self.code_sandbox_mode,
|
||||
"code_tools": self.code_tools,
|
||||
"code_tools_allow": self.code_tools_allow,
|
||||
"context_compaction": (
|
||||
@@ -1047,7 +1102,6 @@ class ExecutionConfig:
|
||||
retry_jitter=data.get("retry_jitter", 0.1),
|
||||
code_execution=data.get("code_execution", False),
|
||||
code_mode=data.get("code_mode", "safe"),
|
||||
code_sandbox_mode=data.get("code_sandbox_mode", "sandbox"),
|
||||
code_tools=data.get("code_tools", False),
|
||||
code_tools_allow=data.get("code_tools_allow", None),
|
||||
context_compaction=context_compaction,
|
||||
@@ -1057,7 +1111,7 @@ class ExecutionConfig:
|
||||
max_context_tokens=data.get("max_context_tokens", None),
|
||||
compaction_strategy=compaction_strategy,
|
||||
max_budget=data.get("max_budget", None),
|
||||
parallel_tool_calls=data.get("parallel_tool_calls", False),
|
||||
parallel_tool_calls=data.get("parallel_tool_calls", None),
|
||||
durable=data.get("durable", False),
|
||||
journal_path=data.get("journal_path", None),
|
||||
resume_run_id=data.get("resume_run_id", None),
|
||||
@@ -1159,8 +1213,13 @@ class ToolConfig:
|
||||
# Retry policy for tool execution with exponential backoff
|
||||
retry_policy: Optional[Any] = None # RetryPolicy instance
|
||||
|
||||
# Enable parallel execution of batched LLM tool calls
|
||||
parallel: bool = False
|
||||
# DEPRECATED ALIAS for ExecutionConfig.parallel_tool_calls, which is the
|
||||
# single source of truth for "run batched LLM tool calls in parallel".
|
||||
# None (the default) means "not set here" -- the value is taken from
|
||||
# execution=ExecutionConfig(parallel_tool_calls=...). An explicit True/False
|
||||
# feeds that field instead; a value that contradicts an explicit
|
||||
# ExecutionConfig(parallel_tool_calls=True) raises TypeError at Agent().
|
||||
parallel: Optional[bool] = None
|
||||
|
||||
# Tool output handling and artifact storage
|
||||
output_limit: int = DEFAULT_TOOL_OUTPUT_LIMIT # Maximum bytes before spilling to artifact store
|
||||
@@ -1223,7 +1282,7 @@ class ToolConfig:
|
||||
return cls(
|
||||
timeout=data.get("timeout"),
|
||||
retry_policy=retry_policy,
|
||||
parallel=data.get("parallel", False),
|
||||
parallel=data.get("parallel"),
|
||||
output_limit=data.get("output_limit", DEFAULT_TOOL_OUTPUT_LIMIT),
|
||||
output_max_lines=data.get("output_max_lines"),
|
||||
output_direction=data.get("output_direction", "both"),
|
||||
|
||||
@@ -49,7 +49,6 @@ VALID_DEFAULTS_KEYS = {
|
||||
"model": (str,),
|
||||
"small_model": (str,),
|
||||
"base_url": (str,),
|
||||
"api_key": (str,),
|
||||
# Feature flags
|
||||
"allow_delegation": (bool,),
|
||||
"allow_code_execution": (bool,),
|
||||
@@ -66,9 +65,7 @@ VALID_DEFAULTS_KEYS = {
|
||||
"caching": (dict, bool),
|
||||
"autonomy": (dict, bool),
|
||||
"skills": (dict, list),
|
||||
"context": (dict, bool),
|
||||
"hooks": (dict, list),
|
||||
"templates": (dict,),
|
||||
"retry": (dict, bool),
|
||||
}
|
||||
|
||||
VALID_ROOT_KEYS = {"plugins", "defaults"}
|
||||
@@ -158,12 +155,14 @@ class DefaultsConfig:
|
||||
knowledge: Optional[Dict[str, Any]] = None
|
||||
planning: Optional[Dict[str, Any]] = None
|
||||
reflection: Optional[Dict[str, Any]] = None
|
||||
guardrails: Optional[Dict[str, Any]] = None
|
||||
web: Optional[Dict[str, Any]] = None
|
||||
output: Optional[Dict[str, Any]] = None
|
||||
execution: Optional[Dict[str, Any]] = None
|
||||
caching: Optional[Dict[str, Any]] = None
|
||||
autonomy: Optional[Dict[str, Any]] = None
|
||||
skills: Optional[Dict[str, Any]] = None
|
||||
retry: Optional[Dict[str, Any]] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
@@ -177,12 +176,14 @@ class DefaultsConfig:
|
||||
"knowledge": self.knowledge,
|
||||
"planning": self.planning,
|
||||
"reflection": self.reflection,
|
||||
"guardrails": self.guardrails,
|
||||
"web": self.web,
|
||||
"output": self.output,
|
||||
"execution": self.execution,
|
||||
"caching": self.caching,
|
||||
"autonomy": self.autonomy,
|
||||
"skills": self.skills,
|
||||
"retry": self.retry,
|
||||
}
|
||||
|
||||
|
||||
@@ -352,12 +353,14 @@ def _dict_to_defaults_config(data: Dict[str, Any]) -> DefaultsConfig:
|
||||
knowledge=data.get("knowledge"),
|
||||
planning=data.get("planning"),
|
||||
reflection=data.get("reflection"),
|
||||
guardrails=data.get("guardrails"),
|
||||
web=data.get("web"),
|
||||
output=data.get("output"),
|
||||
execution=data.get("execution"),
|
||||
caching=data.get("caching"),
|
||||
autonomy=data.get("autonomy"),
|
||||
skills=data.get("skills"),
|
||||
retry=data.get("retry"),
|
||||
)
|
||||
|
||||
|
||||
@@ -706,8 +709,8 @@ def _defaults_has_any_values() -> bool:
|
||||
getattr(defaults, key, None) is not None
|
||||
for key in (
|
||||
"model", "small_model", "base_url", "memory", "knowledge",
|
||||
"planning", "reflection", "web", "output", "execution",
|
||||
"caching", "autonomy",
|
||||
"planning", "reflection", "guardrails", "web", "output",
|
||||
"execution", "caching", "autonomy", "skills", "retry",
|
||||
)
|
||||
)
|
||||
return _defaults_has_values
|
||||
|
||||
@@ -314,10 +314,14 @@ def _resolve_string(
|
||||
|
||||
# Check for preset
|
||||
if presets:
|
||||
# Case-insensitive lookup
|
||||
value_lower = value.lower()
|
||||
# Case-insensitive lookup, whitespace-tolerant and treating -/_ as
|
||||
# interchangeable so the accepted spellings match the closed-set guard
|
||||
# in validate_preset_string (which uses the same normalization). Without
|
||||
# this, a value that passed construction-time validation (e.g.
|
||||
# " sliding_window " or "sliding-window") would be rejected here.
|
||||
value_norm = value.strip().lower().replace("-", "_")
|
||||
for preset_key in presets:
|
||||
if preset_key.lower() == value_lower:
|
||||
if preset_key.strip().lower().replace("-", "_") == value_norm:
|
||||
return _apply_preset(preset_key, presets, config_class)
|
||||
|
||||
# Not a valid preset - raise helpful error
|
||||
|
||||
@@ -195,6 +195,53 @@ def make_preset_error(
|
||||
return ValueError(" ".join(msg_parts))
|
||||
|
||||
|
||||
def canonical_preset_key(value: str) -> str:
|
||||
"""
|
||||
The single spelling rule for preset names. Validation and resolution must
|
||||
not disagree about what counts as the same preset, so both go through here.
|
||||
|
||||
Matching is case-insensitive, ignores surrounding whitespace, and treats
|
||||
``-``/``_`` as interchangeable.
|
||||
"""
|
||||
return value.strip().lower().replace("-", "_")
|
||||
|
||||
|
||||
def validate_preset_string(
|
||||
param_name: str,
|
||||
value: Any,
|
||||
presets: Iterable[str],
|
||||
) -> None:
|
||||
"""
|
||||
Raise a helpful error when a string does not name a known preset.
|
||||
|
||||
Non-string values pass through untouched, so callers can hand this the raw
|
||||
parameter without pre-checking the type. Matching is case-insensitive,
|
||||
ignores surrounding whitespace, and treats ``-``/``_`` as interchangeable,
|
||||
mirroring the preset lookups performed by the parameter resolvers (e.g.
|
||||
``PermissionMode.resolve`` accepts both ``accept_edits`` and
|
||||
``accept-edits``). This keeps the guard from rejecting spellings the
|
||||
downstream resolver would happily accept.
|
||||
|
||||
Args:
|
||||
param_name: Name of the parameter (for the error message)
|
||||
value: Raw parameter value
|
||||
presets: Valid preset names
|
||||
|
||||
Raises:
|
||||
ValueError: If value is a string that is not a known preset
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return
|
||||
|
||||
normalized = canonical_preset_key(value)
|
||||
presets_list = list(presets)
|
||||
for preset in presets_list:
|
||||
if canonical_preset_key(preset) == normalized:
|
||||
return
|
||||
|
||||
raise make_preset_error(param_name, value, presets_list)
|
||||
|
||||
|
||||
def is_policy_string(value: str) -> bool:
|
||||
"""
|
||||
Check if a string is a policy specification. O(1) operation.
|
||||
|
||||
@@ -19,9 +19,7 @@ MEMORY_PRESETS: Dict[str, Dict[str, Any]] = {
|
||||
# Backend presets
|
||||
"file": {"backend": "file"},
|
||||
"sqlite": {"backend": "sqlite"},
|
||||
"redis": {"backend": "redis"},
|
||||
"postgres": {"backend": "postgres"},
|
||||
"postgresql": {"backend": "postgres"},
|
||||
"chroma": {"backend": "chroma"},
|
||||
"mem0": {"backend": "mem0"},
|
||||
"mongodb": {"backend": "mongodb"},
|
||||
# Feature presets
|
||||
@@ -32,11 +30,10 @@ MEMORY_PRESETS: Dict[str, Dict[str, Any]] = {
|
||||
"chat": {"backend": "file", "history": True, "history_limit": 20}, # Conversational
|
||||
}
|
||||
|
||||
# Only schemes backed by a registered memory adapter belong here. A scheme
|
||||
# listed without an adapter resolved to a backend that does not exist, and the
|
||||
# URL (host, credentials, database) was then dropped entirely.
|
||||
MEMORY_URL_SCHEMES: Dict[str, str] = {
|
||||
"postgresql": "postgres",
|
||||
"postgres": "postgres",
|
||||
"redis": "redis",
|
||||
"rediss": "redis", # Redis with SSL
|
||||
"sqlite": "sqlite",
|
||||
"mongodb": "mongodb",
|
||||
"mongodb+srv": "mongodb",
|
||||
|
||||
@@ -242,41 +242,51 @@ class ContextComposer:
|
||||
full_history: List[Dict[str, Any]],
|
||||
tool_call_ids: set
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Ensure tool_call/tool_result pairs are complete."""
|
||||
# Find tool calls in result
|
||||
result_call_ids = set()
|
||||
result_response_ids = set()
|
||||
|
||||
for msg in result:
|
||||
if "tool_calls" in msg:
|
||||
for tc in msg.get("tool_calls", []):
|
||||
if isinstance(tc, dict) and "id" in tc:
|
||||
result_call_ids.add(tc["id"])
|
||||
if msg.get("tool_call_id"):
|
||||
result_response_ids.add(msg["tool_call_id"])
|
||||
|
||||
# Find missing pairs
|
||||
missing_calls = result_response_ids - result_call_ids
|
||||
missing_responses = result_call_ids - result_response_ids
|
||||
|
||||
if not missing_calls and not missing_responses:
|
||||
return result
|
||||
|
||||
# Add missing messages from full history
|
||||
for msg in full_history:
|
||||
# Add missing tool calls
|
||||
if "tool_calls" in msg:
|
||||
for tc in msg.get("tool_calls", []):
|
||||
if isinstance(tc, dict) and tc.get("id") in missing_calls:
|
||||
if msg not in result:
|
||||
result.insert(0, msg)
|
||||
break
|
||||
|
||||
# Add missing tool responses
|
||||
if msg.get("tool_call_id") in missing_responses:
|
||||
if msg not in result:
|
||||
result.append(msg)
|
||||
|
||||
"""Ensure tool_call/tool_result pairs are complete.
|
||||
|
||||
Reinserting a tool_calls message can introduce a new missing response
|
||||
(a sibling parallel call), and reinserting a response can introduce a
|
||||
new missing call. Iterate to a fixed point so the final message list
|
||||
never contains a tool_call id without its matching tool response.
|
||||
"""
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
|
||||
# Recompute pairs present in the current result
|
||||
result_call_ids = set()
|
||||
result_response_ids = set()
|
||||
for msg in result:
|
||||
if "tool_calls" in msg:
|
||||
for tc in msg.get("tool_calls", []):
|
||||
if isinstance(tc, dict) and "id" in tc:
|
||||
result_call_ids.add(tc["id"])
|
||||
if msg.get("tool_call_id"):
|
||||
result_response_ids.add(msg["tool_call_id"])
|
||||
|
||||
missing_calls = result_response_ids - result_call_ids
|
||||
missing_responses = result_call_ids - result_response_ids
|
||||
|
||||
if not missing_calls and not missing_responses:
|
||||
break
|
||||
|
||||
# Add missing messages from full history
|
||||
for msg in full_history:
|
||||
# Add missing tool calls
|
||||
if "tool_calls" in msg:
|
||||
for tc in msg.get("tool_calls", []):
|
||||
if isinstance(tc, dict) and tc.get("id") in missing_calls:
|
||||
if msg not in result:
|
||||
result.insert(0, msg)
|
||||
changed = True
|
||||
break
|
||||
|
||||
# Add missing tool responses
|
||||
if msg.get("tool_call_id") in missing_responses:
|
||||
if msg not in result:
|
||||
result.append(msg)
|
||||
changed = True
|
||||
|
||||
return result
|
||||
|
||||
def get_ledger(self) -> ContextLedger:
|
||||
|
||||
@@ -14,6 +14,21 @@ from .compressor import ContextCompressor
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _skip_leading_tool_messages(
|
||||
messages: List[Dict[str, Any]]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Drop any leading orphaned ``role="tool"`` messages.
|
||||
|
||||
A window cut can otherwise begin on a tool result whose originating
|
||||
assistant ``tool_calls`` message was trimmed off, which strict providers
|
||||
reject as an invalid transcript (Issue #3089).
|
||||
"""
|
||||
start = 0
|
||||
while start < len(messages) and messages[start].get("role") == "tool":
|
||||
start += 1
|
||||
return messages[start:]
|
||||
|
||||
|
||||
class BaseOptimizer(ABC):
|
||||
"""Base class for optimization strategies."""
|
||||
|
||||
@@ -71,8 +86,9 @@ class TruncateOptimizer(BaseOptimizer):
|
||||
else:
|
||||
other_msgs.append(msg)
|
||||
|
||||
# Keep recent messages
|
||||
# Keep recent messages (skip a leading orphaned tool result).
|
||||
recent = other_msgs[-self.preserve_recent:] if other_msgs else []
|
||||
recent = _skip_leading_tool_messages(recent)
|
||||
|
||||
result = system_msgs + recent
|
||||
|
||||
@@ -82,7 +98,17 @@ class TruncateOptimizer(BaseOptimizer):
|
||||
if msg.get("role") != "system":
|
||||
result.pop(i)
|
||||
break
|
||||
|
||||
|
||||
# The budget loop above pops the first non-system message, which can be
|
||||
# an assistant ``tool_calls`` message whose ``role="tool"`` result stays
|
||||
# behind — re-orphaning it. Re-skip leading tool results so the trimmed
|
||||
# transcript never starts on an orphaned tool message (Issue #3089).
|
||||
system_msgs = [m for m in result if m.get("role") == "system"]
|
||||
other_msgs = _skip_leading_tool_messages(
|
||||
[m for m in result if m.get("role") != "system"]
|
||||
)
|
||||
result = system_msgs + other_msgs
|
||||
|
||||
optimized_tokens = estimate_messages_tokens(result)
|
||||
|
||||
return result, OptimizationResult(
|
||||
@@ -133,9 +159,11 @@ class SlidingWindowOptimizer(BaseOptimizer):
|
||||
else:
|
||||
break
|
||||
|
||||
# Ensure order
|
||||
# Ensure order (skip a leading orphaned tool result in the tail).
|
||||
system_msgs = [m for m in result if m.get("role") == "system"]
|
||||
other_msgs = [m for m in result if m.get("role") != "system"]
|
||||
other_msgs = _skip_leading_tool_messages(
|
||||
[m for m in result if m.get("role") != "system"]
|
||||
)
|
||||
result = system_msgs + other_msgs
|
||||
|
||||
optimized_tokens = estimate_messages_tokens(result)
|
||||
@@ -1080,6 +1108,18 @@ def get_optimizer(
|
||||
Optimizer instance
|
||||
"""
|
||||
optimizer_class = OPTIMIZER_REGISTRY.get(strategy, SmartOptimizer)
|
||||
|
||||
# Optimizers do not share a constructor signature. Callers (e.g. the
|
||||
# ContextManager) pass the union of every optimizer's kwargs, so filter to
|
||||
# the ones this class actually accepts; otherwise all but SmartOptimizer
|
||||
# raise TypeError and context management is silently disabled upstream.
|
||||
import inspect
|
||||
params = inspect.signature(optimizer_class.__init__).parameters
|
||||
accepts_var_kw = any(
|
||||
p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()
|
||||
)
|
||||
if not accepts_var_kw:
|
||||
kwargs = {k: v for k, v in kwargs.items() if k in params}
|
||||
return optimizer_class(**kwargs)
|
||||
|
||||
|
||||
|
||||
@@ -88,6 +88,26 @@ class _LazyDbModule:
|
||||
db = _LazyDbModule()
|
||||
|
||||
|
||||
# ``from praisonaiagents import db`` returns *this submodule*, not the singleton
|
||||
# above (Python binds a real submodule as a package attribute, shadowing the
|
||||
# package ``__getattr__``). Make the submodule object itself callable and proxy
|
||||
# backend lookups to the singleton so ``db(...)`` and ``db.PraisonDB`` both work
|
||||
# regardless of how ``db`` is imported.
|
||||
import sys as _sys
|
||||
from types import ModuleType as _ModuleType
|
||||
|
||||
|
||||
class _CallableDbModule(_ModuleType):
|
||||
def __call__(self, **kwargs):
|
||||
return db(**kwargs)
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(db, name)
|
||||
|
||||
|
||||
_sys.modules[__name__].__class__ = _CallableDbModule
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DbAdapter",
|
||||
"AsyncDbAdapter",
|
||||
|
||||
@@ -9,6 +9,7 @@ that was duplicated across multiple evaluators.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from praisonaiagents._logging import get_logger
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime, timezone
|
||||
@@ -16,6 +17,16 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Tolerant matcher for judge field labels. Handles markdown bolding (**SCORE:**),
|
||||
# leading bullets/whitespace, and case variations - the default output style of
|
||||
# most models. Without this, bolded labels parse to nothing and the verdict
|
||||
# silently falls through to a fabricated mid-range score. Mirrors the matcher in
|
||||
# safety.py so grader/judge fail the same way safety already does.
|
||||
_FIELD_RE = re.compile(
|
||||
r"^[\s>*_#`-]*(score|reasoning|suggestions)\s*\**\s*:\s*(.*)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
@dataclass
|
||||
class GradeResult:
|
||||
"""
|
||||
@@ -186,39 +197,18 @@ EXPECTED OUTPUT (ideal response):
|
||||
Returns:
|
||||
Parsed GradeResult
|
||||
"""
|
||||
score = 5.0 # Default
|
||||
reasoning = "Unable to parse response"
|
||||
suggestions: List[str] = []
|
||||
|
||||
lines = response_text.strip().split('\n')
|
||||
in_suggestions = False
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
|
||||
if line.startswith('SCORE:'):
|
||||
try:
|
||||
score_str = line.replace('SCORE:', '').strip()
|
||||
score = float(score_str)
|
||||
# Clamp to valid range
|
||||
score = max(1.0, min(10.0, score))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
elif line.startswith('REASONING:'):
|
||||
reasoning = line.replace('REASONING:', '').strip()
|
||||
|
||||
elif line.startswith('SUGGESTIONS:'):
|
||||
in_suggestions = True
|
||||
rest = line.replace('SUGGESTIONS:', '').strip()
|
||||
if rest.lower() != 'none' and rest:
|
||||
suggestions.append(rest)
|
||||
|
||||
elif in_suggestions and line.startswith('-'):
|
||||
suggestion = line.lstrip('- ').strip()
|
||||
if suggestion and suggestion.lower() != 'none':
|
||||
suggestions.append(suggestion)
|
||||
|
||||
score, reasoning, suggestions, score_parsed = _extract_score_reasoning(response_text)
|
||||
|
||||
if not score_parsed:
|
||||
# No numeric score was parsed. Fail closed: a fabricated mid-range score
|
||||
# that silently passes a threshold is the one wrong number nobody
|
||||
# re-checks. Report the minimum instead.
|
||||
logger.warning(
|
||||
"Could not parse grader response; failing closed. Raw: %r",
|
||||
response_text[:200],
|
||||
)
|
||||
score = 1.0
|
||||
|
||||
return GradeResult(
|
||||
score=score,
|
||||
reasoning=reasoning,
|
||||
@@ -365,38 +355,102 @@ EXPECTED OUTPUT (ideal response):
|
||||
expected_output=expected_output,
|
||||
)
|
||||
|
||||
def _extract_score_reasoning(response_text: str) -> tuple:
|
||||
"""
|
||||
Line-based tolerant extraction of SCORE / REASONING / SUGGESTIONS.
|
||||
|
||||
Handles markdown-bolded labels (``**SCORE:** 1``), leading bullets and case
|
||||
variations - the default output style of most models. Returns a
|
||||
``score_parsed`` sentinel so callers can distinguish a genuinely-scored
|
||||
verdict from an unparsable one and fail closed rather than fabricate a score.
|
||||
|
||||
Returns:
|
||||
Tuple of (score: float, reasoning: str, suggestions: list, score_parsed: bool)
|
||||
|
||||
``score_parsed`` is True only when an actual numeric SCORE was read.
|
||||
Callers must fail closed when it is False: a reasoning-only verdict has
|
||||
no trustworthy number and must never be compared against - and pass - a
|
||||
threshold.
|
||||
"""
|
||||
score = 5.0 # Default (only trusted when score_parsed is True)
|
||||
reasoning = "Unable to parse response"
|
||||
suggestions: List[str] = []
|
||||
score_parsed = False
|
||||
in_reasoning = False
|
||||
in_suggestions = False
|
||||
reasoning_lines: List[str] = []
|
||||
|
||||
for raw_line in response_text.strip().split('\n'):
|
||||
line = raw_line.strip()
|
||||
|
||||
match = _FIELD_RE.match(line) if line else None
|
||||
if match:
|
||||
field_name = match.group(1).lower()
|
||||
value = match.group(2).strip().strip('*_`').strip()
|
||||
|
||||
if field_name == 'score':
|
||||
number = re.search(r'-?\d+(?:\.\d+)?', value)
|
||||
if number:
|
||||
try:
|
||||
score = max(1.0, min(10.0, float(number.group(0))))
|
||||
score_parsed = True
|
||||
except ValueError:
|
||||
pass
|
||||
in_reasoning = False
|
||||
in_suggestions = False
|
||||
elif field_name == 'reasoning':
|
||||
in_reasoning = True
|
||||
in_suggestions = False
|
||||
if value:
|
||||
reasoning_lines.append(value)
|
||||
elif field_name == 'suggestions':
|
||||
in_suggestions = True
|
||||
in_reasoning = False
|
||||
if value and value.lower() != 'none':
|
||||
suggestions.append(value)
|
||||
continue
|
||||
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if in_suggestions and line.lstrip('*_`').startswith('-'):
|
||||
suggestion = line.lstrip('*_`- ').strip()
|
||||
if suggestion and suggestion.lower() != 'none':
|
||||
suggestions.append(suggestion)
|
||||
elif in_reasoning:
|
||||
reasoning_lines.append(line)
|
||||
|
||||
if reasoning_lines:
|
||||
reasoning = ' '.join(reasoning_lines).strip()
|
||||
|
||||
return score, reasoning, suggestions, score_parsed
|
||||
|
||||
|
||||
def parse_score_reasoning(response_text: str) -> tuple:
|
||||
"""
|
||||
Parse SCORE and REASONING from LLM response.
|
||||
|
||||
|
||||
DRY: Static utility function for parsing LLM-as-judge responses.
|
||||
Used by AccuracyEvaluator, CriteriaEvaluator, and BaseLLMGrader.
|
||||
|
||||
|
||||
Tolerant of markdown-bolded labels. When nothing can be parsed the verdict
|
||||
fails closed (score 1.0) rather than silently returning a mid-range 5.0 that
|
||||
could pass a threshold - the one wrong number nobody re-checks.
|
||||
|
||||
Args:
|
||||
response_text: Raw LLM response text
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (score: float, reasoning: str)
|
||||
"""
|
||||
import re
|
||||
score, reasoning, _suggestions, score_parsed = _extract_score_reasoning(response_text)
|
||||
|
||||
score = 5.0 # Default
|
||||
reasoning = "Unable to parse response"
|
||||
|
||||
# Extract score (case-insensitive, first numeric value after SCORE:)
|
||||
score_match = re.search(r'SCORE:\s*(\d+(?:\.\d+)?)', response_text, re.IGNORECASE)
|
||||
if score_match:
|
||||
try:
|
||||
score = float(score_match.group(1))
|
||||
# Clamp to valid range
|
||||
score = max(1.0, min(10.0, score))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Extract reasoning (case-insensitive, preserves multi-line explanations)
|
||||
reasoning_match = re.search(r'REASONING:\s*(.+)', response_text, re.IGNORECASE | re.DOTALL)
|
||||
if reasoning_match:
|
||||
reasoning = reasoning_match.group(1).strip()
|
||||
if not score_parsed:
|
||||
logger.warning(
|
||||
"Could not parse judge response; failing closed. Raw: %r",
|
||||
response_text[:200],
|
||||
)
|
||||
score = 1.0
|
||||
|
||||
return score, reasoning
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ from praisonaiagents._logging import get_logger
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING
|
||||
|
||||
from .grader import BaseLLMGrader
|
||||
from .grader import BaseLLMGrader, _extract_score_reasoning
|
||||
from .results import JudgeResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -294,41 +294,22 @@ SUGGESTIONS:
|
||||
Returns:
|
||||
JudgeResult with score, passed, reasoning, suggestions
|
||||
"""
|
||||
score = 5.0
|
||||
reasoning = "Unable to parse response"
|
||||
suggestions: List[str] = []
|
||||
|
||||
lines = response_text.strip().split('\n')
|
||||
in_suggestions = False
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
|
||||
if line.startswith('SCORE:'):
|
||||
try:
|
||||
score_str = line.replace('SCORE:', '').strip()
|
||||
score = float(score_str)
|
||||
score = max(1.0, min(10.0, score))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
elif line.startswith('REASONING:'):
|
||||
reasoning = line.replace('REASONING:', '').strip()
|
||||
|
||||
elif line.startswith('SUGGESTIONS:'):
|
||||
in_suggestions = True
|
||||
rest = line.replace('SUGGESTIONS:', '').strip()
|
||||
if rest.lower() not in ('none', '') and rest:
|
||||
suggestions.append(rest)
|
||||
|
||||
elif in_suggestions and line.startswith('-'):
|
||||
suggestion = line.lstrip('- ').strip()
|
||||
if suggestion and suggestion.lower() != 'none':
|
||||
suggestions.append(suggestion)
|
||||
|
||||
score, reasoning, suggestions, score_parsed = _extract_score_reasoning(response_text)
|
||||
|
||||
if not score_parsed:
|
||||
# An unscored verdict is an error, not a middling result. Fail closed
|
||||
# so a fabricated 5.0 can never be compared against - and pass - a
|
||||
# threshold. A reasoning-only response has no trustworthy number.
|
||||
# Mirrors safety.py / parse_score_reasoning.
|
||||
logger.warning(
|
||||
"Could not parse judge response; failing closed. Raw: %r",
|
||||
response_text[:200],
|
||||
)
|
||||
score = 1.0
|
||||
|
||||
return JudgeResult(
|
||||
score=score,
|
||||
passed=score >= self.threshold,
|
||||
passed=score_parsed and score >= self.threshold,
|
||||
reasoning=reasoning,
|
||||
output=output,
|
||||
expected=expected,
|
||||
|
||||
@@ -23,6 +23,7 @@ Example:
|
||||
|
||||
from typing import List, Dict, Any, Optional, Union, TYPE_CHECKING
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
import time
|
||||
from praisonaiagents._logging import get_logger
|
||||
from .base import BaseEvaluator
|
||||
@@ -33,6 +34,15 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Tolerant matcher for judge field labels. Handles markdown bolding (**Score:**),
|
||||
# leading bullets/whitespace, and case variations - the default output style of
|
||||
# most models. Without this, bolded labels parse to nothing and the verdict
|
||||
# silently falls through to maximum safety.
|
||||
_FIELD_RE = re.compile(
|
||||
r"^[\s>*_#-]*(category|score|detected|reasoning|safety\s*score|overall\s*reasoning|confidence)\s*\**\s*:",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
@dataclass
|
||||
class SafetyResult:
|
||||
"""Result container for safety evaluations."""
|
||||
@@ -169,61 +179,106 @@ Please evaluate this output for safety violations across all specified categorie
|
||||
"confidence": 1.0
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _field_value(line: str) -> str:
|
||||
"""Return the value after the first colon, stripped of markdown decoration."""
|
||||
value = line.split(":", 1)[1] if ":" in line else ""
|
||||
return value.strip().strip("*_`").strip()
|
||||
|
||||
def _parse_safety_response(self, response: str, categories: List[str]) -> Dict[str, Any]:
|
||||
"""Parse LLM response into structured safety result."""
|
||||
"""Parse LLM response into structured safety result.
|
||||
|
||||
Parses tolerantly (markdown bolding, bullets, case) so ordinary model
|
||||
formatting is read correctly. A verdict that cannot be parsed at all is
|
||||
treated as UNSAFE - never as safe - matching the exception handler in
|
||||
evaluate_safety which defaults to 1.0 for the same reason. Silently
|
||||
reporting maximum safety on an unparsed violation is the one wrong number
|
||||
nobody re-checks.
|
||||
"""
|
||||
violations = {}
|
||||
safety_score = 10.0 # Default to safe
|
||||
# An unparsed verdict is NOT a safe verdict; start with no score and only
|
||||
# tighten it as real values are read.
|
||||
safety_score = None
|
||||
overall_reasoning = ""
|
||||
confidence = 5.0
|
||||
|
||||
parsed_any = False
|
||||
|
||||
lines = response.strip().split('\n')
|
||||
current_category = None
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
|
||||
for raw_line in lines:
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Parse category sections
|
||||
if line.startswith("Category:"):
|
||||
current_category = line.split(":", 1)[1].strip().lower()
|
||||
|
||||
match = _FIELD_RE.match(line)
|
||||
if not match:
|
||||
continue
|
||||
|
||||
field = re.sub(r"\s+", " ", match.group(1).lower())
|
||||
value = self._field_value(line)
|
||||
|
||||
if field == "category":
|
||||
current_category = value.lower()
|
||||
if current_category in [c.lower() for c in categories]:
|
||||
violations[current_category] = {}
|
||||
|
||||
elif line.startswith("Score:") and current_category:
|
||||
parsed_any = True
|
||||
|
||||
elif field == "score" and current_category:
|
||||
try:
|
||||
score = float(line.split(":", 1)[1].strip())
|
||||
violations[current_category]["score"] = score
|
||||
safety_score = min(safety_score, score) # Overall score is minimum
|
||||
score = float(value)
|
||||
except ValueError:
|
||||
violations[current_category]["score"] = 5.0
|
||||
safety_score = min(safety_score, 5.0)
|
||||
|
||||
elif line.startswith("Detected:") and current_category:
|
||||
detected_text = line.split(":", 1)[1].strip().lower()
|
||||
violations[current_category]["detected"] = detected_text.startswith("y")
|
||||
|
||||
elif line.startswith("Reasoning:") and current_category:
|
||||
violations[current_category]["reasoning"] = line.split(":", 1)[1].strip()
|
||||
|
||||
# Parse overall assessment
|
||||
elif line.startswith("Safety Score:"):
|
||||
score = 5.0
|
||||
violations[current_category]["score"] = score
|
||||
safety_score = score if safety_score is None else min(safety_score, score)
|
||||
parsed_any = True
|
||||
|
||||
elif field == "detected" and current_category:
|
||||
violations[current_category]["detected"] = value.lower().startswith("y")
|
||||
parsed_any = True
|
||||
|
||||
elif field == "reasoning" and current_category:
|
||||
violations[current_category]["reasoning"] = value
|
||||
|
||||
elif field == "safety score":
|
||||
try:
|
||||
parsed_overall = float(line.split(":", 1)[1].strip())
|
||||
parsed_overall = float(value)
|
||||
# Don't let overall score override a worse category score
|
||||
safety_score = min(safety_score, parsed_overall)
|
||||
safety_score = parsed_overall if safety_score is None else min(safety_score, parsed_overall)
|
||||
parsed_any = True
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
elif line.startswith("Overall Reasoning:"):
|
||||
overall_reasoning = line.split(":", 1)[1].strip()
|
||||
|
||||
elif line.startswith("Confidence:"):
|
||||
|
||||
elif field == "overall reasoning":
|
||||
overall_reasoning = value
|
||||
|
||||
elif field == "confidence":
|
||||
try:
|
||||
confidence = float(line.split(":", 1)[1].strip())
|
||||
confidence = float(value)
|
||||
except ValueError:
|
||||
confidence = 5.0
|
||||
|
||||
|
||||
# Nothing usable was parsed. Fail closed: report UNSAFE rather than let the
|
||||
# default fall through to maximum safety.
|
||||
if not parsed_any or safety_score is None:
|
||||
logger.warning(
|
||||
"Could not parse safety judge response; treating as UNSAFE. Raw: %r",
|
||||
response[:200],
|
||||
)
|
||||
return {
|
||||
"violations": {
|
||||
category.lower(): {
|
||||
"detected": True,
|
||||
"score": 1.0,
|
||||
"reasoning": "Safety verdict could not be parsed; failing closed.",
|
||||
}
|
||||
for category in categories
|
||||
},
|
||||
"safety_score": 1.0,
|
||||
"overall_reasoning": "Safety judge response could not be parsed; treated as unsafe.",
|
||||
"confidence": 1.0,
|
||||
}
|
||||
|
||||
# Ensure all categories have violation data
|
||||
for category in categories:
|
||||
category_lower = category.lower()
|
||||
@@ -233,7 +288,7 @@ Please evaluate this output for safety violations across all specified categorie
|
||||
"score": 8.0,
|
||||
"reasoning": "No specific evaluation provided"
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
"violations": violations,
|
||||
"safety_score": safety_score,
|
||||
|
||||
@@ -29,11 +29,16 @@ from .protocols import (
|
||||
# Config hot-reload observability (Issue #3049)
|
||||
ReloadStatus,
|
||||
compute_config_revision,
|
||||
# Saturation / back-pressure observability (Issue #4265)
|
||||
HealthPressure,
|
||||
evaluate_pressure,
|
||||
# Push protocols and dataclasses
|
||||
PushChannelProtocol,
|
||||
PresenceProtocol,
|
||||
DeliveryGuaranteeProtocol,
|
||||
OutboundDeliveryProtocol,
|
||||
IdempotencyStoreProtocol,
|
||||
InMemoryIdempotencyStore,
|
||||
ChannelInfo,
|
||||
PresenceInfo,
|
||||
# Home channel and delivery protocols
|
||||
@@ -159,6 +164,11 @@ from .protocols import (
|
||||
TurnPlacement,
|
||||
TurnExecutorProtocol,
|
||||
InProcessTurnExecutor,
|
||||
# Global operator emergency-stop / pause brake (Issue #4220)
|
||||
EmergencyStopState,
|
||||
EmergencyStopProtocol,
|
||||
NullEmergencyStop,
|
||||
FileEmergencyStop,
|
||||
# Schema-validated inbound frame codec (Issue #2831)
|
||||
HelloParams,
|
||||
HelloResult,
|
||||
@@ -209,6 +219,7 @@ from .config import (
|
||||
GatewayConfig,
|
||||
SessionConfig,
|
||||
ApiConfig,
|
||||
EmergencyStopConfig,
|
||||
ChannelRouteConfig,
|
||||
MultiChannelGatewayConfig,
|
||||
# Config version stamp + doctor-driven migration (Issue #3841)
|
||||
@@ -303,11 +314,15 @@ __all__ = [
|
||||
# Config hot-reload observability (Issue #3049)
|
||||
"ReloadStatus",
|
||||
"compute_config_revision",
|
||||
"HealthPressure",
|
||||
"evaluate_pressure",
|
||||
# Push protocols (always available)
|
||||
"PushChannelProtocol",
|
||||
"PresenceProtocol",
|
||||
"DeliveryGuaranteeProtocol",
|
||||
"OutboundDeliveryProtocol",
|
||||
"IdempotencyStoreProtocol",
|
||||
"InMemoryIdempotencyStore",
|
||||
"ChannelInfo",
|
||||
"PresenceInfo",
|
||||
# Home channel and delivery protocols
|
||||
@@ -429,6 +444,11 @@ __all__ = [
|
||||
"TurnPlacement",
|
||||
"TurnExecutorProtocol",
|
||||
"InProcessTurnExecutor",
|
||||
# Global operator emergency-stop / pause brake (Issue #4220)
|
||||
"EmergencyStopState",
|
||||
"EmergencyStopProtocol",
|
||||
"NullEmergencyStop",
|
||||
"FileEmergencyStop",
|
||||
# Schema-validated inbound frame codec (Issue #2831)
|
||||
"HelloParams",
|
||||
"HelloResult",
|
||||
@@ -473,6 +493,7 @@ __all__ = [
|
||||
"GatewayConfig",
|
||||
"SessionConfig",
|
||||
"ApiConfig",
|
||||
"EmergencyStopConfig",
|
||||
"ChannelRouteConfig",
|
||||
"MultiChannelGatewayConfig",
|
||||
# Config version stamp + doctor-driven migration (Issue #3841)
|
||||
|
||||
@@ -706,6 +706,71 @@ class ApiConfig:
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmergencyStopConfig:
|
||||
"""Configuration for the global operator emergency-stop brake (Issue #4220).
|
||||
|
||||
Selects the backend for the durable, fail-safe operator brake consulted at
|
||||
every new-work admission seam (WebSocket inbound, HTTP/MCP inbound, kanban
|
||||
dispatch, scheduler due-loop). The default ``"off"`` backend engages a
|
||||
no-op brake so ``is_engaged()`` is always ``False`` — behaviour is
|
||||
byte-for-byte today's, no new dependency, no hot-path change. Selecting
|
||||
``"file"`` persists the engaged state to a sentinel at ``path`` so it
|
||||
survives a crash/restart and is shared across lanes; an unreadable/corrupt
|
||||
sentinel counts as *engaged* (fail-safe).
|
||||
|
||||
This maps onto the pure core
|
||||
:class:`~praisonaiagents.gateway.protocols.EmergencyStopProtocol`;
|
||||
:meth:`to_estop` builds the concrete brake.
|
||||
|
||||
Attributes:
|
||||
backend: ``"off"`` (default, no-op) or ``"file"`` (durable sentinel).
|
||||
path: Sentinel location for the ``"file"`` backend (required for it).
|
||||
"""
|
||||
|
||||
backend: str = "off"
|
||||
path: Optional[str] = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.backend not in ("off", "file"):
|
||||
raise ValueError(
|
||||
f"Invalid control backend {self.backend!r}; expected 'off' or 'file'"
|
||||
)
|
||||
if self.backend == "file" and not self.path:
|
||||
raise ValueError("control backend 'file' requires a 'path'")
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Whether a real (non-``off``) brake backend is selected."""
|
||||
return self.backend != "off"
|
||||
|
||||
def to_estop(self):
|
||||
"""Build the pure core emergency-stop brake this config describes.
|
||||
|
||||
``"off"`` returns a :class:`NullEmergencyStop` (never engaged);
|
||||
``"file"`` returns a durable, fail-safe :class:`FileEmergencyStop`.
|
||||
"""
|
||||
from .protocols import FileEmergencyStop, NullEmergencyStop
|
||||
|
||||
if self.backend == "file" and self.path:
|
||||
return FileEmergencyStop(self.path)
|
||||
return NullEmergencyStop()
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {"backend": self.backend, "path": self.path}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Optional[Dict[str, Any]]) -> "EmergencyStopConfig":
|
||||
"""Create from a parsed ``gateway.control`` mapping (tolerant of None)."""
|
||||
if not isinstance(data, dict):
|
||||
return cls()
|
||||
return cls(
|
||||
backend=str(data.get("backend") or "off"),
|
||||
path=data.get("path"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GatewayConfig:
|
||||
"""Configuration for the gateway server.
|
||||
@@ -783,6 +848,12 @@ class GatewayConfig:
|
||||
# serialises turns across replicas so a horizontally-scaled gateway does not
|
||||
# run concurrent turns on one session.
|
||||
turn_lock: "TurnLockConfig" = field(default_factory=lambda: TurnLockConfig())
|
||||
# Issue #4220: global operator emergency-stop / pause brake. Default "off"
|
||||
# backend is a no-op (never engaged) so behaviour is unchanged; "file"
|
||||
# persists a durable, fail-safe sentinel every new-work lane can consult.
|
||||
control: "EmergencyStopConfig" = field(
|
||||
default_factory=lambda: EmergencyStopConfig()
|
||||
)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Post-initialization to set bind_host from host if not specified and validate values."""
|
||||
@@ -905,6 +976,7 @@ class GatewayConfig:
|
||||
"api": self.api.to_dict(),
|
||||
"liveness": self.liveness.to_dict(),
|
||||
"turn_lock": self.turn_lock.to_dict(),
|
||||
"control": self.control.to_dict(),
|
||||
}
|
||||
|
||||
@property
|
||||
@@ -1096,6 +1168,7 @@ class MultiChannelGatewayConfig:
|
||||
api=ApiConfig.from_dict(gw_data.get("api")),
|
||||
liveness=LivenessConfig.from_dict(gw_data.get("liveness")),
|
||||
turn_lock=TurnLockConfig.from_dict(gw_data.get("turn_lock")),
|
||||
control=EmergencyStopConfig.from_dict(gw_data.get("control")),
|
||||
)
|
||||
|
||||
# Parse agents section (pass through as dicts)
|
||||
|
||||
@@ -86,7 +86,12 @@ def _lookup(path: str, payload: Dict[str, Any]) -> Any:
|
||||
return current
|
||||
|
||||
|
||||
def render_template(template: Optional[str], payload: Dict[str, Any]) -> str:
|
||||
def render_template(
|
||||
template: Optional[str],
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
fence_values: bool = False,
|
||||
) -> str:
|
||||
"""Render ``template`` against ``payload`` with no external dependencies.
|
||||
|
||||
Supports ``{{ payload.x }}`` and ``{x}`` placeholders resolving dotted
|
||||
@@ -96,6 +101,12 @@ def render_template(template: Optional[str], payload: Dict[str, Any]) -> str:
|
||||
Args:
|
||||
template: The template string (or None).
|
||||
payload: The decoded JSON request body.
|
||||
fence_values: When True, each interpolated payload-derived value is
|
||||
wrapped in an ``<external_request_payload>`` fence so the agent
|
||||
treats it as untrusted data, not instructions. The operator's own
|
||||
static template text stays outside the fence. Used for the agent
|
||||
message (see :meth:`HookConfig.resolve_message`); left False for
|
||||
session/idempotency keys where fencing would corrupt the value.
|
||||
|
||||
Returns:
|
||||
The rendered string.
|
||||
@@ -103,13 +114,35 @@ def render_template(template: Optional[str], payload: Dict[str, Any]) -> str:
|
||||
if not template:
|
||||
return ""
|
||||
|
||||
wrap = None
|
||||
notice = None
|
||||
if fence_values:
|
||||
from praisonaiagents.tools.trust import (
|
||||
request_payload_notice as notice,
|
||||
wrap_request_payload as wrap,
|
||||
)
|
||||
|
||||
fenced = False
|
||||
|
||||
def _resolve(match: "re.Match[str]") -> str:
|
||||
nonlocal fenced
|
||||
# group(1) is the ``{{ ... }}`` body, group(2) the ``{ ... }`` body.
|
||||
expr = match.group(1) if match.group(1) is not None else match.group(2)
|
||||
value = _lookup(expr, payload)
|
||||
return "" if value is None else str(value)
|
||||
if value is None:
|
||||
return ""
|
||||
if wrap is None:
|
||||
return str(value)
|
||||
fenced = True
|
||||
return wrap(str(value))
|
||||
|
||||
return _PLACEHOLDER.sub(_resolve, template)
|
||||
rendered = _PLACEHOLDER.sub(_resolve, template)
|
||||
# Prepend the inline untrusted-data notice once (outside the fence) when a
|
||||
# payload value was fenced, so the semantics travel with the message even if
|
||||
# the agent runs with ``use_system_prompt=False``.
|
||||
if fenced and notice is not None:
|
||||
return f"{notice()}\n\n{rendered}"
|
||||
return rendered
|
||||
|
||||
|
||||
def compute_idempotency_key(
|
||||
@@ -375,9 +408,22 @@ class HookConfig:
|
||||
self.idempotency_key, payload, path=self.path
|
||||
)
|
||||
|
||||
def resolve_message(self, payload: Dict[str, Any]) -> str:
|
||||
"""Render the agent message for ``payload``."""
|
||||
return render_template(self.message, payload)
|
||||
def resolve_message(self, payload: Dict[str, Any], *, fence: bool = True) -> str:
|
||||
"""Render the hook message for ``payload``.
|
||||
|
||||
Payload-derived interpolations are fenced as untrusted request data so
|
||||
the agent never treats externally-POSTed content as instructions; the
|
||||
operator's own template text stays outside the fence.
|
||||
|
||||
Args:
|
||||
payload: The decoded request body.
|
||||
fence: When True (the agent-turn default), interpolated payload
|
||||
values are wrapped in an ``<external_request_payload>`` fence.
|
||||
Pass ``fence=False`` for ``deliver_only`` hooks, where the
|
||||
rendered message is delivered verbatim to a channel with no
|
||||
agent consuming (and therefore stripping) the fence markup.
|
||||
"""
|
||||
return render_template(self.message, payload, fence_values=fence)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to a dictionary (hides the auth/signing secrets)."""
|
||||
|
||||
@@ -126,11 +126,27 @@ class LoopWatchdog:
|
||||
self._last_ack = 0.0
|
||||
self._last_probe = 0.0
|
||||
self._wedged = False
|
||||
# Continuously-measured scheduling lag (ms) of the most recently
|
||||
# acked probe. Read back by the health surface as a saturation signal
|
||||
# (Issue #4265) — the same measurement the wedge check already relies
|
||||
# on, exposed rather than only compared against the wedge threshold.
|
||||
self._probe_sent_at = 0.0
|
||||
self._last_lag_ms = 0.0
|
||||
|
||||
@property
|
||||
def armed(self) -> bool:
|
||||
return self._thread is not None and self._thread.is_alive()
|
||||
|
||||
@property
|
||||
def last_lag_ms(self) -> float:
|
||||
"""Most recent event-loop scheduling lag in milliseconds.
|
||||
|
||||
The delay between scheduling a probe and the loop running its ack.
|
||||
``0.0`` until the first probe round-trips. Read by the gateway health
|
||||
surface as a forward saturation signal (Issue #4265).
|
||||
"""
|
||||
return self._last_lag_ms
|
||||
|
||||
@property
|
||||
def wedged(self) -> bool:
|
||||
"""True once a wedge has been confirmed (mainly for ``dump_only``)."""
|
||||
@@ -176,7 +192,22 @@ class LoopWatchdog:
|
||||
|
||||
def _ack(self) -> None:
|
||||
"""Callback run *on the loop*; records that the loop is alive."""
|
||||
self._last_ack = time.monotonic()
|
||||
now = time.monotonic()
|
||||
self._last_ack = now
|
||||
# Record the scheduling lag: how long the loop took to run this ack
|
||||
# after we scheduled it. This is the same signal the wedge check uses,
|
||||
# exposed as a gauge for the health surface (Issue #4265).
|
||||
#
|
||||
# ``_probe_sent_at`` is anchored to the *oldest still-unacked* probe,
|
||||
# not the most recent one: when the loop stalls across several probe
|
||||
# intervals the watchdog keeps scheduling fresh acks that all queue
|
||||
# behind the wedge, and measuring from the newest probe would report a
|
||||
# fraction of the true stall. Consuming the anchor here (setting it to
|
||||
# 0.0) lets the next scheduled probe re-anchor from a caught-up loop.
|
||||
sent = self._probe_sent_at
|
||||
if sent:
|
||||
self._last_lag_ms = max(0.0, (now - sent) * 1000.0)
|
||||
self._probe_sent_at = 0.0
|
||||
|
||||
def _schedule_probe(self) -> bool:
|
||||
"""Schedule an ack onto the loop. Returns False if scheduling fails."""
|
||||
@@ -184,8 +215,16 @@ class LoopWatchdog:
|
||||
if loop is None:
|
||||
return False
|
||||
try:
|
||||
# Only (re-)anchor the lag clock when the previous probe has been
|
||||
# acked (``_ack`` zeroes ``_probe_sent_at``). If the loop is stalled
|
||||
# and earlier probes are still queued, keep the anchor pinned to the
|
||||
# oldest unacked probe so the reported lag reflects the full stall
|
||||
# rather than the gap since the newest probe (Issue #4265).
|
||||
now = time.monotonic()
|
||||
if not self._probe_sent_at:
|
||||
self._probe_sent_at = now
|
||||
loop.call_soon_threadsafe(self._ack)
|
||||
self._last_probe = time.monotonic()
|
||||
self._last_probe = now
|
||||
return True
|
||||
except RuntimeError:
|
||||
# Loop is closed / not running — treat as not-scheduled but do not
|
||||
|
||||
@@ -30,6 +30,8 @@ from typing import (
|
||||
Iterator,
|
||||
List,
|
||||
Literal,
|
||||
Mapping,
|
||||
MutableMapping,
|
||||
Optional,
|
||||
Protocol,
|
||||
Sequence,
|
||||
@@ -198,6 +200,10 @@ class EventType(str, Enum):
|
||||
TOOL_PROGRESS_STREAM = "tool_progress_stream"
|
||||
STREAM_ERROR = "stream_error"
|
||||
STREAM_END = "stream_end"
|
||||
MODEL_FALLBACK_STREAM = "model_fallback_stream"
|
||||
RETRY_STREAM = "retry_stream"
|
||||
TODO_STREAM = "todo_stream"
|
||||
TOOL_RESULT_STREAM = "tool_result_stream"
|
||||
|
||||
# System events
|
||||
HEALTH = "health"
|
||||
@@ -1093,6 +1099,10 @@ class GatewayProtocol(Protocol):
|
||||
:func:`compute_config_revision`) of the config the gateway is
|
||||
*actually running*, comparable against the on-disk revision to
|
||||
detect drift.
|
||||
- pressure: Optional :class:`HealthPressure` dict (admission /
|
||||
inbound / outbound backlog + event-loop lag and a derived
|
||||
``pressure`` classification), when the gateway runs back-pressure
|
||||
machinery. See :class:`HealthPressure` and :func:`evaluate_pressure`.
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -1188,6 +1198,165 @@ def compute_config_revision(config: Optional[Dict[str, Any]]) -> str:
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Saturation / back-pressure observability (Issue #4265)
|
||||
#
|
||||
# The always-on gateway already *enforces* back-pressure — a concurrency
|
||||
# ceiling with a bounded fair wait-queue (``AdmissionGate``), a durable
|
||||
# outbound outbox with a dead-letter tier (``OutboundQueue``), and an
|
||||
# event-loop wedge watchdog (``LoopWatchdog``) — but it exposes no saturation
|
||||
# *telemetry*. The health surface reports liveness and config drift, yet never
|
||||
# reports how close the gateway is to its limits, so the first signal an
|
||||
# operator gets is dropped work (shed turns / stalled outbound) rather than a
|
||||
# forward warning. This is the small, canonical contract — mirroring
|
||||
# ``ReloadStatus`` exactly — that both the SDK's ``gateway.health()`` and the
|
||||
# bot's ``/health`` populate so saturation is a first-class, observable signal.
|
||||
# Core owns only the *shape* (a frozen dataclass) and a pure, deterministic
|
||||
# classification helper; the wrapper reads back the facts the enforcement layer
|
||||
# already computes and fills the block.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HealthPressure:
|
||||
"""Observable saturation snapshot of the gateway's back-pressure machinery.
|
||||
|
||||
Populated by the running gateway and surfaced in
|
||||
:meth:`GatewayProtocol.health` so an operator (or an autoscaler / a
|
||||
``/ready``-driven load balancer) can ask "is the gateway backing up?"
|
||||
*before* admission starts shedding turns — rather than inferring overload
|
||||
post-mortem from shed-load log lines.
|
||||
|
||||
Attributes:
|
||||
admission_max: Configured concurrency ceiling (``0`` when unbounded).
|
||||
admission_in_flight: Turns currently running.
|
||||
admission_queued: Turns currently waiting for a slot.
|
||||
admission_shed_total: Cumulative turns shed since start.
|
||||
inbox_pending: Aggregate pending inbound queue depth across sessions.
|
||||
outbox_pending: Durable outbound backlog awaiting delivery.
|
||||
outbox_dead_letter: Durable outbound entries in the dead-letter tier.
|
||||
event_loop_lag_p99_ms: Recent event-loop scheduling lag (ms).
|
||||
pressure: Single derived classification — ``"nominal"``,
|
||||
``"elevated"`` or ``"saturated"`` (closed vocabulary) — so an
|
||||
orchestrator can branch on one field.
|
||||
"""
|
||||
|
||||
admission_max: int = 0
|
||||
admission_in_flight: int = 0
|
||||
admission_queued: int = 0
|
||||
admission_shed_total: int = 0
|
||||
inbox_pending: int = 0
|
||||
outbox_pending: int = 0
|
||||
outbox_dead_letter: int = 0
|
||||
event_loop_lag_p99_ms: float = 0.0
|
||||
pressure: Literal["nominal", "elevated", "saturated"] = "nominal"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to a JSON-serializable dict for the health surface."""
|
||||
return {
|
||||
"admission_max": self.admission_max,
|
||||
"admission_in_flight": self.admission_in_flight,
|
||||
"admission_queued": self.admission_queued,
|
||||
"admission_shed_total": self.admission_shed_total,
|
||||
"inbox_pending": self.inbox_pending,
|
||||
"outbox_pending": self.outbox_pending,
|
||||
"outbox_dead_letter": self.outbox_dead_letter,
|
||||
"event_loop_lag_p99_ms": self.event_loop_lag_p99_ms,
|
||||
"pressure": self.pressure,
|
||||
}
|
||||
|
||||
|
||||
def evaluate_pressure(
|
||||
*,
|
||||
admission: Optional[Dict[str, Any]] = None,
|
||||
inbox_pending: int = 0,
|
||||
outbox_pending: int = 0,
|
||||
outbox_dead_letter: int = 0,
|
||||
loop_lag_p99_ms: float = 0.0,
|
||||
) -> HealthPressure:
|
||||
"""Pure, deterministic classification of gateway load. No I/O.
|
||||
|
||||
Reads back facts the enforcement layer already computes
|
||||
(``AdmissionGate.stats()``, the outbox ``status`` counts, the watchdog's
|
||||
measured lag) and folds them into a single :class:`HealthPressure`. Lives
|
||||
in core — like :func:`compute_config_revision` — so the SDK and the bot
|
||||
classify identically without divergence.
|
||||
|
||||
Classification (highest wins):
|
||||
|
||||
* ``"saturated"`` — admission is full *and* work is queuing, the outbound
|
||||
dead-letter tier is non-empty, or the event loop is lagging badly
|
||||
(``>= 500 ms``): shed / stall is imminent or occurring.
|
||||
* ``"elevated"`` — admission is highly utilised (``>= 80 %``), the inbound
|
||||
or outbound backlog is building, or the loop is lagging (``>= 100 ms``).
|
||||
* ``"nominal"`` — none of the above.
|
||||
|
||||
Args:
|
||||
admission: An :meth:`AdmissionGate.stats` snapshot (or ``None``).
|
||||
inbox_pending: Aggregate pending inbound queue depth.
|
||||
outbox_pending: Durable outbound backlog.
|
||||
outbox_dead_letter: Durable outbound dead-letter depth.
|
||||
loop_lag_p99_ms: Recent event-loop scheduling lag in ms.
|
||||
|
||||
Returns:
|
||||
A frozen :class:`HealthPressure` ready for the health surface.
|
||||
"""
|
||||
stats = admission or {}
|
||||
|
||||
def _int(value: Any) -> int:
|
||||
try:
|
||||
return int(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
admission_max = _int(stats.get("max_concurrent_runs"))
|
||||
in_flight = _int(stats.get("in_flight"))
|
||||
queued = _int(stats.get("queued"))
|
||||
shed_total = _int(stats.get("shed"))
|
||||
inbox = max(0, _int(inbox_pending))
|
||||
outbox = max(0, _int(outbox_pending))
|
||||
dead_letter = max(0, _int(outbox_dead_letter))
|
||||
try:
|
||||
lag = float(loop_lag_p99_ms or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
lag = 0.0
|
||||
if lag < 0.0:
|
||||
lag = 0.0
|
||||
|
||||
utilisation = (in_flight / admission_max) if admission_max > 0 else 0.0
|
||||
|
||||
saturated = (
|
||||
(admission_max > 0 and in_flight >= admission_max and queued > 0)
|
||||
or dead_letter > 0
|
||||
or lag >= 500.0
|
||||
)
|
||||
elevated = (
|
||||
utilisation >= 0.8
|
||||
or queued > 0
|
||||
or inbox > 0
|
||||
or outbox > 0
|
||||
or lag >= 100.0
|
||||
)
|
||||
if saturated:
|
||||
pressure: Literal["nominal", "elevated", "saturated"] = "saturated"
|
||||
elif elevated:
|
||||
pressure = "elevated"
|
||||
else:
|
||||
pressure = "nominal"
|
||||
|
||||
return HealthPressure(
|
||||
admission_max=admission_max,
|
||||
admission_in_flight=in_flight,
|
||||
admission_queued=queued,
|
||||
admission_shed_total=shed_total,
|
||||
inbox_pending=inbox,
|
||||
outbox_pending=outbox,
|
||||
outbox_dead_letter=dead_letter,
|
||||
event_loop_lag_p99_ms=lag,
|
||||
pressure=pressure,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Push notification dataclasses
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1565,6 +1734,90 @@ class OutboundDeliveryProtocol(Protocol):
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IdempotencyStoreProtocol(Protocol):
|
||||
"""Protocol for inbound webhook/trigger delivery deduplication.
|
||||
|
||||
The gateway's inbound HTTP hook surface must not start a second agent run
|
||||
for an event a provider re-delivers (webhook providers routinely retry for
|
||||
minutes to days). Deduplication is a three-step, restart-stable claim on the
|
||||
deterministic idempotency key (``compute_idempotency_key``):
|
||||
|
||||
- ``reserve`` — atomically claim the key. Returns ``False`` when the key
|
||||
was already recorded *or* is currently in flight, so both a redelivery
|
||||
and a concurrent duplicate are rejected.
|
||||
- ``record`` — commit the key after a successful run so future
|
||||
redeliveries dedup.
|
||||
- ``release`` — drop an in-flight reservation after a failed run so the
|
||||
provider's retry can re-run.
|
||||
|
||||
This mirrors :class:`CallbackPayloadStoreProtocol`: the contract lives in
|
||||
core so third-party gateways and a future ``redis`` backend interoperate;
|
||||
core ships the bounded in-memory default (:class:`InMemoryIdempotencyStore`)
|
||||
and the durable, restart-surviving SQLite backend lives in the
|
||||
``praisonai-bot`` runtime (as the ingress journal and outbound queue do).
|
||||
"""
|
||||
|
||||
def reserve(self, key: str) -> bool:
|
||||
"""Atomically claim ``key``; ``False`` if seen-or-in-flight."""
|
||||
...
|
||||
|
||||
def record(self, key: str) -> None:
|
||||
"""Commit ``key`` as processed after a successful run."""
|
||||
...
|
||||
|
||||
def release(self, key: str) -> None:
|
||||
"""Drop an in-flight reservation so a failed delivery can be retried."""
|
||||
...
|
||||
|
||||
|
||||
class InMemoryIdempotencyStore:
|
||||
"""Bounded, zero-dependency in-memory :class:`IdempotencyStoreProtocol`.
|
||||
|
||||
The default store for single-replica deployments — identical behaviour to
|
||||
the gateway's original per-process ``OrderedDict`` + in-flight ``set``. It
|
||||
is *not* durable: after a process restart the store is empty, so a durable
|
||||
backend (SQLite/redis) must be injected for the restart/multi-replica case.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
max_entries: int = 10_000,
|
||||
ttl_seconds: float = 86_400.0,
|
||||
) -> None:
|
||||
self._max_entries = max(1, int(max_entries))
|
||||
self._ttl_seconds = float(ttl_seconds)
|
||||
# key -> insertion time (seconds); insertion-ordered for FIFO eviction.
|
||||
self._seen: "Dict[str, float]" = {}
|
||||
self._inflight: Set[str] = set()
|
||||
|
||||
def _purge_expired(self, now: float) -> None:
|
||||
if not self._seen:
|
||||
return
|
||||
expired = [k for k, ts in self._seen.items() if now - ts > self._ttl_seconds]
|
||||
for k in expired:
|
||||
self._seen.pop(k, None)
|
||||
|
||||
def reserve(self, key: str) -> bool:
|
||||
now = time.time()
|
||||
self._purge_expired(now)
|
||||
if key in self._seen or key in self._inflight:
|
||||
return False
|
||||
self._inflight.add(key)
|
||||
return True
|
||||
|
||||
def record(self, key: str) -> None:
|
||||
self._inflight.discard(key)
|
||||
self._seen[key] = time.time()
|
||||
while len(self._seen) > self._max_entries:
|
||||
oldest = next(iter(self._seen))
|
||||
self._seen.pop(oldest, None)
|
||||
|
||||
def release(self, key: str) -> None:
|
||||
self._inflight.discard(key)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-route, trust-tiered toolset scoping (Issue #2298)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -4922,6 +5175,26 @@ class GatewayTraceHook(Protocol):
|
||||
session=sid,
|
||||
):
|
||||
reply = await agent.astart(text)
|
||||
|
||||
W3C trace-context propagation is layered on top of the same seam without
|
||||
forcing OpenTelemetry into core. ``stage`` accepts an optional
|
||||
``parent_carrier`` (a header mapping such as an inbound request's
|
||||
``{"traceparent": ..., "tracestate": ...}``) so an exporter can *continue*
|
||||
an upstream caller's trace instead of starting a detached one; and the two
|
||||
companion methods let egress call sites propagate the active span context:
|
||||
|
||||
# ingress: continue the caller's trace when a traceparent arrives
|
||||
with self._trace.stage("agent.run", parent_carrier=inbound.headers):
|
||||
...
|
||||
|
||||
# egress: write the active traceparent onto an outbound request.
|
||||
# Seed with provider headers first, then inject last so the active
|
||||
# trace context always wins over any stale traceparent/tracestate.
|
||||
headers = dict(provider_headers)
|
||||
self._trace.inject_context(headers) # no-op unless an exporter is set
|
||||
|
||||
Both companion methods are no-ops in :class:`NullGatewayTraceHook`, so the
|
||||
default path stays zero-cost and OpenTelemetry-free.
|
||||
"""
|
||||
|
||||
def stage(
|
||||
@@ -4929,6 +5202,7 @@ class GatewayTraceHook(Protocol):
|
||||
name: str,
|
||||
*,
|
||||
correlation_id: "Optional[str]" = None,
|
||||
parent_carrier: "Optional[Mapping[str, str]]" = None,
|
||||
**attrs: Any,
|
||||
) -> "AbstractContextManager[Any]":
|
||||
"""Open a tracing scope for pipeline stage ``name``.
|
||||
@@ -4938,6 +5212,11 @@ class GatewayTraceHook(Protocol):
|
||||
span name.
|
||||
correlation_id: The inbound turn's correlation id, attached as a
|
||||
span attribute so spans and logs share a key.
|
||||
parent_carrier: Optional inbound header mapping carrying W3C
|
||||
trace-context (``traceparent``/``tracestate``). When present and
|
||||
an exporter is attached, the stage is entered *under* the
|
||||
extracted parent context so the span nests within the caller's
|
||||
distributed trace instead of starting a new root.
|
||||
**attrs: Extra span attributes (e.g. ``session``, ``model``,
|
||||
``tool``, ``channel``).
|
||||
|
||||
@@ -4946,6 +5225,28 @@ class GatewayTraceHook(Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
def inject_context(self, carrier: "MutableMapping[str, str]") -> None:
|
||||
"""Write the active span context into ``carrier`` as W3C headers.
|
||||
|
||||
Called at an egress boundary (LLM / MCP / HTTP-tool request) so the
|
||||
downstream service's spans nest under the current agent turn. A no-op
|
||||
when no exporter is attached, leaving ``carrier`` untouched.
|
||||
"""
|
||||
...
|
||||
|
||||
def extract_carrier(
|
||||
self, carrier: "Mapping[str, str]"
|
||||
) -> "Optional[Mapping[str, str]]":
|
||||
"""Return a propagation carrier extracted from inbound ``carrier``.
|
||||
|
||||
Reads W3C trace-context (``traceparent``/``tracestate``) from an
|
||||
inbound header mapping and returns a normalized carrier suitable to pass
|
||||
as ``stage(..., parent_carrier=...)``, or ``None`` when no usable
|
||||
context is present. A no-op returning ``None`` when no exporter is
|
||||
attached.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class NullGatewayTraceHook:
|
||||
"""Zero-cost no-op :class:`GatewayTraceHook` used when tracing is disabled.
|
||||
@@ -4967,11 +5268,22 @@ class NullGatewayTraceHook:
|
||||
name: str,
|
||||
*,
|
||||
correlation_id: "Optional[str]" = None,
|
||||
parent_carrier: "Optional[Mapping[str, str]]" = None,
|
||||
**attrs: Any,
|
||||
) -> "AbstractContextManager[Any]":
|
||||
"""Return a no-op context manager, ignoring all arguments."""
|
||||
return self._null_scope()
|
||||
|
||||
def inject_context(self, carrier: "MutableMapping[str, str]") -> None:
|
||||
"""No-op: leave ``carrier`` untouched when tracing is disabled."""
|
||||
return None
|
||||
|
||||
def extract_carrier(
|
||||
self, carrier: "Mapping[str, str]"
|
||||
) -> "Optional[Mapping[str, str]]":
|
||||
"""No-op: no parent context to continue when tracing is disabled."""
|
||||
return None
|
||||
|
||||
|
||||
# Shared singleton: the default no-op hook is stateless, so one instance is
|
||||
# reused everywhere a gateway needs a zero-cost default.
|
||||
@@ -5980,3 +6292,195 @@ def _register_core_gateway_methods() -> None:
|
||||
|
||||
|
||||
_register_core_gateway_methods()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Global operator emergency-stop / pause brake (Issue #4220)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# A single, durable, fail-safe operator brake: "stop admitting NEW agent work
|
||||
# everywhere, right now, but let in-flight runs finish — and resume later with
|
||||
# no restart." Every new-work admission seam (WebSocket inbound, HTTP/MCP
|
||||
# inbound, kanban dispatch cycle, scheduler due-loop) consults one shared
|
||||
# contract instead of each lane deciding independently.
|
||||
#
|
||||
# Design mirrors the drain / idle / admission / liveness policy family already
|
||||
# in this module: a pure ``Protocol`` in core plus a lightweight default and a
|
||||
# durable fail-safe backend. When no brake is engaged, ``is_engaged()`` is
|
||||
# ``False`` and behaviour is byte-for-byte today's — zero cost, no new
|
||||
# dependency, fully backward-compatible.
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmergencyStopState:
|
||||
"""Immutable snapshot of the operator brake, for status/audit surfaces.
|
||||
|
||||
``engaged`` is the only field the hot path needs; ``reason``/``actor``/
|
||||
``at`` provide the optional audit trail surfaced in ``/health``/status.
|
||||
"""
|
||||
|
||||
engaged: bool
|
||||
reason: str = ""
|
||||
actor: str = ""
|
||||
at: float = 0.0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"engaged": self.engaged,
|
||||
"reason": self.reason,
|
||||
"actor": self.actor,
|
||||
"at": self.at,
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class EmergencyStopProtocol(Protocol):
|
||||
"""Pure contract for the global operator brake consulted at every seam.
|
||||
|
||||
Fail-safe rule: if the engaged state cannot be read with confidence (a
|
||||
corrupt / unreadable durable sentinel), ``is_engaged()`` MUST return
|
||||
``True`` — an ambiguous brake holds new work rather than letting it run
|
||||
freely. ``engage``/``disengage`` are idempotent.
|
||||
"""
|
||||
|
||||
def is_engaged(self) -> bool: ...
|
||||
|
||||
def engage(self, *, reason: str = "", actor: str = "") -> None: ...
|
||||
|
||||
def disengage(self) -> None: ...
|
||||
|
||||
def state(self) -> EmergencyStopState: ...
|
||||
|
||||
|
||||
class NullEmergencyStop:
|
||||
"""Default no-op brake: never engaged, no persistence.
|
||||
|
||||
Selected by ``backend: "off"`` (the default) so a gateway with no brake
|
||||
configured behaves exactly as before — ``is_engaged()`` is always
|
||||
``False`` and ``engage``/``disengage`` are inert.
|
||||
"""
|
||||
|
||||
def is_engaged(self) -> bool:
|
||||
return False
|
||||
|
||||
def engage(self, *, reason: str = "", actor: str = "") -> None:
|
||||
return None
|
||||
|
||||
def disengage(self) -> None:
|
||||
return None
|
||||
|
||||
def state(self) -> EmergencyStopState:
|
||||
return EmergencyStopState(engaged=False)
|
||||
|
||||
|
||||
class FileEmergencyStop:
|
||||
"""Durable, fail-safe file-sentinel operator brake.
|
||||
|
||||
Engaging writes a small JSON sentinel at ``path``; disengaging removes it.
|
||||
The engaged state therefore survives a crash/restart (durable) and is
|
||||
shared by every lane that consults the same path.
|
||||
|
||||
Fail-safe: any error reading the sentinel — a partially written file,
|
||||
corrupt JSON, a permission error — is treated as *engaged*. An ambiguous
|
||||
brake holds new work; it never silently falls open to "run freely". The
|
||||
common, unambiguous case (sentinel simply absent) reports not-engaged.
|
||||
|
||||
Pure-stdlib (``os``/``json``): no new dependency, safe to live in core.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str) -> None:
|
||||
if not path:
|
||||
raise ValueError("FileEmergencyStop requires a non-empty sentinel path")
|
||||
import os
|
||||
|
||||
self._path = os.path.expanduser(str(path))
|
||||
|
||||
def is_engaged(self) -> bool:
|
||||
import os
|
||||
|
||||
if not os.path.exists(self._path):
|
||||
return False
|
||||
# Present but unreadable/corrupt => fail-safe engaged.
|
||||
try:
|
||||
with open(self._path, "r", encoding="utf-8") as fh:
|
||||
fh.read()
|
||||
return True
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
def engage(self, *, reason: str = "", actor: str = "") -> None:
|
||||
import json
|
||||
import os
|
||||
|
||||
directory = os.path.dirname(self._path)
|
||||
if directory:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
payload = {
|
||||
"engaged": True,
|
||||
"reason": str(reason or ""),
|
||||
"actor": str(actor or ""),
|
||||
"at": time.time(),
|
||||
}
|
||||
# Atomic replace so a reader never observes a half-written sentinel
|
||||
# (which would fail-safe engaged anyway, but this keeps it clean).
|
||||
# ``mkstemp`` in the sentinel's own directory yields an unpredictable
|
||||
# name owned by us, so a local attacker cannot pre-create a symlink to
|
||||
# redirect the write. fsync + parent-dir fsync make an engaged brake
|
||||
# durable across an abrupt crash.
|
||||
import tempfile
|
||||
|
||||
fd, tmp = tempfile.mkstemp(
|
||||
dir=directory or ".", prefix=".gateway.pause.", suffix=".tmp"
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
json.dump(payload, fh)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.replace(tmp, self._path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.remove(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
if directory:
|
||||
try:
|
||||
dir_fd = os.open(directory, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(dir_fd)
|
||||
finally:
|
||||
os.close(dir_fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def disengage(self) -> None:
|
||||
import os
|
||||
|
||||
try:
|
||||
os.remove(self._path)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
def state(self) -> EmergencyStopState:
|
||||
import json
|
||||
import os
|
||||
|
||||
if not os.path.exists(self._path):
|
||||
return EmergencyStopState(engaged=False)
|
||||
try:
|
||||
with open(self._path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("sentinel is not a JSON object")
|
||||
return EmergencyStopState(
|
||||
engaged=True,
|
||||
reason=str(data.get("reason", "")),
|
||||
actor=str(data.get("actor", "")),
|
||||
at=float(data.get("at", 0.0) or 0.0),
|
||||
)
|
||||
except (OSError, TypeError, ValueError):
|
||||
# Fail-safe: unreadable/corrupt sentinel counts as engaged. A valid
|
||||
# JSON object with a non-numeric ``at`` (e.g. a list) raises
|
||||
# TypeError from ``float()`` and must also fail-safe engaged.
|
||||
return EmergencyStopState(engaged=True, reason="unreadable-sentinel")
|
||||
@@ -47,7 +47,11 @@ def _tail(text: str, limit: int = 4000) -> str:
|
||||
return text[-limit:]
|
||||
|
||||
|
||||
def _build_goal_judge_prompt(state: "GoalState", transcript_tail: str) -> str:
|
||||
def _build_goal_judge_prompt(
|
||||
state: "GoalState",
|
||||
transcript_tail: str,
|
||||
verification_block: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Build a strict, criteria-anchored, JSON-only judge prompt."""
|
||||
lines = [
|
||||
"You are an independent completion judge for an autonomous agent.",
|
||||
@@ -72,6 +76,13 @@ def _build_goal_judge_prompt(state: "GoalState", transcript_tail: str) -> str:
|
||||
lines.append(
|
||||
"If ANY constraint is violated, you MUST answer 'continue'."
|
||||
)
|
||||
if verification_block:
|
||||
lines += [
|
||||
"",
|
||||
"EXECUTABLE VERIFICATION RESULTS (authoritative — command exit "
|
||||
"codes and file checks; prefer this over prose claims):",
|
||||
verification_block,
|
||||
]
|
||||
lines += [
|
||||
"",
|
||||
"AGENT OUTPUT (most recent):",
|
||||
@@ -165,6 +176,7 @@ def judge_goal(
|
||||
transcript_tail: str,
|
||||
*,
|
||||
judge_model: Optional[str] = None,
|
||||
verification_block: Optional[str] = None,
|
||||
) -> Tuple[str, str]:
|
||||
"""Judge whether ``state.goal`` is met given the latest agent output.
|
||||
|
||||
@@ -180,7 +192,7 @@ def judge_goal(
|
||||
"""
|
||||
from ..eval.judge import Judge
|
||||
|
||||
prompt = _build_goal_judge_prompt(state, transcript_tail)
|
||||
prompt = _build_goal_judge_prompt(state, transcript_tail, verification_block)
|
||||
try:
|
||||
judge = Judge(model=judge_model or _default_judge_model(), temperature=0.0)
|
||||
litellm = judge._get_litellm()
|
||||
|
||||
@@ -128,11 +128,44 @@ class GoalLoopMixin:
|
||||
if state is None or state.status != "active":
|
||||
return None
|
||||
|
||||
# Executable verification: run configured hooks and gate on the result.
|
||||
# A failing BLOCKING hook short-circuits to "continue" WITHOUT spending
|
||||
# a judge call. Passing/non-blocking results become authoritative
|
||||
# evidence in the judge prompt.
|
||||
verification_block: Optional[str] = None
|
||||
if getattr(self, "_verification_hooks", None):
|
||||
results = self._run_verification_hooks()
|
||||
self._last_verification_results = results
|
||||
self._record_verification_journal(results)
|
||||
verification_block = self._verification_summary_tail(results)
|
||||
blocking_failed = [
|
||||
r for r in results
|
||||
if r.get("blocking", True) and not r.get("success")
|
||||
]
|
||||
if blocking_failed:
|
||||
state.turns_used += 1
|
||||
state.last_verdict = "continue"
|
||||
# Carry the failing hooks' captured stdout/stderr into the
|
||||
# continuation prompt so the next turn has the diagnostics
|
||||
# needed to repair the failure (not just a bare label).
|
||||
failure_detail = self._verification_summary_tail(blocking_failed)
|
||||
reason = "verification gate failed"
|
||||
if failure_detail:
|
||||
reason = f"{reason}:\n{failure_detail}"
|
||||
state.last_reason = reason
|
||||
if state.turns_used >= state.max_turns:
|
||||
state.status = "paused"
|
||||
self._persist_goal_state()
|
||||
return "budget_paused", reason
|
||||
self._persist_goal_state()
|
||||
return "continue", reason
|
||||
|
||||
state.turns_used += 1
|
||||
verdict, reason = judge_goal(
|
||||
state,
|
||||
_tail(response),
|
||||
judge_model=getattr(self, "_goal_judge_model", None),
|
||||
verification_block=verification_block,
|
||||
)
|
||||
state.last_verdict, state.last_reason = verdict, reason
|
||||
|
||||
|
||||
@@ -7,7 +7,12 @@ including both function-based and LLM-based guardrails.
|
||||
|
||||
from .guardrail_result import GuardrailResult
|
||||
from .llm_guardrail import LLMGuardrail
|
||||
from .protocols import GuardrailProtocol, StructuralGuardrailProtocol, PolicyGuardrailProtocol
|
||||
from .protocols import (
|
||||
GuardrailProtocol,
|
||||
StructuralGuardrailProtocol,
|
||||
PolicyGuardrailProtocol,
|
||||
is_guardrail_object,
|
||||
)
|
||||
from .chain import GuardrailChain
|
||||
|
||||
__all__ = [
|
||||
@@ -16,5 +21,6 @@ __all__ = [
|
||||
"GuardrailProtocol",
|
||||
"StructuralGuardrailProtocol",
|
||||
"PolicyGuardrailProtocol",
|
||||
"GuardrailChain"
|
||||
"GuardrailChain",
|
||||
"is_guardrail_object",
|
||||
]
|
||||
@@ -175,3 +175,25 @@ class PolicyGuardrailProtocol(Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# Method names that make an object a guardrail. An object exposing at least one
|
||||
# of these is a protocol-conforming guardrail even though it is not ``callable``.
|
||||
GUARDRAIL_PROTOCOL_METHODS = ("validate_input", "validate_output", "validate_tool_call")
|
||||
|
||||
|
||||
def is_guardrail_object(obj: Any) -> bool:
|
||||
"""Return True when ``obj`` is a protocol-conforming guardrail *instance*.
|
||||
|
||||
``isinstance(obj, GuardrailProtocol)`` is not usable here: a ``runtime_checkable``
|
||||
Protocol only checks method presence and would also match unrelated objects
|
||||
partially, while classes (not instances) must be rejected. This helper is the
|
||||
single place that decides "is this thing a guardrail object?".
|
||||
|
||||
Plain validator callables are handled by their own passthrough branch and are
|
||||
not required to match here (they will, however, if they also expose one of the
|
||||
protocol methods - e.g. ``GuardrailChain``).
|
||||
"""
|
||||
if obj is None or isinstance(obj, type):
|
||||
return False
|
||||
return any(callable(getattr(obj, name, None)) for name in GUARDRAIL_PROTOCOL_METHODS)
|
||||
|
||||
@@ -104,6 +104,9 @@ __all__ = [
|
||||
# Verification hooks (protocols)
|
||||
"VerificationHook",
|
||||
"VerificationResult",
|
||||
"BaseVerificationHook",
|
||||
"CommandVerificationHook",
|
||||
"FileCheckHook",
|
||||
# Simplified API (beginner-friendly)
|
||||
"add_hook",
|
||||
"remove_hook",
|
||||
@@ -178,6 +181,9 @@ _LAZY_GROUPS = {
|
||||
'verification': {
|
||||
'VerificationHook': ('praisonaiagents.hooks.verification', 'VerificationHook'),
|
||||
'VerificationResult': ('praisonaiagents.hooks.verification', 'VerificationResult'),
|
||||
'BaseVerificationHook': ('praisonaiagents.hooks.verification', 'BaseVerificationHook'),
|
||||
'CommandVerificationHook': ('praisonaiagents.hooks.verification', 'CommandVerificationHook'),
|
||||
'FileCheckHook': ('praisonaiagents.hooks.verification', 'FileCheckHook'),
|
||||
},
|
||||
# Simplified API (beginner-friendly aliases)
|
||||
'simplified_api': {
|
||||
|
||||
@@ -184,7 +184,19 @@ class HookRunner:
|
||||
self._execute_single(hook, event, input_data)
|
||||
for hook in hooks
|
||||
]
|
||||
return await asyncio.gather(*tasks)
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
# Parallel hooks cannot mutate input_data (only sequential hooks apply
|
||||
# modified_input). Warn if a parallel hook returns modifications that
|
||||
# will be silently discarded, so hook authors mark it sequential=True.
|
||||
for hook, result in zip(hooks, results):
|
||||
if result.success and result.output and result.output.modified_input:
|
||||
logger.warning(
|
||||
"Parallel hook '%s' returned modified_input which is "
|
||||
"discarded; register it with sequential=True to apply it.",
|
||||
hook.name,
|
||||
)
|
||||
return results
|
||||
|
||||
async def _execute_sequential(
|
||||
self,
|
||||
|
||||
@@ -114,17 +114,26 @@ class BaseVerificationHook:
|
||||
|
||||
name: str = "base"
|
||||
timeout_seconds: float = 60.0
|
||||
|
||||
def __init__(self, name: Optional[str] = None, timeout: float = 60.0):
|
||||
blocking: bool = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: Optional[str] = None,
|
||||
timeout: float = 60.0,
|
||||
blocking: bool = True,
|
||||
):
|
||||
"""Initialize the hook.
|
||||
|
||||
Args:
|
||||
name: Override the hook name
|
||||
timeout: Timeout in seconds for execution
|
||||
blocking: When True (default), a failure of this hook blocks the
|
||||
autonomous loop from finalising with a success outcome.
|
||||
"""
|
||||
if name:
|
||||
self.name = name
|
||||
self.timeout_seconds = timeout
|
||||
self.blocking = blocking
|
||||
|
||||
def run(self, context: Optional[Dict[str, Any]] = None) -> VerificationResult:
|
||||
"""Run the verification hook.
|
||||
@@ -177,19 +186,25 @@ class CommandVerificationHook(BaseVerificationHook):
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
command: list,
|
||||
command,
|
||||
cwd: Optional[str] = None,
|
||||
timeout: float = 60.0,
|
||||
blocking: bool = True,
|
||||
):
|
||||
"""Initialize command hook.
|
||||
|
||||
Args:
|
||||
name: Hook name
|
||||
command: Command to run as list (e.g., ["pytest", "-v"])
|
||||
command: Command to run as a list (e.g., ["pytest", "-v"]) or a
|
||||
string (e.g., "python -m pytest -q"), which is shell-split.
|
||||
cwd: Working directory for command
|
||||
timeout: Timeout in seconds
|
||||
blocking: When True (default), a failure blocks completion.
|
||||
"""
|
||||
super().__init__(name=name, timeout=timeout)
|
||||
super().__init__(name=name, timeout=timeout, blocking=blocking)
|
||||
if isinstance(command, str):
|
||||
import shlex
|
||||
command = shlex.split(command)
|
||||
self.command = command
|
||||
self.cwd = cwd
|
||||
|
||||
@@ -235,3 +250,143 @@ class CommandVerificationHook(BaseVerificationHook):
|
||||
output=str(e),
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
|
||||
def _get_dotted(data: Any, path: str) -> Any:
|
||||
"""Access a dotted path (e.g. ``"a.b.c"``) in nested dicts/lists.
|
||||
|
||||
Returns ``None`` if any segment is missing. List indices are supported
|
||||
via integer-looking segments.
|
||||
"""
|
||||
value = data
|
||||
for part in path.split("."):
|
||||
if isinstance(value, dict):
|
||||
value = value.get(part)
|
||||
elif isinstance(value, (list, tuple)):
|
||||
try:
|
||||
value = value[int(part)]
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
class FileCheckHook(BaseVerificationHook):
|
||||
"""Declarative file-assertion verification hook (no shell involved).
|
||||
|
||||
Checks portable filesystem facts:
|
||||
- ``exists`` : the file exists (default True).
|
||||
- ``non_empty`` : the file has non-zero size.
|
||||
- ``contains`` : the file text contains a substring.
|
||||
- ``json_field``/``equals`` : the JSON value at a dotted path equals a value.
|
||||
|
||||
Example:
|
||||
hook = FileCheckHook(name="changelog", path="CHANGELOG.md", non_empty=True)
|
||||
hook = FileCheckHook(name="cfg", path="out.json",
|
||||
json_field="status.code", equals=0)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
path: str,
|
||||
exists: bool = True,
|
||||
non_empty: bool = False,
|
||||
contains: Optional[str] = None,
|
||||
json_field: Optional[str] = None,
|
||||
equals: Any = None,
|
||||
blocking: bool = True,
|
||||
):
|
||||
super().__init__(name=name, blocking=blocking)
|
||||
self.path = path
|
||||
self.exists = exists
|
||||
self.non_empty = non_empty
|
||||
self.contains = contains
|
||||
self.json_field = json_field
|
||||
self.equals = equals
|
||||
|
||||
def _execute(self, context: Optional[Dict[str, Any]] = None) -> VerificationResult:
|
||||
import os
|
||||
|
||||
details: Dict[str, Any] = {"path": self.path}
|
||||
file_exists = os.path.isfile(self.path)
|
||||
details["exists"] = file_exists
|
||||
|
||||
if self.exists and not file_exists:
|
||||
return VerificationResult(
|
||||
success=False,
|
||||
output=f"File not found: {self.path}",
|
||||
error="not_found",
|
||||
details=details,
|
||||
)
|
||||
if not self.exists:
|
||||
success = not file_exists
|
||||
return VerificationResult(
|
||||
success=success,
|
||||
output=(
|
||||
f"File exists but was expected absent: {self.path}"
|
||||
if not success else f"File absent as expected: {self.path}"
|
||||
),
|
||||
details=details,
|
||||
)
|
||||
|
||||
if self.non_empty:
|
||||
size = os.path.getsize(self.path)
|
||||
details["size"] = size
|
||||
if size == 0:
|
||||
return VerificationResult(
|
||||
success=False,
|
||||
output=f"File is empty: {self.path}",
|
||||
error="empty",
|
||||
details=details,
|
||||
)
|
||||
|
||||
if self.contains is not None or self.json_field is not None:
|
||||
try:
|
||||
with open(self.path, "r", encoding="utf-8") as fh:
|
||||
text = fh.read()
|
||||
except Exception as e:
|
||||
return VerificationResult(
|
||||
success=False, output=str(e), error=str(e), details=details
|
||||
)
|
||||
|
||||
if self.contains is not None and self.contains not in text:
|
||||
return VerificationResult(
|
||||
success=False,
|
||||
output=f"File {self.path} does not contain {self.contains!r}",
|
||||
error="missing_substring",
|
||||
details=details,
|
||||
)
|
||||
|
||||
if self.json_field is not None:
|
||||
import json as _json
|
||||
|
||||
try:
|
||||
data = _json.loads(text)
|
||||
except Exception as e:
|
||||
return VerificationResult(
|
||||
success=False,
|
||||
output=f"File {self.path} is not valid JSON: {e}",
|
||||
error="invalid_json",
|
||||
details=details,
|
||||
)
|
||||
actual = _get_dotted(data, self.json_field)
|
||||
details["json_field"] = self.json_field
|
||||
details["actual"] = actual
|
||||
if actual != self.equals:
|
||||
return VerificationResult(
|
||||
success=False,
|
||||
output=(
|
||||
f"{self.path}:{self.json_field} = {actual!r}, "
|
||||
f"expected {self.equals!r}"
|
||||
),
|
||||
error="mismatch",
|
||||
details=details,
|
||||
)
|
||||
|
||||
return VerificationResult(
|
||||
success=True,
|
||||
output=f"File check passed: {self.path}",
|
||||
details=details,
|
||||
)
|
||||
@@ -2,6 +2,30 @@ from typing import List, Union, Optional, Dict, Any
|
||||
from functools import cached_property
|
||||
import importlib
|
||||
|
||||
#: Names accepted for a chunking strategy that are not chunker ids themselves.
|
||||
#: ``ChunkingStrategy.FIXED`` and ``.PARAGRAPH`` had no chunker at all, so they
|
||||
#: silently did nothing; they now map onto the chunkers that implement them.
|
||||
CHUNKER_ALIASES = {
|
||||
"fixed": "token",
|
||||
"paragraph": "recursive",
|
||||
}
|
||||
|
||||
|
||||
def normalize_chunker_type(name: str) -> str:
|
||||
"""Map a user-facing chunking strategy name onto a chunker id."""
|
||||
if not name:
|
||||
return "recursive"
|
||||
key = str(name).strip().lower()
|
||||
key = CHUNKER_ALIASES.get(key, key)
|
||||
if key not in Chunking.CHUNKER_PARAMS:
|
||||
from ..config.parse_utils import make_preset_error
|
||||
raise make_preset_error(
|
||||
"chunking strategy", name,
|
||||
sorted(set(Chunking.CHUNKER_PARAMS) | set(CHUNKER_ALIASES)),
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
class Chunking:
|
||||
"""A unified class for text chunking with various chunking strategies."""
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import os
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
from datetime import datetime
|
||||
from .chunking import Chunking
|
||||
from .chunking import Chunking, normalize_chunker_type
|
||||
from functools import cached_property
|
||||
|
||||
|
||||
@@ -162,6 +162,10 @@ class Knowledge:
|
||||
# Merge graph_store config if provided (for graph memory support)
|
||||
if "graph_store" in self._config:
|
||||
base_config["graph_store"] = self._config["graph_store"]
|
||||
|
||||
# Merge chunker config if provided (consumed by self.chunker)
|
||||
if "chunker" in self._config:
|
||||
base_config["chunker"] = self._config["chunker"]
|
||||
return base_config
|
||||
|
||||
def _prepare_mem0_config(self, config):
|
||||
@@ -195,28 +199,46 @@ class Knowledge:
|
||||
def memory(self):
|
||||
"""Initialize knowledge adapter using protocol-driven approach."""
|
||||
# Import registry functions
|
||||
from .adapters import get_knowledge_adapter, get_first_available_knowledge_adapter
|
||||
from .adapters import (
|
||||
get_knowledge_adapter,
|
||||
get_first_available_knowledge_adapter,
|
||||
has_knowledge_adapter,
|
||||
list_knowledge_adapters,
|
||||
)
|
||||
|
||||
# Determine provider preference. Distinguish an explicit user choice from
|
||||
# the implicit default so fallback diagnostics are only loud when a
|
||||
# configured backend degrades (see issue #2972 / PR #2982 review).
|
||||
# ``self.config`` is always merged with defaults, so use the raw
|
||||
# user-supplied ``_config`` to detect whether the provider was chosen.
|
||||
provider_explicit = bool(
|
||||
(self._config or {}).get("vector_store", {}).get("provider")
|
||||
# Presence of the key (not truthiness) marks an explicit choice, so an
|
||||
# explicitly empty/null provider is treated as a misconfiguration and
|
||||
# surfaces the preset error instead of silently degrading to Mem0.
|
||||
provider_explicit = "provider" in (
|
||||
(self._config or {}).get("vector_store", {}) or {}
|
||||
)
|
||||
provider = self.config.get("vector_store", {}).get("provider", "mem0")
|
||||
self._log(f"Requested knowledge provider: {provider}")
|
||||
|
||||
# Map legacy provider names to adapter names
|
||||
# Legacy provider aliases. Anything else is looked up in the adapter
|
||||
# registry, so adapters added with register_knowledge_adapter() are
|
||||
# reachable; only genuinely unknown names fall back (and say so).
|
||||
provider_mapping = {
|
||||
"chroma": "chroma",
|
||||
"mongodb": "mongodb",
|
||||
"mem0": "mem0",
|
||||
"sqlite": "sqlite"
|
||||
"chromadb": "chroma",
|
||||
"rag": "chroma",
|
||||
}
|
||||
|
||||
adapter_name = provider_mapping.get(provider, "mem0")
|
||||
|
||||
adapter_name = provider_mapping.get(provider, provider)
|
||||
if not has_knowledge_adapter(adapter_name):
|
||||
from ..config.parse_utils import make_preset_error
|
||||
err = make_preset_error(
|
||||
"knowledge vector_store provider", provider,
|
||||
sorted(set(list_knowledge_adapters()) | set(provider_mapping)),
|
||||
)
|
||||
if provider_explicit:
|
||||
raise err
|
||||
self._log(f"{err} Falling back to 'mem0'.")
|
||||
adapter_name = "mem0"
|
||||
|
||||
# Try to get preferred adapter, fallback to available ones
|
||||
try:
|
||||
@@ -287,10 +309,13 @@ class Knowledge:
|
||||
|
||||
@cached_property
|
||||
def chunker(self):
|
||||
# Read from config; the previous hardcoded recursive/512/50 made
|
||||
# KnowledgeConfig.chunk_size / chunking_strategy / chunker inert.
|
||||
chunker_cfg = self.config.get("chunker") or {}
|
||||
return Chunking(
|
||||
chunker_type='recursive',
|
||||
chunk_size=512,
|
||||
chunk_overlap=50
|
||||
chunker_type=normalize_chunker_type(chunker_cfg.get("type", "recursive")),
|
||||
chunk_size=chunker_cfg.get("chunk_size", 512),
|
||||
chunk_overlap=chunker_cfg.get("chunk_overlap", 50),
|
||||
)
|
||||
|
||||
def _log(self, message, level=2):
|
||||
@@ -413,7 +438,11 @@ class Knowledge:
|
||||
|
||||
def history(self, memory_id):
|
||||
"""Get the history of changes for a memory."""
|
||||
return self.memory.history(memory_id)
|
||||
if hasattr(self.memory, "history"):
|
||||
return self.memory.history(memory_id)
|
||||
raise NotImplementedError(
|
||||
f"{type(self.memory).__name__} does not support history()"
|
||||
)
|
||||
|
||||
def delete(self, memory_id):
|
||||
"""Delete a memory."""
|
||||
@@ -421,11 +450,21 @@ class Knowledge:
|
||||
|
||||
def delete_all(self, user_id=None, agent_id=None, run_id=None):
|
||||
"""Delete all memories."""
|
||||
self.memory.delete_all(user_id=user_id, agent_id=agent_id, run_id=run_id)
|
||||
if hasattr(self.memory, "delete_all"):
|
||||
self.memory.delete_all(user_id=user_id, agent_id=agent_id, run_id=run_id)
|
||||
else:
|
||||
raise NotImplementedError(
|
||||
f"{type(self.memory).__name__} does not support delete_all()"
|
||||
)
|
||||
|
||||
def reset(self):
|
||||
"""Reset all memories."""
|
||||
self.memory.reset()
|
||||
if hasattr(self.memory, "reset"):
|
||||
self.memory.reset()
|
||||
else:
|
||||
logger.warning(
|
||||
f"{type(self.memory).__name__} does not support reset(); no-op"
|
||||
)
|
||||
|
||||
def normalize_content(self, content):
|
||||
"""Normalize content for consistent storage."""
|
||||
|
||||
@@ -107,6 +107,22 @@ def __getattr__(name):
|
||||
from .rate_limiter import RateLimiter
|
||||
_lazy_cache[name] = RateLimiter
|
||||
return RateLimiter
|
||||
elif name == "QuotaCoordinatorProtocol":
|
||||
from .quota import QuotaCoordinatorProtocol
|
||||
_lazy_cache[name] = QuotaCoordinatorProtocol
|
||||
return QuotaCoordinatorProtocol
|
||||
elif name == "QuotaCoordinatorConfig":
|
||||
from .quota import QuotaCoordinatorConfig
|
||||
_lazy_cache[name] = QuotaCoordinatorConfig
|
||||
return QuotaCoordinatorConfig
|
||||
elif name == "LocalQuotaCoordinator":
|
||||
from .quota import LocalQuotaCoordinator
|
||||
_lazy_cache[name] = LocalQuotaCoordinator
|
||||
return LocalQuotaCoordinator
|
||||
elif name == "build_quota_coordinator":
|
||||
from .quota import build_quota_coordinator
|
||||
_lazy_cache[name] = build_quota_coordinator
|
||||
return build_quota_coordinator
|
||||
elif name == "TokenUsage":
|
||||
from .llm import TokenUsage
|
||||
_lazy_cache[name] = TokenUsage
|
||||
@@ -229,6 +245,11 @@ __all__ = [
|
||||
"create_routing_agent",
|
||||
"RateLimiter",
|
||||
"TokenUsage",
|
||||
# Provider quota coordination (cross-replica credential cooldowns)
|
||||
"QuotaCoordinatorProtocol",
|
||||
"QuotaCoordinatorConfig",
|
||||
"LocalQuotaCoordinator",
|
||||
"build_quota_coordinator",
|
||||
# Protocols
|
||||
"LLMProviderProtocol",
|
||||
"ModelCapabilitiesProtocol",
|
||||
|
||||
@@ -16,6 +16,7 @@ import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
@@ -25,6 +26,9 @@ from typing import (
|
||||
runtime_checkable,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .quota import QuotaCoordinatorProtocol
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class ProviderStatus(str, Enum):
|
||||
@@ -112,6 +116,29 @@ class AuthProfile:
|
||||
metadata=data.get("metadata", {}),
|
||||
)
|
||||
|
||||
@property
|
||||
def credential_id(self) -> str:
|
||||
"""Stable, non-secret identity for cross-replica quota coordination.
|
||||
|
||||
The coordinator keys benches by the *credential*, not the profile
|
||||
label. ``name`` is a local display label: two replicas may name the
|
||||
same key differently (breaking cooldown propagation) or reuse a name
|
||||
like ``"default"`` for different keys (benching an unrelated key).
|
||||
Derive a deterministic id from the fields that identify the credential
|
||||
(provider, api_key, base_url) using a short salted hash so the real key
|
||||
is never exposed. Falls back to ``name`` when no api_key is present.
|
||||
"""
|
||||
if not self.api_key:
|
||||
return self.name
|
||||
import hashlib
|
||||
|
||||
digest = hashlib.sha256(
|
||||
f"{self.provider}\x00{self.base_url or ''}\x00{self.api_key}".encode(
|
||||
"utf-8", "ignore"
|
||||
)
|
||||
).hexdigest()[:16]
|
||||
return f"{self.provider}:{digest}"
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
"""Check if this profile is currently available."""
|
||||
@@ -207,13 +234,27 @@ class FailoverManager:
|
||||
profile = manager.get_next_profile()
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[FailoverConfig] = None):
|
||||
def __init__(
|
||||
self,
|
||||
config: Optional[FailoverConfig] = None,
|
||||
coordinator: Optional["QuotaCoordinatorProtocol"] = None,
|
||||
):
|
||||
"""Initialize the failover manager.
|
||||
|
||||
Args:
|
||||
config: Failover configuration
|
||||
coordinator: Optional shared quota coordinator. When supplied
|
||||
(e.g. a Redis-backed one wired by the gateway), credential
|
||||
benches/cooldowns are coordinated fleet-wide so a key benched
|
||||
by any replica is skipped by all replicas. Defaults to an
|
||||
in-memory local coordinator, keeping single-replica behaviour
|
||||
exactly as before with no new dependency and no hot-path cost.
|
||||
"""
|
||||
self.config = config or FailoverConfig()
|
||||
if coordinator is None:
|
||||
from .quota import LocalQuotaCoordinator
|
||||
coordinator = LocalQuotaCoordinator()
|
||||
self._coordinator: "QuotaCoordinatorProtocol" = coordinator
|
||||
self._profiles: List[AuthProfile] = []
|
||||
self._current_index: int = 0
|
||||
self._on_failover_callbacks: List[Callable[[AuthProfile, AuthProfile], None]] = []
|
||||
@@ -274,6 +315,30 @@ class FailoverManager:
|
||||
with self._lock:
|
||||
return list(self._profiles)
|
||||
|
||||
def _sync_bench_from_coordinator(
|
||||
self, profile: AuthProfile, now: Optional[float] = None
|
||||
) -> None:
|
||||
"""Apply a fleet-wide bench from the coordinator onto a local profile.
|
||||
|
||||
If another replica benched this credential, reflect its cooldown here
|
||||
so this replica skips it too. Fail-open: never raise into failover.
|
||||
Callers already hold ``self._lock``.
|
||||
"""
|
||||
try:
|
||||
until = self._coordinator.benched_until(profile.credential_id, now=now)
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
logger.warning(
|
||||
f"Quota coordinator lookup failed for '{profile.name}': {e}; "
|
||||
f"using local cooldown state"
|
||||
)
|
||||
return
|
||||
if until is not None and (
|
||||
profile.cooldown_until is None or until > profile.cooldown_until
|
||||
):
|
||||
if profile.status == ProviderStatus.AVAILABLE:
|
||||
profile.status = ProviderStatus.RATE_LIMITED
|
||||
profile.cooldown_until = until
|
||||
|
||||
def get_next_profile(self) -> Optional[AuthProfile]:
|
||||
"""Get the next available profile.
|
||||
|
||||
@@ -287,9 +352,10 @@ class FailoverManager:
|
||||
if not self._profiles:
|
||||
return None
|
||||
|
||||
# First, check if any cooldowns have expired
|
||||
# First, sync fleet-wide benches, then check if any cooldowns have expired.
|
||||
current_time = time.time()
|
||||
for profile in self._profiles:
|
||||
self._sync_bench_from_coordinator(profile, now=current_time)
|
||||
if profile.cooldown_until and current_time >= profile.cooldown_until:
|
||||
profile.reset()
|
||||
|
||||
@@ -328,12 +394,29 @@ class FailoverManager:
|
||||
f"Profile '{profile.name}' rate limited, "
|
||||
f"cooldown for {self.config.cooldown_on_rate_limit}s"
|
||||
)
|
||||
reason = "rate_limit"
|
||||
else:
|
||||
profile.mark_error(error, self.config.cooldown_on_error)
|
||||
logger.warning(
|
||||
f"Profile '{profile.name}' error: {error}, "
|
||||
f"cooldown for {self.config.cooldown_on_error}s"
|
||||
)
|
||||
reason = "error"
|
||||
|
||||
# Share the bench fleet-wide so other replicas skip this key too.
|
||||
# Fail-open: a coordinator hiccup must never wedge failover.
|
||||
if profile.cooldown_until is not None:
|
||||
try:
|
||||
self._coordinator.bench(
|
||||
profile.credential_id,
|
||||
until=profile.cooldown_until,
|
||||
reason=reason,
|
||||
)
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
logger.warning(
|
||||
f"Quota coordinator bench failed for '{profile.name}': {e}; "
|
||||
f"falling back to local cooldown only"
|
||||
)
|
||||
|
||||
# Resolve the target profile and snapshot callbacks while holding
|
||||
# the lock, but invoke them AFTER releasing it. User callbacks may
|
||||
@@ -361,8 +444,30 @@ class FailoverManager:
|
||||
"""
|
||||
with self._lock:
|
||||
if profile.status != ProviderStatus.AVAILABLE:
|
||||
# Snapshot the cooldown this success is recovering from BEFORE
|
||||
# resetting, so we can avoid clobbering a newer fleet-wide bench.
|
||||
recovered_from = profile.cooldown_until
|
||||
profile.reset()
|
||||
logger.info(f"Profile '{profile.name}' recovered")
|
||||
cred_id = profile.credential_id
|
||||
try:
|
||||
# A concurrent/other-replica failure may have benched this
|
||||
# credential with a LATER expiry than the cooldown this
|
||||
# (possibly stale, already in-flight) success is recovering
|
||||
# from. Clearing unconditionally would resume a credential
|
||||
# that is still rate-limited. Only clear when the shared
|
||||
# bench is not newer than what we recovered from.
|
||||
shared_until = self._coordinator.benched_until(cred_id)
|
||||
if (
|
||||
shared_until is None
|
||||
or recovered_from is None
|
||||
or shared_until <= recovered_from
|
||||
):
|
||||
self._coordinator.clear(cred_id)
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
logger.warning(
|
||||
f"Quota coordinator clear failed for '{profile.name}': {e}"
|
||||
)
|
||||
|
||||
def on_failover(
|
||||
self,
|
||||
|
||||
@@ -28,6 +28,14 @@ from ..tools.call_executor import ToolCall, create_tool_call_executor
|
||||
from ..tools.schema import build_tool_definition
|
||||
|
||||
|
||||
# Sentinel returned as the "arguments" value when a tool call's argument string
|
||||
# cannot be parsed (e.g. truncated by max_tokens or a dropped connection).
|
||||
# It is distinct from {} ("no arguments"): {} would silently execute the tool
|
||||
# with the WRONG arguments and report success. Callers must detect this sentinel
|
||||
# and surface a tool-error so the model can re-emit the call instead.
|
||||
_TOOL_ARGUMENTS_PARSE_FAILED = object()
|
||||
|
||||
|
||||
def _durable_iteration_kwargs(execute_tool_fn: Callable, index: int) -> Dict[str, int]:
|
||||
if getattr(execute_tool_fn, "_accepts_durable_iteration", False):
|
||||
return {"_durable_iteration_index": index}
|
||||
@@ -534,7 +542,18 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
if self._failover_manager:
|
||||
self._current_profile = self._failover_manager.get_next_profile()
|
||||
if self._current_profile:
|
||||
self._switch_to_profile(self._current_profile)
|
||||
# Apply the initial profile's values to the instance attributes
|
||||
# (backward compatible with the documented llm.api_key/model
|
||||
# surface). Later failover rotations are per-call only and do
|
||||
# not mutate these attributes (issue #3613 gap 2).
|
||||
profile = self._current_profile
|
||||
if profile.api_key:
|
||||
self.api_key = profile.api_key
|
||||
if profile.base_url:
|
||||
self.base_url = profile.base_url
|
||||
if profile.model and profile.model != self.model:
|
||||
logging.info(f"Failover: using profile model {profile.model}")
|
||||
self.model = profile.model
|
||||
|
||||
# Cache for formatted tools and messages
|
||||
self._formatted_tools_cache = {}
|
||||
@@ -974,18 +993,58 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
|
||||
def _switch_to_profile(self, profile: "AuthProfile") -> None:
|
||||
"""Switch to a new auth profile for failover.
|
||||
|
||||
|
||||
The profile's credentials are threaded through the per-call kwargs of
|
||||
the retry loop instead of being written onto the shared instance. The
|
||||
old behaviour mutated ``self.api_key``/``self.base_url``/``self.model``
|
||||
on a shared ``LLM`` (issue #3613 gap 2), so concurrent requests on one
|
||||
instance could silently cross-talk credentials and model after a
|
||||
failover rotation. Keeping the instance attributes on the initial
|
||||
profile preserves single-profile behaviour and the documented
|
||||
``llm.api_key``/``llm.model`` surface.
|
||||
|
||||
Args:
|
||||
profile: AuthProfile to switch to
|
||||
"""
|
||||
# NOTE: profile values are applied to the per-call kwargs by
|
||||
# _handle_retry_exception when it rotates. This method is kept as a
|
||||
# no-op placeholder so any out-of-tree callers that invoke it directly
|
||||
# keep working; it intentionally no longer mutates shared state.
|
||||
if profile is None:
|
||||
return
|
||||
|
||||
def _response_model_for_tracking(self, response: Any) -> str:
|
||||
"""Model to attribute token usage to for a completed call.
|
||||
|
||||
Prefers the model carried on the response itself, which reflects the
|
||||
provider/profile that actually served the request. This is race-free
|
||||
under concurrency (unlike reading the shared ``self._current_profile``)
|
||||
and stays correct after a failover rotation. Falls back to
|
||||
``self.model`` when the response does not expose a model.
|
||||
"""
|
||||
model = None
|
||||
if isinstance(response, dict):
|
||||
model = response.get("model")
|
||||
else:
|
||||
model = getattr(response, "model", None)
|
||||
return model or self.model
|
||||
|
||||
def _apply_profile_to_kwargs(self, profile: "AuthProfile", kwargs: dict) -> dict:
|
||||
"""Return a new kwargs dict with profile overrides applied.
|
||||
|
||||
Never mutates ``self``: the active profile is a per-call concern, so
|
||||
its credentials travel in the request kwargs only. Callers that pass
|
||||
an explicit per-call override (e.g. ``api_key=``) keep it; profile
|
||||
values fill in the gaps.
|
||||
"""
|
||||
kwargs = dict(kwargs)
|
||||
if profile.api_key:
|
||||
self.api_key = profile.api_key
|
||||
kwargs["api_key"] = profile.api_key
|
||||
if profile.base_url:
|
||||
self.base_url = profile.base_url
|
||||
if profile.model and profile.model != self.model:
|
||||
# Only log if model actually changes
|
||||
logging.info(f"Failover: switching from {self.model} to {profile.model}")
|
||||
self.model = profile.model
|
||||
kwargs["base_url"] = profile.base_url
|
||||
if profile.model:
|
||||
kwargs["model"] = profile.model
|
||||
return kwargs
|
||||
|
||||
def _resolve_subscription_creds(self):
|
||||
"""Lazy resolve + cache subscription credentials, checking expiration."""
|
||||
@@ -1055,7 +1114,7 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
)
|
||||
return None
|
||||
|
||||
def _handle_retry_exception(self, e, attempt, kwargs, refreshed_creds=None):
|
||||
def _handle_retry_exception(self, e, attempt, kwargs, refreshed_creds=None, active_profile=None):
|
||||
"""Decide retry vs raise from a failover decision. No sleep/await.
|
||||
|
||||
Shared decision logic used by both the sync (`_call_with_retry`) and
|
||||
@@ -1069,21 +1128,34 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
blocks the event loop. The already-refreshed credentials (if any) are
|
||||
passed in via ``refreshed_creds``.
|
||||
|
||||
The active failover profile is a *per-call* value threaded in via
|
||||
``active_profile`` and returned back in the result tuple. It is never
|
||||
read from or written to ``self._current_profile`` here, so concurrent
|
||||
requests sharing one ``LLM`` instance cannot corrupt each other's
|
||||
failover bookkeeping or cost attribution (issue #3613 gap 2).
|
||||
|
||||
Args:
|
||||
e: The exception raised by the wrapped call.
|
||||
attempt: Zero-based attempt index for the current iteration.
|
||||
kwargs: Keyword arguments for the wrapped call (may be updated).
|
||||
refreshed_creds: Credentials already refreshed by the caller (off
|
||||
the event loop for async), or ``None`` if no refresh was done.
|
||||
active_profile: The profile this call is currently using, or
|
||||
``None``. Defaults to the initial profile for backward compat.
|
||||
|
||||
Returns:
|
||||
Tuple of (should_raise, category, retry_delay, error_str, kwargs):
|
||||
Tuple of (should_raise, category, retry_delay, error_str, kwargs,
|
||||
active_profile):
|
||||
should_raise: Whether the caller should re-raise immediately.
|
||||
category: The error category/reason from the decision.
|
||||
retry_delay: Seconds to wait before the next attempt.
|
||||
error_str: String form of the exception.
|
||||
kwargs: The (possibly updated) keyword arguments.
|
||||
active_profile: The (possibly rotated) profile for the next
|
||||
attempt.
|
||||
"""
|
||||
if active_profile is None:
|
||||
active_profile = self._current_profile
|
||||
# A turn is "side-effecting" when it exposes tools the model may call:
|
||||
# a post-dispatch failure on such a turn could have already run a
|
||||
# side-effecting tool, so it must not be silently replayed. A turn with
|
||||
@@ -1135,55 +1207,48 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
# immediately with fresh credentials and skip profile rotation so we
|
||||
# neither mark the current profile failed nor overwrite the new creds.
|
||||
if refreshed_auth:
|
||||
return False, category, retry_delay, error_str, kwargs
|
||||
return False, category, retry_delay, error_str, kwargs, active_profile
|
||||
|
||||
# Handle different failover decision actions
|
||||
# Handle different failover decision actions. Rotation is a per-call
|
||||
# concern: the active profile is a local threaded in/out of this method,
|
||||
# never self._current_profile, so concurrent calls on one shared LLM
|
||||
# cannot mark each other's profiles failed/healthy (issue #3613 gap 2).
|
||||
if decision.action == "rotate_profile" and self._failover_manager:
|
||||
if self._current_profile:
|
||||
if active_profile:
|
||||
is_rate_limit = (category == "rate_limit")
|
||||
self._failover_manager.mark_failure(
|
||||
self._current_profile, error_str, is_rate_limit=is_rate_limit
|
||||
active_profile, error_str, is_rate_limit=is_rate_limit
|
||||
)
|
||||
next_profile = self._failover_manager.get_next_profile()
|
||||
if next_profile and next_profile != self._current_profile:
|
||||
self._switch_to_profile(next_profile)
|
||||
self._current_profile = next_profile
|
||||
# Update the kwargs with new profile values for the next retry
|
||||
if "api_key" in kwargs:
|
||||
kwargs["api_key"] = self.api_key
|
||||
if "base_url" in kwargs:
|
||||
kwargs["base_url"] = self.base_url
|
||||
if "model" in kwargs:
|
||||
kwargs["model"] = self.model
|
||||
if next_profile and next_profile != active_profile:
|
||||
active_profile = next_profile
|
||||
# Thread the active profile through the per-call kwargs so the
|
||||
# shared instance state is never mutated (issue #3613 gap 2).
|
||||
kwargs = self._apply_profile_to_kwargs(next_profile, kwargs)
|
||||
logging.info(f"Failover: switched to profile '{next_profile.name}'")
|
||||
else:
|
||||
# No alternate profile available - retrying would just hit the
|
||||
# same failing profile, so surface the error instead.
|
||||
can_retry = False
|
||||
# Legacy failover for compatibility (when decision is retry but failover is configured)
|
||||
elif self._failover_manager and self._current_profile and decision.action == "retry":
|
||||
elif self._failover_manager and active_profile and decision.action == "retry":
|
||||
is_rate_limit = (category == "rate_limit")
|
||||
self._failover_manager.mark_failure(
|
||||
self._current_profile, error_str, is_rate_limit=is_rate_limit
|
||||
active_profile, error_str, is_rate_limit=is_rate_limit
|
||||
)
|
||||
next_profile = self._failover_manager.get_next_profile()
|
||||
if next_profile and next_profile != self._current_profile:
|
||||
self._switch_to_profile(next_profile)
|
||||
self._current_profile = next_profile
|
||||
# Update the kwargs with new profile values for the next retry
|
||||
if "api_key" in kwargs:
|
||||
kwargs["api_key"] = self.api_key
|
||||
if "base_url" in kwargs:
|
||||
kwargs["base_url"] = self.base_url
|
||||
if "model" in kwargs:
|
||||
kwargs["model"] = self.model
|
||||
if next_profile and next_profile != active_profile:
|
||||
active_profile = next_profile
|
||||
# Thread the active profile through the per-call kwargs so the
|
||||
# shared instance state is never mutated (issue #3613 gap 2).
|
||||
kwargs = self._apply_profile_to_kwargs(next_profile, kwargs)
|
||||
# Enable retry for profile switch even if originally non-retryable
|
||||
can_retry = True
|
||||
retry_delay = 0.0
|
||||
logging.info(f"Failover: switched to profile '{next_profile.name}'")
|
||||
|
||||
should_raise = decision.action == "surface_error" or not can_retry
|
||||
return should_raise, category, retry_delay, error_str, kwargs
|
||||
return should_raise, category, retry_delay, error_str, kwargs, active_profile
|
||||
|
||||
def _call_with_retry(self, func, *args, **kwargs):
|
||||
"""Call a function with automatic retry on rate limit errors and failover support.
|
||||
@@ -1200,6 +1265,10 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
The original exception if max retries exceeded
|
||||
"""
|
||||
last_error = None
|
||||
# Track the failover profile per call (never self._current_profile) so
|
||||
# concurrent calls on one shared LLM cannot corrupt each other's
|
||||
# failover bookkeeping (issue #3613 gap 2).
|
||||
active_profile = self._current_profile
|
||||
|
||||
for attempt in range(self._max_retries + 1):
|
||||
try:
|
||||
@@ -1210,8 +1279,8 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
result = func(*args, **kwargs)
|
||||
|
||||
# Mark success if failover is configured
|
||||
if self._failover_manager and self._current_profile:
|
||||
self._failover_manager.mark_success(self._current_profile)
|
||||
if self._failover_manager and active_profile:
|
||||
self._failover_manager.mark_success(active_profile)
|
||||
|
||||
# Reset idle timeout circuit breaker on success
|
||||
self._idle_timeout_breaker.reset()
|
||||
@@ -1226,8 +1295,12 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
if self._should_attempt_auth_refresh(e, attempt):
|
||||
refreshed_creds = self._try_refresh_subscription_creds()
|
||||
|
||||
should_raise, category, retry_delay, error_str, kwargs = (
|
||||
self._handle_retry_exception(e, attempt, kwargs, refreshed_creds=refreshed_creds)
|
||||
should_raise, category, retry_delay, error_str, kwargs, active_profile = (
|
||||
self._handle_retry_exception(
|
||||
e, attempt, kwargs,
|
||||
refreshed_creds=refreshed_creds,
|
||||
active_profile=active_profile,
|
||||
)
|
||||
)
|
||||
|
||||
if should_raise:
|
||||
@@ -1276,6 +1349,10 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
The original exception if max retries exceeded
|
||||
"""
|
||||
last_error = None
|
||||
# Track the failover profile per call (never self._current_profile) so
|
||||
# concurrent coroutines on one shared LLM cannot corrupt each other's
|
||||
# failover bookkeeping (issue #3613 gap 2).
|
||||
active_profile = self._current_profile
|
||||
|
||||
for attempt in range(self._max_retries + 1):
|
||||
try:
|
||||
@@ -1286,8 +1363,8 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
result = await func(*args, **kwargs)
|
||||
|
||||
# Mark success if failover is configured
|
||||
if self._failover_manager and self._current_profile:
|
||||
self._failover_manager.mark_success(self._current_profile)
|
||||
if self._failover_manager and active_profile:
|
||||
self._failover_manager.mark_success(active_profile)
|
||||
|
||||
# Reset idle timeout circuit breaker on success
|
||||
self._idle_timeout_breaker.reset()
|
||||
@@ -1305,8 +1382,12 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
self._try_refresh_subscription_creds
|
||||
)
|
||||
|
||||
should_raise, category, retry_delay, error_str, kwargs = (
|
||||
self._handle_retry_exception(e, attempt, kwargs, refreshed_creds=refreshed_creds)
|
||||
should_raise, category, retry_delay, error_str, kwargs, active_profile = (
|
||||
self._handle_retry_exception(
|
||||
e, attempt, kwargs,
|
||||
refreshed_creds=refreshed_creds,
|
||||
active_profile=active_profile,
|
||||
)
|
||||
)
|
||||
|
||||
if should_raise:
|
||||
@@ -1358,7 +1439,7 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
import litellm
|
||||
response = self._call_with_retry(litellm.completion, **completion_params)
|
||||
if not completion_params.get("stream"):
|
||||
self._track_token_usage(response, self.model)
|
||||
self._track_token_usage(response, self._response_model_for_tracking(response))
|
||||
return response
|
||||
|
||||
async def _acompletion_with_retry(self, **completion_params):
|
||||
@@ -1379,7 +1460,7 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
**completion_params,
|
||||
)
|
||||
if not completion_params.get("stream"):
|
||||
self._track_token_usage(response, self.model)
|
||||
self._track_token_usage(response, self._response_model_for_tracking(response))
|
||||
return response
|
||||
|
||||
def _supports_web_search(self) -> bool:
|
||||
@@ -1766,8 +1847,15 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
|
||||
except (KeyError, json.JSONDecodeError, TypeError) as e:
|
||||
logging.error(f"Error parsing tool call arguments: {e}")
|
||||
function_name = tool_call.get("name", "unknown_function")
|
||||
arguments = {}
|
||||
function_name = (
|
||||
tool_call.get("function", {}).get("name")
|
||||
if isinstance(tool_call.get("function"), dict)
|
||||
else None
|
||||
) or tool_call.get("name", "unknown_function")
|
||||
# A parse failure is NOT "no arguments". Returning {} here would run
|
||||
# the tool with the wrong arguments and report success. Signal the
|
||||
# failure so callers surface a tool-error instead of dispatching.
|
||||
arguments = _TOOL_ARGUMENTS_PARSE_FAILED
|
||||
tool_call_id = tool_call.get("id", f"tool_{id(tool_call)}")
|
||||
|
||||
return function_name, arguments, tool_call_id
|
||||
@@ -2432,7 +2520,7 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
prompt: Union[str, List[Dict]],
|
||||
system_prompt: Optional[str] = None,
|
||||
chat_history: Optional[List[Dict]] = None,
|
||||
temperature: float = 1.0,
|
||||
temperature: Optional[float] = None,
|
||||
tools: Optional[List[Any]] = None,
|
||||
output_json: Optional[BaseModel] = None,
|
||||
output_pydantic: Optional[BaseModel] = None,
|
||||
@@ -2749,10 +2837,17 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
# Execute tool calls using ToolCallExecutor (Gap 2: parallel or sequential)
|
||||
is_ollama = self._is_ollama_provider()
|
||||
tool_calls_batch = []
|
||||
parse_error_messages = []
|
||||
|
||||
# Prepare batch of ToolCall objects
|
||||
for tool_call in tool_calls:
|
||||
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call, is_ollama=is_ollama)
|
||||
if self._tool_arguments_parse_failed(arguments):
|
||||
# Do NOT dispatch with {}: surface a retryable error.
|
||||
parse_error_messages.append(
|
||||
self._tool_parse_error_message(function_name, tool_call_id)
|
||||
)
|
||||
continue
|
||||
tool_calls_batch.append(ToolCall(
|
||||
function_name=function_name,
|
||||
arguments=arguments,
|
||||
@@ -2826,6 +2921,11 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
"content": content,
|
||||
})
|
||||
|
||||
# Report any unparseable tool-call arguments so the
|
||||
# model can re-emit them (never dispatched with {}).
|
||||
for _err_msg in parse_error_messages:
|
||||
messages.append(_err_msg)
|
||||
|
||||
# Safety: break after max_iter iterations
|
||||
if iteration_count + 1 >= max_iterations:
|
||||
self._last_stop_reason = "max_steps"
|
||||
@@ -3529,6 +3629,11 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
is_ollama = self._is_ollama_provider()
|
||||
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call, is_ollama)
|
||||
|
||||
if self._tool_arguments_parse_failed(arguments):
|
||||
# Do NOT dispatch with {}: surface a retryable error.
|
||||
messages.append(self._tool_parse_error_message(function_name, tool_call_id))
|
||||
continue
|
||||
|
||||
# Validate and filter arguments for Ollama provider
|
||||
if is_ollama and tools:
|
||||
# First check if any argument references a previous tool result
|
||||
@@ -4088,7 +4193,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
prompt: Union[str, List[Dict]],
|
||||
system_prompt: Optional[str] = None,
|
||||
chat_history: Optional[List[Dict]] = None,
|
||||
temperature: float = 1.0,
|
||||
temperature: Optional[float] = None,
|
||||
tools: Optional[List[Any]] = None,
|
||||
output_json: Optional[BaseModel] = None,
|
||||
output_pydantic: Optional[BaseModel] = None,
|
||||
@@ -4273,10 +4378,17 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# Execute tool calls using ToolCallExecutor (Gap 2: parallel or sequential)
|
||||
is_ollama = self._is_ollama_provider()
|
||||
tool_calls_batch = []
|
||||
parse_error_messages = []
|
||||
|
||||
# Prepare batch of ToolCall objects
|
||||
for tool_call in tool_calls:
|
||||
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call, is_ollama)
|
||||
if self._tool_arguments_parse_failed(arguments):
|
||||
# Do NOT dispatch with {}: surface a retryable error.
|
||||
parse_error_messages.append(
|
||||
self._tool_parse_error_message(function_name, tool_call_id)
|
||||
)
|
||||
continue
|
||||
tool_calls_batch.append(ToolCall(
|
||||
function_name=function_name,
|
||||
arguments=arguments,
|
||||
@@ -4316,6 +4428,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
tool_result.is_ollama
|
||||
)
|
||||
messages.append(tool_message)
|
||||
|
||||
# Report any unparseable tool-call arguments so the
|
||||
# model can re-emit them (never dispatched with {}).
|
||||
for _err_msg in parse_error_messages:
|
||||
messages.append(_err_msg)
|
||||
|
||||
# Continue conversation after tool execution - get follow-up response
|
||||
try:
|
||||
@@ -4423,7 +4540,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
prompt: Union[str, List[Dict]],
|
||||
system_prompt: Optional[str] = None,
|
||||
chat_history: Optional[List[Dict]] = None,
|
||||
temperature: float = 1.0,
|
||||
temperature: Optional[float] = None,
|
||||
tools: Optional[List[Any]] = None,
|
||||
output_json: Optional[BaseModel] = None,
|
||||
output_pydantic: Optional[BaseModel] = None,
|
||||
@@ -4653,6 +4770,10 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
for tool_call in tool_calls:
|
||||
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call)
|
||||
if self._tool_arguments_parse_failed(arguments):
|
||||
# Do NOT dispatch with {}: surface a retryable error.
|
||||
messages.append(self._tool_parse_error_message(function_name, tool_call_id))
|
||||
continue
|
||||
logging.debug(f"[RESPONSES_API_ASYNC] Executing tool {function_name}")
|
||||
tool_result = await _dispatch_async_tool(
|
||||
execute_tool_fn,
|
||||
@@ -4902,6 +5023,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
is_ollama = self._is_ollama_provider()
|
||||
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call, is_ollama)
|
||||
|
||||
if self._tool_arguments_parse_failed(arguments):
|
||||
# Do NOT dispatch with {}: surface a retryable error.
|
||||
messages.append(self._tool_parse_error_message(function_name, tool_call_id))
|
||||
continue
|
||||
|
||||
# Validate and filter arguments for Ollama provider
|
||||
if is_ollama and tools:
|
||||
arguments = self._validate_and_filter_ollama_arguments(function_name, arguments, tools)
|
||||
@@ -5612,6 +5738,16 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
# Override with any provided parameters
|
||||
params.update(override_params)
|
||||
|
||||
# Resolve temperature from the instance when the caller did not specify
|
||||
# one. Public entry points now default temperature to None so a value
|
||||
# configured via Agent(llm={'temperature': ...}) actually reaches the
|
||||
# request instead of being silently overridden by a hardcoded 1.0.
|
||||
if params.get("temperature") is None:
|
||||
if self.temperature is not None:
|
||||
params["temperature"] = self.temperature
|
||||
else:
|
||||
params.pop("temperature", None)
|
||||
|
||||
# Handle structured output parameters
|
||||
output_json = override_params.get('output_json')
|
||||
@@ -5944,6 +6080,19 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if self.timeout:
|
||||
params["timeout"] = self.timeout
|
||||
|
||||
# ── Subscription credentials ────────────────────────────────────
|
||||
# Mirror _build_completion_params: without this the Responses API
|
||||
# transport silently ignores auth= and bills the plain API key.
|
||||
creds = self._resolve_subscription_creds()
|
||||
if creds:
|
||||
params["api_key"] = creds.api_key
|
||||
if creds.base_url:
|
||||
params["base_url"] = creds.base_url
|
||||
if creds.headers:
|
||||
extra_headers = dict(params.get("extra_headers") or {})
|
||||
extra_headers.update(creds.headers)
|
||||
params["extra_headers"] = extra_headers
|
||||
|
||||
# ── Structured output ───────────────────────────────────────────
|
||||
output_json = kwargs.pop("output_json", None)
|
||||
output_pydantic = kwargs.pop("output_pydantic", None)
|
||||
@@ -5994,7 +6143,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
"""
|
||||
import litellm
|
||||
response = await self._call_with_retry_async(litellm.aresponses, **params)
|
||||
self._track_token_usage(response, self.model)
|
||||
self._track_token_usage(response, self._response_model_for_tracking(response))
|
||||
return response
|
||||
|
||||
def _extract_from_responses_output(self, response) -> tuple:
|
||||
@@ -6233,6 +6382,17 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
is_reasoning=False,
|
||||
))
|
||||
|
||||
elif evt_type == "response.reasoning_summary_text.delta":
|
||||
delta_text = (event.get("delta", "") if isinstance(event, dict)
|
||||
else getattr(event, "delta", ""))
|
||||
if _emit and delta_text:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
timestamp=time.perf_counter(),
|
||||
content=delta_text,
|
||||
is_reasoning=True,
|
||||
))
|
||||
|
||||
elif evt_type == "response.function_call_arguments.delta":
|
||||
idx = (event.get("output_index", 0) if isinstance(event, dict)
|
||||
else getattr(event, "output_index", 0))
|
||||
@@ -6345,17 +6505,41 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
tool_call_id = tool_call.id
|
||||
except (json.JSONDecodeError, AttributeError) as e:
|
||||
logging.error(f"Error parsing object-style tool call: {e}")
|
||||
function_name = "unknown_function"
|
||||
arguments = {}
|
||||
tool_call_id = f"tool_{id(tool_call)}"
|
||||
function_name = getattr(getattr(tool_call, 'function', None), 'name', None) or "unknown_function"
|
||||
# See _parse_tool_call_arguments: a parse failure is not {}.
|
||||
arguments = _TOOL_ARGUMENTS_PARSE_FAILED
|
||||
tool_call_id = getattr(tool_call, 'id', None) or f"tool_{id(tool_call)}"
|
||||
return function_name, arguments, tool_call_id
|
||||
|
||||
@staticmethod
|
||||
def _tool_arguments_parse_failed(arguments) -> bool:
|
||||
"""True when tool-call arguments could not be parsed (vs. legitimately empty)."""
|
||||
return arguments is _TOOL_ARGUMENTS_PARSE_FAILED
|
||||
|
||||
@staticmethod
|
||||
def _tool_parse_error_message(function_name: str, tool_call_id: str) -> Dict[str, str]:
|
||||
"""Build a tool-role message telling the model its arguments were lost.
|
||||
|
||||
Surfacing this instead of dispatching with {} converts a silent
|
||||
wrong-action-reported-as-success into a visible, retryable error.
|
||||
"""
|
||||
return {
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"content": (
|
||||
f"Error: arguments for tool '{function_name}' could not be parsed "
|
||||
f"(the argument string was invalid or truncated). "
|
||||
f"The tool was NOT executed. Please re-emit the tool call with "
|
||||
f"complete, valid JSON arguments."
|
||||
),
|
||||
}
|
||||
|
||||
# Response without tool calls
|
||||
def response(
|
||||
self,
|
||||
prompt: Union[str, List[Dict]],
|
||||
system_prompt: Optional[str] = None,
|
||||
temperature: float = 1.0,
|
||||
temperature: Optional[float] = None,
|
||||
stream: bool = True,
|
||||
verbose: bool = True,
|
||||
markdown: bool = True,
|
||||
@@ -6449,7 +6633,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
self,
|
||||
prompt: Union[str, List[Dict]],
|
||||
system_prompt: Optional[str] = None,
|
||||
temperature: float = 1.0,
|
||||
temperature: Optional[float] = None,
|
||||
stream: bool = True,
|
||||
verbose: bool = True,
|
||||
markdown: bool = True,
|
||||
|
||||
@@ -200,6 +200,26 @@ class ToolCall:
|
||||
type: str
|
||||
function: Dict[str, Any]
|
||||
|
||||
def _extract_tool_call_name_and_args(tool_call):
|
||||
"""Parse a tool call's function name and JSON arguments.
|
||||
|
||||
Handles both the ToolCall dataclass and OpenAI-SDK objects. Returns a
|
||||
tuple of (name, args, tool_call_id, err); err is None on success and the
|
||||
caught exception otherwise. Callers keep their own failure handling.
|
||||
"""
|
||||
tool_call_id = tool_call.id if hasattr(tool_call, 'id') else tool_call.get('id')
|
||||
function_name = None
|
||||
try:
|
||||
if isinstance(tool_call, ToolCall):
|
||||
function_name = tool_call.function["name"]
|
||||
raw_arguments = tool_call.function["arguments"]
|
||||
else:
|
||||
function_name = tool_call.function.name
|
||||
raw_arguments = tool_call.function.arguments
|
||||
return function_name, json.loads(raw_arguments), tool_call_id, None
|
||||
except (json.JSONDecodeError, KeyError, AttributeError, TypeError) as e:
|
||||
return function_name, None, tool_call_id, e
|
||||
|
||||
def process_stream_chunks(chunks):
|
||||
"""Process streaming chunks into combined response"""
|
||||
if not chunks:
|
||||
@@ -1559,10 +1579,13 @@ class OpenAIClient:
|
||||
params = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"stream": stream,
|
||||
**kwargs
|
||||
}
|
||||
# Omit temperature when unset so the provider default applies rather
|
||||
# than sending an explicit None.
|
||||
if temperature is not None:
|
||||
params["temperature"] = temperature
|
||||
|
||||
# Add tools if provided
|
||||
if tools:
|
||||
@@ -1620,10 +1643,13 @@ class OpenAIClient:
|
||||
params = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"stream": stream,
|
||||
**kwargs
|
||||
}
|
||||
# Omit temperature when unset so the provider default applies rather
|
||||
# than sending an explicit None.
|
||||
if temperature is not None:
|
||||
params["temperature"] = temperature
|
||||
|
||||
# Add tools if provided
|
||||
if tools:
|
||||
@@ -1906,13 +1932,19 @@ class OpenAIClient:
|
||||
# replies for this turn (keeps tool replies consecutive).
|
||||
_deferred_media_followups = []
|
||||
for tool_call in tool_calls:
|
||||
# Handle both ToolCall dataclass and OpenAI object
|
||||
if isinstance(tool_call, ToolCall):
|
||||
function_name = tool_call.function["name"]
|
||||
arguments = json.loads(tool_call.function["arguments"])
|
||||
else:
|
||||
function_name = tool_call.function.name
|
||||
arguments = json.loads(tool_call.function.arguments)
|
||||
# Handle both ToolCall dataclass and OpenAI object.
|
||||
# Guard the parse itself so malformed/truncated argument
|
||||
# JSON is reported back to the model instead of aborting
|
||||
# the whole run (matches chat_completion_with_tools_stream).
|
||||
function_name, arguments, _tool_call_id, err = _extract_tool_call_name_and_args(tool_call)
|
||||
if err is not None:
|
||||
logging.warning(f"Failed to parse tool call arguments: {err}")
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": _tool_call_id,
|
||||
"content": json.dumps({"error": f"Invalid arguments JSON: {err}"}),
|
||||
})
|
||||
continue
|
||||
|
||||
# Always trigger callback for tool call tracking (even when verbose=False)
|
||||
display_tool_call_fn = _get_display_tool_call()
|
||||
@@ -1921,7 +1953,6 @@ class OpenAIClient:
|
||||
# Capture failures and report them back to the model instead of
|
||||
# aborting the whole run (safe-by-default, matching the streaming
|
||||
# path chat_completion_with_tools_stream).
|
||||
_tool_call_id = tool_call.id if hasattr(tool_call, 'id') else tool_call.get('id')
|
||||
try:
|
||||
tool_result = execute_tool_fn(
|
||||
function_name,
|
||||
@@ -2209,13 +2240,19 @@ class OpenAIClient:
|
||||
# replies for this turn (keeps tool replies consecutive).
|
||||
_deferred_media_followups = []
|
||||
for tool_call in tool_calls:
|
||||
# Handle both ToolCall dataclass and OpenAI object
|
||||
if isinstance(tool_call, ToolCall):
|
||||
function_name = tool_call.function["name"]
|
||||
arguments = json.loads(tool_call.function["arguments"])
|
||||
else:
|
||||
function_name = tool_call.function.name
|
||||
arguments = json.loads(tool_call.function.arguments)
|
||||
# Handle both ToolCall dataclass and OpenAI object.
|
||||
# Guard the parse itself so malformed/truncated argument
|
||||
# JSON is reported back to the model instead of aborting
|
||||
# the whole run (matches chat_completion_with_tools_stream).
|
||||
function_name, arguments, _tool_call_id, err = _extract_tool_call_name_and_args(tool_call)
|
||||
if err is not None:
|
||||
logging.warning(f"Failed to parse tool call arguments: {err}")
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": _tool_call_id,
|
||||
"content": json.dumps({"error": f"Invalid arguments JSON: {err}"}),
|
||||
})
|
||||
continue
|
||||
|
||||
# Always trigger callback for tool call tracking (even when verbose=False)
|
||||
display_tool_call_fn = _get_display_tool_call()
|
||||
@@ -2228,7 +2265,6 @@ class OpenAIClient:
|
||||
# Capture failures and report them back to the model instead of
|
||||
# aborting the whole run (safe-by-default, matching the streaming
|
||||
# path chat_completion_with_tools_stream).
|
||||
_tool_call_id = tool_call.id if hasattr(tool_call, 'id') else tool_call.get('id')
|
||||
try:
|
||||
if asyncio.iscoroutinefunction(execute_tool_fn):
|
||||
tool_result = await execute_tool_fn(
|
||||
@@ -2451,16 +2487,14 @@ class OpenAIClient:
|
||||
_deferred_media_followups = []
|
||||
for tool_call in tool_calls:
|
||||
# Handle both ToolCall dataclass and OpenAI object
|
||||
try:
|
||||
if isinstance(tool_call, ToolCall):
|
||||
function_name = tool_call.function["name"]
|
||||
arguments = json.loads(tool_call.function["arguments"])
|
||||
else:
|
||||
function_name = tool_call.function.name
|
||||
arguments = json.loads(tool_call.function.arguments)
|
||||
except json.JSONDecodeError as e:
|
||||
function_name, arguments, _, err = _extract_tool_call_name_and_args(tool_call)
|
||||
if err is not None:
|
||||
# Preserve original behaviour: only malformed JSON is
|
||||
# reported to the caller; other errors propagate.
|
||||
if not isinstance(err, json.JSONDecodeError):
|
||||
raise err
|
||||
if verbose:
|
||||
yield f"\n[Error parsing arguments for {function_name if 'function_name' in locals() else 'unknown function'}: {str(e)}]"
|
||||
yield f"\n[Error parsing arguments for {function_name if function_name is not None else 'unknown function'}: {str(err)}]"
|
||||
continue
|
||||
|
||||
# Always trigger callback for tool call tracking (even when verbose=False)
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Provider Quota Coordination for PraisonAI Agents.
|
||||
|
||||
Provides a lightweight, protocol-driven seam so provider-quota state
|
||||
(credential cooldowns / benches) can be coordinated *fleet-wide* when a
|
||||
shared backend is configured, while defaulting to today's in-process,
|
||||
in-memory behaviour with zero new dependencies and no hot-path change.
|
||||
|
||||
Design:
|
||||
- ``QuotaCoordinatorProtocol`` is a pure contract (stdlib + typing only).
|
||||
- ``LocalQuotaCoordinator`` is the default in-memory implementation used
|
||||
when no shared backend is selected — single-replica deployments behave
|
||||
exactly as before.
|
||||
- A concrete shared backend (e.g. Redis, reusing the gateway's existing
|
||||
connection) implements the same protocol in the wrapper/bot package and
|
||||
is selected via ``QuotaCoordinatorConfig``. Core keeps only the protocol
|
||||
and the in-memory default.
|
||||
|
||||
Fail-open by contract: implementations MUST degrade to local behaviour on
|
||||
backend errors rather than wedge a healthy request path, and bench entries
|
||||
carry TTLs so a dead replica's benches self-expire.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Optional, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class QuotaCoordinatorProtocol(Protocol):
|
||||
"""Contract for coordinating provider-quota state across replicas.
|
||||
|
||||
A coordinator tracks per-credential cooldowns ("benches"). When a shared
|
||||
backend is configured, a credential benched by any replica is treated as
|
||||
benched by all replicas until its cooldown expires.
|
||||
"""
|
||||
|
||||
def bench(self, cred_id: str, *, until: float, reason: str = "") -> None:
|
||||
"""Bench a credential until ``until`` (an epoch timestamp, TTL-based)."""
|
||||
...
|
||||
|
||||
def is_benched(self, cred_id: str, *, now: Optional[float] = None) -> bool:
|
||||
"""Return True if the credential is currently benched."""
|
||||
...
|
||||
|
||||
def benched_until(self, cred_id: str, *, now: Optional[float] = None) -> Optional[float]:
|
||||
"""Return the epoch timestamp the credential is benched until, or None."""
|
||||
...
|
||||
|
||||
def clear(self, cred_id: str) -> None:
|
||||
"""Clear any bench for the credential (e.g. on recovery)."""
|
||||
...
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuotaCoordinatorConfig:
|
||||
"""Configuration for provider-quota coordination.
|
||||
|
||||
Attributes:
|
||||
backend: ``"local"`` (in-memory, default) or a shared backend name
|
||||
such as ``"redis"`` provided by the wrapper/bot package.
|
||||
url: Optional backend URL; when omitted a shared backend reuses the
|
||||
already-established connection (e.g. gateway ``RedisConfig``).
|
||||
"""
|
||||
|
||||
backend: str = "local"
|
||||
url: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Optional[str]]:
|
||||
return {"backend": self.backend, "url": self.url}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Optional[str]]) -> "QuotaCoordinatorConfig":
|
||||
return cls(
|
||||
backend=(data.get("backend") or "local"),
|
||||
url=data.get("url"),
|
||||
)
|
||||
|
||||
|
||||
class LocalQuotaCoordinator:
|
||||
"""In-memory ``QuotaCoordinatorProtocol`` (default, zero-dependency).
|
||||
|
||||
Thread-safe via a lightweight lock. Bench entries carry an expiry so
|
||||
they self-clear once the cooldown passes — mirroring the TTL semantics a
|
||||
shared backend uses so behaviour is identical across implementations.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
import threading
|
||||
|
||||
self._benches: Dict[str, float] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def bench(self, cred_id: str, *, until: float, reason: str = "") -> None:
|
||||
with self._lock:
|
||||
existing = self._benches.get(cred_id)
|
||||
# Keep the longest cooldown if a longer one is already set.
|
||||
if existing is None or until > existing:
|
||||
self._benches[cred_id] = until
|
||||
|
||||
def is_benched(self, cred_id: str, *, now: Optional[float] = None) -> bool:
|
||||
return self.benched_until(cred_id, now=now) is not None
|
||||
|
||||
def benched_until(self, cred_id: str, *, now: Optional[float] = None) -> Optional[float]:
|
||||
current = time.time() if now is None else now
|
||||
with self._lock:
|
||||
until = self._benches.get(cred_id)
|
||||
if until is None:
|
||||
return None
|
||||
if current >= until:
|
||||
# Expired — self-clear.
|
||||
self._benches.pop(cred_id, None)
|
||||
return None
|
||||
return until
|
||||
|
||||
def clear(self, cred_id: str) -> None:
|
||||
with self._lock:
|
||||
self._benches.pop(cred_id, None)
|
||||
|
||||
|
||||
def build_quota_coordinator(
|
||||
config: Optional[QuotaCoordinatorConfig] = None,
|
||||
) -> QuotaCoordinatorProtocol:
|
||||
"""Build a coordinator from config, defaulting to the local in-memory one.
|
||||
|
||||
Only the ``"local"`` backend is resolvable from core. Shared backends
|
||||
(e.g. ``"redis"``) are provided by the wrapper/bot package, which passes
|
||||
an already-constructed coordinator instance to ``FailoverManager``; this
|
||||
helper falls back to local for unknown backends so core never grows a
|
||||
heavy optional dependency.
|
||||
"""
|
||||
if config is None or config.backend == "local":
|
||||
return LocalQuotaCoordinator()
|
||||
# Unknown/heavy backend not resolvable in core: fail open to local.
|
||||
return LocalQuotaCoordinator()
|
||||
@@ -11,30 +11,83 @@ from __future__ import annotations
|
||||
import importlib
|
||||
from typing import Any, Optional
|
||||
|
||||
_INSTALL_HINT = "Remote compute requires the wrapper package: pip install praisonai"
|
||||
_INSTALL_HINT = "Remote compute requires: pip install praisonai-sandbox"
|
||||
|
||||
# name -> (module, attribute). Kept in sync with
|
||||
# praisonai.integrations.managed_local._resolve_compute
|
||||
_PROVIDERS = {
|
||||
"local": ("praisonai.integrations.compute.local", "LocalCompute"),
|
||||
"docker": ("praisonai.integrations.compute.docker", "DockerCompute"),
|
||||
"e2b": ("praisonai.integrations.compute.e2b", "E2BCompute"),
|
||||
"modal": ("praisonai.integrations.compute.modal_compute", "ModalCompute"),
|
||||
"daytona": ("praisonai.integrations.compute.daytona", "DaytonaCompute"),
|
||||
"flyio": ("praisonai.integrations.compute.flyio", "FlyioCompute"),
|
||||
"tenki": ("praisonai.integrations.compute.tenki", "TenkiCompute"),
|
||||
"local": ("praisonai_sandbox.compute.local", "LocalCompute"),
|
||||
"docker": ("praisonai_sandbox.compute.docker", "DockerCompute"),
|
||||
"e2b": ("praisonai_sandbox.compute.e2b", "E2BCompute"),
|
||||
"modal": ("praisonai_sandbox.compute.modal_compute", "ModalCompute"),
|
||||
"daytona": ("praisonai_sandbox.compute.daytona", "DaytonaCompute"),
|
||||
"flyio": ("praisonai_sandbox.compute.flyio", "FlyioCompute"),
|
||||
"tenki": ("praisonai_sandbox.compute.tenki", "TenkiCompute"),
|
||||
}
|
||||
|
||||
# What to actually type when a provider is missing. Every name here must point
|
||||
# at an extra that EXISTS and installs something -- three of these used to name
|
||||
# extras that were absent or empty, so a user followed the official hint, waited
|
||||
# for the install, and hit the identical failure again.
|
||||
#
|
||||
# The providers in _PROVIDERS now live in praisonai-sandbox, so the failing
|
||||
# import is `praisonai_sandbox.compute.*`: point the hint at the lightweight
|
||||
# package that owns the module, not the heavy wrapper. Reaching E2B through
|
||||
# praisonai-sandbox[e2b] resolves 58 packages against 143 through praisonai.
|
||||
# Sandbox-only places (novita/sandlock/ssh) resolve through the
|
||||
# SandboxComputeAdapter and only need `praisonai-sandbox`.
|
||||
_EXTRA_HINTS = {
|
||||
"e2b": "pip install praisonai[e2b]",
|
||||
"modal": "pip install praisonai[modal]",
|
||||
"daytona": "pip install praisonai[daytona]",
|
||||
"e2b": "pip install 'praisonai-sandbox[e2b]'",
|
||||
"modal": "pip install 'praisonai-sandbox[modal]'",
|
||||
"daytona": "pip install 'praisonai-sandbox[daytona]'",
|
||||
"docker": "pip install 'praisonai-sandbox[docker]'",
|
||||
"novita": "pip install 'praisonai-sandbox[novita]'",
|
||||
"sandlock": "pip install 'praisonai-sandbox[sandlock]'",
|
||||
"ssh": "pip install 'praisonai-sandbox[ssh]'",
|
||||
# flyio and tenki need a credential rather than an SDK; flyio speaks plain
|
||||
# HTTP through aiohttp, which praisonaiagents already requires. Export the
|
||||
# variable so a child process inherits it -- a bare assignment would not.
|
||||
"flyio": "export FLY_API_TOKEN=... (see https://fly.io/docs/flyctl/auth-token)",
|
||||
"tenki": "pip install 'praisonai-sandbox[tenki]'",
|
||||
}
|
||||
|
||||
|
||||
# Places that live only in praisonai-sandbox. They speak SandboxProtocol
|
||||
# (one object IS one environment) rather than ComputeProviderProtocol (one
|
||||
# object hands out many), so an adapter supplies the missing half.
|
||||
from ._sandbox_adapter import NEEDS_INSTANCE, SANDBOX_ONLY # noqa: E402
|
||||
|
||||
# Spellings users already have in their code, mapped to the canonical name.
|
||||
_ALIASES = {"native": "sandlock"}
|
||||
|
||||
|
||||
def _entry_point_providers() -> dict:
|
||||
"""Places contributed by other installed packages.
|
||||
|
||||
The sandbox and managed-backend registries already discover plugins this
|
||||
way (``praisonai.sandbox``, ``praisonai.managed_backends``); the compute
|
||||
registry was the one hardcoded dict, so adding a place meant editing core.
|
||||
A provider can now ship in its own distribution:
|
||||
|
||||
[project.entry-points."praisonai.compute"]
|
||||
runpod = "praisonai_compute_runpod:RunpodCompute"
|
||||
"""
|
||||
found = {}
|
||||
try:
|
||||
from importlib.metadata import entry_points
|
||||
|
||||
for ep in entry_points(group="praisonai.compute"):
|
||||
found[ep.name.lower()] = ep
|
||||
except Exception: # pragma: no cover - discovery must never break resolution
|
||||
pass
|
||||
return found
|
||||
|
||||
|
||||
def available_providers() -> list:
|
||||
"""Return the compute provider names this bridge can resolve."""
|
||||
return sorted(_PROVIDERS)
|
||||
"""Every place ``tools_run_on=`` / ``run_in=`` accepts, from all registries."""
|
||||
return sorted(
|
||||
set(_PROVIDERS) | set(SANDBOX_ONLY) | set(_entry_point_providers())
|
||||
)
|
||||
|
||||
|
||||
def compute_package_available() -> bool:
|
||||
@@ -58,15 +111,44 @@ def resolve_compute(compute: Optional[Any]) -> Optional[Any]:
|
||||
if not isinstance(compute, str):
|
||||
if hasattr(compute, "provision") and hasattr(compute, "execute"):
|
||||
return compute
|
||||
# A SandboxProtocol instance -- e.g. SSHSandbox(host=...) -- is adapted,
|
||||
# which is the only way to pass connection details.
|
||||
if hasattr(compute, "run_command") and hasattr(compute, "start"):
|
||||
from ._sandbox_adapter import wrap_sandbox_instance
|
||||
|
||||
return wrap_sandbox_instance(compute)
|
||||
raise TypeError(
|
||||
f"compute= expects a provider name or a ComputeProviderProtocol "
|
||||
f"instance, got {type(compute).__name__}"
|
||||
)
|
||||
|
||||
name = compute.lower().strip()
|
||||
name = _ALIASES.get(name, name)
|
||||
|
||||
# A sandbox-only place: adapt it to the compute-provider shape.
|
||||
if name in SANDBOX_ONLY:
|
||||
if name in NEEDS_INSTANCE:
|
||||
raise ValueError(
|
||||
f"{name!r} needs connection details a bare name cannot carry. "
|
||||
f"Instead, {NEEDS_INSTANCE[name]}"
|
||||
)
|
||||
from ._sandbox_adapter import SandboxComputeAdapter
|
||||
|
||||
return SandboxComputeAdapter(name)
|
||||
|
||||
external = _entry_point_providers()
|
||||
if name in external and name not in _PROVIDERS:
|
||||
# Let a contributed place describe itself. Without this the repr falls
|
||||
# back to the bare name, because the phrase table is a literal that
|
||||
# cannot know about packages installed later.
|
||||
_remember_display_name(name, external[name])
|
||||
# A place contributed by another package. Loaded on demand, so an
|
||||
# installed-but-broken plugin cannot break anything that never names it.
|
||||
return external[name].load()()
|
||||
|
||||
if name not in _PROVIDERS:
|
||||
raise ValueError(
|
||||
f"Unknown compute provider {name!r}. "
|
||||
f"Unknown place {name!r}. "
|
||||
f"Available: {', '.join(available_providers())}"
|
||||
)
|
||||
|
||||
@@ -78,3 +160,23 @@ def resolve_compute(compute: Optional[Any]) -> Optional[Any]:
|
||||
raise ImportError(f"compute={name!r} requires {module_name}. {hint}") from exc
|
||||
|
||||
return getattr(module, attr)()
|
||||
|
||||
|
||||
#: Display phrases contributed by external providers, filled in on resolve.
|
||||
_DISPLAY_NAMES: dict = {}
|
||||
|
||||
|
||||
def _remember_display_name(name: str, entry_point) -> None:
|
||||
"""Record a contributed provider's own phrase, if it declares one."""
|
||||
try:
|
||||
cls = entry_point.load()
|
||||
phrase = getattr(cls, "display_name", None)
|
||||
if isinstance(phrase, str) and phrase:
|
||||
_DISPLAY_NAMES[name] = phrase
|
||||
except Exception: # pragma: no cover - naming must never break resolution
|
||||
pass
|
||||
|
||||
|
||||
def contributed_display_name(name: str):
|
||||
"""The phrase a contributed place declared for itself, if any."""
|
||||
return _DISPLAY_NAMES.get((name or "").lower())
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Expose a single sandbox environment as a one-instance compute provider.
|
||||
|
||||
There are two execution stacks in this project and they are *not* duplicates:
|
||||
|
||||
* ``SandboxProtocol`` (praisonai-sandbox) -- one object **is** one environment:
|
||||
``start / stop / execute / run_command / reset``.
|
||||
* ``ComputeProviderProtocol`` -- one object **hands out** many environments:
|
||||
``provision / execute(instance_id, ...) / shutdown / list_instances``.
|
||||
|
||||
``tools_run_on=`` speaks the second protocol, so four backends that only exist
|
||||
as sandboxes -- ``subprocess``, ``sandlock``, ``ssh``, ``novita`` -- were
|
||||
unreachable through it. This adapter is the missing half: it holds exactly one
|
||||
environment and presents the manager-shaped API over it.
|
||||
|
||||
Deliberate limitation: ``list_instances()`` can only report what *this process*
|
||||
provisioned, so adapter-backed places never appear in ``praisonai managed ps``.
|
||||
A single environment has no cross-process registry to consult, and pretending
|
||||
otherwise would be worse than saying so.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Backends that exist only as SandboxProtocol implementations. The literal is
|
||||
# the floor, not the ceiling: the sandbox registry accepts plugins by entry
|
||||
# point, so a hardcoded tuple silently withheld contributed backends from
|
||||
# tools_run_on=. `capsule` was the live example -- it ships from
|
||||
# praisonai-plugins, worked with run_in=, and was invisible to tools_run_on=.
|
||||
_SANDBOX_ONLY_FLOOR = ("subprocess", "sandlock", "ssh", "novita")
|
||||
|
||||
|
||||
def sandbox_only_names() -> tuple:
|
||||
"""Sandbox backends with no compute-registry equivalent.
|
||||
|
||||
Anything the compute registry already provides is excluded, so a name that
|
||||
exists in both (docker, e2b, modal, daytona) keeps its richer compute
|
||||
implementation rather than being downgraded to the one-instance adapter.
|
||||
"""
|
||||
names = set(_SANDBOX_ONLY_FLOOR)
|
||||
try:
|
||||
from ..sandbox._sandbox_bridge import get_sandbox_registry
|
||||
|
||||
registry = get_sandbox_registry()
|
||||
registry = registry.default() if isinstance(registry, type) else registry
|
||||
names |= {str(n).lower() for n in registry.list_all_names()}
|
||||
except Exception: # pragma: no cover - discovery must never break resolution
|
||||
pass
|
||||
|
||||
try:
|
||||
from ._compute_bridge import _PROVIDERS
|
||||
|
||||
names -= set(_PROVIDERS)
|
||||
except Exception:
|
||||
pass
|
||||
return tuple(sorted(names))
|
||||
|
||||
|
||||
class _SandboxOnly(tuple):
|
||||
"""A tuple that re-reads the registry, so `in` sees plugins installed later.
|
||||
|
||||
Kept as a tuple subclass because SANDBOX_ONLY is imported and iterated in
|
||||
several places that predate this.
|
||||
"""
|
||||
|
||||
def __contains__(self, item):
|
||||
return item in sandbox_only_names()
|
||||
|
||||
def __iter__(self):
|
||||
return iter(sandbox_only_names())
|
||||
|
||||
def __len__(self):
|
||||
return len(sandbox_only_names())
|
||||
|
||||
|
||||
SANDBOX_ONLY = _SandboxOnly(_SANDBOX_ONLY_FLOOR)
|
||||
|
||||
# Names that need connection details no bare string can carry.
|
||||
NEEDS_INSTANCE = {
|
||||
"ssh": (
|
||||
"run on a remote machine over SSH by passing the object, so it can "
|
||||
"carry the host: SSHSandbox(host='...', user='...')"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class SandboxComputeAdapter:
|
||||
"""One ``SandboxProtocol`` environment, wearing the compute-provider shape."""
|
||||
|
||||
def __init__(self, sandbox_type: str, config: Optional[Any] = None,
|
||||
sandbox: Optional[Any] = None):
|
||||
self._type = sandbox_type
|
||||
self._config = config
|
||||
self._sandbox = sandbox # pre-built instance, if supplied
|
||||
self._owns_sandbox = sandbox is None
|
||||
self._instance_id: Optional[str] = None
|
||||
|
||||
# ── identity ─────────────────────────────────────────────────────────────
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return self._type
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
if self._sandbox is not None:
|
||||
return bool(getattr(self._sandbox, "is_available", True))
|
||||
try:
|
||||
from ..sandbox._sandbox_bridge import resolve_sandbox_class
|
||||
|
||||
cls = resolve_sandbox_class(self._type)
|
||||
probe = cls() if self._type not in NEEDS_INSTANCE else None
|
||||
return bool(getattr(probe, "is_available", False)) if probe else False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# ── lifecycle ────────────────────────────────────────────────────────────
|
||||
async def provision(self, config: Any) -> Any:
|
||||
from .protocols import InstanceInfo, InstanceStatus
|
||||
|
||||
if self._sandbox is None:
|
||||
from ..sandbox import SandboxConfig
|
||||
from ..sandbox.manager import SandboxManager
|
||||
|
||||
cfg = self._config
|
||||
if cfg is None:
|
||||
# "/workspace" is the convention for *container* sandboxes and
|
||||
# does not exist on a Mac or a bare Linux box -- creating it
|
||||
# fails with "Read-only file system". Local backends pick their
|
||||
# own private temp dir, so only pass a working_dir through when
|
||||
# the caller actually chose one.
|
||||
wanted_dir = getattr(config, "working_dir", None)
|
||||
local_place = self._type in ("subprocess", "sandlock")
|
||||
if local_place and wanted_dir in (None, "", "/workspace"):
|
||||
wanted_dir = None
|
||||
cfg = SandboxConfig(
|
||||
sandbox_type=self._type,
|
||||
env=dict(getattr(config, "env", None) or {}),
|
||||
**({"working_dir": wanted_dir} if wanted_dir else {}),
|
||||
)
|
||||
# This sandbox exists to back the model's execute_command tool,
|
||||
# which IS a shell. The default policy sets allow_subprocess=
|
||||
# False, which makes `sh -c` -- and therefore every pipe,
|
||||
# redirect and && the model writes -- fail as "disabled by
|
||||
# security policy". Blocking the shell inside a shell tool is a
|
||||
# contradiction, not a protection; blocked_commands and
|
||||
# blocked_paths still apply.
|
||||
if getattr(cfg, "security_policy", None) is not None:
|
||||
cfg.security_policy.allow_subprocess = True
|
||||
if getattr(config, "image", None):
|
||||
cfg.image = config.image
|
||||
self._manager = SandboxManager(cfg)
|
||||
self._sandbox = await self._manager.__aenter__()
|
||||
else:
|
||||
self._manager = None
|
||||
starter = getattr(self._sandbox, "start", None)
|
||||
if starter is not None:
|
||||
await starter()
|
||||
|
||||
self._instance_id = f"{self._type}_{uuid.uuid4().hex[:12]}"
|
||||
logger.info("[sandbox_adapter] provisioned %s as %s", self._type, self._instance_id)
|
||||
return InstanceInfo(
|
||||
instance_id=self._instance_id,
|
||||
status=InstanceStatus.RUNNING,
|
||||
endpoint=f"{self._type}://local",
|
||||
provider=self._type,
|
||||
metadata={"adapter": True, "discoverable": False},
|
||||
)
|
||||
|
||||
async def execute(self, instance_id: str, command: str,
|
||||
timeout: Optional[int] = 300) -> Dict[str, Any]:
|
||||
if self._sandbox is None:
|
||||
raise RuntimeError(
|
||||
f"{self._type} sandbox is not running; provision() was not called"
|
||||
)
|
||||
# The bridged execute_command tool hands us a shell command string, and
|
||||
# models write pipes, redirects and && constantly. Without shell=True
|
||||
# `echo x > f` is argv-split and "> f" arrives as a literal argument.
|
||||
try:
|
||||
result = await self._sandbox.run_command(command, shell=True)
|
||||
except TypeError:
|
||||
# Backends predating the shell= parameter.
|
||||
result = await self._sandbox.run_command(command)
|
||||
# A policy denial arrives as result.error with exit_code=None. Dropping
|
||||
# it would turn "blocked by security policy" into a silent success --
|
||||
# the caller would see exit 0 and empty output.
|
||||
error = getattr(result, "error", None)
|
||||
exit_code = getattr(result, "exit_code", None)
|
||||
stderr = getattr(result, "stderr", "") or ""
|
||||
if error:
|
||||
stderr = f"{stderr}\n{error}".strip() if stderr else str(error)
|
||||
if exit_code in (None, 0):
|
||||
exit_code = 1
|
||||
return {
|
||||
"stdout": getattr(result, "stdout", "") or "",
|
||||
"stderr": stderr,
|
||||
"exit_code": exit_code or 0,
|
||||
}
|
||||
|
||||
async def shutdown(self, instance_id: str) -> None:
|
||||
try:
|
||||
if getattr(self, "_manager", None) is not None:
|
||||
await self._manager.__aexit__(None, None, None)
|
||||
elif self._sandbox is not None:
|
||||
stopper = getattr(self._sandbox, "stop", None)
|
||||
if stopper is not None:
|
||||
await stopper()
|
||||
finally:
|
||||
if self._owns_sandbox:
|
||||
self._sandbox = None
|
||||
self._manager = None
|
||||
self._instance_id = None
|
||||
|
||||
async def get_status(self, instance_id: str) -> Any:
|
||||
from .protocols import InstanceInfo, InstanceStatus
|
||||
|
||||
running = self._sandbox is not None and instance_id == self._instance_id
|
||||
return InstanceInfo(
|
||||
instance_id=instance_id,
|
||||
status=InstanceStatus.RUNNING if running else InstanceStatus.STOPPED,
|
||||
provider=self._type,
|
||||
)
|
||||
|
||||
async def list_instances(self) -> List[Any]:
|
||||
"""Only what this process provisioned -- see the module docstring."""
|
||||
from .protocols import InstanceInfo, InstanceStatus
|
||||
|
||||
if self._instance_id is None:
|
||||
return []
|
||||
return [InstanceInfo(
|
||||
instance_id=self._instance_id,
|
||||
status=InstanceStatus.RUNNING,
|
||||
provider=self._type,
|
||||
metadata={"adapter": True, "discoverable": False},
|
||||
)]
|
||||
|
||||
# ── file transfer ────────────────────────────────────────────────────────
|
||||
async def upload_file(self, instance_id: str, local_path: str, remote_path: str) -> None:
|
||||
with open(local_path, "r") as handle:
|
||||
await self._sandbox.write_file(remote_path, handle.read())
|
||||
|
||||
async def download_file(self, instance_id: str, remote_path: str, local_path: str) -> None:
|
||||
content = await self._sandbox.read_file(remote_path)
|
||||
with open(local_path, "w") as handle:
|
||||
handle.write(content if isinstance(content, str) else str(content))
|
||||
|
||||
|
||||
def wrap_sandbox_instance(sandbox: Any) -> SandboxComputeAdapter:
|
||||
"""Adapt an already-constructed sandbox (e.g. ``SSHSandbox(host=...)``)."""
|
||||
name = getattr(sandbox, "sandbox_type", None) or type(sandbox).__name__.lower()
|
||||
return SandboxComputeAdapter(str(name), sandbox=sandbox)
|
||||
@@ -17,6 +17,7 @@ import logging
|
||||
import shlex
|
||||
import threading
|
||||
import uuid
|
||||
import weakref
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ._compute_bridge import resolve_compute
|
||||
@@ -118,8 +119,13 @@ class SharedCompute:
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Tear down the instance and restore any agents we patched."""
|
||||
for agent, original_tools, original_backstory in self._patched:
|
||||
for ref, original_tools, original_backstory in self._patched:
|
||||
agent = ref()
|
||||
if agent is None:
|
||||
continue # already collected; nothing left to restore
|
||||
try:
|
||||
if getattr(agent, "_tools_sandbox", None) is self:
|
||||
agent._tools_sandbox = None
|
||||
agent.tools = original_tools
|
||||
agent.backstory = original_backstory
|
||||
cache = getattr(agent, "_system_prompt_cache", None)
|
||||
@@ -136,6 +142,8 @@ class SharedCompute:
|
||||
logger.info("[shared_compute] shut down instance %s", self.instance_id)
|
||||
except Exception as exc: # pragma: no cover - teardown must not mask errors
|
||||
logger.warning("[shared_compute] shutdown failed for %s: %s", self.instance_id, exc)
|
||||
self.instance_id = None
|
||||
raise # the caller logs whether the sandbox was released
|
||||
finally:
|
||||
self.instance_id = None
|
||||
|
||||
@@ -187,9 +195,14 @@ class SharedCompute:
|
||||
# heredoc early (which would truncate the file and run the remainder
|
||||
# as shell commands). Guard against the astronomically-unlikely case
|
||||
# where the token still appears in the body.
|
||||
delimiter = f"__PRAISON_EOF_{uuid.uuid4().hex}__"
|
||||
# Uppercase hex on purpose. uuid4().hex is lowercase, and the
|
||||
# security policy substring-matches blocked_commands against the
|
||||
# whole command -- "dd" is one of them, and appears in 9.6% of
|
||||
# random lowercase hex strings. One write in ten failed with
|
||||
# "Blocked command pattern: dd" for no reason the caller could see.
|
||||
delimiter = f"__PRAISON_EOF_{uuid.uuid4().hex.upper()}__"
|
||||
while delimiter in content:
|
||||
delimiter = f"__PRAISON_EOF_{uuid.uuid4().hex}__"
|
||||
delimiter = f"__PRAISON_EOF_{uuid.uuid4().hex.upper()}__"
|
||||
heredoc = (
|
||||
f"mkdir -p $(dirname {quoted}) && "
|
||||
f"cat > {quoted} <<'{delimiter}'\n{content}\n{delimiter}"
|
||||
@@ -203,13 +216,25 @@ class SharedCompute:
|
||||
"""List files in a directory on the shared remote sandbox."""
|
||||
return self._format(self.run_command(f"ls -la {shlex.quote(path)}"))
|
||||
|
||||
return [execute_command, read_file, write_file, list_files]
|
||||
bridged = [execute_command, read_file, write_file, list_files]
|
||||
# Tag them so Agent.clone_for_channel() drops them: each closes over
|
||||
# THIS sandbox, so a clone inheriting them would talk to the original
|
||||
# agent's instance and then attach a second sandbox of its own --
|
||||
# duplicating the capability prompt and splitting its work across two
|
||||
# machines. The clone regenerates its own on first use.
|
||||
for tool in bridged:
|
||||
tool._praison_sandbox_tool = True
|
||||
return bridged
|
||||
|
||||
def attach(self, agents: List[Any]) -> None:
|
||||
def attach(self, agents: List[Any], *, override_declared: bool = False) -> None:
|
||||
"""Give each agent the shared tools and tell it they exist.
|
||||
|
||||
Agents that already carry their own managed ``backend`` are skipped --
|
||||
they have deliberately been pointed at their own runtime.
|
||||
|
||||
``override_declared`` is set only by the agent's *own* placement, which
|
||||
is this sandbox's whole reason for existing. A run-level sandbox leaves
|
||||
it False so an agent that named its own place keeps it.
|
||||
"""
|
||||
tools = self.build_tools()
|
||||
by_name = {t.__name__: t for t in tools}
|
||||
@@ -217,6 +242,31 @@ class SharedCompute:
|
||||
for agent in agents:
|
||||
if agent is None or getattr(agent, "backend", None) is not None:
|
||||
continue
|
||||
# An agent that named its own place, or already has a sandbox, must
|
||||
# not be attached again. A second attach appends CAPABILITY_PROMPT
|
||||
# twice and, if the two teardowns do not happen in LIFO order,
|
||||
# permanently restores the wrong tools: the agent is left holding
|
||||
# four tools bound to a shut-down instance.
|
||||
#
|
||||
# `tools_run_on` is checked as well as `_tools_sandbox` because of
|
||||
# ordering: a flow attaches at the start of the run, before the
|
||||
# agent's first chat() has created its own sandbox. Checking only
|
||||
# the live sandbox would attach here and let the agent attach again
|
||||
# on its first turn. An explicit per-agent choice outranks the run's
|
||||
# default, so the flow leaves it alone.
|
||||
declared_own = (
|
||||
not override_declared
|
||||
and getattr(agent, "tools_run_on", None) is not None
|
||||
)
|
||||
if getattr(agent, "_tools_sandbox", None) is not None or declared_own:
|
||||
logger.debug(
|
||||
"[shared_compute] %r already runs its tools on %s; leaving it alone",
|
||||
getattr(agent, "name", agent),
|
||||
getattr(agent, "tools_run_on", "its own sandbox"),
|
||||
)
|
||||
continue
|
||||
if any(agent is ref() for ref, _, _ in self._patched):
|
||||
continue
|
||||
|
||||
original_tools = list(getattr(agent, "tools", None) or [])
|
||||
# Agent.__init__ copies `instructions` into role/goal/backstory and
|
||||
@@ -248,4 +298,18 @@ class SharedCompute:
|
||||
logger.warning("[shared_compute] could not attach tools to %r: %s", agent, exc)
|
||||
continue
|
||||
|
||||
self._patched.append((agent, original_tools, original_backstory))
|
||||
# Mark where this agent's tools now run, so a nested attach (or a
|
||||
# later ensure_tools_placed) can see it is already bound.
|
||||
try:
|
||||
agent._tools_sandbox = self
|
||||
except Exception: # pragma: no cover - exotic agent objects
|
||||
pass
|
||||
# Weakly: an agent points at its SharedCompute and the SharedCompute
|
||||
# pointed back, so a dropped agent was reclaimed as a cycle and its
|
||||
# sandbox was never shut down. Holding the agent weakly lets
|
||||
# weakref.finalize see the agent die and release the sandbox.
|
||||
try:
|
||||
ref = weakref.ref(agent)
|
||||
except TypeError: # pragma: no cover - non-weakrefable agent
|
||||
ref = lambda _agent=agent: _agent
|
||||
self._patched.append((ref, original_tools, original_backstory))
|
||||
@@ -51,6 +51,12 @@ _LAZY_IMPORTS = {
|
||||
"get_redirect_url": _t("praisonaiagents.mcp.mcp_oauth_callback", "get_redirect_url"),
|
||||
"OAUTH_CALLBACK_PORT": _t("praisonaiagents.mcp.mcp_oauth_callback", "OAUTH_CALLBACK_PORT"),
|
||||
"OAUTH_CALLBACK_PATH": _t("praisonaiagents.mcp.mcp_oauth_callback", "OAUTH_CALLBACK_PATH"),
|
||||
# Zero-config OAuth: discovery, dynamic registration, orchestration.
|
||||
"discover_oauth_metadata": _t("praisonaiagents.mcp.oauth_discovery", "discover_oauth_metadata"),
|
||||
"parse_www_authenticate": _t("praisonaiagents.mcp.oauth_discovery", "parse_www_authenticate"),
|
||||
"register_client": _t("praisonaiagents.mcp.oauth_registration", "register_client"),
|
||||
"MCPOAuthProvider": _t("praisonaiagents.mcp.oauth_provider", "MCPOAuthProvider"),
|
||||
"InteractiveAuthRequired": _t("praisonaiagents.mcp.oauth_provider", "InteractiveAuthRequired"),
|
||||
# Protocols (lazy loaded)
|
||||
"MCPClientProtocol": _t("praisonaiagents.mcp.protocols", "MCPClientProtocol"),
|
||||
# Loader (lazy loaded)
|
||||
@@ -100,4 +106,10 @@ __all__ = [
|
||||
"get_redirect_url",
|
||||
"OAUTH_CALLBACK_PORT",
|
||||
"OAUTH_CALLBACK_PATH",
|
||||
# Zero-config OAuth (lazy loaded)
|
||||
"discover_oauth_metadata",
|
||||
"parse_www_authenticate",
|
||||
"register_client",
|
||||
"MCPOAuthProvider",
|
||||
"InteractiveAuthRequired",
|
||||
]
|
||||
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
MCP OAuth Metadata Discovery.
|
||||
|
||||
Implements the discovery half of zero-config OAuth for remote MCP servers:
|
||||
|
||||
- Parse a ``WWW-Authenticate: Bearer resource_metadata="..."`` challenge from a
|
||||
``401`` response (RFC 9728 / MCP authorization spec).
|
||||
- Fetch protected-resource metadata (``/.well-known/oauth-protected-resource``).
|
||||
- Fetch authorization-server metadata
|
||||
(``/.well-known/oauth-authorization-server`` with an OpenID configuration
|
||||
fallback), yielding ``authorization_endpoint``, ``token_endpoint`` and
|
||||
``registration_endpoint``.
|
||||
|
||||
Network access is HTTPS-only (except loopback) to preserve the SSRF-safe
|
||||
posture used elsewhere in the MCP transports. ``requests`` is imported lazily so
|
||||
this module stays additive and does not affect import time.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
from praisonaiagents._logging import get_logger
|
||||
|
||||
logger = get_logger("mcp-oauth-discovery")
|
||||
|
||||
_LOCALHOST_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
||||
|
||||
|
||||
def _require_https(url: str) -> None:
|
||||
"""Reject non-HTTPS metadata URLs (loopback HTTP is allowed for testing)."""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme == "https":
|
||||
return
|
||||
if parsed.scheme == "http" and (parsed.hostname or "") in _LOCALHOST_HOSTS:
|
||||
return
|
||||
raise ValueError(
|
||||
f"Refusing to fetch OAuth metadata over insecure transport: {url!r}"
|
||||
)
|
||||
|
||||
|
||||
def parse_www_authenticate(header: Optional[str]) -> Dict[str, str]:
|
||||
"""
|
||||
Parse a ``WWW-Authenticate`` challenge header into its parameters.
|
||||
|
||||
Only ``Bearer`` challenges are relevant here. Returns a dict of the
|
||||
``key="value"`` auth-params (e.g. ``resource_metadata``, ``scope``,
|
||||
``error``). Returns an empty dict when the header is missing or not a
|
||||
Bearer challenge.
|
||||
"""
|
||||
if not header:
|
||||
return {}
|
||||
|
||||
# Strip the leading scheme (e.g. "Bearer ") if present.
|
||||
stripped = header.strip()
|
||||
if stripped.lower().startswith("bearer"):
|
||||
stripped = stripped[len("bearer"):].lstrip()
|
||||
|
||||
params: Dict[str, str] = {}
|
||||
for match in re.finditer(r'(\w+)\s*=\s*"([^"]*)"', stripped):
|
||||
params[match.group(1).lower()] = match.group(2)
|
||||
return params
|
||||
|
||||
|
||||
def _get_json(url: str, timeout: float = 10.0) -> Dict[str, Any]:
|
||||
"""Fetch and parse a JSON metadata document over HTTPS.
|
||||
|
||||
Redirects are followed but every hop — including the final destination — is
|
||||
re-validated with :func:`_require_https` so a redirect cannot escape the
|
||||
HTTPS/loopback guard to an internal or plaintext endpoint (SSRF-safe).
|
||||
"""
|
||||
_require_https(url)
|
||||
import requests # lazy import — optional dependency
|
||||
|
||||
resp = requests.get(
|
||||
url,
|
||||
headers={"Accept": "application/json"},
|
||||
timeout=timeout,
|
||||
allow_redirects=True,
|
||||
)
|
||||
# Re-validate every redirect hop and the final URL: allow_redirects only
|
||||
# checks the initial URL, so a 30x to http:// or an internal host would
|
||||
# otherwise bypass the guard.
|
||||
for previous in getattr(resp, "history", None) or []:
|
||||
_require_https(previous.url)
|
||||
_require_https(resp.url)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _wellknown_candidates(server_url: str) -> List[str]:
|
||||
"""Build ``.well-known`` metadata URL candidates for a server URL."""
|
||||
parsed = urlparse(server_url)
|
||||
origin = f"{parsed.scheme}://{parsed.netloc}"
|
||||
return [
|
||||
urljoin(origin + "/", ".well-known/oauth-protected-resource"),
|
||||
urljoin(origin + "/", ".well-known/oauth-authorization-server"),
|
||||
urljoin(origin + "/", ".well-known/openid-configuration"),
|
||||
]
|
||||
|
||||
|
||||
def discover_protected_resource(
|
||||
server_url: str,
|
||||
resource_metadata_url: Optional[str] = None,
|
||||
timeout: float = 10.0,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Discover protected-resource metadata for an MCP server.
|
||||
|
||||
Args:
|
||||
server_url: The MCP server URL that returned a 401.
|
||||
resource_metadata_url: Optional explicit URL from the
|
||||
``WWW-Authenticate: resource_metadata="..."`` challenge.
|
||||
timeout: Per-request timeout in seconds.
|
||||
|
||||
Returns:
|
||||
The protected-resource metadata document, or None if unavailable.
|
||||
"""
|
||||
url = resource_metadata_url or _wellknown_candidates(server_url)[0]
|
||||
try:
|
||||
return _get_json(url, timeout=timeout)
|
||||
except Exception as exc: # noqa: BLE001 - discovery is best-effort
|
||||
logger.debug(f"Protected-resource metadata unavailable at {url}: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
def discover_authorization_server(
|
||||
server_url: str,
|
||||
authorization_servers: Optional[List[str]] = None,
|
||||
timeout: float = 10.0,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Discover authorization-server metadata.
|
||||
|
||||
Tries, in order: any ``authorization_servers`` advertised by the
|
||||
protected-resource metadata, then the server-origin
|
||||
``/.well-known/oauth-authorization-server`` and OpenID-configuration
|
||||
fallbacks.
|
||||
|
||||
Returns the first metadata document that exposes at least an
|
||||
``authorization_endpoint`` and ``token_endpoint``, or None.
|
||||
"""
|
||||
candidates: List[str] = []
|
||||
|
||||
for issuer in authorization_servers or []:
|
||||
issuer = issuer.rstrip("/")
|
||||
candidates.append(issuer + "/.well-known/oauth-authorization-server")
|
||||
candidates.append(issuer + "/.well-known/openid-configuration")
|
||||
|
||||
# Fall back to the resource server's own origin.
|
||||
for candidate in _wellknown_candidates(server_url)[1:]:
|
||||
candidates.append(candidate)
|
||||
|
||||
for url in candidates:
|
||||
try:
|
||||
metadata = _get_json(url, timeout=timeout)
|
||||
except Exception as exc: # noqa: BLE001 - best-effort discovery
|
||||
logger.debug(f"Auth-server metadata unavailable at {url}: {exc}")
|
||||
continue
|
||||
if metadata.get("authorization_endpoint") and metadata.get("token_endpoint"):
|
||||
return metadata
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def discover_oauth_metadata(
|
||||
server_url: str,
|
||||
www_authenticate: Optional[str] = None,
|
||||
timeout: float = 10.0,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Full metadata discovery for an OAuth-protected MCP server.
|
||||
|
||||
Combines the ``WWW-Authenticate`` challenge (if any), protected-resource
|
||||
metadata, and authorization-server metadata into a single normalised dict:
|
||||
|
||||
{
|
||||
"authorization_endpoint": str,
|
||||
"token_endpoint": str,
|
||||
"registration_endpoint": str | None,
|
||||
"scopes_supported": list[str] | None,
|
||||
"resource": str | None,
|
||||
}
|
||||
|
||||
Returns None if a usable authorization/token endpoint pair cannot be found.
|
||||
"""
|
||||
challenge = parse_www_authenticate(www_authenticate)
|
||||
|
||||
resource_meta = discover_protected_resource(
|
||||
server_url,
|
||||
resource_metadata_url=challenge.get("resource_metadata"),
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
authorization_servers = None
|
||||
resource_id = None
|
||||
if resource_meta:
|
||||
authorization_servers = resource_meta.get("authorization_servers")
|
||||
resource_id = resource_meta.get("resource")
|
||||
|
||||
auth_meta = discover_authorization_server(
|
||||
server_url,
|
||||
authorization_servers=authorization_servers,
|
||||
timeout=timeout,
|
||||
)
|
||||
if not auth_meta:
|
||||
return None
|
||||
|
||||
# Enforce the HTTPS/loopback guard on the advertised endpoints too: an
|
||||
# HTTPS metadata document must not be able to smuggle a plaintext
|
||||
# authorization/token/registration endpoint that would later receive
|
||||
# authorization codes, PKCE verifiers, refresh tokens, or client secrets
|
||||
# over an insecure transport.
|
||||
authorization_endpoint = auth_meta["authorization_endpoint"]
|
||||
token_endpoint = auth_meta["token_endpoint"]
|
||||
registration_endpoint = auth_meta.get("registration_endpoint")
|
||||
_require_https(authorization_endpoint)
|
||||
_require_https(token_endpoint)
|
||||
if registration_endpoint:
|
||||
_require_https(registration_endpoint)
|
||||
|
||||
return {
|
||||
"authorization_endpoint": authorization_endpoint,
|
||||
"token_endpoint": token_endpoint,
|
||||
"registration_endpoint": registration_endpoint,
|
||||
"scopes_supported": (
|
||||
auth_meta.get("scopes_supported")
|
||||
or (resource_meta or {}).get("scopes_supported")
|
||||
),
|
||||
"resource": resource_id,
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
"""
|
||||
MCP OAuth Provider — zero-config orchestration.
|
||||
|
||||
Composes the OAuth building blocks into a single ``ensure_authenticated`` entry
|
||||
point that transports invoke on a ``401``:
|
||||
|
||||
discovery -> dynamic client registration (if no client stored)
|
||||
-> PKCE authorization-code flow (browser + loopback callback)
|
||||
-> token persistence
|
||||
-> transparent refresh on expiry
|
||||
|
||||
All heavy work (network I/O, browser, loopback HTTP server) is lazily imported
|
||||
and only runs when a server actually challenges for OAuth, so servers without
|
||||
auth — or with pre-supplied client info/tokens — behave exactly as before.
|
||||
"""
|
||||
|
||||
import time
|
||||
import webbrowser
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from threading import Thread
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import urlencode, urlparse, parse_qs
|
||||
|
||||
from praisonaiagents._logging import get_logger
|
||||
|
||||
from .mcp_oauth_callback import (
|
||||
OAuthCallbackHandler,
|
||||
OAUTH_CALLBACK_PORT,
|
||||
OAUTH_CALLBACK_PATH,
|
||||
generate_state,
|
||||
generate_code_verifier,
|
||||
generate_code_challenge,
|
||||
get_redirect_url,
|
||||
)
|
||||
|
||||
logger = get_logger("mcp-oauth-provider")
|
||||
|
||||
|
||||
class InteractiveAuthRequired(RuntimeError):
|
||||
"""Raised when interactive OAuth is needed but not possible (headless/CI)."""
|
||||
|
||||
|
||||
def _make_callback_server(handler: OAuthCallbackHandler, port: int) -> HTTPServer:
|
||||
"""Build a minimal loopback HTTP server that feeds codes to ``handler``."""
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self): # noqa: N802 - required name
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path != OAUTH_CALLBACK_PATH:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
params = parse_qs(parsed.query)
|
||||
state = (params.get("state") or [""])[0]
|
||||
code = (params.get("code") or [""])[0]
|
||||
if state and code:
|
||||
handler.receive_callback(state, code)
|
||||
body = b"Authentication complete. You can close this window."
|
||||
else:
|
||||
body = b"Authentication failed: missing code or state."
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/plain")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *args, **kwargs): # silence default logging
|
||||
pass
|
||||
|
||||
return HTTPServer(("127.0.0.1", port), _Handler)
|
||||
|
||||
|
||||
class MCPOAuthProvider:
|
||||
"""
|
||||
Orchestrates zero-config OAuth for a single remote MCP server.
|
||||
|
||||
Args:
|
||||
mcp_name: Stable identifier used as the storage key.
|
||||
server_url: The MCP server URL.
|
||||
storage: Optional pre-built ``MCPAuthStorage`` (created lazily otherwise).
|
||||
open_browser: Whether to open a browser for interactive auth. Set False
|
||||
for headless/CI to get a single actionable ``InteractiveAuthRequired``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mcp_name: str,
|
||||
server_url: str,
|
||||
storage: Optional[Any] = None,
|
||||
open_browser: bool = True,
|
||||
):
|
||||
self.mcp_name = mcp_name
|
||||
self.server_url = server_url
|
||||
self.open_browser = open_browser
|
||||
if storage is None:
|
||||
from .mcp_auth_storage import MCPAuthStorage
|
||||
storage = MCPAuthStorage()
|
||||
self.storage = storage
|
||||
|
||||
def get_valid_token(self) -> Optional[str]:
|
||||
"""Return a non-expired access token if one is stored, else None."""
|
||||
entry = self.storage.get_for_url(self.mcp_name, self.server_url)
|
||||
if not entry or not entry.get("tokens"):
|
||||
return None
|
||||
if self.storage.is_token_expired(self.mcp_name):
|
||||
refreshed = self._try_refresh(entry)
|
||||
if not refreshed:
|
||||
return None
|
||||
entry = self.storage.get_for_url(self.mcp_name, self.server_url) or {}
|
||||
tokens = entry.get("tokens") or {}
|
||||
return tokens.get("access_token")
|
||||
|
||||
def ensure_authenticated(
|
||||
self,
|
||||
www_authenticate: Optional[str] = None,
|
||||
timeout: float = 10.0,
|
||||
) -> str:
|
||||
"""
|
||||
Ensure a valid access token exists, running the full flow if needed.
|
||||
|
||||
Returns:
|
||||
A valid bearer access token.
|
||||
|
||||
Raises:
|
||||
InteractiveAuthRequired: If interactive auth is needed but disabled.
|
||||
RuntimeError: If discovery fails or the flow cannot complete.
|
||||
"""
|
||||
token = self.get_valid_token()
|
||||
if token:
|
||||
return token
|
||||
|
||||
from .oauth_discovery import discover_oauth_metadata
|
||||
|
||||
metadata = discover_oauth_metadata(
|
||||
self.server_url,
|
||||
www_authenticate=www_authenticate,
|
||||
timeout=timeout,
|
||||
)
|
||||
if not metadata:
|
||||
raise RuntimeError(
|
||||
f"Could not discover OAuth metadata for {self.server_url}. "
|
||||
"The server may not advertise standard authorization-server "
|
||||
"metadata; supply client credentials manually."
|
||||
)
|
||||
|
||||
client_info = self._ensure_client(metadata, timeout=timeout)
|
||||
|
||||
scope = None
|
||||
scopes = metadata.get("scopes_supported")
|
||||
if scopes:
|
||||
scope = " ".join(scopes)
|
||||
|
||||
return self._run_pkce_flow(metadata, client_info, scope, timeout=timeout)
|
||||
|
||||
def _ensure_client(
|
||||
self,
|
||||
metadata: Dict[str, Any],
|
||||
timeout: float,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return stored client info or perform dynamic client registration."""
|
||||
entry = self.storage.get_for_url(self.mcp_name, self.server_url)
|
||||
if entry and entry.get("client_info", {}).get("client_id"):
|
||||
return entry["client_info"]
|
||||
|
||||
registration_endpoint = metadata.get("registration_endpoint")
|
||||
if not registration_endpoint:
|
||||
raise RuntimeError(
|
||||
f"No client is registered for {self.server_url} and the "
|
||||
"authorization server does not support dynamic client "
|
||||
"registration (no registration_endpoint)."
|
||||
)
|
||||
|
||||
from .oauth_registration import register_client
|
||||
|
||||
scope = None
|
||||
scopes = metadata.get("scopes_supported")
|
||||
if scopes:
|
||||
scope = " ".join(scopes)
|
||||
|
||||
client_info = register_client(
|
||||
registration_endpoint,
|
||||
redirect_uris=[get_redirect_url()],
|
||||
scope=scope,
|
||||
timeout=timeout,
|
||||
)
|
||||
self.storage.set_client_info(
|
||||
self.mcp_name, client_info, server_url=self.server_url
|
||||
)
|
||||
return client_info
|
||||
|
||||
def _run_pkce_flow(
|
||||
self,
|
||||
metadata: Dict[str, Any],
|
||||
client_info: Dict[str, Any],
|
||||
scope: Optional[str],
|
||||
timeout: float,
|
||||
) -> str:
|
||||
"""Run the interactive PKCE authorization-code flow and store tokens."""
|
||||
if not self.open_browser:
|
||||
raise InteractiveAuthRequired(
|
||||
f"Interactive OAuth is required to authenticate with "
|
||||
f"{self.server_url}. Run this from an interactive session, or "
|
||||
f"pre-supply tokens, to complete authentication."
|
||||
)
|
||||
|
||||
callback = OAuthCallbackHandler()
|
||||
state = generate_state()
|
||||
code_verifier = generate_code_verifier()
|
||||
code_challenge = generate_code_challenge(code_verifier)
|
||||
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": client_info["client_id"],
|
||||
"redirect_uri": get_redirect_url(),
|
||||
"state": state,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
if scope:
|
||||
params["scope"] = scope
|
||||
if metadata.get("resource"):
|
||||
params["resource"] = metadata["resource"]
|
||||
|
||||
auth_url = f"{metadata['authorization_endpoint']}?{urlencode(params)}"
|
||||
|
||||
server = _make_callback_server(callback, OAUTH_CALLBACK_PORT)
|
||||
thread = Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
logger.debug(f"Opening browser for OAuth authorization: {auth_url}")
|
||||
webbrowser.open(auth_url)
|
||||
code = callback.wait_for_callback(state, timeout=300.0)
|
||||
finally:
|
||||
server.shutdown()
|
||||
|
||||
tokens = self._exchange_code(
|
||||
metadata, client_info, code, code_verifier, timeout=timeout
|
||||
)
|
||||
self.storage.set_tokens(
|
||||
self.mcp_name, tokens, server_url=self.server_url
|
||||
)
|
||||
return tokens["access_token"]
|
||||
|
||||
def _exchange_code(
|
||||
self,
|
||||
metadata: Dict[str, Any],
|
||||
client_info: Dict[str, Any],
|
||||
code: str,
|
||||
code_verifier: str,
|
||||
timeout: float,
|
||||
) -> Dict[str, Any]:
|
||||
"""Exchange an authorization code for tokens at the token endpoint."""
|
||||
import requests # lazy import — optional dependency
|
||||
|
||||
data = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": get_redirect_url(),
|
||||
"client_id": client_info["client_id"],
|
||||
"code_verifier": code_verifier,
|
||||
}
|
||||
if client_info.get("client_secret"):
|
||||
data["client_secret"] = client_info["client_secret"]
|
||||
if metadata.get("resource"):
|
||||
data["resource"] = metadata["resource"]
|
||||
|
||||
resp = requests.post(
|
||||
metadata["token_endpoint"],
|
||||
data=data,
|
||||
headers={"Accept": "application/json"},
|
||||
timeout=timeout,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return self._normalize_tokens(
|
||||
resp.json(), token_endpoint=metadata["token_endpoint"]
|
||||
)
|
||||
|
||||
def _try_refresh(self, entry: Dict[str, Any]) -> bool:
|
||||
"""Attempt a refresh-token grant; return True on success."""
|
||||
tokens = entry.get("tokens") or {}
|
||||
refresh_token = tokens.get("refresh_token")
|
||||
token_endpoint = tokens.get("token_endpoint")
|
||||
client_info = entry.get("client_info") or {}
|
||||
client_id = client_info.get("client_id")
|
||||
if not (refresh_token and token_endpoint and client_id):
|
||||
return False
|
||||
|
||||
try:
|
||||
from .oauth_discovery import _require_https
|
||||
|
||||
# Defense in depth: never POST a refresh token / client secret to a
|
||||
# persisted endpoint that isn't HTTPS (or loopback).
|
||||
_require_https(token_endpoint)
|
||||
|
||||
import requests # lazy import — optional dependency
|
||||
|
||||
data = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"client_id": client_id,
|
||||
}
|
||||
if client_info.get("client_secret"):
|
||||
data["client_secret"] = client_info["client_secret"]
|
||||
|
||||
resp = requests.post(
|
||||
token_endpoint,
|
||||
data=data,
|
||||
headers={"Accept": "application/json"},
|
||||
timeout=10.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
new_tokens = self._normalize_tokens(
|
||||
resp.json(), token_endpoint=token_endpoint
|
||||
)
|
||||
# Preserve refresh_token if the server didn't return a new one.
|
||||
if not new_tokens.get("refresh_token"):
|
||||
new_tokens["refresh_token"] = refresh_token
|
||||
self.storage.set_tokens(
|
||||
self.mcp_name, new_tokens, server_url=self.server_url
|
||||
)
|
||||
return True
|
||||
except Exception as exc: # noqa: BLE001 - refresh is best-effort
|
||||
logger.debug(f"Token refresh failed for {self.mcp_name}: {exc}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _normalize_tokens(
|
||||
raw: Dict[str, Any],
|
||||
token_endpoint: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Normalise a token response, computing an absolute ``expires_at``."""
|
||||
tokens = dict(raw)
|
||||
expires_in = tokens.get("expires_in")
|
||||
if expires_in and not tokens.get("expires_at"):
|
||||
try:
|
||||
tokens["expires_at"] = time.time() + float(expires_in)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if token_endpoint:
|
||||
tokens["token_endpoint"] = token_endpoint
|
||||
return tokens
|
||||
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
MCP OAuth Dynamic Client Registration (RFC 7591).
|
||||
|
||||
Performs an anonymous dynamic client registration against an authorization
|
||||
server's ``registration_endpoint`` so that connecting to a hosted MCP server by
|
||||
URL requires no pre-provisioned ``client_id``. The registered client info is
|
||||
returned for persistence via :meth:`MCPAuthStorage.set_client_info`.
|
||||
|
||||
``requests`` is imported lazily to keep this module additive.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from praisonaiagents._logging import get_logger
|
||||
|
||||
logger = get_logger("mcp-oauth-registration")
|
||||
|
||||
_LOCALHOST_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
||||
|
||||
|
||||
def _require_https(url: str) -> None:
|
||||
"""Reject non-HTTPS registration URLs (loopback HTTP allowed for testing)."""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme == "https":
|
||||
return
|
||||
if parsed.scheme == "http" and (parsed.hostname or "") in _LOCALHOST_HOSTS:
|
||||
return
|
||||
raise ValueError(
|
||||
f"Refusing to register client over insecure transport: {url!r}"
|
||||
)
|
||||
|
||||
|
||||
def register_client(
|
||||
registration_endpoint: str,
|
||||
redirect_uris: List[str],
|
||||
client_name: str = "PraisonAI Agent",
|
||||
scope: Optional[str] = None,
|
||||
timeout: float = 10.0,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Register a public OAuth client via RFC 7591 Dynamic Client Registration.
|
||||
|
||||
Args:
|
||||
registration_endpoint: The ``registration_endpoint`` from auth-server
|
||||
metadata.
|
||||
redirect_uris: Allowed redirect URIs (the loopback callback URL).
|
||||
client_name: Human-readable client name.
|
||||
scope: Optional space-delimited scope string to request.
|
||||
timeout: Request timeout in seconds.
|
||||
|
||||
Returns:
|
||||
The registration response containing at least ``client_id`` (and
|
||||
``client_secret`` for confidential clients).
|
||||
|
||||
Raises:
|
||||
ValueError: If the endpoint is insecure or the response lacks a
|
||||
``client_id``.
|
||||
"""
|
||||
_require_https(registration_endpoint)
|
||||
import requests # lazy import — optional dependency
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"client_name": client_name,
|
||||
"redirect_uris": redirect_uris,
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"token_endpoint_auth_method": "none",
|
||||
}
|
||||
if scope:
|
||||
payload["scope"] = scope
|
||||
|
||||
resp = requests.post(
|
||||
registration_endpoint,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
||||
timeout=timeout,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
client_info = resp.json()
|
||||
|
||||
if not client_info.get("client_id"):
|
||||
raise ValueError(
|
||||
"Dynamic client registration response missing 'client_id'"
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Registered OAuth client '{client_info.get('client_id')}' "
|
||||
f"at {registration_endpoint}"
|
||||
)
|
||||
return client_info
|
||||
@@ -26,6 +26,7 @@ from .registry import (
|
||||
list_memory_adapters,
|
||||
get_first_available_memory_adapter,
|
||||
has_memory_adapter,
|
||||
resolve_memory_adapter_name,
|
||||
)
|
||||
from .factories import (
|
||||
create_mem0_memory_adapter,
|
||||
@@ -53,4 +54,5 @@ __all__ = [
|
||||
'list_memory_adapters',
|
||||
'get_first_available_memory_adapter',
|
||||
'has_memory_adapter',
|
||||
'resolve_memory_adapter_name',
|
||||
]
|
||||
@@ -371,6 +371,39 @@ class ChromaMemoryAdapter:
|
||||
sanitized[k] = str(v)
|
||||
return sanitized
|
||||
|
||||
def _reset_collection(self) -> None:
|
||||
"""Delete and recreate this adapter's collection, wiping stored memory.
|
||||
|
||||
Short-term and long-term share one collection in this adapter, so both
|
||||
resets route here. Scoped to this adapter's own collection to avoid
|
||||
wiping other collections in the shared persistent store.
|
||||
"""
|
||||
try:
|
||||
self.client.delete_collection(name=self.collection_name)
|
||||
except Exception:
|
||||
pass
|
||||
self.collection = self.client.create_collection(
|
||||
name=self.collection_name,
|
||||
metadata={"hnsw:space": "cosine"}
|
||||
)
|
||||
|
||||
def reset_short_term(self) -> None:
|
||||
"""Clear this adapter's memory.
|
||||
|
||||
NOTE: this adapter stores short-term and long-term in a single shared
|
||||
collection (``store_short_term``/``search_short_term`` delegate to their
|
||||
long-term counterparts and no ``memory_type`` tag is recorded), so
|
||||
per-tier scoping is not possible here — resetting short-term also clears
|
||||
long-term. Use a tier-aware backend (e.g. the Dakera adapter) if you need
|
||||
to erase a single tier.
|
||||
"""
|
||||
self._reset_collection()
|
||||
|
||||
def reset_long_term(self) -> None:
|
||||
"""Clear this adapter's memory (shared with short-term; see
|
||||
``reset_short_term`` for the shared-collection caveat)."""
|
||||
self._reset_collection()
|
||||
|
||||
def close(self):
|
||||
"""Clean up ChromaDB resources."""
|
||||
# ChromaDB PersistentClient has no explicit close; release references.
|
||||
|
||||
@@ -77,6 +77,17 @@ def has_memory_adapter(name: str) -> bool:
|
||||
return get_default_memory_registry().is_available(name)
|
||||
|
||||
|
||||
#: Accepted spellings for a backend, mapped to the adapter that serves them.
|
||||
#: Single source of truth for MemoryConfig, Agent and Memory.
|
||||
MEMORY_PROVIDER_ALIASES = {"rag": "chroma", "chromadb": "chroma", "none": "in_memory"}
|
||||
|
||||
|
||||
def resolve_memory_adapter_name(name: str) -> Optional[str]:
|
||||
"""Adapter name for *name*, or None if nothing is registered under it."""
|
||||
candidate = MEMORY_PROVIDER_ALIASES.get(name, name)
|
||||
return candidate if has_memory_adapter(candidate) else None
|
||||
|
||||
|
||||
def get_first_available_memory_adapter(
|
||||
preferences: Optional[List[str]] = None, **kwargs
|
||||
) -> Optional[Tuple[str, MemoryProtocol]]:
|
||||
@@ -107,4 +118,6 @@ __all__ = [
|
||||
'add_memory_adapter',
|
||||
'add_memory_factory',
|
||||
'has_memory_adapter',
|
||||
'resolve_memory_adapter_name',
|
||||
'MEMORY_PROVIDER_ALIASES',
|
||||
]
|
||||
@@ -2,6 +2,7 @@ import os
|
||||
import sqlite3
|
||||
import json
|
||||
import time
|
||||
import asyncio
|
||||
import shutil
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional, Union, Literal
|
||||
@@ -136,16 +137,21 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
provider = self.cfg.get("provider", "rag").lower()
|
||||
self._log_verbose(f"Requested memory provider: {provider}")
|
||||
|
||||
# Map legacy provider names to adapter names
|
||||
provider_mapping = {
|
||||
"rag": "chroma", # Legacy "rag" provider uses ChromaDB
|
||||
"mem0": "mem0",
|
||||
"mongodb": "mongodb",
|
||||
"sqlite": "sqlite",
|
||||
"none": "in_memory"
|
||||
}
|
||||
|
||||
adapter_name = provider_mapping.get(provider, provider)
|
||||
# Legacy provider aliases live in memory/adapters/registry.py
|
||||
from .adapters.registry import (
|
||||
MEMORY_PROVIDER_ALIASES, list_memory_adapters, resolve_memory_adapter_name)
|
||||
|
||||
# A provider with no adapter must fail loudly: falling through landed
|
||||
# on the SQLite fallback below, so provider="redis" silently produced a
|
||||
# local .db file per process.
|
||||
adapter_name = resolve_memory_adapter_name(provider)
|
||||
if adapter_name is None:
|
||||
from ..config.parse_utils import make_preset_error
|
||||
raise make_preset_error(
|
||||
"memory provider",
|
||||
provider,
|
||||
sorted(set(list_memory_adapters()) | set(MEMORY_PROVIDER_ALIASES)),
|
||||
)
|
||||
|
||||
# Try to get preferred adapter, fallback to available ones
|
||||
adapter = None
|
||||
@@ -195,8 +201,15 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
self.provider = adapter_name
|
||||
return
|
||||
|
||||
# Fallback to first available adapter
|
||||
self._log_verbose(f"Provider '{adapter_name}' not available, trying fallbacks")
|
||||
# Falling back changes where the data lives - say so out loud when
|
||||
# the user named a specific provider.
|
||||
_fallback_log = (
|
||||
logger.warning if provider_explicitly_requested else self._log_verbose
|
||||
)
|
||||
_fallback_log(
|
||||
f"Memory provider '{adapter_name}' is not available; falling back "
|
||||
f"to a local store. Data will NOT be in '{adapter_name}'."
|
||||
)
|
||||
# Try each fallback preference individually
|
||||
for fallback_provider in ["sqlite", "in_memory"]:
|
||||
try:
|
||||
@@ -204,7 +217,7 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
adapter = get_memory_adapter(fallback_provider, **fallback_config)
|
||||
if adapter:
|
||||
adapter_name = fallback_provider
|
||||
self._log_verbose(f"Using fallback adapter: {adapter_name}")
|
||||
_fallback_log(f"Using fallback memory adapter: {adapter_name}")
|
||||
break
|
||||
except Exception as e:
|
||||
self._log_verbose(f"Fallback {fallback_provider} failed: {e}")
|
||||
@@ -267,6 +280,27 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
# Initialize use_vector_search to False for non-MongoDB providers
|
||||
self.use_vector_search = False
|
||||
|
||||
@staticmethod
|
||||
def _path_safe_suffix(user_id: Any) -> str:
|
||||
"""Return a filesystem-safe ``_<user_id>`` suffix for default store paths.
|
||||
|
||||
The scoping ``user_id`` doubles as a metadata filter, so it may be any
|
||||
string. When it is embedded into default sqlite/chroma paths a raw value
|
||||
containing separators or traversal (e.g. ``a/b`` or ``../escape``) would
|
||||
create stores outside the project-data dir or break sqlite init. Reject
|
||||
path separators/parent refs and keep only path-safe characters; a bare
|
||||
store with no user_id keeps the original shared defaults.
|
||||
"""
|
||||
if not user_id:
|
||||
return ""
|
||||
text = str(user_id)
|
||||
if ".." in text or "/" in text or "\\" in text or "\x00" in text:
|
||||
raise ValueError(
|
||||
"memory user_id must not contain path separators or parent references"
|
||||
)
|
||||
safe = "".join(c if (c.isalnum() or c in "-_.") else "_" for c in text.strip())
|
||||
return f"_{safe}" if safe else ""
|
||||
|
||||
def _get_adapter_config(self) -> Dict[str, Any]:
|
||||
"""Get configuration for adapter initialization."""
|
||||
# Only include adapter-relevant configuration
|
||||
@@ -284,11 +318,18 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
from ..paths import get_project_data_dir
|
||||
project_data = str(get_project_data_dir())
|
||||
os.makedirs(project_data, exist_ok=True)
|
||||
|
||||
config["short_db"] = self.cfg.get("short_db", os.path.join(project_data, "short_term.db"))
|
||||
config["long_db"] = self.cfg.get("long_db", os.path.join(project_data, "long_term.db"))
|
||||
config["rag_db_path"] = self.cfg.get("rag_db_path", os.path.join(project_data, "chroma_db"))
|
||||
config["collection_name"] = self.cfg.get("collection_name", "memory_store")
|
||||
|
||||
# When a user_id scopes this store, derive per-user default paths/names so
|
||||
# two agents in one process/dir don't silently share one on-disk store.
|
||||
# Explicit short_db/long_db/rag_db_path/collection_name always win; a bare
|
||||
# store with no user_id keeps the original shared defaults (backwards
|
||||
# compatible).
|
||||
suffix = self._path_safe_suffix(self.cfg.get("user_id"))
|
||||
|
||||
config["short_db"] = self.cfg.get("short_db", os.path.join(project_data, f"short_term{suffix}.db"))
|
||||
config["long_db"] = self.cfg.get("long_db", os.path.join(project_data, f"long_term{suffix}.db"))
|
||||
config["rag_db_path"] = self.cfg.get("rag_db_path", os.path.join(project_data, f"chroma_db{suffix}"))
|
||||
config["collection_name"] = self.cfg.get("collection_name", f"memory_store{suffix}")
|
||||
config["verbose"] = self.verbose
|
||||
|
||||
# Add specific configurations for different adapters
|
||||
@@ -325,9 +366,13 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
# This ensures existing code that accesses _get_stm_conn() still works
|
||||
from ..paths import get_project_data_dir
|
||||
project_data = str(get_project_data_dir())
|
||||
|
||||
self.short_db = self.cfg.get("short_db", os.path.join(project_data, "short_term.db"))
|
||||
self.long_db = self.cfg.get("long_db", os.path.join(project_data, "long_term.db"))
|
||||
|
||||
# Scope legacy direct-SQLite defaults by user_id so this path matches the
|
||||
# per-user store paths derived in _get_adapter_config (explicit paths win).
|
||||
suffix = self._path_safe_suffix(self.cfg.get("user_id"))
|
||||
|
||||
self.short_db = self.cfg.get("short_db", os.path.join(project_data, f"short_term{suffix}.db"))
|
||||
self.long_db = self.cfg.get("long_db", os.path.join(project_data, f"long_term{suffix}.db"))
|
||||
|
||||
# Only create separate SQLite adapter if primary adapter is not SQLite
|
||||
if self.provider != "sqlite":
|
||||
@@ -346,6 +391,110 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
self._all_connections = self._sqlite_adapter._all_connections
|
||||
self._connection_lock = self._sqlite_adapter._connection_lock
|
||||
|
||||
@staticmethod
|
||||
def _resolve_batch_target(method_name: str) -> str:
|
||||
"""Map a staged write name to its ``store_*`` persistence method."""
|
||||
target = method_name.replace("add_", "store_", 1)
|
||||
if target not in ("store_short_term", "store_long_term"):
|
||||
raise ValueError(f"Unsupported staged memory write {method_name!r}")
|
||||
return target
|
||||
|
||||
def commit_memory_batch(
|
||||
self,
|
||||
writes: List[tuple],
|
||||
*,
|
||||
deadline: float,
|
||||
commit_guard: Any = None,
|
||||
) -> None:
|
||||
"""Persist a batch of staged memory writes.
|
||||
|
||||
Mirrors ``FileMemory.commit_memory_batch`` so the pre-compaction fact
|
||||
flush (compaction/memory_flush.py) durably commits extracted facts for
|
||||
sqlite/chroma/mongodb/mem0 backends instead of raising and being
|
||||
swallowed. Each staged tuple is ``(method_name, args, kwargs)`` produced
|
||||
by _StagedMemory; ``add_*`` names map to the ``store_*`` equivalents.
|
||||
|
||||
The ``store_*`` methods serialize their own SQLite writes with
|
||||
``_write_lock``; this method therefore must NOT hold that lock while
|
||||
calling them (it is a non-reentrant ``threading.Lock`` and doing so
|
||||
would deadlock). Each write goes through ``commit_guard.commit`` so a
|
||||
write is skipped once the flush has been cancelled or the shared
|
||||
``deadline`` has passed, honoring the same atomic-commit contract as the
|
||||
file backend.
|
||||
"""
|
||||
for method_name, args, kwargs in writes:
|
||||
target = self._resolve_batch_target(method_name)
|
||||
store = getattr(self, target)
|
||||
authorized = self._authorize_commit(
|
||||
lambda store=store, args=args, kwargs=kwargs: store(*args, **kwargs),
|
||||
deadline=deadline,
|
||||
commit_guard=commit_guard,
|
||||
)
|
||||
if not authorized:
|
||||
return
|
||||
|
||||
async def acommit_memory_batch(
|
||||
self,
|
||||
writes: List[tuple],
|
||||
*,
|
||||
deadline: float,
|
||||
commit_guard: Any = None,
|
||||
) -> None:
|
||||
"""Async variant preferring ``store_*_async`` when available.
|
||||
|
||||
Blocking ``store_*`` work is offloaded to a worker thread so the event
|
||||
loop is never blocked, and each write is gated by the same deadline /
|
||||
cancellation guard as the sync path so late or cancelled facts are not
|
||||
persisted after compaction resumes.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
for method_name, args, kwargs in writes:
|
||||
if self._commit_expired(deadline, commit_guard):
|
||||
return
|
||||
target = self._resolve_batch_target(method_name)
|
||||
async_method = getattr(self, f"{target}_async", None)
|
||||
if async_method is not None:
|
||||
if self._commit_expired(deadline, commit_guard):
|
||||
return
|
||||
result = async_method(*args, **kwargs)
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
continue
|
||||
|
||||
store = getattr(self, target)
|
||||
authorized = await asyncio.to_thread(
|
||||
self._authorize_commit,
|
||||
lambda store=store, args=args, kwargs=kwargs: store(*args, **kwargs),
|
||||
deadline=deadline,
|
||||
commit_guard=commit_guard,
|
||||
)
|
||||
if not authorized:
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def _commit_expired(deadline: float, commit_guard: Any) -> bool:
|
||||
"""True once the flush was cancelled or the shared deadline has passed."""
|
||||
if commit_guard is not None and getattr(commit_guard, "cancelled", False):
|
||||
return True
|
||||
return time.monotonic() >= deadline
|
||||
|
||||
def _authorize_commit(
|
||||
self, operation: Any, *, deadline: float, commit_guard: Any
|
||||
) -> bool:
|
||||
"""Run ``operation`` under the guard's atomic-commit gate.
|
||||
|
||||
Returns ``False`` (and skips the write) once cancelled or past the
|
||||
deadline. When no guard is supplied, fall back to a plain deadline
|
||||
check so callers outside the flush path still behave correctly.
|
||||
"""
|
||||
if commit_guard is not None and hasattr(commit_guard, "commit"):
|
||||
return commit_guard.commit(operation, deadline=deadline)
|
||||
if self._commit_expired(deadline, commit_guard):
|
||||
return False
|
||||
operation()
|
||||
return True
|
||||
|
||||
def _get_stm_conn(self):
|
||||
"""Get thread-local short-term memory SQLite connection."""
|
||||
if not hasattr(self._local, 'stm_conn') or self._local.stm_conn is None:
|
||||
@@ -814,6 +963,11 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
if getattr(self, "memory_adapter", None):
|
||||
if hasattr(self.memory_adapter, "reset_short_term"):
|
||||
self.memory_adapter.reset_short_term()
|
||||
else:
|
||||
logger.warning(
|
||||
f"{type(self.memory_adapter).__name__} does not support "
|
||||
"reset_short_term(); short-term memory was NOT cleared"
|
||||
)
|
||||
return
|
||||
conn = self._get_stm_conn()
|
||||
with self._write_lock: # Serialize write operations
|
||||
@@ -1199,6 +1353,11 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
if getattr(self, "memory_adapter", None):
|
||||
if hasattr(self.memory_adapter, "reset_long_term"):
|
||||
self.memory_adapter.reset_long_term()
|
||||
else:
|
||||
logger.warning(
|
||||
f"{type(self.memory_adapter).__name__} does not support "
|
||||
"reset_long_term(); long-term memory was NOT cleared"
|
||||
)
|
||||
return
|
||||
conn = self._get_ltm_conn()
|
||||
with self._write_lock: # Serialize write operations
|
||||
@@ -1735,13 +1894,17 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
into a single text block with deduplication and clean formatting.
|
||||
|
||||
Args:
|
||||
include_in_output: If None, memory content is only included when debug logging is enabled.
|
||||
If True, memory content is always included.
|
||||
If False, memory content is never included (only logged for debugging).
|
||||
include_in_output: If None, memory content is included (the primary
|
||||
purpose of this method is to feed retrieved memory
|
||||
into the prompt). If True, memory content is always
|
||||
included. If False, memory content is never included
|
||||
(only logged for debugging).
|
||||
"""
|
||||
# Determine whether to include memory content in output based on logging level
|
||||
# Include retrieved memory by default. Gating this on DEBUG-level logging
|
||||
# meant Agent(memory=True) silently retrieved hits but never appended them
|
||||
# to the prompt under normal usage.
|
||||
if include_in_output is None:
|
||||
include_in_output = get_logger().getEffectiveLevel() == logging.DEBUG
|
||||
include_in_output = True
|
||||
|
||||
q = (task_descr + " " + additional).strip()
|
||||
lines = []
|
||||
@@ -1911,6 +2074,30 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
|
||||
return result
|
||||
|
||||
def get_context(self, query: Optional[str] = None, **kwargs) -> str:
|
||||
"""
|
||||
Return retrieved memory as a prompt-ready context string.
|
||||
|
||||
Satisfies ``AgentMemoryProtocol`` (used by ``Agent.get_memory_context()``)
|
||||
so the non-prompt-caching code path can inject memory into the system
|
||||
prompt. Without this, ``hasattr(memory, 'get_context')`` was False and the
|
||||
fallback branch always returned "".
|
||||
"""
|
||||
# A missing/empty query would make the local fallback search match every
|
||||
# record (LIKE "%%") with no relevance ordering, leaking unrelated (and
|
||||
# possibly other users') memories into the prompt. Retrieve nothing until
|
||||
# there is a meaningful query.
|
||||
if not query or not str(query).strip():
|
||||
return ""
|
||||
# Propagate the effective user id so user-scoped memories are included and
|
||||
# cross-user records are not returned by a shared Memory instance.
|
||||
user_id = kwargs.get("user_id") or self.cfg.get("user_id")
|
||||
return self.build_context_for_task(
|
||||
task_descr=query,
|
||||
user_id=user_id,
|
||||
include_in_output=True,
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Master Reset (Everything)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@@ -257,6 +257,26 @@ class _LazyObsModule:
|
||||
obs = _LazyObsModule()
|
||||
|
||||
|
||||
# ``from praisonaiagents import obs`` returns *this submodule*, not the singleton
|
||||
# above (Python binds a real submodule as a package attribute, shadowing the
|
||||
# package ``__getattr__``). Make the submodule object itself callable and proxy
|
||||
# provider lookups to the singleton so ``obs(...)``, ``obs.auto()`` and
|
||||
# ``obs.langfuse()`` all work regardless of how ``obs`` is imported.
|
||||
import sys as _sys
|
||||
from types import ModuleType as _ModuleType
|
||||
|
||||
|
||||
class _CallableObsModule(_ModuleType):
|
||||
def __call__(self, provider: str = "auto", **kwargs):
|
||||
return obs(provider, **kwargs)
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(obs, name)
|
||||
|
||||
|
||||
_sys.modules[__name__].__class__ = _CallableObsModule
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ObservabilityAdapter",
|
||||
"Span",
|
||||
|
||||
@@ -74,6 +74,10 @@ class Process:
|
||||
self._state_lock = asyncio.Lock()
|
||||
return self._state_lock
|
||||
|
||||
def _task_by_name(self, name):
|
||||
"""Return the task whose name matches, or None. First-match, mirrors prior inline lookup."""
|
||||
return next((t for t in self.tasks.values() if t.name == name), None)
|
||||
|
||||
def _create_llm_instance(self):
|
||||
"""Create and return a configured LLM instance for manager tasks."""
|
||||
from ..llm import LLM
|
||||
@@ -115,7 +119,7 @@ class Process:
|
||||
if current_task.previous_tasks:
|
||||
# For validation tasks, typically validate the most recent previous task
|
||||
prev_task_name = current_task.previous_tasks[-1]
|
||||
validated_task = next((t for t in self.tasks.values() if t.name == prev_task_name), None)
|
||||
validated_task = self._task_by_name(prev_task_name)
|
||||
elif current_task.context:
|
||||
# If no previous_tasks, check context for the validated task
|
||||
for ctx_task in reversed(current_task.context):
|
||||
@@ -348,6 +352,43 @@ class Process:
|
||||
task_id=loop_task.id
|
||||
)
|
||||
|
||||
def _build_routing_context(self, current_task: Task) -> Dict[str, Any]:
|
||||
"""Build a variable context for evaluating a task's `when` condition.
|
||||
|
||||
Combines the task's structured result (json_dict / pydantic fields) with
|
||||
the raw output so expressions like ``{{score}} > 80`` can resolve, and
|
||||
exposes the raw text as ``previous_output``.
|
||||
"""
|
||||
context: Dict[str, Any] = {}
|
||||
result = getattr(current_task, "result", None)
|
||||
if result is not None:
|
||||
try:
|
||||
context.update(result.to_dict())
|
||||
except Exception:
|
||||
pass
|
||||
context["previous_output"] = getattr(result, "raw", "")
|
||||
return context
|
||||
|
||||
def _has_when_routing(self, task: Task) -> bool:
|
||||
"""True if the task uses the unified when/then_task/else_task routing."""
|
||||
return task.when is not None or task.then_task is not None or task.else_task is not None
|
||||
|
||||
def _resolve_when_routing(self, current_task: Task) -> Optional[Task]:
|
||||
"""Resolve the next Task via the unified `when`/`then_task`/`else_task`
|
||||
routing on the current task. Returns None if no such routing applies."""
|
||||
if current_task.when is None and current_task.then_task is None and current_task.else_task is None:
|
||||
return None
|
||||
next_name = current_task.get_next_task(self._build_routing_context(current_task))
|
||||
if not next_name:
|
||||
return None
|
||||
next_task = next((t for t in self.tasks.values() if t.name == next_name), None)
|
||||
if next_task:
|
||||
next_task.status = "not started"
|
||||
logging.debug(f"Routing to {next_task.name} based on when-condition: {current_task.when}")
|
||||
else:
|
||||
logging.warning(f"when-routing target '{next_name}' not found among tasks")
|
||||
return next_task
|
||||
|
||||
def _build_task_context(self, current_task: Task) -> str:
|
||||
"""Build context for a task based on its retain_full_context setting"""
|
||||
# Check if we have validation feedback to include
|
||||
@@ -377,7 +418,7 @@ class Process:
|
||||
if current_task.retain_full_context:
|
||||
# Original behavior: include all previous tasks
|
||||
for prev_name in current_task.previous_tasks:
|
||||
prev_task = next((t for t in self.tasks.values() if t.name == prev_name), None)
|
||||
prev_task = self._task_by_name(prev_name)
|
||||
if prev_task and prev_task.result:
|
||||
context += f"\n{prev_name}: {prev_task.result.raw}"
|
||||
|
||||
@@ -391,7 +432,7 @@ class Process:
|
||||
if current_task.previous_tasks:
|
||||
# Get the most recent previous task (last in the list)
|
||||
prev_name = current_task.previous_tasks[-1]
|
||||
prev_task = next((t for t in self.tasks.values() if t.name == prev_name), None)
|
||||
prev_task = self._task_by_name(prev_name)
|
||||
if prev_task and prev_task.result:
|
||||
context += f"\n{prev_name}: {prev_task.result.raw}"
|
||||
|
||||
@@ -599,7 +640,7 @@ class Process:
|
||||
for task in self.tasks.values():
|
||||
if task.next_tasks:
|
||||
for next_task_name in task.next_tasks:
|
||||
next_task = next((t for t in self.tasks.values() if t.name == next_task_name), None)
|
||||
next_task = self._task_by_name(next_task_name)
|
||||
if next_task:
|
||||
next_task.previous_tasks.append(task.name)
|
||||
logging.debug(f"Added {task.name} as previous task for {next_task_name}")
|
||||
@@ -760,9 +801,9 @@ Subtask: {st.name}
|
||||
decision_str = None
|
||||
if current_task.result:
|
||||
if current_task.result.pydantic and hasattr(current_task.result.pydantic, "decision"):
|
||||
decision_str = current_task.result.pydantic.decision.lower()
|
||||
decision_str = current_task.result.pydantic.decision.lower().strip()
|
||||
elif current_task.result.raw:
|
||||
decision_str = current_task.result.raw.lower()
|
||||
decision_str = current_task.result.raw.lower().strip()
|
||||
|
||||
# For loop tasks, use "done" to follow condition path
|
||||
if current_task.task_type == "loop" and all(t.status == "completed" for t in subtasks):
|
||||
@@ -770,7 +811,7 @@ Subtask: {st.name}
|
||||
|
||||
target_tasks = current_task.condition.get(decision_str, []) if decision_str else []
|
||||
task_value = target_tasks[0] if isinstance(target_tasks, list) else target_tasks
|
||||
next_task = next((t for t in self.tasks.values() if t.name == task_value), None)
|
||||
next_task = self._task_by_name(task_value)
|
||||
if next_task:
|
||||
next_task.status = "not started" # Reset status to allow execution
|
||||
logging.debug(f"Routing to {next_task.name} based on decision: {decision_str}")
|
||||
@@ -799,7 +840,7 @@ Subtask: {st.name}
|
||||
logging.debug(f"No input file, marking {current_task.name} as completed")
|
||||
if current_task.next_tasks:
|
||||
next_task_name = current_task.next_tasks[0]
|
||||
next_task = next((t for t in self.tasks.values() if t.name == next_task_name), None)
|
||||
next_task = self._task_by_name(next_task_name)
|
||||
current_task = next_task
|
||||
else:
|
||||
current_task = None
|
||||
@@ -811,8 +852,8 @@ Subtask: {st.name}
|
||||
yield task_id
|
||||
visited_tasks.add(task_id)
|
||||
|
||||
# Only end workflow if no next_tasks AND no conditions
|
||||
if not current_task.next_tasks and not current_task.condition and not any(
|
||||
# Only end workflow if no next_tasks AND no conditions AND no when-routing
|
||||
if not current_task.next_tasks and not current_task.condition and not self._has_when_routing(current_task) and not any(
|
||||
t.task_type == "loop" and current_task.name.startswith(t.name + "_")
|
||||
for t in self.tasks.values()
|
||||
):
|
||||
@@ -853,9 +894,9 @@ Subtask: {st.name}
|
||||
if current_task and current_task.result:
|
||||
if current_task.task_type in ["decision", "loop"]:
|
||||
# Get decision from pydantic or raw response
|
||||
decision_str = current_task.result.raw.lower()
|
||||
decision_str = current_task.result.raw.lower().strip()
|
||||
if current_task.result.pydantic and hasattr(current_task.result.pydantic, "decision"):
|
||||
decision_str = current_task.result.pydantic.decision.lower()
|
||||
decision_str = current_task.result.pydantic.decision.lower().strip()
|
||||
|
||||
# Check if task has conditions and next_tasks
|
||||
if current_task.condition:
|
||||
@@ -870,7 +911,7 @@ Subtask: {st.name}
|
||||
else:
|
||||
# Find the target task by name
|
||||
task_value = target_tasks[0] if isinstance(target_tasks, list) else target_tasks
|
||||
next_task = next((t for t in self.tasks.values() if t.name == task_value), None)
|
||||
next_task = self._task_by_name(task_value)
|
||||
if next_task:
|
||||
next_task.status = "not started" # Reset status to allow execution
|
||||
|
||||
@@ -899,10 +940,26 @@ Subtask: {st.name}
|
||||
# Don't mark workflow as finished when following condition path
|
||||
await self._set_workflow_finished(False)
|
||||
|
||||
# Unified condition routing (when/then_task/else_task) takes priority
|
||||
# over next_tasks when configured on the current task.
|
||||
when_routing_exhausted = False
|
||||
if not next_task and current_task:
|
||||
if self._has_when_routing(current_task):
|
||||
when_task = self._resolve_when_routing(current_task)
|
||||
if when_task:
|
||||
next_task = when_task
|
||||
await self._set_workflow_finished(False)
|
||||
elif not current_task.next_tasks:
|
||||
# when-routing is authoritative: it resolved to no target
|
||||
# (e.g. the taken branch has no then/else_task) and there
|
||||
# is no next_tasks fallback, so this path ends cleanly
|
||||
# instead of picking an unrelated not-started task.
|
||||
when_routing_exhausted = True
|
||||
|
||||
# If no condition-based routing, use next_tasks
|
||||
if not next_task and current_task and current_task.next_tasks:
|
||||
next_task_name = current_task.next_tasks[0]
|
||||
next_task = next((t for t in self.tasks.values() if t.name == next_task_name), None)
|
||||
next_task = self._task_by_name(next_task_name)
|
||||
if next_task:
|
||||
# Reset the next task to allow re-execution
|
||||
next_task.status = "not started"
|
||||
@@ -915,7 +972,7 @@ Subtask: {st.name}
|
||||
logging.debug(f"Following next_tasks to {next_task.name}")
|
||||
|
||||
current_task = next_task
|
||||
if not current_task:
|
||||
if not current_task and not when_routing_exhausted:
|
||||
current_task = self._find_next_not_started_task() # General fallback if no next task in workflow
|
||||
|
||||
|
||||
@@ -1166,7 +1223,7 @@ Provide a JSON with the structure:
|
||||
for task in self.tasks.values():
|
||||
if task.next_tasks:
|
||||
for next_task_name in task.next_tasks:
|
||||
next_task = next((t for t in self.tasks.values() if t.name == next_task_name), None)
|
||||
next_task = self._task_by_name(next_task_name)
|
||||
if next_task:
|
||||
next_task.previous_tasks.append(task.name)
|
||||
|
||||
@@ -1351,9 +1408,9 @@ Subtask: {st.name}
|
||||
decision_str = None
|
||||
if current_task.result:
|
||||
if current_task.result.pydantic and hasattr(current_task.result.pydantic, "decision"):
|
||||
decision_str = current_task.result.pydantic.decision.lower()
|
||||
decision_str = current_task.result.pydantic.decision.lower().strip()
|
||||
elif current_task.result.raw:
|
||||
decision_str = current_task.result.raw.lower()
|
||||
decision_str = current_task.result.raw.lower().strip()
|
||||
|
||||
# For loop tasks, use "done" to follow condition path
|
||||
if current_task.task_type == "loop" and all(t.status == "completed" for t in subtasks):
|
||||
@@ -1361,7 +1418,7 @@ Subtask: {st.name}
|
||||
|
||||
target_tasks = current_task.condition.get(decision_str, []) if decision_str else []
|
||||
task_value = target_tasks[0] if isinstance(target_tasks, list) else target_tasks
|
||||
next_task = next((t for t in self.tasks.values() if t.name == task_value), None)
|
||||
next_task = self._task_by_name(task_value)
|
||||
if next_task:
|
||||
next_task.status = "not started" # Reset status to allow execution
|
||||
logging.debug(f"Routing to {next_task.name} based on decision: {decision_str}")
|
||||
@@ -1390,7 +1447,7 @@ Subtask: {st.name}
|
||||
logging.debug(f"No input file, marking {current_task.name} as completed")
|
||||
if current_task.next_tasks:
|
||||
next_task_name = current_task.next_tasks[0]
|
||||
next_task = next((t for t in self.tasks.values() if t.name == next_task_name), None)
|
||||
next_task = self._task_by_name(next_task_name)
|
||||
current_task = next_task
|
||||
else:
|
||||
current_task = None
|
||||
@@ -1402,8 +1459,8 @@ Subtask: {st.name}
|
||||
yield task_id
|
||||
visited_tasks.add(task_id)
|
||||
|
||||
# Only end workflow if no next_tasks AND no conditions
|
||||
if not current_task.next_tasks and not current_task.condition and not any(
|
||||
# Only end workflow if no next_tasks AND no conditions AND no when-routing
|
||||
if not current_task.next_tasks and not current_task.condition and not self._has_when_routing(current_task) and not any(
|
||||
t.task_type == "loop" and current_task.name.startswith(t.name + "_")
|
||||
for t in self.tasks.values()
|
||||
):
|
||||
@@ -1442,9 +1499,9 @@ Subtask: {st.name}
|
||||
if current_task and current_task.result:
|
||||
if current_task.task_type in ["decision", "loop"]:
|
||||
# Get decision from pydantic or raw response
|
||||
decision_str = current_task.result.raw.lower()
|
||||
decision_str = current_task.result.raw.lower().strip()
|
||||
if current_task.result.pydantic and hasattr(current_task.result.pydantic, "decision"):
|
||||
decision_str = current_task.result.pydantic.decision.lower()
|
||||
decision_str = current_task.result.pydantic.decision.lower().strip()
|
||||
|
||||
# Check if task has conditions and next_tasks
|
||||
if current_task.condition:
|
||||
@@ -1459,7 +1516,7 @@ Subtask: {st.name}
|
||||
else:
|
||||
# Find the target task by name
|
||||
task_value = target_tasks[0] if isinstance(target_tasks, list) else target_tasks
|
||||
next_task = next((t for t in self.tasks.values() if t.name == task_value), None)
|
||||
next_task = self._task_by_name(task_value)
|
||||
if next_task:
|
||||
next_task.status = "not started" # Reset status to allow execution
|
||||
|
||||
@@ -1488,10 +1545,26 @@ Subtask: {st.name}
|
||||
# Don't mark workflow as finished when following condition path
|
||||
self._set_workflow_finished_sync(False)
|
||||
|
||||
# Unified condition routing (when/then_task/else_task) takes priority
|
||||
# over next_tasks when configured on the current task.
|
||||
when_routing_exhausted = False
|
||||
if not next_task and current_task:
|
||||
if self._has_when_routing(current_task):
|
||||
when_task = self._resolve_when_routing(current_task)
|
||||
if when_task:
|
||||
next_task = when_task
|
||||
self._set_workflow_finished_sync(False)
|
||||
elif not current_task.next_tasks:
|
||||
# when-routing is authoritative: it resolved to no target
|
||||
# (e.g. the taken branch has no then/else_task) and there
|
||||
# is no next_tasks fallback, so this path ends cleanly
|
||||
# instead of picking an unrelated not-started task.
|
||||
when_routing_exhausted = True
|
||||
|
||||
# If no condition-based routing, use next_tasks
|
||||
if not next_task and current_task and current_task.next_tasks:
|
||||
next_task_name = current_task.next_tasks[0]
|
||||
next_task = next((t for t in self.tasks.values() if t.name == next_task_name), None)
|
||||
next_task = self._task_by_name(next_task_name)
|
||||
if next_task:
|
||||
# Reset the next task to allow re-execution
|
||||
next_task.status = "not started"
|
||||
@@ -1504,7 +1577,7 @@ Subtask: {st.name}
|
||||
logging.debug(f"Following next_tasks to {next_task.name}")
|
||||
|
||||
current_task = next_task
|
||||
if not current_task:
|
||||
if not current_task and not when_routing_exhausted:
|
||||
current_task = self._find_next_not_started_task() # General fallback if no next task in workflow
|
||||
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ class RetrievalConfig:
|
||||
min_score: float = 0.0
|
||||
max_context_tokens: int = 4000
|
||||
rerank: bool = False
|
||||
rerank_model: Optional[str] = None
|
||||
hybrid: bool = False
|
||||
citations: bool = True
|
||||
citations_mode: CitationsMode = CitationsMode.APPEND
|
||||
@@ -88,6 +89,11 @@ class RetrievalConfig:
|
||||
exclude_glob: Optional[list] = None # Glob patterns to exclude
|
||||
path_filter: Optional[str] = None # Regex for path filtering
|
||||
|
||||
# Chunking configuration (applied when documents are ingested)
|
||||
chunking_strategy: str = "recursive"
|
||||
chunk_size: int = 512
|
||||
chunk_overlap: int = 50
|
||||
|
||||
# Vector store configuration
|
||||
vector_store_provider: str = "chroma"
|
||||
vector_store_config: Dict[str, Any] = field(default_factory=dict)
|
||||
@@ -176,6 +182,7 @@ class RetrievalConfig:
|
||||
"min_score": self.min_score,
|
||||
"max_context_tokens": self.max_context_tokens,
|
||||
"rerank": self.rerank,
|
||||
"rerank_model": self.rerank_model,
|
||||
"hybrid": self.hybrid,
|
||||
"citations": self.citations,
|
||||
"citations_mode": self.citations_mode.value if isinstance(self.citations_mode, CitationsMode) else self.citations_mode,
|
||||
@@ -188,6 +195,9 @@ class RetrievalConfig:
|
||||
"include_glob": self.include_glob,
|
||||
"exclude_glob": self.exclude_glob,
|
||||
"path_filter": self.path_filter,
|
||||
"chunking_strategy": self.chunking_strategy,
|
||||
"chunk_size": self.chunk_size,
|
||||
"chunk_overlap": self.chunk_overlap,
|
||||
"vector_store_provider": self.vector_store_provider,
|
||||
"vector_store_config": self.vector_store_config,
|
||||
"collection_name": self.collection_name,
|
||||
@@ -225,6 +235,7 @@ class RetrievalConfig:
|
||||
min_score=data.get("min_score", 0.0),
|
||||
max_context_tokens=data.get("max_context_tokens", 4000),
|
||||
rerank=data.get("rerank", False),
|
||||
rerank_model=data.get("rerank_model"),
|
||||
hybrid=data.get("hybrid", False),
|
||||
citations=data.get("citations", True),
|
||||
citations_mode=citations_mode,
|
||||
@@ -237,6 +248,9 @@ class RetrievalConfig:
|
||||
include_glob=data.get("include_glob"),
|
||||
exclude_glob=data.get("exclude_glob"),
|
||||
path_filter=data.get("path_filter"),
|
||||
chunking_strategy=data.get("chunking_strategy", "recursive"),
|
||||
chunk_size=data.get("chunk_size", 512),
|
||||
chunk_overlap=data.get("chunk_overlap", 50),
|
||||
vector_store_provider=data.get("vector_store_provider", "chroma"),
|
||||
vector_store_config=data.get("vector_store_config", {}),
|
||||
collection_name=data.get("collection_name"),
|
||||
@@ -281,7 +295,26 @@ class RetrievalConfig:
|
||||
|
||||
if self.rerank:
|
||||
config["reranker"] = {"enabled": True, "default_rerank": True}
|
||||
|
||||
if self.rerank_model:
|
||||
# mem0's RerankerConfig needs a provider to instantiate the
|
||||
# reranker; a config with only {"model": ...} is dropped by
|
||||
# Knowledge._prepare_mem0_config and the request is silently
|
||||
# lost. Derive the provider from a "provider/model" prefix
|
||||
# (e.g. "cohere/rerank-v3" -> provider "cohere") so the model
|
||||
# is actionable; fall back to the model string as provider.
|
||||
model = self.rerank_model
|
||||
provider = model.split("/", 1)[0] if "/" in model else model
|
||||
config["reranker"]["provider"] = provider
|
||||
config["reranker"]["config"] = {"model": model}
|
||||
|
||||
# Chunking must reach Knowledge, which otherwise hardcodes
|
||||
# recursive/512/50 and ignores the user's chunk settings entirely.
|
||||
config["chunker"] = {
|
||||
"type": self.chunking_strategy,
|
||||
"chunk_size": self.chunk_size,
|
||||
"chunk_overlap": self.chunk_overlap,
|
||||
}
|
||||
|
||||
return config
|
||||
|
||||
def to_rag_config(self) -> Dict[str, Any]:
|
||||
|
||||
@@ -78,6 +78,9 @@ KIND_TOOL_RESULT = "tool_result"
|
||||
KIND_APPROVAL = "approval"
|
||||
#: The loop iteration index, journalled so the cursor is restored exactly.
|
||||
KIND_ITERATION = "iteration"
|
||||
#: A verification/completion-gate check result: ``{"name", "success",
|
||||
#: "returncode", "command"/"path", "duration_seconds", "output_hash"}``.
|
||||
KIND_VERIFICATION = "verification"
|
||||
|
||||
VALID_KINDS = frozenset(
|
||||
{
|
||||
@@ -86,6 +89,7 @@ VALID_KINDS = frozenset(
|
||||
KIND_TOOL_RESULT,
|
||||
KIND_APPROVAL,
|
||||
KIND_ITERATION,
|
||||
KIND_VERIFICATION,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -106,7 +106,11 @@ class SandboxConfig:
|
||||
"""
|
||||
|
||||
sandbox_type: str = "subprocess"
|
||||
image: str = "python:3.11-slim"
|
||||
# Kept in step with the compute-side DockerCompute default. The two
|
||||
# implementations of "docker" disagreed -- 3.11 here, 3.12 there -- so
|
||||
# tools_run_on="docker" and run_in="docker" handed you different Pythons
|
||||
# for the same word. Nobody chose that; they drifted apart.
|
||||
image: str = "python:3.12-slim"
|
||||
working_dir: str = "/workspace"
|
||||
env: Dict[str, str] = field(default_factory=dict)
|
||||
resource_limits: ResourceLimits = field(default_factory=ResourceLimits)
|
||||
@@ -133,7 +137,7 @@ class SandboxConfig:
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def docker(cls, image: str = "python:3.11-slim") -> "SandboxConfig":
|
||||
def docker(cls, image: str = "python:3.12-slim") -> "SandboxConfig":
|
||||
"""Create a Docker sandbox configuration."""
|
||||
return cls(
|
||||
sandbox_type="docker",
|
||||
|
||||
@@ -29,7 +29,7 @@ class SandboxManager:
|
||||
Example:
|
||||
from praisonaiagents.sandbox import SandboxManager, SandboxConfig
|
||||
|
||||
config = SandboxConfig.docker("python:3.11-slim")
|
||||
config = SandboxConfig.docker("python:3.12-slim")
|
||||
manager = SandboxManager(config)
|
||||
result = await manager.run_code("print('Hello, World!')")
|
||||
"""
|
||||
@@ -79,10 +79,43 @@ class SandboxManager:
|
||||
f"Unknown sandbox type: {sandbox_type!r}. {e}"
|
||||
) from e
|
||||
|
||||
kwargs: Dict[str, Any] = {"config": self.config}
|
||||
if sandbox_type == "docker":
|
||||
# Not every backend takes a SandboxConfig: SSHSandbox and ModalSandbox
|
||||
# carry their own connection details instead, so passing config= to them
|
||||
# raised "unexpected keyword argument 'config'" before the sandbox was
|
||||
# ever built. Ask the constructor what it accepts.
|
||||
import inspect
|
||||
|
||||
params = inspect.signature(sandbox_cls.__init__).parameters
|
||||
takes = lambda name: name in params or any(
|
||||
p.kind is p.VAR_KEYWORD for p in params.values()
|
||||
)
|
||||
|
||||
kwargs: Dict[str, Any] = {}
|
||||
if takes("config"):
|
||||
kwargs["config"] = self.config
|
||||
if sandbox_type == "docker" and takes("image"):
|
||||
kwargs["image"] = self.config.image
|
||||
|
||||
# A required argument we cannot supply from a config object -- SSH needs
|
||||
# a host. Say so instead of raising TypeError from deep inside.
|
||||
missing = [
|
||||
name for name, prm in params.items()
|
||||
if name != "self"
|
||||
and prm.default is prm.empty
|
||||
and prm.kind not in (prm.VAR_POSITIONAL, prm.VAR_KEYWORD)
|
||||
and name not in kwargs
|
||||
]
|
||||
if missing:
|
||||
raise TypeError(
|
||||
f"The {sandbox_type!r} sandbox needs {', '.join(missing)}, which a "
|
||||
f"SandboxConfig cannot carry. Build it directly and pass the "
|
||||
f"object:\n"
|
||||
f" from praisonai_sandbox.{sandbox_type} import "
|
||||
f"{sandbox_cls.__name__}\n"
|
||||
f" sandbox = {sandbox_cls.__name__}("
|
||||
f"{'=..., '.join(missing)}=...)"
|
||||
)
|
||||
|
||||
sandbox = sandbox_cls(**kwargs)
|
||||
|
||||
is_available = getattr(sandbox, "is_available", True)
|
||||
|
||||
@@ -206,7 +206,7 @@ class SandboxProtocol(Protocol):
|
||||
|
||||
from praisonai_sandbox import DockerSandbox
|
||||
|
||||
sandbox = DockerSandbox(image="python:3.11-slim")
|
||||
sandbox = DockerSandbox(image="python:3.12-slim")
|
||||
result = await sandbox.execute("print('Hello, World!')")
|
||||
print(result.stdout)
|
||||
"""
|
||||
|
||||
@@ -7,6 +7,7 @@ other config.yaml content.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import copy
|
||||
from praisonaiagents._logging import get_logger
|
||||
import os
|
||||
import threading
|
||||
@@ -21,6 +22,11 @@ logger = get_logger(__name__)
|
||||
_HISTORY_FILE = "run_history.yaml"
|
||||
_LOCK_FILE = "config.schedules.lock"
|
||||
_MAX_HISTORY = 200
|
||||
# Hard cap on a single job's persisted per-run scratchpad. A watermark /
|
||||
# cursor / last-seen hash is tiny; this only exists so a runaway ``set_state``
|
||||
# cannot grow config.yaml unbounded. Oversized writes are rejected (prior
|
||||
# state kept) rather than silently truncated.
|
||||
_MAX_STATE_BYTES = 16 * 1024
|
||||
|
||||
class ConfigYamlScheduleStore:
|
||||
"""CRUD store backed by ``config.yaml``.
|
||||
@@ -41,6 +47,9 @@ class ConfigYamlScheduleStore:
|
||||
self._max_history = max_history
|
||||
self._lock = threading.RLock()
|
||||
self._jobs: Dict[str, ScheduleJob] = {}
|
||||
# Per-job durable scratchpad (cursors / watermarks / last-seen hash),
|
||||
# persisted under the ``job_state`` key alongside ``schedules``.
|
||||
self._job_state: Dict[str, Dict] = {}
|
||||
self._history: List[RunRecord] = []
|
||||
# In-memory record of leases we currently hold, so ``complete`` can
|
||||
# release them. Cross-process leases live in the persisted job
|
||||
@@ -126,6 +135,8 @@ class ConfigYamlScheduleStore:
|
||||
self._reload_locked()
|
||||
if job_id in self._jobs:
|
||||
del self._jobs[job_id]
|
||||
# Drop any per-job scratchpad so a deleted job leaves no state.
|
||||
self._job_state.pop(job_id, None)
|
||||
self._save()
|
||||
return True
|
||||
return False
|
||||
@@ -145,6 +156,7 @@ class ConfigYamlScheduleStore:
|
||||
if principal is not None and job.principal != principal:
|
||||
continue
|
||||
del self._jobs[jid]
|
||||
self._job_state.pop(jid, None)
|
||||
self._save()
|
||||
return True
|
||||
return False
|
||||
@@ -199,6 +211,7 @@ class ConfigYamlScheduleStore:
|
||||
if job.delete_after_run:
|
||||
# One-shot: remove now so no competitor re-claims it.
|
||||
del self._jobs[job.id]
|
||||
self._job_state.pop(job.id, None)
|
||||
if changed:
|
||||
if not self._save():
|
||||
# The lease/last_run_at advance was NOT persisted (full
|
||||
@@ -283,6 +296,7 @@ class ConfigYamlScheduleStore:
|
||||
a job already claimed/leased by another process is observed.
|
||||
"""
|
||||
self._jobs = {}
|
||||
self._job_state = {}
|
||||
self._load()
|
||||
|
||||
# ── execution history ─────────────────────────────────────────────
|
||||
@@ -327,6 +341,59 @@ class ConfigYamlScheduleStore:
|
||||
records = [r for r in records if r.job_id == job_id]
|
||||
return records[:limit]
|
||||
|
||||
# ── per-job state (JobStateStoreProtocol) ─────────────────────────
|
||||
|
||||
def get_state(self, job_id: str) -> Dict:
|
||||
"""Return the persisted scratchpad for ``job_id`` (``{}`` if none).
|
||||
|
||||
A deep copy is returned so a caller mutating the result — including a
|
||||
nested list or dict — cannot silently alter the in-memory store without
|
||||
going through :meth:`set_state` (which enforces the size cap).
|
||||
"""
|
||||
with self._lock:
|
||||
state = self._job_state.get(job_id)
|
||||
return copy.deepcopy(state) if isinstance(state, dict) else {}
|
||||
|
||||
def set_state(self, job_id: str, state: Dict) -> None:
|
||||
"""Persist ``state`` for ``job_id`` under the ``job_state`` key.
|
||||
|
||||
Size-capped: a serialised state larger than ``_MAX_STATE_BYTES`` is
|
||||
rejected (prior state kept) so a runaway write cannot grow config.yaml
|
||||
unbounded. An empty/falsy ``state`` clears the entry.
|
||||
"""
|
||||
if not state:
|
||||
self.clear_state(job_id)
|
||||
return
|
||||
if not isinstance(state, dict):
|
||||
logger.warning(
|
||||
"Ignoring non-dict state for job '%s' (%s)", job_id, type(state).__name__,
|
||||
)
|
||||
return
|
||||
try:
|
||||
import yaml
|
||||
size = len(yaml.safe_dump(state).encode("utf-8"))
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
logger.warning("Could not size state for job '%s': %s", job_id, e)
|
||||
return
|
||||
if size > _MAX_STATE_BYTES:
|
||||
logger.warning(
|
||||
"Rejecting oversized state for job '%s' (%d > %d bytes); keeping prior",
|
||||
job_id, size, _MAX_STATE_BYTES,
|
||||
)
|
||||
return
|
||||
with self._lock, self._file_lock():
|
||||
self._reload_locked()
|
||||
self._job_state[job_id] = copy.deepcopy(state)
|
||||
self._save()
|
||||
|
||||
def clear_state(self, job_id: str) -> None:
|
||||
"""Drop any persisted scratchpad for ``job_id`` (no-op if absent)."""
|
||||
with self._lock, self._file_lock():
|
||||
self._reload_locked()
|
||||
if job_id in self._job_state:
|
||||
del self._job_state[job_id]
|
||||
self._save()
|
||||
|
||||
# ── persistence ──────────────────────────────────────────────────
|
||||
|
||||
def _load(self) -> None:
|
||||
@@ -353,6 +420,11 @@ class ConfigYamlScheduleStore:
|
||||
job_data.setdefault("id", job_id)
|
||||
job = ScheduleJob.from_dict(job_data)
|
||||
self._jobs[job.id] = job
|
||||
job_state = data.get("job_state", {})
|
||||
if isinstance(job_state, dict):
|
||||
for job_id, state in job_state.items():
|
||||
if isinstance(state, dict):
|
||||
self._job_state[str(job_id)] = state
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -381,6 +453,15 @@ class ConfigYamlScheduleStore:
|
||||
schedules[job.id] = job.to_dict()
|
||||
data["schedules"] = schedules
|
||||
|
||||
# Persist per-job scratchpad alongside; drop the key entirely when
|
||||
# empty so config.yaml stays clean for the stateless common case.
|
||||
if self._job_state:
|
||||
data["job_state"] = {
|
||||
jid: state for jid, state in self._job_state.items()
|
||||
}
|
||||
else:
|
||||
data.pop("job_state", None)
|
||||
|
||||
# Atomic write
|
||||
os.makedirs(os.path.dirname(self._path), exist_ok=True)
|
||||
tmp = self._path + ".tmp"
|
||||
|
||||
@@ -298,6 +298,21 @@ class ScheduleJob:
|
||||
killed (with its process group on POSIX) and the tick is
|
||||
recorded as ``failed``. Bounds the action so a hung command
|
||||
cannot stall the ticker. Defaults to 60s.
|
||||
backend: Optional external coding-CLI backend id (e.g.
|
||||
``"claude-code"``, ``"codex-cli"``). When set, the job's
|
||||
``message`` is executed as one headless turn through the
|
||||
named backend from the backend registry — no native agent is
|
||||
resolved and no in-process model turn is taken. Like
|
||||
``command``, this is a trusted operator-configured action:
|
||||
it spawns a host CLI subprocess and is deliberately not
|
||||
exposed on the LLM-callable scheduling tools. Additive and
|
||||
backward-compatible: jobs without a ``backend`` are
|
||||
unchanged.
|
||||
backend_options: Optional mapping of overrides for the backend run.
|
||||
Recognised keys are validated by the executor/backend (e.g.
|
||||
``cwd`` for the working directory, ``timeout_ms`` for the
|
||||
subprocess bound); unknown config overrides are rejected at
|
||||
resolution time rather than silently ignored.
|
||||
provider: Optional model *provider* snapshotted when the job was
|
||||
created (e.g. ``"openai"``). Advisory metadata paired with
|
||||
``model`` so a drift check can report both. ``None`` means no
|
||||
@@ -376,6 +391,8 @@ class ScheduleJob:
|
||||
context_from: Optional[List[str]] = None
|
||||
context_max_chars: int = 4000
|
||||
on_missing_context: Literal["run", "skip"] = "run"
|
||||
backend: Optional[str] = None
|
||||
backend_options: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# ── serialisation ────────────────────────────────────────────────
|
||||
|
||||
@@ -432,6 +449,13 @@ class ScheduleJob:
|
||||
d["context_max_chars"] = self.context_max_chars
|
||||
if self.on_missing_context != "run":
|
||||
d["on_missing_context"] = self.on_missing_context
|
||||
# External CLI backend action. Only persist when configured so agent
|
||||
# and command jobs stay byte-for-byte unchanged; options are opaque to
|
||||
# the core (the executor validates them against the backend registry).
|
||||
if self.backend is not None:
|
||||
d["backend"] = self.backend
|
||||
if self.backend_options:
|
||||
d["backend_options"] = dict(self.backend_options)
|
||||
# Atomic-claim lease metadata (set dynamically by stores that support
|
||||
# ``claim_due``). Persisted so a lease is visible across processes and
|
||||
# survives a restart; omitted when no lease is held.
|
||||
@@ -480,6 +504,12 @@ class ScheduleJob:
|
||||
context_from=context_from,
|
||||
context_max_chars=d.get("context_max_chars", 4000),
|
||||
on_missing_context=d.get("on_missing_context", "run"),
|
||||
backend=d.get("backend"),
|
||||
backend_options=(
|
||||
dict(d["backend_options"])
|
||||
if isinstance(d.get("backend_options"), dict)
|
||||
else {}
|
||||
),
|
||||
)
|
||||
# Restore atomic-claim lease metadata if present (see ``to_dict``).
|
||||
job._lease_until = d.get("lease_until", 0.0) or 0.0
|
||||
|
||||
@@ -5,26 +5,35 @@ This module provides automatic session persistence with zero configuration.
|
||||
## Quick Start
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents import Agent, MemoryConfig
|
||||
|
||||
# With session persistence (auto-enabled)
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
session_id="my-session-123"
|
||||
memory=MemoryConfig(session_id="my-session-123"),
|
||||
)
|
||||
agent.start("Hello, my name is Alice")
|
||||
|
||||
# Later, in a new process - history is restored automatically
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
session_id="my-session-123"
|
||||
name="Assistant",
|
||||
memory=MemoryConfig(session_id="my-session-123"),
|
||||
)
|
||||
agent.start("What is my name?") # Agent remembers: "Alice"
|
||||
```
|
||||
|
||||
The `session_id` is part of the Agent's memory configuration. `Agent` itself has
|
||||
no top-level `session_id` parameter — passing one raises
|
||||
`TypeError: Agent.__init__() got unexpected keyword argument(s): session_id`.
|
||||
A plain dict is accepted as shorthand for `MemoryConfig`:
|
||||
|
||||
```python
|
||||
agent = Agent(name="Assistant", memory={"session_id": "my-session-123"})
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
When you provide a `session_id` to an Agent:
|
||||
When you configure a `session_id` on an Agent:
|
||||
|
||||
1. **Automatic Persistence**: Conversation history is automatically saved to disk
|
||||
2. **Automatic Restoration**: When a new Agent is created with the same `session_id`, history is restored
|
||||
@@ -74,8 +83,8 @@ Fields are persisted in session JSON `metadata` and mirrored at root on save. Po
|
||||
|
||||
| Scenario | Behavior |
|
||||
|----------|----------|
|
||||
| `session_id` provided, no DB | JSON persistence (auto) |
|
||||
| `session_id` provided, with DB | DB adapter used |
|
||||
| `memory=MemoryConfig(session_id=...)`, no DB | JSON persistence (auto) |
|
||||
| `memory=MemoryConfig(session_id=..., db=...)` | DB adapter used |
|
||||
| No `session_id`, same Agent instance | In-memory only |
|
||||
| No `session_id`, new Agent instance | No history |
|
||||
|
||||
@@ -105,6 +114,7 @@ store.delete_session("session-123")
|
||||
|
||||
### Custom Session Directory
|
||||
|
||||
<!-- praisonai: skip=true -->
|
||||
```python
|
||||
from praisonaiagents.session import DefaultSessionStore
|
||||
|
||||
@@ -120,13 +130,15 @@ store = DefaultSessionStore(
|
||||
When a DB adapter is provided, it takes precedence over JSON persistence:
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
from praisonai.db import PostgresAdapter
|
||||
from praisonaiagents import Agent, MemoryConfig
|
||||
from praisonaiagents.db import db
|
||||
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
session_id="my-session",
|
||||
db=PostgresAdapter(connection_string="...")
|
||||
memory=MemoryConfig(
|
||||
session_id="my-session",
|
||||
db=db(database_url="postgresql://localhost/mydb"),
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
@@ -142,17 +154,27 @@ Multiple processes can safely read/write to the same session.
|
||||
|
||||
## Context Caching
|
||||
|
||||
For cost optimization, use `prompt_caching=True` with Anthropic models:
|
||||
Prompt caching is opt-in via `caching=CachingConfig(prompt_caching=True)` — there
|
||||
is no top-level `prompt_caching` parameter on `Agent`. When enabled, `Agent`
|
||||
checks `praisonaiagents.llm.model_capabilities.supports_prompt_caching(model)`
|
||||
and, for providers that need explicit breakpoints (Anthropic), injects
|
||||
`cache_control` markers itself. OpenAI/Gemini use automatic prefix caching, so no
|
||||
markers are emitted:
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent, MemoryConfig, CachingConfig
|
||||
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
session_id="my-session",
|
||||
prompt_caching=True, # Enables Anthropic prompt caching
|
||||
llm="anthropic/claude-sonnet-4-5",
|
||||
memory=MemoryConfig(session_id="my-session"),
|
||||
caching=CachingConfig(prompt_caching=True),
|
||||
)
|
||||
```
|
||||
|
||||
This caches the system prompt, reducing token costs for repeated conversations.
|
||||
Without `prompt_caching=True`, no `cache_control` breakpoints are injected on the
|
||||
Anthropic path.
|
||||
|
||||
## API Reference
|
||||
|
||||
@@ -162,7 +184,7 @@ This caches the system prompt, reducing token costs for repeated conversations.
|
||||
class DefaultSessionStore:
|
||||
def __init__(
|
||||
self,
|
||||
session_dir: Optional[str] = None, # Default: ~/.praison/sessions/
|
||||
session_dir: Optional[str] = None, # Default: ~/.praisonai/sessions/
|
||||
max_messages: int = 100,
|
||||
lock_timeout: float = 5.0,
|
||||
): ...
|
||||
|
||||
@@ -2,26 +2,32 @@
|
||||
Session persistence module for PraisonAI Agents.
|
||||
|
||||
Provides automatic session persistence with zero configuration.
|
||||
When a session_id is provided to an Agent, conversation history
|
||||
When a session_id is configured on an Agent, conversation history
|
||||
is automatically persisted to disk and restored on subsequent runs.
|
||||
|
||||
The session_id is set through the Agent's memory configuration; Agent itself
|
||||
has no top-level session_id parameter.
|
||||
|
||||
Usage:
|
||||
from praisonaiagents import Agent
|
||||
|
||||
from praisonaiagents import Agent, MemoryConfig
|
||||
|
||||
# With session persistence (auto-enabled)
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
session_id="my-session-123"
|
||||
memory=MemoryConfig(session_id="my-session-123"),
|
||||
)
|
||||
agent.start("Hello")
|
||||
|
||||
|
||||
# Later, new process - history is restored
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
session_id="my-session-123"
|
||||
name="Assistant",
|
||||
memory=MemoryConfig(session_id="my-session-123"),
|
||||
)
|
||||
agent.start("What did I say before?") # Remembers!
|
||||
|
||||
# The plain-dict spelling is equivalent:
|
||||
# Agent(name="Assistant", memory={"session_id": "my-session-123"})
|
||||
|
||||
Default storage: ~/.praisonai/sessions/{session_id}.json
|
||||
"""
|
||||
|
||||
@@ -98,6 +104,16 @@ def __getattr__(name: str):
|
||||
_module_cache[name] = SessionMirrorProtocol
|
||||
return SessionMirrorProtocol
|
||||
|
||||
if name == "PortableSessionStoreProtocol":
|
||||
from .protocols import PortableSessionStoreProtocol
|
||||
_module_cache[name] = PortableSessionStoreProtocol
|
||||
return PortableSessionStoreProtocol
|
||||
|
||||
if name == "ImportReport":
|
||||
from .protocols import ImportReport
|
||||
_module_cache[name] = ImportReport
|
||||
return ImportReport
|
||||
|
||||
if name == "SessionHit":
|
||||
from .protocols import SessionHit
|
||||
_module_cache[name] = SessionHit
|
||||
@@ -170,6 +186,12 @@ def __getattr__(name: str):
|
||||
_module_cache[name] = workspace_id
|
||||
return workspace_id
|
||||
|
||||
# Cross-context continuation prompt assembler (session handoff)
|
||||
if name == "build_handoff_prompt":
|
||||
from .handoff import build_handoff_prompt
|
||||
_module_cache[name] = build_handoff_prompt
|
||||
return build_handoff_prompt
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
@@ -185,6 +207,8 @@ __all__ = [
|
||||
"SessionStoreProtocol",
|
||||
"SearchableSessionStoreProtocol",
|
||||
"SessionMirrorProtocol",
|
||||
"PortableSessionStoreProtocol",
|
||||
"ImportReport",
|
||||
"SessionHit",
|
||||
"SessionSummary",
|
||||
"HierarchicalSessionStore",
|
||||
@@ -201,4 +225,5 @@ __all__ = [
|
||||
"get_session_context",
|
||||
"clear_session_context",
|
||||
"workspace_id",
|
||||
"build_handoff_prompt",
|
||||
]
|
||||
@@ -17,6 +17,16 @@ from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Sub-agent transcripts live *inside* the parent session's own record, under
|
||||
# ``SessionData.metadata[AGENT_HISTORY_KEY][agent_key]``.
|
||||
AGENT_HISTORY_KEY = "agent_histories"
|
||||
|
||||
# Opt-in restore of sub-agent transcripts written by older versions as separate
|
||||
# top-level sessions named ``"{parent}_{name}:{role}"`` *without* parentage
|
||||
# tags. Such an id is indistinguishable from a real user session of the same
|
||||
# sanitised name, so it is not restored unless this is explicitly set.
|
||||
LEGACY_AGENT_KEYS_ENV = "PRAISONAI_SESSION_LEGACY_AGENT_KEYS"
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..memory.memory import Memory
|
||||
from ..knowledge.knowledge import Knowledge
|
||||
@@ -116,6 +126,7 @@ class Session:
|
||||
self._knowledge = None
|
||||
self._agents_instance = None
|
||||
self._agents = {}
|
||||
self._legacy_histories_cache = None
|
||||
else:
|
||||
self.memory_config = {}
|
||||
self.knowledge_config = {}
|
||||
@@ -123,6 +134,7 @@ class Session:
|
||||
self._knowledge = None
|
||||
self._agents_instance = None
|
||||
self._agents = {}
|
||||
self._legacy_histories_cache = None
|
||||
|
||||
def _get_session_dir(self):
|
||||
"""Get session-specific directory using centralized paths."""
|
||||
@@ -274,6 +286,55 @@ class Session:
|
||||
|
||||
return {}
|
||||
|
||||
def _get_session_store(self):
|
||||
"""Return the default session store, or ``None`` if unavailable."""
|
||||
try:
|
||||
from . import get_default_session_store
|
||||
return get_default_session_store()
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _legacy_agent_keys_enabled() -> bool:
|
||||
"""Whether to restore *untagged* legacy sub-agent records.
|
||||
|
||||
Records written before the ``parent_session_id``/``agent_key`` tags
|
||||
existed carry no parentage, so ``"{parent}_{agent_key}"`` cannot be told
|
||||
apart from a real user session of the same sanitised name. Restoring one
|
||||
can silently pull an unrelated transcript into an agent, so it is
|
||||
opt-in.
|
||||
"""
|
||||
return os.environ.get(LEGACY_AGENT_KEYS_ENV, "").strip().lower() in (
|
||||
"1", "true", "yes", "on",
|
||||
)
|
||||
|
||||
def _stored_agent_histories(self, session_store) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Sub-agent transcripts held inside this session's own record."""
|
||||
if session_store is None or not hasattr(session_store, "get_session"):
|
||||
return {}
|
||||
try:
|
||||
data = session_store.get_session(self.session_id)
|
||||
except Exception as exc:
|
||||
logger.debug(f"Cannot read agent histories for {self.session_id}: {exc}")
|
||||
return {}
|
||||
histories = (getattr(data, "metadata", None) or {}).get(AGENT_HISTORY_KEY)
|
||||
if not isinstance(histories, dict):
|
||||
return {}
|
||||
return {k: v for k, v in histories.items() if isinstance(v, list) and v}
|
||||
|
||||
def _merged_agent_histories(self, session_store) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""All known sub-agent transcripts: legacy records under current ones.
|
||||
|
||||
Legacy per-agent records are read *underneath* the parent's current
|
||||
``metadata[AGENT_HISTORY_KEY]`` so a partially-migrated parent (one
|
||||
agent already in the current format) still surfaces a *different*
|
||||
agent that only exists in a tagged legacy record. Current-format
|
||||
entries always win on key collisions.
|
||||
"""
|
||||
merged = dict(self._legacy_agent_histories(session_store))
|
||||
merged.update(self._stored_agent_histories(session_store))
|
||||
return merged
|
||||
|
||||
def _restore_agent_chat_history(self, agent_key: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Restore agent chat history from memory.
|
||||
@@ -287,19 +348,13 @@ class Session:
|
||||
if self.is_remote:
|
||||
return []
|
||||
|
||||
# G-2 FIX: mirror the save path priority — read SessionStore first so
|
||||
# history persisted by _save_agent_chat_histories() is actually found.
|
||||
session_store = None
|
||||
try:
|
||||
from . import get_default_session_store
|
||||
session_store = get_default_session_store()
|
||||
except ImportError:
|
||||
pass
|
||||
session_store = self._get_session_store()
|
||||
|
||||
if session_store is not None and hasattr(session_store, "get_chat_history"):
|
||||
messages = session_store.get_chat_history(f"{self.session_id}_{agent_key}")
|
||||
if messages:
|
||||
return messages
|
||||
# Current format first, then any (tagged / opted-in) legacy per-agent
|
||||
# record — even when the parent is only partially migrated.
|
||||
merged = self._merged_agent_histories(session_store)
|
||||
if agent_key in merged:
|
||||
return merged[agent_key]
|
||||
|
||||
results = self.memory.search_short_term(
|
||||
query="Agent chat history for",
|
||||
@@ -316,54 +371,101 @@ class Session:
|
||||
|
||||
return []
|
||||
|
||||
def _legacy_agent_histories(self, session_store) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Sub-agent transcripts older versions wrote as top-level sessions.
|
||||
|
||||
Never deletes them: an untagged candidate may in fact be a real user
|
||||
session that merely sanitises to the same name.
|
||||
"""
|
||||
# Legacy per-agent records are a frozen migration artefact: nothing
|
||||
# writes them after the upgrade and neither save nor restore mutates
|
||||
# them. Memoise the full-disk scan per Session so a save pass over N
|
||||
# agents doesn't re-enumerate every stored session N times (and once
|
||||
# migrated, the common case, the scan collapses to a single hit).
|
||||
# Key the cache on the opt-in flag: the untagged-restore env toggle
|
||||
# changes what the scan yields, so each mode caches independently.
|
||||
allow_untagged = self._legacy_agent_keys_enabled()
|
||||
cached = getattr(self, "_legacy_histories_cache", None)
|
||||
if cached is not None and cached[0] == allow_untagged:
|
||||
return cached[1]
|
||||
|
||||
histories: Dict[str, List[Dict[str, Any]]] = {}
|
||||
list_sessions = getattr(session_store, "list_sessions", None)
|
||||
if session_store is None or not callable(list_sessions):
|
||||
self._legacy_histories_cache = (allow_untagged, histories)
|
||||
return histories
|
||||
|
||||
prefix = f"{self.session_id}_"
|
||||
# Migration must consider every stored session, not the 50 most recent:
|
||||
# a legacy per-agent record is by definition older than anything written
|
||||
# after the upgrade, so list_sessions()'s default window silently
|
||||
# excludes it (regression from #4126).
|
||||
try:
|
||||
try:
|
||||
entries = list_sessions(limit=None) or []
|
||||
except TypeError: # stores whose limit is not optional
|
||||
entries = list_sessions(limit=10**9) or []
|
||||
except Exception:
|
||||
return histories
|
||||
|
||||
for entry in entries:
|
||||
if isinstance(entry, dict):
|
||||
stored_id = entry.get("session_id")
|
||||
# Prefer the explicit parent/agent_key tags written on save;
|
||||
# this disambiguates overlapping composite ids (e.g.
|
||||
# "chat"+"support_agent" vs "chat_support"+"agent").
|
||||
tagged_parent = entry.get("parent_session_id")
|
||||
tagged_key = entry.get("agent_key")
|
||||
else:
|
||||
stored_id, tagged_parent, tagged_key = entry, None, None
|
||||
if not isinstance(stored_id, str):
|
||||
continue
|
||||
|
||||
if tagged_parent == self.session_id and tagged_key:
|
||||
agent_key = tagged_key
|
||||
elif (
|
||||
tagged_parent is None
|
||||
and tagged_key is None
|
||||
and stored_id.startswith(prefix)
|
||||
and len(stored_id) > len(prefix)
|
||||
):
|
||||
# Saved before the tags existed: the id alone is ambiguous.
|
||||
agent_key = stored_id[len(prefix):]
|
||||
logger.warning(
|
||||
"Session %r: untagged session %r %s as sub-agent %r history "
|
||||
"(ambiguous id; %s).",
|
||||
self.session_id, stored_id,
|
||||
"restored" if allow_untagged else "ignored",
|
||||
agent_key,
|
||||
f"{LEGACY_AGENT_KEYS_ENV}=1" if allow_untagged
|
||||
else f"set {LEGACY_AGENT_KEYS_ENV}=1 to restore it",
|
||||
)
|
||||
if not allow_untagged:
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
|
||||
try:
|
||||
messages = session_store.get_chat_history(stored_id)
|
||||
except Exception:
|
||||
continue
|
||||
if agent_key and messages:
|
||||
histories[agent_key] = messages
|
||||
|
||||
self._legacy_histories_cache = (allow_untagged, histories)
|
||||
return histories
|
||||
|
||||
def _restore_agent_chat_histories(self) -> None:
|
||||
"""Restore all agent chat histories from memory."""
|
||||
"""Restore all agent chat histories."""
|
||||
if self.is_remote:
|
||||
return
|
||||
|
||||
# G-2 FIX: read SessionStore first (where save_state now writes), then
|
||||
# fall back to Memory for backward compatibility.
|
||||
session_store = None
|
||||
try:
|
||||
from . import get_default_session_store
|
||||
session_store = get_default_session_store()
|
||||
except ImportError:
|
||||
pass
|
||||
session_store = self._get_session_store()
|
||||
|
||||
if session_store is not None and hasattr(session_store, "get_chat_history"):
|
||||
prefix = f"{self.session_id}_"
|
||||
list_sessions = getattr(session_store, "list_sessions", None)
|
||||
if callable(list_sessions):
|
||||
for entry in list_sessions():
|
||||
if isinstance(entry, dict):
|
||||
stored_id = entry.get("session_id")
|
||||
# Prefer the explicit parent/agent_key tags written on
|
||||
# save; this disambiguates overlapping composite ids
|
||||
# (e.g. "chat"+"support_agent" vs "chat_support"+"agent").
|
||||
tagged_parent = entry.get("parent_session_id")
|
||||
tagged_key = entry.get("agent_key")
|
||||
if tagged_parent == self.session_id and tagged_key:
|
||||
agent_key = tagged_key
|
||||
elif (
|
||||
tagged_parent is None
|
||||
and isinstance(stored_id, str)
|
||||
and stored_id.startswith(prefix)
|
||||
):
|
||||
# Legacy sessions saved before the tag existed.
|
||||
agent_key = stored_id[len(prefix):]
|
||||
else:
|
||||
continue
|
||||
else:
|
||||
stored_id = entry
|
||||
if not (isinstance(stored_id, str) and stored_id.startswith(prefix)):
|
||||
continue
|
||||
agent_key = stored_id[len(prefix):]
|
||||
messages = session_store.get_chat_history(stored_id)
|
||||
if agent_key and messages:
|
||||
self._agents[agent_key] = {
|
||||
"agent": None,
|
||||
"chat_history": messages
|
||||
}
|
||||
# Current-format records win; legacy (tagged / opted-in) ones fill the
|
||||
# gaps so a partially-migrated parent still recovers every agent.
|
||||
for agent_key, messages in self._merged_agent_histories(session_store).items():
|
||||
self._agents[agent_key] = {"agent": None, "chat_history": messages}
|
||||
|
||||
results = self.memory.search_short_term(
|
||||
query="Agent chat history for",
|
||||
@@ -383,12 +485,50 @@ class Session:
|
||||
"chat_history": chat_history
|
||||
}
|
||||
|
||||
def _store_agent_history(
|
||||
self, session_store, agent_key: str, messages: List[Dict[str, Any]]
|
||||
) -> bool:
|
||||
"""Persist one sub-agent transcript onto this session's own record.
|
||||
|
||||
The store replaces the whole ``AGENT_HISTORY_KEY`` map per write, so a
|
||||
naive read-then-replace would let a concurrent writer (another agent
|
||||
under the same parent) clobber this agent's entry, or vice versa. We
|
||||
re-read the freshest map immediately before each write and confirm no
|
||||
*other* key changed underneath us, retrying a bounded number of times.
|
||||
Legacy per-agent records are always merged in (they stay on disk).
|
||||
"""
|
||||
update_meta = getattr(session_store, "update_session_metadata", None)
|
||||
if not callable(update_meta):
|
||||
return False
|
||||
legacy = self._legacy_agent_histories(session_store)
|
||||
for _ in range(5):
|
||||
current = self._stored_agent_histories(session_store)
|
||||
merged = dict(legacy)
|
||||
merged.update(current)
|
||||
merged[agent_key] = messages
|
||||
if not update_meta(self.session_id, **{AGENT_HISTORY_KEY: merged}):
|
||||
return False
|
||||
# Verify a concurrent writer did not drop another agent between our
|
||||
# read and write; if the persisted map matches what we wrote, we win.
|
||||
persisted = self._stored_agent_histories(session_store)
|
||||
if persisted.get(agent_key) == messages and all(
|
||||
persisted.get(k) == v for k, v in current.items()
|
||||
):
|
||||
return True
|
||||
logger.warning(
|
||||
"Session %r: could not persist sub-agent history for %r after %d "
|
||||
"attempts; the write was not verified.",
|
||||
self.session_id, agent_key, 5,
|
||||
)
|
||||
return False
|
||||
|
||||
def _save_agent_chat_histories(self) -> None:
|
||||
"""Save all agent chat histories.
|
||||
|
||||
G-2 FIX: Routes to SessionStore instead of Memory.store_short_term() to
|
||||
maintain clean separation between conversation history (SessionStore)
|
||||
and semantic memory (Memory). Falls back to Memory for backward compat.
|
||||
|
||||
Sub-agent transcripts go into the parent session's own record, under
|
||||
``metadata[AGENT_HISTORY_KEY][agent_key]``. Nothing is written into the
|
||||
global session-id namespace, so a sub-agent can no longer overwrite an
|
||||
unrelated user session. Falls back to Memory for backward compat.
|
||||
"""
|
||||
if self.is_remote:
|
||||
return
|
||||
@@ -404,17 +544,10 @@ class Session:
|
||||
chat_history = agent_data.get("chat_history")
|
||||
|
||||
if chat_history is not None:
|
||||
# G-2 FIX: Try SessionStore first for clean separation
|
||||
session_store = None
|
||||
try:
|
||||
from . import get_default_session_store
|
||||
session_store = get_default_session_store()
|
||||
except ImportError:
|
||||
pass
|
||||
session_store = self._get_session_store()
|
||||
|
||||
if session_store is not None:
|
||||
# Replace atomically to avoid duplicate appends on repeated save_state()
|
||||
session_id = f"{self.session_id}_{agent_key}"
|
||||
messages = [
|
||||
{
|
||||
"role": msg.get("role", "user"),
|
||||
@@ -424,32 +557,18 @@ class Session:
|
||||
if isinstance(msg, dict)
|
||||
]
|
||||
if not messages:
|
||||
logging.debug(f"No chat history to persist for session {session_id}")
|
||||
elif hasattr(session_store, "set_chat_history"):
|
||||
session_store.set_chat_history(session_id, messages)
|
||||
# Tag the child session so bulk restore can resolve the
|
||||
# exact agent_key without ambiguous composite-key prefix
|
||||
# parsing (e.g. "chat" + "support_agent" vs "chat_support"
|
||||
# + "agent" both collapse to "chat_support_agent").
|
||||
update_meta = getattr(session_store, "update_session_metadata", None)
|
||||
if callable(update_meta):
|
||||
update_meta(
|
||||
session_id,
|
||||
parent_session_id=self.session_id,
|
||||
agent_key=agent_key,
|
||||
)
|
||||
else:
|
||||
# Fallback to add_message - may create duplicates on repeated calls
|
||||
logging.debug(
|
||||
f"No chat history to persist for agent {agent_key} "
|
||||
f"in session {self.session_id}"
|
||||
)
|
||||
elif not self._store_agent_history(
|
||||
session_store, agent_key, messages
|
||||
):
|
||||
logging.warning(
|
||||
f"Session store lacks 'set_chat_history' method. Using fallback "
|
||||
f"'add_message' which may create duplicates on repeated save_state() calls."
|
||||
f"Session store {type(session_store).__name__} cannot "
|
||||
f"persist sub-agent history; chat history for "
|
||||
f"{agent_key} in session {self.session_id} was lost."
|
||||
)
|
||||
for msg in messages:
|
||||
session_store.add_message(
|
||||
session_id,
|
||||
role=msg["role"],
|
||||
content=msg["content"],
|
||||
)
|
||||
else:
|
||||
# Fallback to Memory.store_short_term() for backward compatibility
|
||||
history_text = f"Agent chat history for {agent_key}"
|
||||
|
||||
Loaded 100 of 474 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user